From eb833c949d110f75e611f30385b3e648ec9c8c7b Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 28 Jun 2019 11:30:47 +0100 Subject: [PATCH 001/150] Added contributing and security policies (#142) --- .github/CODE_OF_CONDUCT.md | 76 ++++++++++++++++++++++++++++++++++++++ .github/CONTRIBUTING.md | 31 ++++++++++++++++ .github/SECURITY.md | 21 +++++++++++ composer.json | 18 ++++++--- 4 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/SECURITY.md diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..72084f84 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# CONTRIBUTOR COVENANT CODE OF CONDUCT + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at graham@alt-three.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000..0f98282a --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# CONTRIBUTION GUIDELINES + +Contributions are **welcome** and will be fully **credited**. + +We accept contributions via pull requests on Github. Please review these guidelines before continuing. + +## Guidelines + +* Please follow the [PSR-2 Coding Style Guide](https://www.php-fig.org/psr/psr-2/), enforced by [StyleCI](https://styleci.io/). +* Ensure that the current tests pass, and if you've added something new, add the tests where relevant. +* Send a coherent commit history, making sure each individual commit in your pull request is meaningful. +* You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts. +* If you are changing or adding to the behaviour or public api, you may need to update the docs. +* Please remember that we follow [SemVer](https://semver.org/). + +## Running Tests + +First, install the dependencies using [Composer](https://getcomposer.org/): + +```bash +$ composer install +``` + +Then run [PHPUnit](https://phpunit.de/): + +```bash +$ vendor/bin/phpunit +``` + +* The tests will be automatically run by [Travis CI](https://travis-ci.org/) against pull requests. +* We also have [StyleCI](https://styleci.io/) setup to automatically fix any code style issues. diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..79a128e7 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,21 @@ +# SECURITY POLICY + +## Supported Versions + +After each new major (minor) release, the previous release will be supported +for no less than 12 (6) months, unless explictly otherwise. This may mean that +there are multiple supported versions at any given time. + +## Reporting a Vulnerability + +If you discover a security vulnerability within this package, please send an +email to one the security contacts. All security vulnerabilities will be +promptly addressed. Please do not disclose security-related issues publicly +until a fix has been announced. + +### Security Contacts + +* Graham Campbell (graham@alt-three.com) +* Julien Didier (genzo.wm@gmail.com) +* Grégoire Pineau (lyrixx@lyrixx.info) +* Alexandre Salomé (alexandre.salome@gmail.com) diff --git a/composer.json b/composer.json index ae72c6d2..32165d10 100644 --- a/composer.json +++ b/composer.json @@ -4,14 +4,20 @@ "license": "MIT", "authors": [ { - "name": "Alexandre Salomé", - "email": "alexandre.salome@gmail.com", - "homepage": "http://alexandre-salome.fr" + "name": "Graham Campbell", + "email": "graham@alt-three.com" + }, + { + "name": "Julien Didier", + "email": "genzo.wm@gmail.com" }, { - "name": "Julien DIDIER", - "email": "genzo.wm@gmail.com", - "homepage": "http://www.jdidier.net" + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Alexandre Salomé", + "email": "alexandre.salome@gmail.com" } ], "homepage": "http://gitonomy.com", From 9c63c12152d585b87435734b7c11dfe82a2a891b Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 28 Jun 2019 22:27:23 +0100 Subject: [PATCH 002/150] Added Tidelift information (#144) --- .github/FUNDING.yml | 1 + README.md | 12 ++++++++++++ 2 files changed, 13 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..856fb026 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +tidelift: "packagist/gitonomy/gitlib" diff --git a/README.md b/README.md index fb03092d..4b3cb021 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,15 @@ It makes shell calls, which makes it less performant than any solution. Anyway, it's convenient and don't need to build anything to use it. That's how we love it. *Documentation*: http://gitonomy.com/doc/gitlib/master/ + +--- + +
+ + Get professional support for Git lib with a Tidelift subscription + +
+ + Tidelift helps make open source sustainable for maintainers while giving companies
assurances about security, maintenance, and licensing for their dependencies. +
+
From 1b9f3eb0b189e697a17ca5b3c8f27b7727953e9d Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Sat, 29 Jun 2019 15:12:01 +0100 Subject: [PATCH 003/150] Updated badges (#143) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b3cb021..58fa583d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ Git lib for Gitonomy ==================== -[![Build Status](https://secure.travis-ci.org/gitonomy/gitlib.png)](https://travis-ci.org/gitonomy/gitlib) +[![Build Status](https://img.shields.io/travis/gitonomy/gitlib/master.svg?style=flat-square)](https://travis-ci.org/gitonomy/gitlib) +[![StyleCI](https://github.styleci.io/repos/5709354/shield?branch=master)](https://github.styleci.io/repos/5709354) +Software License This library provides methods to access Git repository from PHP. From cff9b158eb01c804c0758558b4010c8537d44aee Mon Sep 17 00:00:00 2001 From: Giorgio Azzinnaro Date: Wed, 3 Jul 2019 13:21:37 +0200 Subject: [PATCH 004/150] Data in annotated tags (#115) * improved Tag with TagParser unit test for annotated tags * use gitonomy/foobar as annotated tag was added there --- src/Gitonomy/Git/Parser/TagParser.php | 90 ++++++++++++ src/Gitonomy/Git/Reference/Tag.php | 151 +++++++++++++++++++++ tests/Gitonomy/Git/Tests/ReferenceTest.php | 20 +++ 3 files changed, 261 insertions(+) create mode 100644 src/Gitonomy/Git/Parser/TagParser.php diff --git a/src/Gitonomy/Git/Parser/TagParser.php b/src/Gitonomy/Git/Parser/TagParser.php new file mode 100644 index 00000000..337f6c8b --- /dev/null +++ b/src/Gitonomy/Git/Parser/TagParser.php @@ -0,0 +1,90 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ +namespace Gitonomy\Git\Parser; + +use Gitonomy\Git\Exception\RuntimeException; + +class TagParser extends ParserBase +{ + public $object; + public $type; + public $tag; + public $taggerName; + public $taggerEmail; + public $taggerDate; + public $gpgSignature; + public $message; + + protected function doParse() + { + $this->consume('object '); + $this->object = $this->consumeHash(); + $this->consumeNewLine(); + + $this->consume('type '); + $this->type = $this->consumeTo("\n"); + $this->consumeNewLine(); + + $this->consume('tag '); + $this->tag = $this->consumeTo("\n"); + $this->consumeNewLine(); + + $this->consume('tagger '); + list($this->taggerName, $this->taggerEmail, $this->taggerDate) = $this->consumeNameEmailDate(); + $this->taggerDate = $this->parseDate($this->taggerDate); + + $this->consumeNewLine(); + $this->consumeNewLine(); + + try { + $this->message = $this->consumeTo("-----BEGIN PGP SIGNATURE-----"); + $this->gpgSignature = $this->consumeGPGSignature(); + } catch (RuntimeException $e) { + $this->message = $this->consumeAll(); + } + } + + protected function consumeGPGSignature() + { + $expected = "-----BEGIN PGP SIGNATURE-----"; + $length = strlen($expected); + $actual = substr($this->content, $this->cursor, $length); + if ($actual != $expected) { + return ''; + } + $this->cursor += $length; + + return $this->consumeTo("-----END PGP SIGNATURE-----"); + } + + protected function consumeNameEmailDate() + { + if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+ [+-]\d{4}))/A', $this->content, $vars, 0, $this->cursor)) { + throw new RuntimeException('Unable to parse name, email and date'); + } + + $this->cursor += strlen($vars[1]); + + return array($vars[2], $vars[3], $vars[4]); + } + + protected function parseDate($text) + { + $date = \DateTime::createFromFormat('U e O', $text.' UTC'); + + if (!$date instanceof \DateTime) { + throw new RuntimeException(sprintf('Unable to convert "%s" to datetime', $text)); + } + + return $date; + } +} diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index 8e42376d..af4b09a1 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -13,7 +13,9 @@ namespace Gitonomy\Git\Reference; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Reference; +use Gitonomy\Git\Parser\TagParser; /** * Representation of a tag reference. @@ -30,4 +32,153 @@ public function getName() return $vars[1]; } + + /** + * Check if tag is annotated. + * + * @return bool + */ + public function isAnnotated() + { + try { + $this->repository->run('cat-file', array('tag', $this->revision)); + } catch (ProcessException $e) { + return false; // Is not an annotated tag + } + + return true; + } + + /** + * Returns the tagger name. + * + * @return string A name + */ + public function getTaggerName() + { + return $this->getData('taggerName'); + } + + /** + * Returns the comitter email. + * + * @return string An email + */ + public function getTaggerEmail() + { + return $this->getData('taggerEmail'); + } + + /** + * Returns the authoring date. + * + * @return DateTime A time object + */ + public function getTaggerDate() + { + return $this->getData('taggerDate'); + } + + /** + * Returns the message of the commit. + * + * @return string A tag message + */ + public function getMessage() + { + return $this->getData('message'); + } + + /** + * Returns the subject message (the first line). + * + * @return string The subject message + */ + public function getSubjectMessage() + { + return $this->getData('subjectMessage'); + } + + /** + * Return the body message. + * + * @return string The body message + */ + public function getBodyMessage() + { + return $this->getData('bodyMessage'); + } + + /** + * Return the GPG signature. + * + * @return string The GPG signature + */ + public function getGPGSignature() + { + return $this->getData('gpgSignature'); + } + + /** + * Check whether tag is signed. + * + * @return boolean + */ + public function isSigned() + { + try { + $this->getGPGSignature(); + return true; + } catch (\InvalidArgumentException $e) { + return false; + } + } + + private function getData($name) + { + if (!$this->isAnnotated()) { + return false; + } + + if (isset($this->data[$name])) { + return $this->data[$name]; + } + + if ($name === 'subjectMessage') { + $lines = explode("\n", $this->getData('message')); + $this->data['subjectMessage'] = reset($lines); + + return $this->data['subjectMessage']; + } + + if ($name === 'bodyMessage') { + $message = $this->getData('message'); + + $lines = explode("\n", $message); + + array_shift($lines); + array_pop($lines); + + $data['bodyMessage'] = implode("\n", $lines); + + return $data['bodyMessage']; + } + + $parser = new TagParser(); + $result = $this->repository->run('cat-file', array('tag', $this->revision)); + + $parser->parse($result); + + $this->data['taggerName'] = $parser->taggerName; + $this->data['taggerEmail'] = $parser->taggerEmail; + $this->data['taggerDate'] = $parser->taggerDate; + $this->data['message'] = $parser->message; + $this->data['gpgSignature'] = $parser->gpgSignature; + + if (!isset($this->data[$name])) { + throw new \InvalidArgumentException(sprintf('No data named "%s" in Tag.', $name)); + } + + return $this->data[$name]; + } } diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 7025f9be..45b626e6 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -74,11 +74,31 @@ public function testGetTag($repository) $tag = $repository->getReferences()->getTag('0.1'); $this->assertTrue($tag instanceof Tag, 'Tag object is correct type'); + $this->assertFalse($tag->isAnnotated(), 'Tag is not annotated'); $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommitHash(), 'Commit hash is correct'); $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommit()->getHash(), 'Commit hash is correct'); } + /** + * @dataProvider provideFoobar + */ + public function testAnnotatedTag($repository) + { + $tag = $repository->getReferences()->getTag('annotated'); + + $this->assertTrue($tag instanceof Tag, 'Tag object is correct type'); + $this->assertTrue($tag->isAnnotated(), 'Tag is annotated'); + $this->assertFalse($tag->isSigned(), 'Tag is not signed'); + + $this->assertEquals('Graham Campbell', $tag->getTaggerName(), 'Tagger name is correct'); + $this->assertEquals('graham@alt-three.com', $tag->getTaggerEmail(), 'Tagger email is correct'); + $this->assertEquals(1471428000, $tag->getTaggerDate()->getTimestamp(), 'Tag date is correct'); + + $this->assertEquals('heading', $tag->getSubjectMessage(), 'Message heading is correct'); + $this->assertEquals("body\nbody", $tag->getBodyMessage(), 'Message body is correct'); + } + /** * @dataProvider provideFoobar * @expectedException Gitonomy\Git\Exception\ReferenceNotFoundException From e027bfead0f87090f52700fc21b4f082c9827d93 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Wed, 3 Jul 2019 15:29:46 +0100 Subject: [PATCH 005/150] Apply fixes from StyleCI (#145) --- src/Gitonomy/Git/Parser/TagParser.php | 9 +++++---- src/Gitonomy/Git/Reference/Tag.php | 11 ++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/Gitonomy/Git/Parser/TagParser.php b/src/Gitonomy/Git/Parser/TagParser.php index 337f6c8b..659be18a 100644 --- a/src/Gitonomy/Git/Parser/TagParser.php +++ b/src/Gitonomy/Git/Parser/TagParser.php @@ -9,6 +9,7 @@ * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ + namespace Gitonomy\Git\Parser; use Gitonomy\Git\Exception\RuntimeException; @@ -46,7 +47,7 @@ protected function doParse() $this->consumeNewLine(); try { - $this->message = $this->consumeTo("-----BEGIN PGP SIGNATURE-----"); + $this->message = $this->consumeTo('-----BEGIN PGP SIGNATURE-----'); $this->gpgSignature = $this->consumeGPGSignature(); } catch (RuntimeException $e) { $this->message = $this->consumeAll(); @@ -55,7 +56,7 @@ protected function doParse() protected function consumeGPGSignature() { - $expected = "-----BEGIN PGP SIGNATURE-----"; + $expected = '-----BEGIN PGP SIGNATURE-----'; $length = strlen($expected); $actual = substr($this->content, $this->cursor, $length); if ($actual != $expected) { @@ -63,7 +64,7 @@ protected function consumeGPGSignature() } $this->cursor += $length; - return $this->consumeTo("-----END PGP SIGNATURE-----"); + return $this->consumeTo('-----END PGP SIGNATURE-----'); } protected function consumeNameEmailDate() @@ -74,7 +75,7 @@ protected function consumeNameEmailDate() $this->cursor += strlen($vars[1]); - return array($vars[2], $vars[3], $vars[4]); + return [$vars[2], $vars[3], $vars[4]]; } protected function parseDate($text) diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index af4b09a1..e80fde00 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -12,10 +12,10 @@ namespace Gitonomy\Git\Reference; -use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Exception\ProcessException; -use Gitonomy\Git\Reference; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Parser\TagParser; +use Gitonomy\Git\Reference; /** * Representation of a tag reference. @@ -41,7 +41,7 @@ public function getName() public function isAnnotated() { try { - $this->repository->run('cat-file', array('tag', $this->revision)); + $this->repository->run('cat-file', ['tag', $this->revision]); } catch (ProcessException $e) { return false; // Is not an annotated tag } @@ -122,12 +122,13 @@ public function getGPGSignature() /** * Check whether tag is signed. * - * @return boolean + * @return bool */ public function isSigned() { try { $this->getGPGSignature(); + return true; } catch (\InvalidArgumentException $e) { return false; @@ -165,7 +166,7 @@ private function getData($name) } $parser = new TagParser(); - $result = $this->repository->run('cat-file', array('tag', $this->revision)); + $result = $this->repository->run('cat-file', ['tag', $this->revision]); $parser->parse($result); From 9029121f05bbf8ce7661cd5331be2adec36887df Mon Sep 17 00:00:00 2001 From: "Chun-Sheng, Li" Date: Fri, 25 Oct 2019 01:10:41 +0800 Subject: [PATCH 006/150] Test enhancement (#146) --- phpunit.xml.dist | 2 +- tests/Gitonomy/Git/Tests/AdminTest.php | 34 +++++++++---------- tests/Gitonomy/Git/Tests/CommitTest.php | 15 ++++---- tests/Gitonomy/Git/Tests/DiffTest.php | 10 +++--- tests/Gitonomy/Git/Tests/HooksTest.php | 6 ++-- tests/Gitonomy/Git/Tests/LogTest.php | 6 ++-- .../Gitonomy/Git/Tests/PushReferenceTest.php | 6 ++-- tests/Gitonomy/Git/Tests/ReferenceTest.php | 24 ++++++------- tests/Gitonomy/Git/Tests/RepositoryTest.php | 2 +- tests/Gitonomy/Git/Tests/RevisionTest.php | 6 ++-- tests/Gitonomy/Git/Tests/TreeTest.php | 6 ++-- tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 2 +- 12 files changed, 60 insertions(+), 59 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 36964385..4d3e2b95 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -14,7 +14,7 @@ - tests/Gitonomy/Git/Tests + tests/Gitonomy/Git/Tests diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index d000dd44..cf7937f8 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -20,12 +20,12 @@ class AdminTest extends AbstractTest { private $tmpDir; - public function setUp() + protected function setUp() { $this->tmpDir = self::createTempDir(); } - public function tearDown() + protected function tearDown() { $this->deleteDir(self::createTempDir()); } @@ -37,8 +37,8 @@ public function testBare() $objectDir = $this->tmpDir.'/objects'; $this->assertTrue($repository->isBare(), 'Repository is bare'); - $this->assertTrue(is_dir($objectDir), 'objects/ folder is present'); - $this->assertTrue($repository instanceof Repository, 'Admin::init returns a repository'); + $this->assertDirectoryExists($objectDir, 'objects/ folder is present'); + $this->assertInstanceOf(Repository::class, $repository, 'Admin::init returns a repository'); $this->assertEquals($this->tmpDir, $repository->getGitDir(), 'The folder passed as argument is git dir'); $this->assertNull($repository->getWorkingDir(), 'No working dir in bare repository'); } @@ -50,8 +50,8 @@ public function testNotBare() $objectDir = $this->tmpDir.'/.git/objects'; $this->assertFalse($repository->isBare(), 'Repository is not bare'); - $this->assertTrue(is_dir($objectDir), 'objects/ folder is present'); - $this->assertTrue($repository instanceof Repository, 'Admin::init returns a repository'); + $this->assertDirectoryExists($objectDir, 'objects/ folder is present'); + $this->assertInstanceOf(Repository::class, $repository, 'Admin::init returns a repository'); $this->assertEquals($this->tmpDir.'/.git', $repository->getGitDir(), 'git dir as subfolder of argument'); $this->assertEquals($this->tmpDir, $repository->getWorkingDir(), 'working dir present in bare repository'); } @@ -67,12 +67,12 @@ public function testClone($repository) $newRefs = array_keys($new->getReferences()->getAll()); - $this->assertTrue(in_array('refs/heads/master', $newRefs)); - $this->assertTrue(in_array('refs/tags/0.1', $newRefs)); + $this->assertContains('refs/heads/master', $newRefs); + $this->assertContains('refs/tags/0.1', $newRefs); if ($repository->isBare()) { $this->assertEquals($newDir, $new->getGitDir()); - $this->assertTrue(in_array('refs/heads/new-feature', $newRefs)); + $this->assertContains('refs/heads/new-feature', $newRefs); } else { $this->assertEquals($newDir.'/.git', $new->getGitDir()); $this->assertEquals($newDir, $new->getWorkingDir()); @@ -90,7 +90,7 @@ public function testCloneBranchBare() self::registerDeletion($new); $head = $new->getHead(); - $this->assertTrue($head instanceof Branch, 'HEAD is a branch'); + $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); } @@ -105,7 +105,7 @@ public function testCloneBranchNotBare() self::registerDeletion($new); $head = $new->getHead(); - $this->assertTrue($head instanceof Branch, 'HEAD is a branch'); + $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); } @@ -120,14 +120,14 @@ public function testMirror($repository) $newRefs = array_keys($new->getReferences()->getAll()); - $this->assertTrue(in_array('refs/heads/master', $newRefs)); - $this->assertTrue(in_array('refs/tags/0.1', $newRefs)); + $this->assertContains('refs/heads/master', $newRefs); + $this->assertContains('refs/tags/0.1', $newRefs); $this->assertEquals($newDir, $new->getGitDir()); if ($repository->isBare()) { - $this->assertTrue(in_array('refs/heads/new-feature', $newRefs)); + $this->assertContains('refs/heads/new-feature', $newRefs); } else { - $this->assertTrue(in_array('refs/remotes/origin/new-feature', $newRefs)); + $this->assertContains('refs/remotes/origin/new-feature', $newRefs); } } @@ -169,8 +169,8 @@ public function testCloneRepository() $newRefs = array_keys($new->getReferences()->getAll()); - $this->assertTrue(in_array('refs/heads/master', $newRefs)); - $this->assertTrue(in_array('refs/tags/0.1', $newRefs)); + $this->assertContains('refs/heads/master', $newRefs); + $this->assertContains('refs/tags/0.1', $newRefs); $this->assertEquals($newDir.'/.git', $new->getGitDir()); $this->assertEquals($newDir, $new->getWorkingDir()); diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index d33b478e..8d187c67 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Commit; +use Gitonomy\Git\Tree; use Gitonomy\Git\Diff\Diff; class CommitTest extends AbstractTest @@ -26,7 +27,7 @@ public function testGetDiff($repository) $diff = $commit->getDiff(); - $this->assertTrue($diff instanceof Diff, 'getDiff() returns a Diff object'); + $this->assertInstanceOf(Diff::class, $diff, 'getDiff() returns a Diff object'); } /** @@ -47,7 +48,7 @@ public function testGetHash($repository) */ public function testInvalideHashThrowException($repository) { - $commit = new Commit($repository, 'that-hash-doest-not-exists'); + new Commit($repository, 'that-hash-doest-not-exists'); } /** @@ -67,7 +68,7 @@ public function testGetParentHashes_WithNoParent($repository) { $commit = $repository->getCommit(self::INITIAL_COMMIT); - $this->assertEquals(0, count($commit->getParentHashes()), 'No parent on initial commit'); + $this->assertCount(0, $commit->getParentHashes(), 'No parent on initial commit'); } /** @@ -78,7 +79,7 @@ public function testGetParentHashes_WithOneParent($repository) $commit = $repository->getCommit(self::LONGFILE_COMMIT); $parents = $commit->getParentHashes(); - $this->assertEquals(1, count($parents), 'One parent found'); + $this->assertCount(1, $parents, 'One parent found'); $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $parents[0], 'Parent hash is correct'); } @@ -90,8 +91,8 @@ public function testGetParents_WithOneParent($repository) $commit = $repository->getCommit(self::LONGFILE_COMMIT); $parents = $commit->getParents(); - $this->assertEquals(1, count($parents), 'One parent found'); - $this->assertTrue($parents[0] instanceof Commit, 'First parent is a Commit object'); + $this->assertCount(1, $parents, 'One parent found'); + $this->assertInstanceOf(Commit::class, $parents[0], 'First parent is a Commit object'); $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $parents[0]->getHash(), "First parents's hash is correct"); } @@ -112,7 +113,7 @@ public function testGetTree($repository) { $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertInstanceOf('Gitonomy\Git\Tree', $commit->getTree(), 'Tree is a tree'); + $this->assertInstanceOf(Tree::class, $commit->getTree(), 'Tree is a tree'); $this->assertEquals('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTree()->getHash(), 'Tree hash is correct'); } diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index d260c5ed..ec0eebe7 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -45,7 +45,7 @@ protected function verifyCreateCommitDiff(Diff $diff) { $files = $diff->getFiles(); - $this->assertEquals(2, count($files), '1 file in diff'); + $this->assertCount(2, $files, '1 file in diff'); $this->assertTrue($files[0]->isCreation(), 'script_A.php created'); @@ -65,7 +65,7 @@ public function testGetFiles_Modification($repository) { $files = $repository->getCommit(self::BEFORE_LONGFILE_COMMIT)->getDiff()->getFiles(); - $this->assertEquals(1, count($files), '1 files in diff'); + $this->assertCount(1, $files, '1 files in diff'); $this->assertTrue($files[0]->isModification(), 'image.jpg modified'); @@ -86,7 +86,7 @@ public function testGetFiles_Deletion($repository) { $files = $repository->getCommit(self::DELETE_COMMIT)->getDiff()->getFiles(); - $this->assertEquals(1, count($files), '1 files modified'); + $this->assertCount(1, $files, '1 files modified'); $this->assertTrue($files[0]->isDeletion(), 'File deletion'); $this->assertEquals('script_B.php', $files[0]->getOldName(), 'verify old filename'); @@ -100,7 +100,7 @@ public function testGetFiles_Rename($repository) { $files = $repository->getCommit(self::RENAME_COMMIT)->getDiff()->getFiles(); - $this->assertEquals(1, count($files), '1 files modified'); + $this->assertCount(1, $files, '1 files modified'); $this->assertTrue($files[0]->isModification()); $this->assertTrue($files[0]->isRename()); @@ -116,7 +116,7 @@ public function testGetFiles_Changemode($repository) { $files = $repository->getCommit(self::CHANGEMODE_COMMIT)->getDiff()->getFiles(); - $this->assertEquals(1, count($files), '1 files modified'); + $this->assertCount(1, $files, '1 files modified'); $this->assertTrue($files[0]->isModification()); $this->assertTrue($files[0]->isChangeMode()); diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 86cb7606..f08901e9 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -49,7 +49,7 @@ public function assertHasHook($repository, $hook) $file = $this->hookPath($repository, $hook); $this->assertTrue($repository->getHooks()->has($hook), "hook $hook in repository"); - $this->assertTrue(file_exists($file), "Hook $hook is present"); + $this->assertFileExists($file, "Hook $hook is present"); } public function assertNoHook($repository, $hook) @@ -57,7 +57,7 @@ public function assertNoHook($repository, $hook) $file = $this->hookPath($repository, $hook); $this->assertFalse($repository->getHooks()->has($hook), "No hook $hook in repository"); - $this->assertFalse(file_exists($file), "Hook $hook is not present"); + $this->assertFileNotExists($file, "Hook $hook is not present"); } /** @@ -154,7 +154,7 @@ public function testRemove($repository) touch($file); $repository->getHooks()->remove('foo'); - $this->assertFalse(file_exists($file)); + $this->assertFileNotExists($file); } /** diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 2f123f9e..cdf15630 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -22,8 +22,8 @@ public function testRevisionAndPath($repository) $logReadme = $repository->getLog(self::LONGFILE_COMMIT, 'README'); $logImage = $repository->getLog(self::LONGFILE_COMMIT, 'image.jpg'); - $this->assertEquals(3, count($logReadme)); - $this->assertEquals(2, count($logImage)); + $this->assertCount(3, $logReadme); + $this->assertCount(2, $logImage); } /** @@ -35,7 +35,7 @@ public function testGetCommits($repository) $commits = $log->getCommits(); - $this->assertEquals(3, count($commits), '3 commits in log'); + $this->assertCount(3, $commits, '3 commits in log'); $this->assertEquals(self::LONGFILE_COMMIT, $commits[0]->getHash(), 'First is requested one'); $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commits[1]->getHash(), "Second is longfile parent\'s"); } diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index 65969d9e..4490a3f9 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -52,7 +52,7 @@ public function testLog($repository) $ref = new PushReference($repository, 'foo', self::INITIAL_COMMIT, self::LONGFILE_COMMIT); $log = $ref->getLog()->getCommits(); - $this->assertEquals(7, count($log), '7 commits in log'); + $this->assertCount(7, $log, '7 commits in log'); $this->assertEquals('add a long file', $log[0]->getShortMessage(), 'First commit is correct'); } @@ -65,7 +65,7 @@ public function testSignedLog($repository) { $ref = new PushReference($repository, 'foo', self::INITIAL_COMMIT, self::SIGNED_COMMIT); $log = $ref->getLog()->getCommits(); - $this->assertEquals(16, count($log), '16 commits in log'); + $this->assertCount(16, $log, '16 commits in log'); $this->assertEquals('signed commit', $log[0]->getShortMessage(), 'Last commit is correct'); } @@ -77,7 +77,7 @@ public function testLogWithExclude($repository) $ref = new PushReference($repository, 'foo', PushReference::ZERO, self::LONGFILE_COMMIT); $log = $ref->getLog([self::INITIAL_COMMIT])->getCommits(); - $this->assertEquals(7, count($log), '7 commits in log'); + $this->assertCount(7, $log, '7 commits in log'); $this->assertEquals('add a long file', $log[0]->getShortMessage(), 'First commit is correct'); } } diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 45b626e6..b1ebcfa9 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -35,7 +35,7 @@ public function testGetBranch($repository) { $branch = $repository->getReferences()->getBranch('master'); - $this->assertTrue($branch instanceof Branch, 'Branch object is correct type'); + $this->assertInstanceOf(Branch::class, $branch, 'Branch object is correct type'); $this->assertEquals($branch->getCommitHash(), $branch->getCommit()->getHash(), 'Hash is correctly resolved'); } @@ -63,7 +63,7 @@ public function testHasTag($repository) */ public function testGetBranch_NotExisting_Error($repository) { - $branch = $repository->getReferences()->getBranch('notexisting'); + $repository->getReferences()->getBranch('notexisting'); } /** @@ -73,7 +73,7 @@ public function testGetTag($repository) { $tag = $repository->getReferences()->getTag('0.1'); - $this->assertTrue($tag instanceof Tag, 'Tag object is correct type'); + $this->assertInstanceOf(Tag::class, $tag, 'Tag object is correct type'); $this->assertFalse($tag->isAnnotated(), 'Tag is not annotated'); $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommitHash(), 'Commit hash is correct'); @@ -87,7 +87,7 @@ public function testAnnotatedTag($repository) { $tag = $repository->getReferences()->getTag('annotated'); - $this->assertTrue($tag instanceof Tag, 'Tag object is correct type'); + $this->assertInstanceOf(Tag::class, $tag, 'Tag object is correct type'); $this->assertTrue($tag->isAnnotated(), 'Tag is annotated'); $this->assertFalse($tag->isSigned(), 'Tag is not signed'); @@ -105,7 +105,7 @@ public function testAnnotatedTag($repository) */ public function testGetTag_NotExisting_Error($repository) { - $branch = $repository->getReferences()->getTag('notexisting'); + $repository->getReferences()->getTag('notexisting'); } /** @@ -116,8 +116,8 @@ public function testResolve($repository) $commit = $repository->getReferences()->getTag('0.1')->getCommit(); $resolved = $repository->getReferences()->resolve($commit->getHash()); - $this->assertEquals(1, count($resolved), '1 revision resolved'); - $this->assertTrue(reset($resolved) instanceof Tag, 'Resolved object is a tag'); + $this->assertCount(1, $resolved, '1 revision resolved'); + $this->assertInstanceOf(Tag::class, reset($resolved), 'Resolved object is a tag'); } /** @@ -128,8 +128,8 @@ public function testResolveTags($repository) $commit = $repository->getReferences()->getTag('0.1')->getCommit(); $resolved = $repository->getReferences()->resolveTags($commit->getHash()); - $this->assertEquals(1, count($resolved), '1 revision resolved'); - $this->assertTrue(reset($resolved) instanceof Tag, 'Resolved object is a tag'); + $this->assertCount(1, $resolved, '1 revision resolved'); + $this->assertInstanceOf(Tag::class, reset($resolved), 'Resolved object is a tag'); } /** @@ -142,12 +142,12 @@ public function testResolveBranches($repository) $resolved = $repository->getReferences()->resolveBranches($master->getCommitHash()); if ($repository->isBare()) { - $this->assertEquals(1, count($resolved), '1 revision resolved'); + $this->assertCount(1, $resolved, '1 revision resolved'); } else { - $this->assertEquals(2, count($resolved), '2 revision resolved'); + $this->assertCount(2, $resolved, '2 revision resolved'); } - $this->assertTrue(reset($resolved) instanceof Branch, 'Resolved object is a branch'); + $this->assertInstanceOf(Branch::class, reset($resolved), 'Resolved object is a branch'); } /** diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index d00a102e..d86dc84e 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -24,7 +24,7 @@ public function testGetBlob_WithExisting_Works($repository) { $blob = $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('README.md'); - $this->assertTrue($blob instanceof Blob, 'getBlob() returns a Blob object'); + $this->assertInstanceOf(Blob::class, $blob, 'getBlob() returns a Blob object'); $this->assertContains('Foo Bar project', $blob->getContent(), 'file is correct'); } diff --git a/tests/Gitonomy/Git/Tests/RevisionTest.php b/tests/Gitonomy/Git/Tests/RevisionTest.php index 2f8428df..1ffb94ad 100644 --- a/tests/Gitonomy/Git/Tests/RevisionTest.php +++ b/tests/Gitonomy/Git/Tests/RevisionTest.php @@ -25,11 +25,11 @@ public function testGetCommit($repository) { $revision = $repository->getRevision(self::LONGFILE_COMMIT.'^'); - $this->assertTrue($revision instanceof Revision, 'Revision object type'); + $this->assertInstanceOf(Revision::class, $revision, 'Revision object type'); $commit = $revision->getCommit(); - $this->assertTrue($commit instanceof Commit, 'getCommit returns a Commit'); + $this->assertInstanceOf(Commit::class, $commit, 'getCommit returns a Commit'); $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commit->getHash(), 'Resolution is correct'); } @@ -53,7 +53,7 @@ public function testGetLog($repository) $log = $revision->getLog(null, 2, 3); - $this->assertTrue($log instanceof Log, 'Log type object'); + $this->assertInstanceOf(Log::class, $log, 'Log type object'); $this->assertEquals(2, $log->getOffset(), 'Log offset is passed'); $this->assertEquals(3, $log->getLimit(), 'Log limit is passed'); $this->assertEquals([$revision], $log->getRevisions()->getAll(), 'Revision is passed'); diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index e6cbc025..4c0476de 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -27,10 +27,10 @@ public function testCase($repository) $entries = $tree->getEntries(); - $this->assertTrue(isset($entries['long.php']), 'long.php is present'); + $this->assertNotEmpty($entries['long.php'], 'long.php is present'); $this->assertTrue($entries['long.php'][1] instanceof Blob, 'long.php is a Blob'); - $this->assertTrue(isset($entries['README.md']), 'README.md is present'); + $this->assertNotEmpty($entries['README.md'], 'README.md is present'); $this->assertTrue($entries['README.md'][1] instanceof Blob, 'README.md is a Blob'); } @@ -45,6 +45,6 @@ public function testResolvePath($repository) $resolved = $tree->resolvePath($path); $entries = $resolved->getEntries(); - $this->assertTrue(isset($entries['d']), 'Successfully resolved source folder'); + $this->assertNotEmpty($entries['d'], 'Successfully resolved source folder'); } } diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index a26002f5..891ac7f3 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -35,7 +35,7 @@ public function testCheckout() $wc->checkout('origin/new-feature', 'new-feature'); $head = $repository->getHead(); - $this->assertTrue($head instanceof Branch, 'HEAD is a branch'); + $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); } From 827384ff3b9739f06c74296c194887727016c5ee Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 24 Oct 2019 18:11:14 +0100 Subject: [PATCH 007/150] Apply fixes from StyleCI (#147) --- tests/Gitonomy/Git/Tests/CommitTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 8d187c67..bd2e9189 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -13,8 +13,8 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Commit; -use Gitonomy\Git\Tree; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Tree; class CommitTest extends AbstractTest { From 4b9314e1f19647b04de5f241b5924668be5e4692 Mon Sep 17 00:00:00 2001 From: "Chun-Sheng, Li" Date: Sat, 26 Oct 2019 01:38:33 +0800 Subject: [PATCH 008/150] Enhance exception tests (#148) --- tests/Gitonomy/Git/Tests/AdminTest.php | 6 +++--- tests/Gitonomy/Git/Tests/BlobTest.php | 5 ++++- tests/Gitonomy/Git/Tests/CommitTest.php | 11 +++++++---- tests/Gitonomy/Git/Tests/HooksTest.php | 14 ++++++++++---- tests/Gitonomy/Git/Tests/ReferenceTest.php | 9 +++++---- tests/Gitonomy/Git/Tests/RepositoryTest.php | 4 +++- tests/Gitonomy/Git/Tests/RevisionTest.php | 8 +++++--- tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 12 ++++++------ 8 files changed, 43 insertions(+), 26 deletions(-) diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index cf7937f8..7234bdff 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Admin; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; @@ -148,11 +149,10 @@ public function testCheckInvalidRepository() $this->assertFalse(Admin::isValidRepository($url)); } - /** - * @expectedException RuntimeException - */ public function testExistingFile() { + $this->expectException(RuntimeException::class); + $file = $this->tmpDir.'/test'; touch($file); diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 8dbe2719..36b1d58d 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -12,6 +12,8 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\RuntimeException; + class BlobTest extends AbstractTest { const README_FRAGMENT = 'Foo Bar project'; @@ -33,10 +35,11 @@ public function testGetContent($repository) /** * @dataProvider provideFoobar - * @expectedException RuntimeException */ public function testNotExisting($repository) { + $this->expectException(RuntimeException::class); + $blob = $repository->getBlob('foobar'); $blob->getContent(); } diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index bd2e9189..bb4b275b 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -14,6 +14,8 @@ use Gitonomy\Git\Commit; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Tree; class CommitTest extends AbstractTest @@ -42,12 +44,12 @@ public function testGetHash($repository) /** * @dataProvider provideFoobar - * - * @expectedException Gitonomy\Git\Exception\ReferenceNotFoundException - * @expectedExceptionMessage Reference not found: "that-hash-doest-not-exists" */ public function testInvalideHashThrowException($repository) { + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('Reference not found: "that-hash-doest-not-exists"'); + new Commit($repository, 'that-hash-doest-not-exists'); } @@ -241,11 +243,12 @@ public function testGetBodyMessage($repository) } /** - * @expectedException InvalidArgumentException * @dataProvider provideFoobar */ public function testGetIncludingBranchesException($repository) { + $this->expectException(InvalidArgumentException::class); + $commit = $repository->getCommit(self::INITIAL_COMMIT); $commit->getIncludingBranches(false, false); diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index f08901e9..e3478c77 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; + class HooksTest extends AbstractTest { private static $symlinkOnWindows = null; @@ -72,10 +75,11 @@ public function testHas($repository) /** * @dataProvider provideFoobar - * @expectedException InvalidArgumentException */ public function testGet_InvalidName_ThrowsException($repository) { + $this->expectException(InvalidArgumentException::class); + $repository->getHooks()->get('foo'); } @@ -105,10 +109,11 @@ public function testSymlink($repository) /** * @dataProvider provideFoobar - * @expectedException LogicException */ public function testSymlink_WithExisting_ThrowsLogicException($repository) { + $this->expectException(LogicException::class); + $this->markAsSkippedIfSymlinkIsMissing(); $file = $this->hookPath($repository, 'target-symlink'); @@ -141,7 +146,7 @@ public function testSet_Existing_ThrowsLogicException($repository) { $repository->getHooks()->set('foo', 'bar'); - $this->expectException('LogicException'); + $this->expectException(LogicException::class); $repository->getHooks()->set('foo', 'bar'); } @@ -159,10 +164,11 @@ public function testRemove($repository) /** * @dataProvider provideFoobar - * @expectedException LogicException */ public function testRemove_NotExisting_ThrowsLogicException($repository) { + $this->expectException(LogicException::class); + $repository->getHooks()->remove('foo'); } diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index b1ebcfa9..d1c00fc6 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -12,13 +12,12 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Tag; class ReferenceTest extends AbstractTest { - private $references; - /** * @dataProvider provideEmpty */ @@ -59,10 +58,11 @@ public function testHasTag($repository) /** * @dataProvider provideFoobar - * @expectedException Gitonomy\Git\Exception\ReferenceNotFoundException */ public function testGetBranch_NotExisting_Error($repository) { + $this->expectException(ReferenceNotFoundException::class); + $repository->getReferences()->getBranch('notexisting'); } @@ -101,10 +101,11 @@ public function testAnnotatedTag($repository) /** * @dataProvider provideFoobar - * @expectedException Gitonomy\Git\Exception\ReferenceNotFoundException */ public function testGetTag_NotExisting_Error($repository) { + $this->expectException(ReferenceNotFoundException::class); + $repository->getReferences()->getTag('notexisting'); } diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index d86dc84e..6eee0e60 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Blob; +use Gitonomy\Git\Exception\RuntimeException; use Prophecy\Argument; class RepositoryTest extends AbstractTest @@ -78,7 +79,6 @@ public function testLoggerOk($repository) /** * @dataProvider provideFoobar - * @expectedException RuntimeException */ public function testLoggerNOk($repository) { @@ -86,6 +86,8 @@ public function testLoggerNOk($repository) $this->markTestSkipped(); } + $this->expectException(RuntimeException::class); + $loggerProphecy = $this->prophesize('Psr\Log\LoggerInterface'); $loggerProphecy ->info(Argument::type('string')) diff --git a/tests/Gitonomy/Git/Tests/RevisionTest.php b/tests/Gitonomy/Git/Tests/RevisionTest.php index 1ffb94ad..84711843 100644 --- a/tests/Gitonomy/Git/Tests/RevisionTest.php +++ b/tests/Gitonomy/Git/Tests/RevisionTest.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Commit; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Log; use Gitonomy\Git\Revision; @@ -36,12 +37,13 @@ public function testGetCommit($repository) /** * @dataProvider provideFoobar - * @expectedException Gitonomy\Git\Exception\ReferenceNotFoundException - * @expectedExceptionMessage Can not find revision "non-existent-commit" */ public function testGetFailingReference($repository) { - $revision = $repository->getRevision('non-existent-commit')->getCommit(); + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('Can not find revision "non-existent-commit"'); + + $repository->getRevision('non-existent-commit')->getCommit(); } /** diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 891ac7f3..dfa048f9 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -13,15 +13,16 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Admin; +use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; class WorkingCopyTest extends AbstractTest { - /** - * @expectedException LogicException - */ public function testNoWorkingCopyInBare() { + $this->expectException(LogicException::class); + $path = self::createTempDir(); $repo = Admin::init($path, true, self::getOptions()); @@ -70,11 +71,10 @@ public function testDiffPending() $this->assertCount(1, $diffPending->getFiles()); } - /** - * @expectedException RuntimeException - */ public function testCheckoutUnexisting() { + $this->expectException(RuntimeException::class); + self::createFoobarRepository(false)->getWorkingCopy()->checkout('foobar'); } From 5d154e0f944e6d0ef4fefe7777b52118057c071a Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 25 Oct 2019 19:29:26 +0100 Subject: [PATCH 009/150] Allow PHPUnit 7 (#149) --- composer.json | 2 +- phpunit.xml.dist | 37 ++++++++++++++++++++++--------------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/composer.json b/composer.json index 32165d10..c5288093 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "symfony/process": "^3.4|^4.0" }, "require-dev": { - "phpunit/phpunit": "^5.7|^6.5", + "phpunit/phpunit": "^5.7|^6.5|^7.0", "psr/log": "^1.0" }, "suggest": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4d3e2b95..333343d5 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,21 +1,28 @@ - - - + - tests/Gitonomy/Git/Tests + ./tests/Gitonomy/Git/Tests - + + + ./src/Gitonomy/Git + + From 47c3813369c56a3438eee34b1815bd3f6493b59b Mon Sep 17 00:00:00 2001 From: Blaine Bublitz Date: Mon, 4 Nov 2019 15:54:42 -0700 Subject: [PATCH 010/150] Add enterprise language (#150) --- README.md | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 58fa583d..afd88c11 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,26 @@ Anyway, it's convenient and don't need to build anything to use it. That's how w *Documentation*: http://gitonomy.com/doc/gitlib/master/ ---- - -
- - Get professional support for Git lib with a Tidelift subscription - -
- - Tidelift helps make open source sustainable for maintainers while giving companies
assurances about security, maintenance, and licensing for their dependencies. -
-
+## Quick Start + +You can install git lib using [Composer](https://getcomposer.org/). Simply require the version you need: + +```bash +$ composer require gitonomy/gitlib +``` + +or edit your `composer.json` file by hand: + +```json +{ + "require": { + "gitonomy/gitlib": "^1.1" + } +} +``` + +## For Enterprise + +Available as part of the Tidelift Subscription + +The maintainers of `gitonomy/gitlib` and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/packagist-gitonomy-gitlib?utm_source=packagist-gitonomy-gitlib&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) From 53507845cf0f2d28eb08fd717606693ce8217117 Mon Sep 17 00:00:00 2001 From: SamLast Date: Tue, 3 Dec 2019 12:48:49 +0100 Subject: [PATCH 011/150] Allow Symfony Process 5.0 (#152) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c5288093..534456e8 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ }, "require": { "php": "^5.6 || ^7.0", - "symfony/process": "^3.4|^4.0" + "symfony/process": "^3.4|^4.0|^5.0" }, "require-dev": { "phpunit/phpunit": "^5.7|^6.5|^7.0", From 8b45eb378ea9e4bd05eea5b42f4504833635dd3c Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Tue, 3 Dec 2019 12:04:43 +0000 Subject: [PATCH 012/150] Test on PHP 7.4 and all Symfony vesions (#155) --- .travis.yml | 51 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index 39cdc5c2..93aa0fff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,48 @@ language: php -dist: xenial - -php: - - 5.6 - - 7.0 - - 7.1 - - 7.2 - - 7.3 +matrix: + include: + - php: 5.6 + dist: xenial + env: SYMFONY_VERSION=^3.4 + - php: 7.0 + dist: xenial + env: SYMFONY_VERSION=^3.4 + - php: 7.1 + dist: bionic + env: SYMFONY_VERSION=^3.4 + - php: 7.1 + dist: bionic + env: SYMFONY_VERSION=^4.0 + - php: 7.2 + dist: bionic + env: SYMFONY_VERSION=^3.4 + - php: 7.2 + dist: bionic + env: SYMFONY_VERSION=^4.0 + - php: 7.2 + dist: bionic + env: SYMFONY_VERSION=^5.0 + - php: 7.3 + dist: bionic + env: SYMFONY_VERSION=^3.4 + - php: 7.3 + dist: bionic + env: SYMFONY_VERSION=^4.0 + - php: 7.3 + dist: bionic + env: SYMFONY_VERSION=^5.0 + - php: 7.4 + dist: bionic + env: SYMFONY_VERSION=^3.4 + - php: 7.4 + dist: bionic + env: SYMFONY_VERSION=^4.0 + - php: 7.4 + dist: bionic + env: SYMFONY_VERSION=^5.0 + +before_install: composer require "symfony/process:${SYMFONY_VERSION}" --no-update install: travis_retry composer install --no-interaction --no-progress --no-suggest From a0bea921266ad1c9626d712e7f8687dcc08ca528 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Sun, 8 Dec 2019 12:42:25 +0000 Subject: [PATCH 013/150] Bumped branch alias --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 534456e8..d2d0d63f 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.1-dev" + "dev-master": "1.2-dev" } } } From 4849e9fcbc03012ee564f13eb66facbf839ca529 Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Fri, 13 Dec 2019 05:17:18 -0500 Subject: [PATCH 014/150] Update documentation (#157) --- README.md | 32 +++++-- composer.json | 1 - doc/api/admin.md | 77 +++++++++++++++ doc/api/admin.rst | 76 --------------- doc/api/blame.md | 58 ++++++++++++ doc/api/blame.rst | 54 ----------- doc/api/{blob.rst => blob.md} | 42 ++++----- doc/api/branch.md | 16 ++++ doc/api/branch.rst | 16 ---- doc/api/commit.md | 171 ++++++++++++++++++++++++++++++++++ doc/api/commit.rst | 168 --------------------------------- doc/api/diff.md | 104 +++++++++++++++++++++ doc/api/diff.rst | 102 -------------------- doc/api/hooks.md | 80 ++++++++++++++++ doc/api/hooks.rst | 76 --------------- doc/api/log.md | 55 +++++++++++ doc/api/log.rst | 54 ----------- doc/api/references.md | 92 ++++++++++++++++++ doc/api/references.rst | 91 ------------------ doc/api/repository.md | 147 +++++++++++++++++++++++++++++ doc/api/repository.rst | 135 --------------------------- doc/api/revision.md | 28 ++++++ doc/api/revision.rst | 28 ------ doc/api/tree.md | 54 +++++++++++ doc/api/tree.rst | 54 ----------- doc/api/workingcopy.md | 47 ++++++++++ doc/api/workingcopy.rst | 45 --------- doc/debug.md | 24 +++++ doc/debug.rst | 22 ----- doc/development.md | 18 ++++ doc/development.rst | 24 ----- doc/index.md | 46 +++++++++ doc/index.rst | 59 ------------ doc/installation.md | 27 ++++++ doc/installation.rst | 20 ---- 35 files changed, 1091 insertions(+), 1052 deletions(-) create mode 100644 doc/api/admin.md delete mode 100644 doc/api/admin.rst create mode 100644 doc/api/blame.md delete mode 100644 doc/api/blame.rst rename doc/api/{blob.rst => blob.md} (51%) create mode 100644 doc/api/branch.md delete mode 100644 doc/api/branch.rst create mode 100644 doc/api/commit.md delete mode 100644 doc/api/commit.rst create mode 100644 doc/api/diff.md delete mode 100644 doc/api/diff.rst create mode 100644 doc/api/hooks.md delete mode 100644 doc/api/hooks.rst create mode 100644 doc/api/log.md delete mode 100644 doc/api/log.rst create mode 100644 doc/api/references.md delete mode 100644 doc/api/references.rst create mode 100644 doc/api/repository.md delete mode 100644 doc/api/repository.rst create mode 100644 doc/api/revision.md delete mode 100644 doc/api/revision.rst create mode 100644 doc/api/tree.md delete mode 100644 doc/api/tree.rst create mode 100644 doc/api/workingcopy.md delete mode 100644 doc/api/workingcopy.rst create mode 100644 doc/debug.md delete mode 100644 doc/debug.rst create mode 100644 doc/development.md delete mode 100644 doc/development.rst create mode 100644 doc/index.md delete mode 100644 doc/index.rst create mode 100644 doc/installation.md delete mode 100644 doc/installation.rst diff --git a/README.md b/README.md index afd88c11..4f38e207 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -Git lib for Gitonomy -==================== +Gitlib for Gitonomy +=================== [![Build Status](https://img.shields.io/travis/gitonomy/gitlib/master.svg?style=flat-square)](https://travis-ci.org/gitonomy/gitlib) [![StyleCI](https://github.styleci.io/repos/5709354/shield?branch=master)](https://github.styleci.io/repos/5709354) -Software License +[![License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://opensource.org/licenses/MIT) This library provides methods to access Git repository from PHP. @@ -11,11 +11,31 @@ It makes shell calls, which makes it less performant than any solution. Anyway, it's convenient and don't need to build anything to use it. That's how we love it. -*Documentation*: http://gitonomy.com/doc/gitlib/master/ +## Documentation + +* [Overview](doc/index.md) +* [Debug](doc/debug.md) +* [Development](doc/development.md) +* [Installation](doc/installation.md) +* API + + [Admin](doc/api/admin.md) + + [Blame](doc/api/blame.md) + + [Blob](doc/api/blob.md) + + [Branch](doc/api/branch.md) + + [Commit](doc/api/commit.md) + + [Diff](doc/api/diff.md) + + [Hooks](doc/api/hooks.md) + + [Log](doc/api/log.md) + + [References](doc/api/references.md) + + [Repository](doc/api/repository.md) + + [Revision](doc/api/revision.md) + + [Tree](doc/api/tree.md) + + [Working Copy](doc/api/workingcopy.md) ## Quick Start -You can install git lib using [Composer](https://getcomposer.org/). Simply require the version you need: +You can install gitlib using [Composer](https://getcomposer.org/). Simply +require the version you need: ```bash $ composer require gitonomy/gitlib @@ -26,7 +46,7 @@ or edit your `composer.json` file by hand: ```json { "require": { - "gitonomy/gitlib": "^1.1" + "gitonomy/gitlib": "^1.2" } } ``` diff --git a/composer.json b/composer.json index d2d0d63f..aa6c2f13 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,6 @@ "email": "alexandre.salome@gmail.com" } ], - "homepage": "http://gitonomy.com", "autoload": { "psr-4": { "Gitonomy\\Git\\": "src/Gitonomy/Git/" diff --git a/doc/api/admin.md b/doc/api/admin.md new file mode 100644 index 00000000..ee2596ec --- /dev/null +++ b/doc/api/admin.md @@ -0,0 +1,77 @@ +Create and access git repositories +================================== + +gitlib provides methods to initialize new repositories. + +Create a repository +------------------- + +To initialize a new repository, use method `Admin::init`. + +```php +// Initialize a bare repository +$repository = Gitonomy\Git\Admin::init('/path/to/repository'); + +// Initialize a non-bare repository +$repository = Gitonomy\Git\Admin::init('/path/to/repository', false); +``` + +Default behavior is to create a bare repository. If you want to +initialize a repository with a working copy,pass `false` as third +argument of Repository constructor. + +Cloning repositories +-------------------- + +You can clone a repository from an URL by doing: + +```php +// Clone to a bare repository +$repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git'); + +// Clone to a non-bare repository +$repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', false); +``` + +Default behavior is to clone in a bare repository. + +You can also clone a repository and point it to a specific branch. In a +non-bare repository, this branch will be checked out: + +```php +// Clone to a bare repository +$repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch'); + +// Clone to a non-bare repository +$repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch' false); +``` + +Clone a Repository object +------------------------- + +If you already have a Repository instance and want to clone it, you can +use this shortcut: + +```php +$new = $repository->cloneTo('/tmp/clone'); +``` + +Mirror a repository +------------------- + +If you want to mirror fully a repository and all references, use the +`mirrorTo` method. This method takes only two arguments, where to mirror +and what to mirror: + +```php +// Mirror to a bare repository +$mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git'); + +// Mirror to a non-bare repository +$mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git', false); +``` + +### References + +- +- diff --git a/doc/api/admin.rst b/doc/api/admin.rst deleted file mode 100644 index f1ec8b66..00000000 --- a/doc/api/admin.rst +++ /dev/null @@ -1,76 +0,0 @@ -Create and access git repositories -================================== - -gitlib provides methods to initialize new repositories. - -Create a repository -------------------- - -To initialize a new repository, use method ``Admin::init``. - -.. code-block:: php - - // Initialize a bare repository - $repository = Gitonomy\Git\Admin::init('/path/to/repository'); - - // Initialize a non-bare repository - $repository = Gitonomy\Git\Admin::init('/path/to/repository', false); - -Default behavior is to create a bare repository. If you want to initialize a -repository with a working copy,pass ``false`` as third argument of Repository -constructor. - -Cloning repositories --------------------- - -You can clone a repository from an URL by doing: - -.. code-block:: php - - // Clone to a bare repository - $repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git'); - - // Clone to a non-bare repository - $repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', false); - -Default behavior is to clone in a bare repository. - -You can also clone a repository and point it to a specific branch. In a non-bare repository, this branch will be checked out: - -.. code-block:: php - - // Clone to a bare repository - $repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch'); - - // Clone to a non-bare repository - $repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch' false); - -Clone a Repository object -------------------------- - -If you already have a Repository instance and want to clone it, you can use this shortcut: - -.. code-block:: php - - $new = $repository->cloneTo('/tmp/clone'); - -Mirror a repository -------------------- - -If you want to mirror fully a repository and all references, use the ``mirrorTo`` method. This method -takes only two arguments, where to mirror and what to mirror: - -.. code-block:: php - - // Mirror to a bare repository - $mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git'); - - // Mirror to a non-bare repository - $mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git', false); - - -References -:::::::::: - -* http://linux.die.net/man/1/git-init -* http://linux.die.net/man/1/git-clone diff --git a/doc/api/blame.md b/doc/api/blame.md new file mode 100644 index 00000000..78ab2ac2 --- /dev/null +++ b/doc/api/blame.md @@ -0,0 +1,58 @@ +Blaming files +============= + +Line-per-line iteration +----------------------- + +To iterate on lines of a blame: + +```php +$blame = $repository->getBlame('master', 'README.md'); + +foreach ($blame->getLines() as $lineNumber => $line) { + $commit = $line->getCommit(); + echo $lineNumber.': '.$line->getContent()." - ".$commit->getAuthorName()."\n"; +} +``` + +The *getLines* method returns an array indexed starting from 1. + +As you can see, you can access the commit object related to the line you +are iterating on. + +If you want to access directly a line: + +```php +$line = $blame->getLine(32); +``` + +The Line object +--------------- + +LineObject represents an item of the blame file. It is composed of those +informations: + +```php +$line->getCommit(); // returns a Commit +$line->getContent(); // returns text + +// you can access author from commmit: +$author = $line->getCommit()->getAuthorName(); +``` + +Group reading by commit +----------------------- + +If you plan to display it, you'll probably need a version where lines +from same commit are grouped. + +To do so, use the *getGroupedLines* method that will return an array +like this: + +```php +$blame = array( + array(Commit, array(1 => Line, 2 => Line, 3 => Line)), + array(Commit, array(4 => Line)), + array(Commit, array(5 => Line, 6 => Line)) +) +``` diff --git a/doc/api/blame.rst b/doc/api/blame.rst deleted file mode 100644 index 7fcbdcdc..00000000 --- a/doc/api/blame.rst +++ /dev/null @@ -1,54 +0,0 @@ -Blaming files -============= - -Line-per-line iteration ------------------------ - -To iterate on lines of a blame: - -.. code-block:: php - - $blame = $repository->getBlame('master', 'README.md'); - - foreach ($blame->getLines() as $lineNumber => $line) { - $commit = $line->getCommit(); - echo $lineNumber.': '.$line->getContent()." - ".$commit->getAuthorName()."\n"; - } - -The *getLines* method returns an array indexed starting from 1. - -As you can see, you can access the commit object related to the line you are iterating on. - -If you want to access directly a line: - -.. code-block:: php - - $line = $blame->getLine(32); - -The Line object ---------------- - -LineObject represents an item of the blame file. It is composed of those informations: - -.. code-block:: php - - $line->getCommit(); // returns a Commit - $line->getContent(); // returns text - - // you can access author from commmit: - $author = $line->getCommit()->getAuthorName(); - -Group reading by commit ------------------------ - -If you plan to display it, you'll probably need a version where lines from same commit are grouped. - -To do so, use the *getGroupedLines* method that will return an array like this: - -.. code-block:: php - - $blame = array( - array(Commit, array(1 => Line, 2 => Line, 3 => Line)), - array(Commit, array(4 => Line)), - array(Commit, array(5 => Line, 6 => Line)) - ) diff --git a/doc/api/blob.rst b/doc/api/blob.md similarity index 51% rename from doc/api/blob.rst rename to doc/api/blob.md index 78d36c25..f5e34cc7 100644 --- a/doc/api/blob.rst +++ b/doc/api/blob.md @@ -2,43 +2,43 @@ Blob ==== In git, a blob represents a file content. You can't access the file name -directly from the *Blob* object; the filename information is stored within -the tree, not in the blob. +directly from the *Blob* object; the filename information is stored +within the tree, not in the blob. -It means that for git, two files with different names but same content will -have the same hash. +It means that for git, two files with different names but same content +will have the same hash. To access a repository *Blob*, you need the hash identifier: -.. code-block:: php - - $repository = new Gitonomy\Git\Repository('/path/to/repository'); - $blob = $repository->getBlob('a7c8d2b4'); +```php +$repository = new Gitonomy\Git\Repository('/path/to/repository'); +$blob = $repository->getBlob('a7c8d2b4'); +``` Get content ----------- To get content from a *Blob* object: -.. code-block:: php - - echo $blob->getContent(); +```php +echo $blob->getContent(); +``` File informations ----------------- To get mimetype of a *Blob* object using finfo extension: -.. code-block:: php - - echo $blob->getMimetype(); +```php +echo $blob->getMimetype(); +``` You can also test if *Blob* is a text of a binary file: -.. code-block:: php - - if ($blob->isText()) { - echo $blob->getContent(), "\n"; - } elseif ($blob->isBinary()) { - echo "File is binary\n"; - } +```php +if ($blob->isText()) { + echo $blob->getContent(), "\n"; +} elseif ($blob->isBinary()) { + echo "File is binary\n"; +} +``` diff --git a/doc/api/branch.md b/doc/api/branch.md new file mode 100644 index 00000000..d6e0872c --- /dev/null +++ b/doc/api/branch.md @@ -0,0 +1,16 @@ +Branch +====== + +To access a *Branch*, starting from a repository object: + +```php +$repository = new Gitonomy\Git\Repository('/path/to/repository'); +$branch = $repository->getReferences()->getBranch('master'); +``` + +You can check is the branch is a local or remote one: + +```php +$branch->isLocal(); +$branch->isRemote(); +``` diff --git a/doc/api/branch.rst b/doc/api/branch.rst deleted file mode 100644 index c7c0c408..00000000 --- a/doc/api/branch.rst +++ /dev/null @@ -1,16 +0,0 @@ -Branch -====== - -To access a *Branch*, starting from a repository object: - -.. code-block:: php - - $repository = new Gitonomy\Git\Repository('/path/to/repository'); - $branch = $repository->getReferences()->getBranch('master'); - -You can check is the branch is a local or remote one: - -.. code-block:: php - - $branch->isLocal(); - $branch->isRemote(); diff --git a/doc/api/commit.md b/doc/api/commit.md new file mode 100644 index 00000000..bd7fc58c --- /dev/null +++ b/doc/api/commit.md @@ -0,0 +1,171 @@ +Commit +====== + +To access a *Commit*, starting from a repository object: + +```php +$repository = new Gitonomy\Git\Repository('/path/to/repository'); +$commit = $repository->getCommit('a7c8d2b4'); +``` + +Browsing parents +---------------- + +A *Commit* can have a natural number of parents: + +- **no parent**: it's an initial commit, the root of a tree +- **one parent**: it means it's not a merge, just a regular commit +- **many parents**: it's a merge-commit + +You have 2 methods available for accessing parents: + +```php +// Access parent hashes +$hashes = $commit->getParentHashes(); + +// Access parent commit objects +$commits = $commit->getParents(); +``` + +For example, if you want to display all parents, starting from a commit: + +```php +function displayLog(Gitonomy\Git\Commit $commit) { + echo '- '.$commit->getShortMessage()."\n"; + foreach ($commit->getParents() as $parent) { + displayLog($parent); + } +} +``` + +Notice that this function will first display all commits from first +merged branch and then display all commits from next branch, and so on. + +Accessing tree +-------------- + +The tree object contains the reference to the files associated to a +given commit. Every commit has one and only one tree, referencing all +files and folders of a given state for a project. For more informations +about the tree, see the chapter dedicated to it. + +To access a tree starting from a commit: + +```php +// Returns the tree hash +$tree = $commit->getTreeHash(); + +// Returns the tree object +$tree = $commit->getTree(); +``` + +Author & Committer informations +------------------------------- + +Each commit has two authoring informations: an author and a committer. +The author is the creator of the modification, authoring a modification +in the repository. The committer is responsible of introducing this +modification to the repository. + +You can access informations from author and committer using those +methods: + +```php +// Author +$commit->getAuthorName(); +$commit->getAuthorEmail(); +$commit->getAuthorDate(); // returns a DateTime object + +// Committer +$commit->getCommitterName(); +$commit->getCommitterEmail(); +$commit->getCommitterDate(); // returns a DateTime object +``` + +Commit message and short message +-------------------------------- + +Each commit also has a message, associated to the modification. This +message can be multilined. + +To access the message, you can use the *getMessage* method: + +```php +$commit->getMessage(); +``` + +For your convenience, this library provides a shortcut method to keep +only the first line or first 50 characters if the first line is too +long: + +```php +$commit->getShortMessage(); +``` + +You can customize it like this: + +```php +$commit->getShortMessage(45, true, '.'); +``` + +- The first parameter is the max length of the message. +- The second parameter determine if the last word should be cut or + preserved +- The third parameter is the separator + +There are also two other methods for your convenience: + +```php +// The first line +$commit->getSubjectMessage(); + +// The body (rest of the message) +$commit->getBodyMessage(); +``` + +Diff of a commit +---------------- + +You can check the modifications introduced by a commit using the +*getDiff* method. When you request a diff for a commit, depending of the +number of parents, the strategy will be different: + +- If you have *no parent*, the diff will be the content of the tree +- If you only have *one parent*, the diff will be between the commit + and his parent +- If you have *multiple parents*, the diff will be the difference + between the commit and the first common ancestor of all parents + +For more informations about the diff API, read the related chapter. + +To access the *Diff* object of a commit, use the method *getDiff*: + +```php +$diff = $commit->getDiff(); +``` + +Last modification of a file +--------------------------- + +To know the last modification of a file, you can use the +*getLastModification* method on a commit. + +Here is a very straightforward example: + +```php +$last = $commit->getLastModification('README'); + +echo "Last README modification:\n"; +echo" Author: ".$last->getAuthorName()."\n"; +echo" Date: ".$last->getAuthorDate()->format('d/m/Y')."\n"; +echo" Message: ".$last->getMessage(); +``` + +Find every branches containing a commit +--------------------------------------- + +```php +$branches = $commit->getIncludingBranches($includeLocalBranches, $includeRemoteBranches); +$localBranches = $commit->getIncludingBranches(true, false); +$remoteBranches = $commit->getIncludingBranches(false, true); +``` diff --git a/doc/api/commit.rst b/doc/api/commit.rst deleted file mode 100644 index d11ea810..00000000 --- a/doc/api/commit.rst +++ /dev/null @@ -1,168 +0,0 @@ -Commit -====== - -To access a *Commit*, starting from a repository object: - -.. code-block:: php - - $repository = new Gitonomy\Git\Repository('/path/to/repository'); - $commit = $repository->getCommit('a7c8d2b4'); - -Browsing parents ----------------- - -A *Commit* can have a natural number of parents: - -* **no parent**: it's an initial commit, the root of a tree -* **one parent**: it means it's not a merge, just a regular commit -* **many parents**: it's a merge-commit - -You have 2 methods available for accessing parents: - -.. code-block:: php - - // Access parent hashes - $hashes = $commit->getParentHashes(); - - // Access parent commit objects - $commits = $commit->getParents(); - -For example, if you want to display all parents, starting from a commit: - -.. code-block:: php - - function displayLog(Gitonomy\Git\Commit $commit) { - echo '- '.$commit->getShortMessage()."\n"; - foreach ($commit->getParents() as $parent) { - displayLog($parent); - } - } - -Notice that this function will first display all commits from first merged -branch and then display all commits from next branch, and so on. - -Accessing tree --------------- - -The tree object contains the reference to the files associated to a given -commit. Every commit has one and only one tree, referencing all files and -folders of a given state for a project. For more informations about the tree, -see the chapter dedicated to it. - -To access a tree starting from a commit: - -.. code-block:: php - - // Returns the tree hash - $tree = $commit->getTreeHash(); - - // Returns the tree object - $tree = $commit->getTree(); - -Author & Committer informations -------------------------------- - -Each commit has two authoring informations: an author and a committer. The -author is the creator of the modification, authoring a modification in the -repository. The committer is responsible of introducing this modification to -the repository. - -You can access informations from author and committer using those methods: - -.. code-block:: php - - // Author - $commit->getAuthorName(); - $commit->getAuthorEmail(); - $commit->getAuthorDate(); // returns a DateTime object - - // Committer - $commit->getCommitterName(); - $commit->getCommitterEmail(); - $commit->getCommitterDate(); // returns a DateTime object - -Commit message and short message --------------------------------- - -Each commit also has a message, associated to the modification. This message -can be multilined. - -To access the message, you can use the *getMessage* method: - -.. code-block:: php - - $commit->getMessage(); - -For your convenience, this library provides a shortcut method to keep only the -first line or first 50 characters if the first line is too long: - -.. code-block:: php - - $commit->getShortMessage(); - -You can customize it like this: - -.. code-block:: php - - $commit->getShortMessage(45, true, '.'); - -* The first parameter is the max length of the message. -* The second parameter determine if the last word should be cut or preserved -* The third parameter is the separator - -There are also two other methods for your convenience: - -.. code-block:: php - - // The first line - $commit->getSubjectMessage(); - - // The body (rest of the message) - $commit->getBodyMessage(); - -Diff of a commit ----------------- - -You can check the modifications introduced by a commit using the *getDiff* -method. When you request a diff for a commit, depending of the number of -parents, the strategy will be different: - -* If you have *no parent*, the diff will be the content of the tree -* If you only have *one parent*, the diff will be between the commit and his - parent -* If you have *multiple parents*, the diff will be the difference between the - commit and the first common ancestor of all parents - -For more informations about the diff API, read the related chapter. - -To access the *Diff* object of a commit, use the method *getDiff*: - -.. code-block:: php - - $diff = $commit->getDiff(); - -Last modification of a file ---------------------------- - -To know the last modification of a file, you can use the *getLastModification* -method on a commit. - -Here is a very straightforward example: - -.. code-block:: php - - $last = $commit->getLastModification('README'); - - echo "Last README modification:\n"; - echo" Author: ".$last->getAuthorName()."\n"; - echo" Date: ".$last->getAuthorDate()->format('d/m/Y')."\n"; - echo" Message: ".$last->getMessage(); - -Find every branches containing a commit ---------------------------------------- - -.. code-block:: php - - $branches = $commit->getIncludingBranches($includeLocalBranches, $includeRemoteBranches); - $localBranches = $commit->getIncludingBranches(true, false); - $remoteBranches = $commit->getIncludingBranches(false, true); diff --git a/doc/api/diff.md b/doc/api/diff.md new file mode 100644 index 00000000..a4878ff6 --- /dev/null +++ b/doc/api/diff.md @@ -0,0 +1,104 @@ +Computing diff +============== + +Even if git is a diff-less storage engine, it's possible to compute +them. + +To compute a diff in git, you need to specify a *revision*. This +revision can be a commit (*2bc7a8*) or a range (*2bc7a8..ff4c21b*). + +For more informations about git revisions: *man gitrevisions*. + +When you have decided the revision you want and have your *Repository* +object, you can call the *getDiff* method on the repository: + +```php +$diff = $repository->getDiff('master@{2 days ago}..master'); +``` + +You can also access it from a *Log* object: + +```php +$log = $repository->getLog('master@{2 days ago}..master'); +$diff = $log->getDiff(); +``` + +Iterating a diff +---------------- + +When you have a *Diff* object, you can iterate over files using method +*getFiles()*. This method returns a list of *File* objects, who +represents the modifications for a single file. + +```php +$files = $diff->getFiles(); +echo sprintf("%s files modified", count($files)); + +foreach ($files as $fileDiff) { + echo sprintf("Old name: (%s) %s\n", $fileDiff->getOldMode(), $fileDiff->getOldName()); + echo sprintf("New name: (%s) %s\n", $fileDiff->getNewMode(), $fileDiff->getNewName()); +} +``` + +The File object +--------------- + +Here is an exhaustive list of the *File* class methods: + +```php +$file->getOldName(); +$file->getNewName(); +$file->getOldDiff(); +$file->getNewDiff(); + +$file->isCreation(); +$file->isDeletion(); +$file->isModification(); + +$file->isRename(); +$file->isChangeMode(); + +$file->getAdditions(); // Number of added lines +$file->getDeletions(); // Number of deleted lines + +$file->isBinary(); // Binary files have no "lines" + +$file->getChanges(); // See next chapter +``` + +The FileChange object +--------------------- + +> **note** +> +> This part of API is not very clean, very consistent. If you have any +> idea or suggestion on how to enhance this, your comment would be +> appreciated. + +A *File* object is composed of many changes. For each of those changes, +a *FileChange* object is associated. + +To access changes from a file, use the *getChanges* method: + +```php +$changes = $file->getChanges(); +foreach ($changes as $change) { + foreach ($lines as $data) { + list ($type, $line) = $data; + if ($type === FileChange::LINE_CONTEXT) { + echo ' '.$line."\n"; + } elseif ($type === FileChange::LINE_ADD) { + echo '+'.$line."\n"; + } else { + echo '-'.$line."\n"; + } + } +} +``` + +To get line numbers, use the range methods: + +```php +echo sprintf("Previously from line %s to %s\n", $change->getOldRangeStart(), $change->getOldRangeEnd()); +echo sprintf("Now from line %s to %s\n", $change->getNewRangeStart(), $change->getNewRangeEnd()); +``` diff --git a/doc/api/diff.rst b/doc/api/diff.rst deleted file mode 100644 index badd0776..00000000 --- a/doc/api/diff.rst +++ /dev/null @@ -1,102 +0,0 @@ -Computing diff -============== - -Even if git is a diff-less storage engine, it's possible to compute them. - -To compute a diff in git, you need to specify a *revision*. This revision can -be a commit (*2bc7a8*) or a range (*2bc7a8..ff4c21b*). - -For more informations about git revisions: *man gitrevisions*. - -When you have decided the revision you want and have your *Repository* object, -you can call the *getDiff* method on the repository: - -.. code-block:: php - - $diff = $repository->getDiff('master@{2 days ago}..master'); - -You can also access it from a *Log* object: - -.. code-block:: php - - $log = $repository->getLog('master@{2 days ago}..master'); - $diff = $log->getDiff(); - -Iterating a diff ----------------- - -When you have a *Diff* object, you can iterate over files using method -*getFiles()*. This method returns a list of *File* objects, who represents the -modifications for a single file. - -.. code-block:: php - - $files = $diff->getFiles(); - echo sprintf("%s files modified", count($files)); - - foreach ($files as $fileDiff) { - echo sprintf("Old name: (%s) %s\n", $fileDiff->getOldMode(), $fileDiff->getOldName()); - echo sprintf("New name: (%s) %s\n", $fileDiff->getNewMode(), $fileDiff->getNewName()); - } - -The File object ---------------- - -Here is an exhaustive list of the *File* class methods: - -.. code-block:: php - - $file->getOldName(); - $file->getNewName(); - $file->getOldDiff(); - $file->getNewDiff(); - - $file->isCreation(); - $file->isDeletion(); - $file->isModification(); - - $file->isRename(); - $file->isChangeMode(); - - $file->getAdditions(); // Number of added lines - $file->getDeletions(); // Number of deleted lines - - $file->isBinary(); // Binary files have no "lines" - - $file->getChanges(); // See next chapter - -The FileChange object ---------------------- - -.. note:: - - This part of API is not very clean, very consistent. If you have any idea - or suggestion on how to enhance this, your comment would be appreciated. - -A *File* object is composed of many changes. For each of those changes, -a *FileChange* object is associated. - -To access changes from a file, use the *getChanges* method: - -.. code-block:: php - - $changes = $file->getChanges(); - foreach ($changes as $change) { - foreach ($lines as $data) { - list ($type, $line) = $data; - if ($type === FileChange::LINE_CONTEXT) { - echo ' '.$line."\n"; - } elseif ($type === FileChange::LINE_ADD) { - echo '+'.$line."\n"; - } else { - echo '-'.$line."\n"; - } - } - } - -To get line numbers, use the range methods: - -.. code-block:: php - - echo sprintf("Previously from line %s to %s\n", $change->getOldRangeStart(), $change->getOldRangeEnd()); - echo sprintf("Now from line %s to %s\n", $change->getNewRangeStart(), $change->getNewRangeEnd()); diff --git a/doc/api/hooks.md b/doc/api/hooks.md new file mode 100644 index 00000000..eb5c8ab4 --- /dev/null +++ b/doc/api/hooks.md @@ -0,0 +1,80 @@ +Hooks +===== + +It's possible to define custom hooks on any repository with git. Those +hooks are located in the *.git/hooks* folder. + +Those files need to be executable. For convenience, gitlib will set them +to *777*. + +With *gitlib*, you can manage hooks over a repository using the *Hooks* +object. + +To access it from a repository, use the *getHooks* method on a +*Repository* object: + +```php +$hooks = $repository->getHooks(); +``` + +Reading hooks +------------- + +To read the content of a hook, use the *get* method like this: + +```php +$content = $hooks->get('pre-receive'); // returns a string +``` + +If the hook does not exist, an exception will be thrown +(*InvalidArgumentException*). + +You can test if a hook is present using the method *has*: + +```php +$hooks->has('pre-receive'); // a boolean indicating presence +``` + +Inserting hooks +--------------- + +You can modify a hook in two different ways: creating a new file or +using a symlink. + +To create the hook using a symlink: + +```php +$hooks->setSymlink('pre-receive', '/path/to/file-to-link'); +``` + +If the hook already exist, a *LogicException* will be thrown. If an +error occured during symlink creation, a *RuntimeException* will be +thrown. + +If you want to directly create a new file in hooks directory, use the +method *set*. This method will create a new file, put content in it and +make it executable: + +```php +$content = <<set('pre-receive', $content); +``` + +If the hook already exists, a *LogicException* will be thrown. + +Removing hooks +-------------- + +To remove a hook from a repository, use the function *remove*: + +```php +$hooks->remove('pre-receive'); +``` diff --git a/doc/api/hooks.rst b/doc/api/hooks.rst deleted file mode 100644 index 20c4abec..00000000 --- a/doc/api/hooks.rst +++ /dev/null @@ -1,76 +0,0 @@ -Hooks -===== - -It's possible to define custom hooks on any repository with git. Those hooks -are located in the *.git/hooks* folder. - -Those files need to be executable. For convenience, gitlib will set them to -*777*. - -With *gitlib*, you can manage hooks over a repository using the *Hooks* object. - -To access it from a repository, use the *getHooks* method on a *Repository* -object: - -.. code-block:: php - - $hooks = $repository->getHooks(); - -Reading hooks -------------- - -To read the content of a hook, use the *get* method like this: - -.. code-block:: php - - $content = $hooks->get('pre-receive'); // returns a string - -If the hook does not exist, an exception will be thrown (*InvalidArgumentException*). - -You can test if a hook is present using the method *has*: - -.. code-block:: php - - $hooks->has('pre-receive'); // a boolean indicating presence - -Inserting hooks ---------------- - -You can modify a hook in two different ways: creating a new file or using a symlink. - -To create the hook using a symlink: - -.. code-block:: php - - $hooks->setSymlink('pre-receive', '/path/to/file-to-link'); - -If the hook already exist, a *LogicException* will be thrown. If an error occured -during symlink creation, a *RuntimeException* will be thrown. - -If you want to directly create a new file in hooks directory, use the -method *set*. This method will create a new file, put content in it and make it -executable: - -.. code-block:: php - - $content = <<set('pre-receive', $content); - -If the hook already exists, a *LogicException* will be thrown. - -Removing hooks --------------- - -To remove a hook from a repository, use the function *remove*: - -.. code-block:: php - - $hooks->remove('pre-receive'); diff --git a/doc/api/log.md b/doc/api/log.md new file mode 100644 index 00000000..a03989ac --- /dev/null +++ b/doc/api/log.md @@ -0,0 +1,55 @@ +Getting log history +=================== + +Crawling manually commits and parents to browse history is surely a good +solution. But when it comes to ordering them or aggregate them from +multiple branches, we tend to use `git log`. + +To get a *Log* object from a repository: + +```php +$log = $repository->getLog(); +``` + +You can pass four arguments to *getLog* method: + +```php +// Global log for repository +$log = $repository->getLog(); + +// Log for master branch +$log = $repository->getLog('master'); + +// Returns last 10 commits on README file +$log = $repository->getLog('master', 'README', 0, 10); + +// Returns last 10 commits on README or UPGRADE files +$log = $repository->getLog('master', array('README', 'UPGRADE'), 0, 10); +``` + +Counting +-------- + +If you want to count overall commits, without offset or limit, use the +*countCommits* method: + +```php +echo sprintf("This log contains %s commits\n", $log->countCommits()); + +// Countable interface +echo sprintf("This log contains %s commits\n", count($log)); +``` + +Offset and limit +---------------- + +Use those methods: + +```php +$log->setOffset(32); +$log->setLimit(40); + +// or read it: +$log->getOffset(); +$log->getLimit(); +``` diff --git a/doc/api/log.rst b/doc/api/log.rst deleted file mode 100644 index 5a795b9e..00000000 --- a/doc/api/log.rst +++ /dev/null @@ -1,54 +0,0 @@ -Getting log history -=================== - -Crawling manually commits and parents to browse history is surely a good -solution. But when it comes to ordering them or aggregate them from multiple -branches, we tend to use ``git log``. - -To get a *Log* object from a repository: - -.. code-block:: php - - $log = $repository->getLog(); - -You can pass four arguments to *getLog* method: - -.. code-block:: php - - // Global log for repository - $log = $repository->getLog(); - - // Log for master branch - $log = $repository->getLog('master'); - - // Returns last 10 commits on README file - $log = $repository->getLog('master', 'README', 0, 10); - - // Returns last 10 commits on README or UPGRADE files - $log = $repository->getLog('master', array('README', 'UPGRADE'), 0, 10); - -Counting --------- - -If you want to count overall commits, without offset or limit, use the *countCommits* method: - -.. code-block:: php - - echo sprintf("This log contains %s commits\n", $log->countCommits()); - - // Countable interface - echo sprintf("This log contains %s commits\n", count($log)); - -Offset and limit ----------------- - -Use those methods: - -.. code-block:: php - - $log->setOffset(32); - $log->setLimit(40); - - // or read it: - $log->getOffset(); - $log->getLimit(); diff --git a/doc/api/references.md b/doc/api/references.md new file mode 100644 index 00000000..c326ba3e --- /dev/null +++ b/doc/api/references.md @@ -0,0 +1,92 @@ +Tags and branches +================= + +Accessing tags and branches +--------------------------- + +With *gitlib*, you can access them via the *ReferenceBag* object. To get +this object from a *Repository*, use the *getReferences* method: + +```php +$references = $repository->getReferences(); +``` + +First, you can test existence of tags and branches like this: + +```php +if ($references->hasBranch('master') && $references->hasTag('0.1')) { + echo "Good start!"; +} +``` + +If you want to access all branches or all tags: + +```php +$branches = $references->getBranches(); +$localBranches = $references->getLocalBranches(); +$remoteBranches = $references->getRemoteBranches(); +$tags = $references->getTags(); +$all = $references->getAll(); +``` + +To get a given branch or tag, call *getBranch* or *getTag* on the +*ReferenceBag*. Those methods return *Branch* and *Tag* objects: + +```php +$master = $references->getBranch('master'); +$feat123 = $references->getLocalBranch('feat123'); +$feat456 = $references->getRemoteBranch('origin/feat456'); +$v0_1 = $references->getTag('0.1'); +``` + +If the reference cannot be resolved, a *ReferenceNotFoundException* will +be thrown. + +On each of those objects, you can access those informations: + +```php +// Get the associated commit +$commit = $master->getCommit(); + +// Get the commit hash +$hash = $master->getCommitHash(); + +// Get the last modification +$lastModification = $master->getLastModification(); +``` + +Create and delete reference +--------------------------- + +You can create new tags and branches on repository, using helper methods +on ReferenceBag object: + +```php +// create a branch +$references = $repository->getReferences(); +$branch = $references->createBranch('foobar', 'a8b7e4...'); // commit to reference + +// create a tag +$references = $repository->getReferences(); +$tag = $references->createTag('0.3', 'a8b7e4...'); // commit to reference + +// delete a branch or a tag +$branch->delete(); +``` + +Resolution from a commit +------------------------ + +To resolve a branch or a commit from a commit, you can use the +*resolveTags* and *resolveBranches* methods on it: + +```php +$branches = $references->resolveBranches($commit); +$tags = $references->resolveTags($commit); + +// Resolve branches and tags +$all = $references->resolve($commit); +``` + +You can pass a *Commit* object or a hash to the method, gitlib will +handle it. diff --git a/doc/api/references.rst b/doc/api/references.rst deleted file mode 100644 index 5b7fa975..00000000 --- a/doc/api/references.rst +++ /dev/null @@ -1,91 +0,0 @@ -Tags and branches -================= - -Accessing tags and branches ---------------------------- - -With *gitlib*, you can access them via the *ReferenceBag* object. To get this -object from a *Repository*, use the *getReferences* method: - -.. code-block:: php - - $references = $repository->getReferences(); - -First, you can test existence of tags and branches like this: - -.. code-block:: php - - if ($references->hasBranch('master') && $references->hasTag('0.1')) { - echo "Good start!"; - } - -If you want to access all branches or all tags: - -.. code-block:: php - - $branches = $references->getBranches(); - $localBranches = $references->getLocalBranches(); - $remoteBranches = $references->getRemoteBranches(); - $tags = $references->getTags(); - $all = $references->getAll(); - -To get a given branch or tag, call *getBranch* or *getTag* on the -*ReferenceBag*. Those methods return *Branch* and *Tag* objects: - -.. code-block:: php - - $master = $references->getBranch('master'); - $feat123 = $references->getLocalBranch('feat123'); - $feat456 = $references->getRemoteBranch('origin/feat456'); - $v0_1 = $references->getTag('0.1'); - -If the reference cannot be resolved, a *ReferenceNotFoundException* will be -thrown. - -On each of those objects, you can access those informations: - -.. code-block:: php - - // Get the associated commit - $commit = $master->getCommit(); - - // Get the commit hash - $hash = $master->getCommitHash(); - - // Get the last modification - $lastModification = $master->getLastModification(); - -Create and delete reference ---------------------------- - -You can create new tags and branches on repository, using helper methods -on ReferenceBag object: - -.. code-block:: php - - // create a branch - $references = $repository->getReferences(); - $branch = $references->createBranch('foobar', 'a8b7e4...'); // commit to reference - - // create a tag - $references = $repository->getReferences(); - $tag = $references->createTag('0.3', 'a8b7e4...'); // commit to reference - - // delete a branch or a tag - $branch->delete(); - -Resolution from a commit ------------------------- - -To resolve a branch or a commit from a commit, you can use the *resolveTags* -and *resolveBranches* methods on it: - -.. code-block:: php - - $branches = $references->resolveBranches($commit); - $tags = $references->resolveTags($commit); - - // Resolve branches and tags - $all = $references->resolve($commit); - -You can pass a *Commit* object or a hash to the method, gitlib will handle it. diff --git a/doc/api/repository.md b/doc/api/repository.md new file mode 100644 index 00000000..4aa6e512 --- /dev/null +++ b/doc/api/repository.md @@ -0,0 +1,147 @@ +Repository methods +================== + +Creating a *Repository* object is possible, providing a *path* argument +to the constructor: + +```php +$repository = new Repository('/path/to/repo'); +``` + +Repository options +------------------ + +The constructor of Repository takes an additional parameter: `$options`. +This parameter can be used used to tune behavior of library. + +Available options are: + +- **debug** (default: true): Enables exception when edge cases are met +- **environment\_variables**: (default: none) An array of environment + variables to be set in sub-process +- **logger**: (default: none) Logger to use for reporting of execution + (a `Psr\Log\LoggerInterface`) +- **command**: (default: `git`) Specify command to execute to run git +- **working\_dir**: If you are using multiple working directories, + this option is for you + +An example: + +```php +$repository = new Repository('/path/to/repo', array( + 'debug' => true, + 'logger' => new Monolog\Logger() +)); +``` + +Test if a repository is bare +---------------------------- + +On a *Repository* object, you can call method *isBare* to test if your +repository is bare or not: + +```php +$repository->isBare(); +``` + +Compute size of a repository +---------------------------- + +To know how much size a repository is using on your drive, you can use +`getSize` method on a *Repository* object. + +> **warning** +> +> This command was only tested with linux. + +The returned size is in kilobytes: + +```php +$size = $repository->getSize(); + +echo "Your repository size is ".$size."KB"; +``` + +Access HEAD +----------- + +`HEAD` represents in git the version you are working on (in working +tree). Your `HEAD` can be attached (using a reference) or detached +(using a commit). + +```php +$head = $repository->getHead(); // Commit or Reference +$head = $repository->getHeadCommit(); // Commit + +if ($repository->isHeadDetached()) { + echo "Sorry man\n"; +} +``` + +Options for repository +---------------------- + +### Logger + +If you are developing, you may appreciate to have a logger inside +repository, telling you every executed command. + +You call method `setLogger` as an option on repository creation: + +```php +$repository->setLogger(new Monolog\Logger('repository')); + +$repository->run('fetch', array('--all')); +``` + +You can also specify as an option on repository creation: + +```php +$logger = new MonologLogger('repository'); +$repository = new Repository('/path/foo', array('logger' => $logger)); +$repository->run('fetch', array('--all')); +``` + +This will output: + +``` +info run command: fetch "--all" +debug last command (fetch) duration: 23.24ms +debug last command (fetch) return code: 0 +debug last command (fetch) output: Fetching origin +``` + +### Disable debug-mode + +Gitlib throws an exception when something seems wrong. If a `git` command exits +with a non-zero code, then execution will be stopped, and a `RuntimeException` +will be thrown. If you want to prevent this, set the `debug` option to` false`. +This will make `Repository` log errors and return empty data instead of +throwing exceptions. + +```php +$repository = new Repository('/tmp/foo', array('debug' => false, 'logger' => $logger)); +``` + +> **note** +> +> If you plan to disable debug, you should rely on the logger to keep a trace +> of the failing cases. + +### Specify git command to use + +You can pass the option `command` to specify which command to use to run git +calls. If you have a git binary located somewhere else, use this option to +specify to gitlib path to your git binary: + +```php +$repository = new Gitonomy\Git\Repository('/tmp/foo', array('command' => '/home/alice/bin/git')); +``` + +### Environment variables + +It is possible to send environment variables to the `git` commands. + +```php +$repository = new Gitonomy\Git\Repository('/tmp/foo', array('environment_variables' => array('GIT_'))) +``` diff --git a/doc/api/repository.rst b/doc/api/repository.rst deleted file mode 100644 index 53fa07a3..00000000 --- a/doc/api/repository.rst +++ /dev/null @@ -1,135 +0,0 @@ -Repository methods -================== - -Creating a *Repository* object is possible, providing a *path* argument to the -constructor: - -.. code-block:: php - - $repository = new Repository('/path/to/repo'); - -Repository options ------------------- - -The constructor of Repository takes an additional parameter: ``$options``. -This parameter can be used used to tune behavior of library. - -Available options are: - -* **debug** (default: true): Enables exception when edge cases are met -* **environment_variables**: (default: none) An array of environment variables to be set in sub-process -* **logger**: (default: none) Logger to use for reporting of execution (a ``Psr\Log\LoggerInterface``) -* **command**: (default: ``git``) Specify command to execute to run git -* **working_dir**: If you are using multiple working directories, this option is for you - -An example: - -.. code-block:: php - - $repository = new Repository('/path/to/repo', array( - 'debug' => true, - 'logger' => new Monolog\Logger() - )); - -Test if a repository is bare ----------------------------- - -On a *Repository* object, you can call method *isBare* to test if your repository is bare or not: - -.. code-block:: php - - $repository->isBare(); - -Compute size of a repository ----------------------------- - -To know how much size a repository is using on your drive, you can use ``getSize`` method on a *Repository* object. - -.. warning:: This command was only tested with linux. - -The returned size is in kilobytes: - -.. code-block:: php - - $size = $repository->getSize(); - - echo "Your repository size is ".$size."KB"; - -Access HEAD ------------ - -``HEAD`` represents in git the version you are working on (in working tree). -Your ``HEAD`` can be attached (using a reference) or detached (using a commit). - -.. code-block:: php - - $head = $repository->getHead(); // Commit or Reference - $head = $repository->getHeadCommit(); // Commit - - if ($repository->isHeadDetached()) { - echo "Sorry man\n"; - } - -Options for repository ----------------------- - -Logger -...... - -If you are developing, you may appreciate to have a logger inside repository, telling you every executed command. - -You call method ``setLogger`` as an option on repository creation: - -.. code-block:: php - - $repository->setLogger(new Monolog\Logger('repository')); - - $repository->run('fetch', array('--all')); - -You can also specify as an option on repository creation: - - $logger = new Monolog\Logger('repository'); - $repository = new Repository('/path/foo', array('logger' => $logger)); - - $repository->run('fetch', array('--all')); - -This will output: - -.. code-block:: text - - info run command: fetch "--all" - debug last command (fetch) duration: 23.24ms - debug last command (fetch) return code: 0 - debug last command (fetch) output: Fetching origin - -Disable debug-mode -.................. - -Gitlib throws an exception when something seems wrong. If a ``git` command returns a non-zero result, it will stop execution and throw an ``RuntimeException``. - -If you want to prevent this, set ``debug`` option to ``false``. This will make Repository log errors and return empty data instead of throwing exceptions. - -.. code-block:: php - - $repository = new Repository('/tmp/foo', array('debug' => false, 'logger' => $logger)); - -.. note:: if you plan to disable debug, you should rely on logger to keep a trace of edge failing cases. - -Specify git command to use -.......................... - -You can pass option ``command`` to specify which command to use to run git calls. If you have a git binary -located somewhere else, use this option to specify to gitlib path to your git binary: - -.. code-block:: php - - $repository = new Gitonomy\Git\Repository('/tmp/foo', array('command' => '/home/alice/bin/git')); - -Environment variables -..................... - -Now you want to set environment variables to use to run ``git`` commands. It might be useful. - -.. code-block:: php - - $repository = new Gitonomy\Git\Repository('/tmp/foo', array('environment_variables' => array('GIT_'))) diff --git a/doc/api/revision.md b/doc/api/revision.md new file mode 100644 index 00000000..81370c80 --- /dev/null +++ b/doc/api/revision.md @@ -0,0 +1,28 @@ +Revision +======== + +To get a revision from a *Repository* object: + +```php +$revision = $repository->getRevision('master@{2 days ago}'); +``` + +Getting the log +--------------- + +You can access a *Log* object starting from a revision using the +*getLog* method. This method takes two parameters: *offset* and *limit*: + +```php +// Returns 100 lasts commits +$log = $revision->getLog(null, 100); +``` + +Resolve a revision +------------------ + +To resolve a revision to a commit: + +```php +$commit = $revision->getCommit(); +``` diff --git a/doc/api/revision.rst b/doc/api/revision.rst deleted file mode 100644 index 8ffc3289..00000000 --- a/doc/api/revision.rst +++ /dev/null @@ -1,28 +0,0 @@ -Revision -======== - -To get a revision from a *Repository* object: - -.. code-block:: php - - $revision = $repository->getRevision('master@{2 days ago}'); - -Getting the log ---------------- - -You can access a *Log* object starting from a revision using the *getLog* -method. This method takes two parameters: *offset* and *limit*: - -.. code-block:: php - - // Returns 100 lasts commits - $log = $revision->getLog(null, 100); - -Resolve a revision ------------------- - -To resolve a revision to a commit: - -.. code-block:: php - - $commit = $revision->getCommit(); diff --git a/doc/api/tree.md b/doc/api/tree.md new file mode 100644 index 00000000..67a40147 --- /dev/null +++ b/doc/api/tree.md @@ -0,0 +1,54 @@ +Tree and files +============== + +To organize folders, git uses trees. In gitlib, those trees are +represented via *Tree* object. + +To get the root tree associated to a commit, use the *getTree* method on +the commit object: + +```php +$tree = $commit->getTree(); +``` + +This tree is the entry point of all of your files. + +The main method for a tree is the *getEntries* method. This method will +return an array, indexed by name. Each of those elements will be the +entry mode and the entry object. + +Let's understand how it works with a concrete example: + +```php +function displayTree(Tree $tree, $indent = 0) +{ + $indent = str_repeat(' ', $indent); + foreach ($tree->getEntries() as $name => $data) { + list($mode, $entry) = $data; + if ($entry instanceof Tree) { + echo $indent.$name."/\n"; + displayTree($tree, $indent + 1); + } else { + echo $indent.$name."\n"; + } + } +} + +displayTree($commit->getTree()); +``` + +This method will recursively display all entries of a tree. + +Resolve a path +-------------- + +To access directly a sub-file, the easier is probably to use the +*resolvePath* method. + +An example: + +```php +$source = $tree->resolvePath('src/Gitonomy/Git'); + +$source instanceof Tree; +``` diff --git a/doc/api/tree.rst b/doc/api/tree.rst deleted file mode 100644 index 4641a9f3..00000000 --- a/doc/api/tree.rst +++ /dev/null @@ -1,54 +0,0 @@ -Tree and files -============== - -To organize folders, git uses trees. In gitlib, those trees are represented -via *Tree* object. - -To get the root tree associated to a commit, use the *getTree* method on the -commit object: - -.. code-block:: php - - $tree = $commit->getTree(); - -This tree is the entry point of all of your files. - -The main method for a tree is the *getEntries* method. This method will -return an array, indexed by name. Each of those elements will be the entry mode -and the entry object. - -Let's understand how it works with a concrete example: - -.. code-block:: php - - function displayTree(Tree $tree, $indent = 0) - { - $indent = str_repeat(' ', $indent); - foreach ($tree->getEntries() as $name => $data) { - list($mode, $entry) = $data; - if ($entry instanceof Tree) { - echo $indent.$name."/\n"; - displayTree($tree, $indent + 1); - } else { - echo $indent.$name."\n"; - } - } - } - - displayTree($commit->getTree()); - -This method will recursively display all entries of a tree. - -Resolve a path --------------- - -To access directly a sub-file, the easier is probably to use the *resolvePath* -method. - -An example: - -.. code-block:: php - - $source = $tree->resolvePath('src/Gitonomy/Git'); - - $source instanceof Tree; diff --git a/doc/api/workingcopy.md b/doc/api/workingcopy.md new file mode 100644 index 00000000..4ecea6c1 --- /dev/null +++ b/doc/api/workingcopy.md @@ -0,0 +1,47 @@ +Working copy +============ + +Working copy is the folder associated to a git repository. In *gitlib*, +you can access this object using the *getWorkingCopy* on a *Repository* +object: + +```php +$repo = new Repository('/path/to/working-dir'); +$wc = $repo->getWorkingCopy(); +``` + +Checkout a revision +------------------- + +You can checkout any revision using *checkout* method. You can also pass +a second argument, which will be passed as argument with `-b`: + +```php +// git checkout master +$wc->checkout('master'); + +// git checkout origin/master -b master +$wc->checkout('origin/master', 'master'); +``` + +You can also pass a *Reference* or a *Commit*. + +Staged modifications +-------------------- + +You can get a diff of modifications pending in staging area. To get the +`Diff` object, call method `getDiffStaged()`: + +```php +$diff = $wc->getDiffStaged(); +``` + +Pending modifications +--------------------- + +You can get pending modifications on tracked files by calling method +`getDiffPending()`: + +```php +$diff = $wc->getDiffPending(); +``` diff --git a/doc/api/workingcopy.rst b/doc/api/workingcopy.rst deleted file mode 100644 index 4b652230..00000000 --- a/doc/api/workingcopy.rst +++ /dev/null @@ -1,45 +0,0 @@ -Working copy -============ - -Working copy is the folder associated to a git repository. In *gitlib*, you -can access this object using the *getWorkingCopy* on a *Repository* object: - -.. code-block:: php - - $repo = new Repository('/path/to/working-dir'); - $wc = $repo->getWorkingCopy(); - -Checkout a revision -------------------- - -You can checkout any revision using *checkout* method. You can also pass a -second argument, which will be passed as argument with ``-b``: - -.. code-block:: php - - // git checkout master - $wc->checkout('master'); - - // git checkout origin/master -b master - $wc->checkout('origin/master', 'master'); - -You can also pass a *Reference* or a *Commit*. - -Staged modifications --------------------- - -You can get a diff of modifications pending in staging area. To get the ``Diff`` object, -call method ``getDiffStaged()``: - -.. code-block:: php - - $diff = $wc->getDiffStaged(); - -Pending modifications ---------------------- - -You can get pending modifications on tracked files by calling method ``getDiffPending()``: - -.. code-block:: php - - $diff = $wc->getDiffPending(); diff --git a/doc/debug.md b/doc/debug.md new file mode 100644 index 00000000..17a8d29b --- /dev/null +++ b/doc/debug.md @@ -0,0 +1,24 @@ +Debug-mode +========== + +gitlib offers a debug mode, to make you see edge-cases of your usage. +This is called debug-mode. + +Debug-mode is enabled by default. If you disable it, gitlib will behave +differently: + +- when an error is met during execution, gitlib will try to minimize + it, to not block execution flow. Errors will still be reporter in + logger. +- logs will be more verbose. They will contain every output, every + return code, every possible information to ease debugging. + +If you want to disable exceptions and try to minimize as much as +possible errors, pass `false` when construction a repository: + +```php +$repository = new Gitonomy\Git\Repository($path'/tmp/repo', $debug = false) +``` + +`$debug` argument should be available in every method you can use to +create a repository. diff --git a/doc/debug.rst b/doc/debug.rst deleted file mode 100644 index 704fd353..00000000 --- a/doc/debug.rst +++ /dev/null @@ -1,22 +0,0 @@ -Debug-mode -========== - -gitlib offers a debug mode, to make you see edge-cases of your usage. This is called -debug-mode. - -Debug-mode is enabled by default. If you disable it, gitlib will behave differently: - -* when an error is met during execution, gitlib will try to minimize it, to not block - execution flow. Errors will still be reporter in logger. -* logs will be more verbose. They will contain every output, every return code, every - possible information to ease debugging. - -If you want to disable exceptions and try to minimize as much as possible errors, pass -``false`` when construction a repository: - -.. code-block:: php - - $repository = new Gitonomy\Git\Repository($path'/tmp/repo', $debug = false) - -``$debug`` argument should be available in every method you can use to create a -repository. diff --git a/doc/development.md b/doc/development.md new file mode 100644 index 00000000..d55cc8ef --- /dev/null +++ b/doc/development.md @@ -0,0 +1,18 @@ +Developing gitlib +================= + +If you plan to contribute to gitlib, here are few things you should know: + +Documentation +------------- + +Documentation is now in [Markdown](https://en.wikipedia.org/wiki/Markdown) and hosted directly on Github + +Test against different git versions +----------------------------------- + +A script `test-git-version.sh` is available in repository to test gitlib against many git versions. + +This script is not usable on Travis-CI, they would hate me for this. It creates a local cache to avoid fetching from Github and compiling if already compiled. + +Use it at your own risk, it's still under experiment. diff --git a/doc/development.rst b/doc/development.rst deleted file mode 100644 index 986b0868..00000000 --- a/doc/development.rst +++ /dev/null @@ -1,24 +0,0 @@ -Developing gitlib -================= - -If you plan to contribute to gitlib, here are few things you should know: - -Documentation generation -:::::::::::::::::::::::: - -Documentation is generated using Sphinx (restructured text). Configuration file -is located in https://github.com/gitonomy/website/blob/master/bin/conf.py - -You will need to fetch vendor modules for PHP blocks especially. If you really -want to generate it, install the website project locally and hack into it. - -Test against different git versions -::::::::::::::::::::::::::::::::::: - -A script ``test-git-version.sh`` is available in repository to test gitlib against -many git versions. - -This script is not usable on Travis-CI, they would hate me for this. It creates -a local cache to avoid fetching from Github and compiling if already compiled. - -Use it at your own risk, it's still under experiment. diff --git a/doc/index.md b/doc/index.md new file mode 100644 index 00000000..b47df386 --- /dev/null +++ b/doc/index.md @@ -0,0 +1,46 @@ +# gitlib - library to manipulate git + +gitlib requires PHP 5.3 and class autoloading (PSR-0) to work properly. +Internally, it relies on `git` method calls to fetch informations from +repository. + +```php +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository'); + +foreach ($repository->getReferences()->getBranches() as $branch) { + echo "- ".$branch->getName(); +} + +$repository->run('fetch', array('--all')); +``` + +## Documentation +* [Debug](debug.md) +* [Development](development.md) +* [Installation](installation.md) + +## API Reference +* [Admin](api/admin.md) +* [Blame](api/blame.md) +* [Blob](api/blob.md) +* [Branch](api/branch.md) +* [Commit](api/commit.md) +* [Diff](api/diff.md) +* [Hooks](api/hooks.md) +* [Log](api/log.md) +* [References](api/references.md) +* [Repository](api/repository.md) +* [Revision](api/revision.md) +* [Tree](api/tree.md) +* [Working Copy](api/workingcopy.md) + +## Missing features + +Some major features are still missing from gitlib: + +- Remotes +- Submodules + +If you want to run git commands on repository, call method `Repository::run` with method and arguments. diff --git a/doc/index.rst b/doc/index.rst deleted file mode 100644 index 919e91c5..00000000 --- a/doc/index.rst +++ /dev/null @@ -1,59 +0,0 @@ -gitlib - library to manipulate git -================================== - -gitlib requires PHP 5.3 and class autoloading (PSR-0) to work properly. Internally, it relies on ``git`` method calls -to fetch informations from repository. - -.. code-block:: php - - use Gitonomy\Git\Repository; - - $repository = new Repository('/path/to/repository'); - - foreach ($repository->getReferences()->getBranches() as $branch) { - echo "- ".$branch->getName(); - } - - $repository->run('fetch', array('--all')); - - -Reference ---------- - -.. toctree:: - :maxdepth: 1 - - api/admin - api/repository - api/hooks - api/workingcopy - api/commit - api/blame - api/blob - api/branch - api/tree - api/log - api/diff - api/references - api/revision - - -Documentation -------------- - -.. toctree:: - :maxdepth: 2 - - installation - debug - development - -Missing features ----------------- - -Some major features are still missing from gitlib: - -* Remotes -* Submodules - -If you want to run git commands on repository, call method ``Repository::run`` with method and arguments. diff --git a/doc/installation.md b/doc/installation.md new file mode 100644 index 00000000..ae7e90d0 --- /dev/null +++ b/doc/installation.md @@ -0,0 +1,27 @@ +Installation of gitlib +====================== + +Autoloading +----------- + +gitlib relies on class autoloading. It does not require any additional setup. + +Using composer +-------------- + +You can install gitlib using [Composer](https://getcomposer.org/). Simply +require the version you need: + +```bash +$ composer require gitonomy/gitlib +``` + +or edit your `composer.json` file by hand: + +```json +{ + "require": { + "gitonomy/gitlib": "^1.2" + } +} +``` diff --git a/doc/installation.rst b/doc/installation.rst deleted file mode 100644 index 06d36d21..00000000 --- a/doc/installation.rst +++ /dev/null @@ -1,20 +0,0 @@ -Installation of gitlib -====================== - -Autoloading -::::::::::: - -gitlib relies on class autoloading. It does not require any additional setup. - -Using composer -:::::::::::::: - -Edit your ``composer.json`` file and add ``gitonomy/gitlib`` in section ``require``: - -.. code-block:: json - - { - "require": { - "gitonomy/gitlib": "^1.0" - } - } From acd7970b11740f4920d10f9c4a2ef3a6b5e44ee5 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 13 Dec 2019 13:01:43 +0000 Subject: [PATCH 015/150] Updated docs (#158) --- .github/CONTRIBUTING.md | 1 + README.md | 68 ++++++++++++++++++++++-------------- doc/{api => }/admin.md | 0 doc/{api => }/blame.md | 2 +- doc/{api => }/blob.md | 4 +-- doc/{api => }/branch.md | 0 doc/{api => }/commit.md | 10 +++--- doc/debug.md | 24 ------------- doc/development.md | 18 ---------- doc/{api => }/diff.md | 16 ++++----- doc/{api => }/hooks.md | 0 doc/index.md | 46 ------------------------ doc/installation.md | 27 -------------- doc/{api => }/log.md | 6 ++-- doc/{api => }/references.md | 2 +- doc/{api => }/repository.md | 22 ++++++------ doc/{api => }/revision.md | 0 doc/{api => }/tree.md | 4 +-- doc/{api => }/workingcopy.md | 0 19 files changed, 75 insertions(+), 175 deletions(-) rename doc/{api => }/admin.md (100%) rename doc/{api => }/blame.md (93%) rename doc/{api => }/blob.md (91%) rename doc/{api => }/branch.md (100%) rename doc/{api => }/commit.md (94%) delete mode 100644 doc/debug.md delete mode 100644 doc/development.md rename doc/{api => }/diff.md (78%) rename doc/{api => }/hooks.md (100%) delete mode 100644 doc/index.md delete mode 100644 doc/installation.md rename doc/{api => }/log.md (81%) rename doc/{api => }/references.md (98%) rename doc/{api => }/repository.md (83%) rename doc/{api => }/revision.md (100%) rename doc/{api => }/tree.md (93%) rename doc/{api => }/workingcopy.md (100%) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 0f98282a..7c8267dc 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -27,5 +27,6 @@ Then run [PHPUnit](https://phpunit.de/): $ vendor/bin/phpunit ``` +* A script `test-git-version.sh` is available in repository to test gitlib against many git versions. * The tests will be automatically run by [Travis CI](https://travis-ci.org/) against pull requests. * We also have [StyleCI](https://styleci.io/) setup to automatically fix any code style issues. diff --git a/README.md b/README.md index 4f38e207..91b8a643 100644 --- a/README.md +++ b/README.md @@ -5,37 +5,16 @@ Gitlib for Gitonomy [![StyleCI](https://github.styleci.io/repos/5709354/shield?branch=master)](https://github.styleci.io/repos/5709354) [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://opensource.org/licenses/MIT) -This library provides methods to access Git repository from PHP. +This library provides methods to access Git repository from PHP 5.6 or 7. It makes shell calls, which makes it less performant than any solution. Anyway, it's convenient and don't need to build anything to use it. That's how we love it. -## Documentation - -* [Overview](doc/index.md) -* [Debug](doc/debug.md) -* [Development](doc/development.md) -* [Installation](doc/installation.md) -* API - + [Admin](doc/api/admin.md) - + [Blame](doc/api/blame.md) - + [Blob](doc/api/blob.md) - + [Branch](doc/api/branch.md) - + [Commit](doc/api/commit.md) - + [Diff](doc/api/diff.md) - + [Hooks](doc/api/hooks.md) - + [Log](doc/api/log.md) - + [References](doc/api/references.md) - + [Repository](doc/api/repository.md) - + [Revision](doc/api/revision.md) - + [Tree](doc/api/tree.md) - + [Working Copy](doc/api/workingcopy.md) - -## Quick Start - -You can install gitlib using [Composer](https://getcomposer.org/). Simply -require the version you need: +Quick Start +----------- + +You can install gitlib using [Composer](https://getcomposer.org/). Simply require the version you need: ```bash $ composer require gitonomy/gitlib @@ -51,7 +30,42 @@ or edit your `composer.json` file by hand: } ``` -## For Enterprise +Example Usage +------------- + +```php +getReferences()->getBranches() as $branch) { + echo "- ".$branch->getName().PHP_EOL; +} + +$repository->run('fetch', ['--all']); +``` + +API Documentation +----------------- + ++ [Admin](doc/admin.md) ++ [Blame](doc/blame.md) ++ [Blob](doc/blob.md) ++ [Branch](doc/branch.md) ++ [Commit](doc/commit.md) ++ [Diff](doc/diff.md) ++ [Hooks](doc/hooks.md) ++ [Log](doc/log.md) ++ [References](doc/references.md) ++ [Repository](doc/repository.md) ++ [Revision](doc/revision.md) ++ [Tree](doc/tree.md) ++ [Working Copy](doc/workingcopy.md) + +For Enterprise +-------------- Available as part of the Tidelift Subscription diff --git a/doc/api/admin.md b/doc/admin.md similarity index 100% rename from doc/api/admin.md rename to doc/admin.md diff --git a/doc/api/blame.md b/doc/blame.md similarity index 93% rename from doc/api/blame.md rename to doc/blame.md index 78ab2ac2..80816c9b 100644 --- a/doc/api/blame.md +++ b/doc/blame.md @@ -11,7 +11,7 @@ $blame = $repository->getBlame('master', 'README.md'); foreach ($blame->getLines() as $lineNumber => $line) { $commit = $line->getCommit(); - echo $lineNumber.': '.$line->getContent()." - ".$commit->getAuthorName()."\n"; + echo $lineNumber.': '.$line->getContent().' - '.$commit->getAuthorName().PHP_EOL; } ``` diff --git a/doc/api/blob.md b/doc/blob.md similarity index 91% rename from doc/api/blob.md rename to doc/blob.md index f5e34cc7..2ed3bd41 100644 --- a/doc/api/blob.md +++ b/doc/blob.md @@ -37,8 +37,8 @@ You can also test if *Blob* is a text of a binary file: ```php if ($blob->isText()) { - echo $blob->getContent(), "\n"; + echo $blob->getContent(), PHP_EOL; } elseif ($blob->isBinary()) { - echo "File is binary\n"; + echo 'File is binary', PHP_EOL; } ``` diff --git a/doc/api/branch.md b/doc/branch.md similarity index 100% rename from doc/api/branch.md rename to doc/branch.md diff --git a/doc/api/commit.md b/doc/commit.md similarity index 94% rename from doc/api/commit.md rename to doc/commit.md index bd7fc58c..08e9b1e3 100644 --- a/doc/api/commit.md +++ b/doc/commit.md @@ -31,7 +31,7 @@ For example, if you want to display all parents, starting from a commit: ```php function displayLog(Gitonomy\Git\Commit $commit) { - echo '- '.$commit->getShortMessage()."\n"; + echo '- '.$commit->getShortMessage().PHP_EOL; foreach ($commit->getParents() as $parent) { displayLog($parent); } @@ -155,10 +155,10 @@ Here is a very straightforward example: ```php $last = $commit->getLastModification('README'); -echo "Last README modification:\n"; -echo" Author: ".$last->getAuthorName()."\n"; -echo" Date: ".$last->getAuthorDate()->format('d/m/Y')."\n"; -echo" Message: ".$last->getMessage(); +echo 'Last README modification'.PHP_EOL; +echo ' Author: '.$last->getAuthorName().PHP_EOL; +echo ' Date: '.$last->getAuthorDate()->format('d/m/Y').PHP_EOL; +echo ' Message: '.$last->getMessage(); ``` Find every branches containing a commit diff --git a/doc/debug.md b/doc/debug.md deleted file mode 100644 index 17a8d29b..00000000 --- a/doc/debug.md +++ /dev/null @@ -1,24 +0,0 @@ -Debug-mode -========== - -gitlib offers a debug mode, to make you see edge-cases of your usage. -This is called debug-mode. - -Debug-mode is enabled by default. If you disable it, gitlib will behave -differently: - -- when an error is met during execution, gitlib will try to minimize - it, to not block execution flow. Errors will still be reporter in - logger. -- logs will be more verbose. They will contain every output, every - return code, every possible information to ease debugging. - -If you want to disable exceptions and try to minimize as much as -possible errors, pass `false` when construction a repository: - -```php -$repository = new Gitonomy\Git\Repository($path'/tmp/repo', $debug = false) -``` - -`$debug` argument should be available in every method you can use to -create a repository. diff --git a/doc/development.md b/doc/development.md deleted file mode 100644 index d55cc8ef..00000000 --- a/doc/development.md +++ /dev/null @@ -1,18 +0,0 @@ -Developing gitlib -================= - -If you plan to contribute to gitlib, here are few things you should know: - -Documentation -------------- - -Documentation is now in [Markdown](https://en.wikipedia.org/wiki/Markdown) and hosted directly on Github - -Test against different git versions ------------------------------------ - -A script `test-git-version.sh` is available in repository to test gitlib against many git versions. - -This script is not usable on Travis-CI, they would hate me for this. It creates a local cache to avoid fetching from Github and compiling if already compiled. - -Use it at your own risk, it's still under experiment. diff --git a/doc/api/diff.md b/doc/diff.md similarity index 78% rename from doc/api/diff.md rename to doc/diff.md index a4878ff6..2ce7870a 100644 --- a/doc/api/diff.md +++ b/doc/diff.md @@ -32,11 +32,11 @@ represents the modifications for a single file. ```php $files = $diff->getFiles(); -echo sprintf("%s files modified", count($files)); +echo sprintf('%s files modified%s', count($files), PHP_EOL); foreach ($files as $fileDiff) { - echo sprintf("Old name: (%s) %s\n", $fileDiff->getOldMode(), $fileDiff->getOldName()); - echo sprintf("New name: (%s) %s\n", $fileDiff->getNewMode(), $fileDiff->getNewName()); + echo sprintf('Old name: (%s) %s%s', $fileDiff->getOldMode(), $fileDiff->getOldName(), PHP_EOL); + echo sprintf('New name: (%s) %s%s', $fileDiff->getNewMode(), $fileDiff->getNewName(), PHP_EOL); } ``` @@ -86,11 +86,11 @@ foreach ($changes as $change) { foreach ($lines as $data) { list ($type, $line) = $data; if ($type === FileChange::LINE_CONTEXT) { - echo ' '.$line."\n"; + echo ' '.$line.PHP_EOL; } elseif ($type === FileChange::LINE_ADD) { - echo '+'.$line."\n"; + echo '+'.$line.PHP_EOL; } else { - echo '-'.$line."\n"; + echo '-'.$line.PHP_EOL; } } } @@ -99,6 +99,6 @@ foreach ($changes as $change) { To get line numbers, use the range methods: ```php -echo sprintf("Previously from line %s to %s\n", $change->getOldRangeStart(), $change->getOldRangeEnd()); -echo sprintf("Now from line %s to %s\n", $change->getNewRangeStart(), $change->getNewRangeEnd()); +echo sprintf('Previously from line %s to %s%s', $change->getOldRangeStart(), $change->getOldRangeEnd(), PHP_EOL); +echo sprintf('Now from line %s to %s%s', $change->getNewRangeStart(), $change->getNewRangeEnd(), PHP_EOL); ``` diff --git a/doc/api/hooks.md b/doc/hooks.md similarity index 100% rename from doc/api/hooks.md rename to doc/hooks.md diff --git a/doc/index.md b/doc/index.md deleted file mode 100644 index b47df386..00000000 --- a/doc/index.md +++ /dev/null @@ -1,46 +0,0 @@ -# gitlib - library to manipulate git - -gitlib requires PHP 5.3 and class autoloading (PSR-0) to work properly. -Internally, it relies on `git` method calls to fetch informations from -repository. - -```php -use Gitonomy\Git\Repository; - -$repository = new Repository('/path/to/repository'); - -foreach ($repository->getReferences()->getBranches() as $branch) { - echo "- ".$branch->getName(); -} - -$repository->run('fetch', array('--all')); -``` - -## Documentation -* [Debug](debug.md) -* [Development](development.md) -* [Installation](installation.md) - -## API Reference -* [Admin](api/admin.md) -* [Blame](api/blame.md) -* [Blob](api/blob.md) -* [Branch](api/branch.md) -* [Commit](api/commit.md) -* [Diff](api/diff.md) -* [Hooks](api/hooks.md) -* [Log](api/log.md) -* [References](api/references.md) -* [Repository](api/repository.md) -* [Revision](api/revision.md) -* [Tree](api/tree.md) -* [Working Copy](api/workingcopy.md) - -## Missing features - -Some major features are still missing from gitlib: - -- Remotes -- Submodules - -If you want to run git commands on repository, call method `Repository::run` with method and arguments. diff --git a/doc/installation.md b/doc/installation.md deleted file mode 100644 index ae7e90d0..00000000 --- a/doc/installation.md +++ /dev/null @@ -1,27 +0,0 @@ -Installation of gitlib -====================== - -Autoloading ------------ - -gitlib relies on class autoloading. It does not require any additional setup. - -Using composer --------------- - -You can install gitlib using [Composer](https://getcomposer.org/). Simply -require the version you need: - -```bash -$ composer require gitonomy/gitlib -``` - -or edit your `composer.json` file by hand: - -```json -{ - "require": { - "gitonomy/gitlib": "^1.2" - } -} -``` diff --git a/doc/api/log.md b/doc/log.md similarity index 81% rename from doc/api/log.md rename to doc/log.md index a03989ac..4dc5a465 100644 --- a/doc/api/log.md +++ b/doc/log.md @@ -24,7 +24,7 @@ $log = $repository->getLog('master'); $log = $repository->getLog('master', 'README', 0, 10); // Returns last 10 commits on README or UPGRADE files -$log = $repository->getLog('master', array('README', 'UPGRADE'), 0, 10); +$log = $repository->getLog('master', ['README', 'UPGRADE'], 0, 10); ``` Counting @@ -34,10 +34,10 @@ If you want to count overall commits, without offset or limit, use the *countCommits* method: ```php -echo sprintf("This log contains %s commits\n", $log->countCommits()); +echo sprintf('This log contains %s commits%s', $log->countCommits(), PHP_EOL); // Countable interface -echo sprintf("This log contains %s commits\n", count($log)); +echo sprintf('This log contains %s commits%s', count($log), PHP_EOL); ``` Offset and limit diff --git a/doc/api/references.md b/doc/references.md similarity index 98% rename from doc/api/references.md rename to doc/references.md index c326ba3e..64430929 100644 --- a/doc/api/references.md +++ b/doc/references.md @@ -15,7 +15,7 @@ First, you can test existence of tags and branches like this: ```php if ($references->hasBranch('master') && $references->hasTag('0.1')) { - echo "Good start!"; + echo 'Good start!'.PHP_EOL; } ``` diff --git a/doc/api/repository.md b/doc/repository.md similarity index 83% rename from doc/api/repository.md rename to doc/repository.md index 4aa6e512..05b4a4e7 100644 --- a/doc/api/repository.md +++ b/doc/repository.md @@ -28,10 +28,10 @@ Available options are: An example: ```php -$repository = new Repository('/path/to/repo', array( +$repository = new Repository('/path/to/repo', [ 'debug' => true, - 'logger' => new Monolog\Logger() -)); + 'logger' => new Monolog\Logger(), +]); ``` Test if a repository is bare @@ -59,7 +59,7 @@ The returned size is in kilobytes: ```php $size = $repository->getSize(); -echo "Your repository size is ".$size."KB"; +echo 'Your repository size is '.$size.'KB'; ``` Access HEAD @@ -74,7 +74,7 @@ $head = $repository->getHead(); // Commit or Reference $head = $repository->getHeadCommit(); // Commit if ($repository->isHeadDetached()) { - echo "Sorry man\n"; + echo 'Sorry man'.PHP_EOL; } ``` @@ -91,15 +91,15 @@ You call method `setLogger` as an option on repository creation: ```php $repository->setLogger(new Monolog\Logger('repository')); -$repository->run('fetch', array('--all')); +$repository->run('fetch', ['--all']); ``` You can also specify as an option on repository creation: ```php $logger = new MonologLogger('repository'); -$repository = new Repository('/path/foo', array('logger' => $logger)); -$repository->run('fetch', array('--all')); +$repository = new Repository('/path/foo', ['logger' => $logger]); +$repository->run('fetch', ['--all']); ``` This will output: @@ -120,7 +120,7 @@ This will make `Repository` log errors and return empty data instead of throwing exceptions. ```php -$repository = new Repository('/tmp/foo', array('debug' => false, 'logger' => $logger)); +$repository = new Repository('/tmp/foo', ['debug' => false, 'logger' => $logger]); ``` > **note** @@ -135,7 +135,7 @@ calls. If you have a git binary located somewhere else, use this option to specify to gitlib path to your git binary: ```php -$repository = new Gitonomy\Git\Repository('/tmp/foo', array('command' => '/home/alice/bin/git')); +$repository = new Gitonomy\Git\Repository('/tmp/foo', ['command' => '/home/alice/bin/git']); ``` ### Environment variables @@ -143,5 +143,5 @@ $repository = new Gitonomy\Git\Repository('/tmp/foo', array('command' => '/home/ It is possible to send environment variables to the `git` commands. ```php -$repository = new Gitonomy\Git\Repository('/tmp/foo', array('environment_variables' => array('GIT_'))) +$repository = new Gitonomy\Git\Repository('/tmp/foo', ['environment_variables' => ['GIT_']]) ``` diff --git a/doc/api/revision.md b/doc/revision.md similarity index 100% rename from doc/api/revision.md rename to doc/revision.md diff --git a/doc/api/tree.md b/doc/tree.md similarity index 93% rename from doc/api/tree.md rename to doc/tree.md index 67a40147..0c5c8ef7 100644 --- a/doc/api/tree.md +++ b/doc/tree.md @@ -26,10 +26,10 @@ function displayTree(Tree $tree, $indent = 0) foreach ($tree->getEntries() as $name => $data) { list($mode, $entry) = $data; if ($entry instanceof Tree) { - echo $indent.$name."/\n"; + echo $indent.$name.'/'.PHP_EOL; displayTree($tree, $indent + 1); } else { - echo $indent.$name."\n"; + echo $indent.$name.PHP_EOL; } } } diff --git a/doc/api/workingcopy.md b/doc/workingcopy.md similarity index 100% rename from doc/api/workingcopy.md rename to doc/workingcopy.md From b4620be3929be291438d8d9dec153f05f4ac5817 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 13 Dec 2019 13:07:53 +0000 Subject: [PATCH 016/150] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 91b8a643..7d02e695 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ use Gitonomy\Git\Repository; $repository = new Repository('/path/to/repository'); foreach ($repository->getReferences()->getBranches() as $branch) { - echo "- ".$branch->getName().PHP_EOL; + echo '- '.$branch->getName().PHP_EOL; } $repository->run('fetch', ['--all']); From 169a1d222c4fdedc810170f6dee797e8cc398ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Manuel=20Casanova=20Gonz=C3=A1lez?= Date: Tue, 7 Jan 2020 10:12:07 +0100 Subject: [PATCH 017/150] Ignore unknown refs instead of throwing an exception (#161) --- src/Gitonomy/Git/ReferenceBag.php | 6 --- tests/Gitonomy/Git/Tests/ReferenceBagTest.php | 47 +++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 tests/Gitonomy/Git/Tests/ReferenceBagTest.php diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 178fd9f2..57aec259 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -366,12 +366,6 @@ protected function initialize() } elseif ($fullname === 'refs/stash') { $reference = new Stash($this->repository, $fullname, $commitHash); $this->references[$fullname] = $reference; - } elseif (preg_match('#^refs/pull/(.*)$#', $fullname)) { - // Do nothing here - } elseif ($fullname === 'refs/notes/gtm-data') { - // Do nothing here - } else { - throw new RuntimeException(sprintf('Unable to parse "%s"', $fullname)); } } } diff --git a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php new file mode 100644 index 00000000..35756bf0 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php @@ -0,0 +1,47 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Repository; + +class ReferenceBagTest extends AbstractTest +{ + /** + * @dataProvider provideFoobar + */ + public function testUnknownReference(Repository $repository) + { + $hash = $repository->getLog()->getSingleCommit()->getHash(); + + $repository->run('update-ref', ['refs/pipelines/1', $hash]); + $repository->run('update-ref', ['refs/merge-request/1/head', $hash]); + $repository->run('update-ref', ['refs/pull/1/head', $hash]); + $repository->run('update-ref', ['refs/notes/gtm-data', $hash]); + + $refs = $repository->getReferences()->getAll(); + if (method_exists($this, 'assertIsArray')) { + $this->assertIsArray($refs); + } else { + $this->assertInternalType('array', $refs); + } + + // Check that at least it has the master ref + $this->assertArrayHasKey('refs/heads/master', $refs); + + // Check that our custom refs have been ignored + $this->assertArrayNotHasKey('refs/pipelines/1', $refs); + $this->assertArrayNotHasKey('refs/merge-request/1/head', $refs); + $this->assertArrayNotHasKey('refs/pull/1/head', $refs); + $this->assertArrayNotHasKey('refs/notes/gtm-data', $refs); + } +} From 4daa5a2e77d3ce48bae2695561ea5b917b0d1631 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 20 Mar 2020 10:24:35 +0000 Subject: [PATCH 018/150] Fixed some tests on Windows (#168) Co-authored-by: Bruce Wells --- tests/Gitonomy/Git/Tests/AbstractTest.php | 2 +- tests/Gitonomy/Git/Tests/CommitTest.php | 10 ++-------- tests/Gitonomy/Git/Tests/HooksTest.php | 2 +- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/tests/Gitonomy/Git/Tests/AbstractTest.php b/tests/Gitonomy/Git/Tests/AbstractTest.php index a47f3792..13f40da9 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTest.php +++ b/tests/Gitonomy/Git/Tests/AbstractTest.php @@ -18,7 +18,7 @@ abstract class AbstractTest extends TestCase { - const REPOSITORY_URL = 'http://github.com/gitonomy/foobar.git'; + const REPOSITORY_URL = 'https://github.com/gitonomy/foobar.git'; const LONGFILE_COMMIT = '4f17752acc9b7c54ba679291bf24cb7d354f0f4f'; const BEFORE_LONGFILE_COMMIT = 'e0ec50e2af75fa35485513f60b2e658e245227e9'; diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index bb4b275b..8dca9c40 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -227,14 +227,8 @@ public function testGetShortMessage($repository) public function testGetBodyMessage($repository) { $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); - $message = <<<'EOL' -If you want to know everything, -I ran something like `chmox +x test.sh` - -Hello and good bye. - -EOL; - + $nl = chr(10); + $message = "If you want to know everything,{$nl}I ran something like `chmox +x test.sh`{$nl}{$nl}Hello and good bye.{$nl}"; $this->assertEquals($message, $commit->getBodyMessage()); $commit = $repository->getCommit(self::INITIAL_COMMIT); diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index e3478c77..597a0b4b 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -104,7 +104,7 @@ public function testSymlink($repository) $repository->getHooks()->setSymlink('foo', $file); $this->assertTrue(is_link($this->hookPath($repository, 'foo')), 'foo hook is a symlink'); - $this->assertEquals($file, readlink($this->hookPath($repository, 'foo')), 'target of symlink is correct'); + $this->assertEquals(str_replace('\\', '/', $file), str_replace('\\', '/', readlink($this->hookPath($repository, 'foo'))), 'target of symlink is correct'); } /** From 0c787ab991dca1ebd1618544d1a699bf57f7d817 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 20 Mar 2020 10:24:44 +0000 Subject: [PATCH 019/150] Fixed Travis CI readme badge (#169) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d02e695..7aae7fca 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Gitlib for Gitonomy =================== -[![Build Status](https://img.shields.io/travis/gitonomy/gitlib/master.svg?style=flat-square)](https://travis-ci.org/gitonomy/gitlib) +[![Build Status](https://img.shields.io/travis/com/gitonomy/gitlib/master.svg?style=flat-square)](https://travis-ci.com/gitonomy/gitlib) [![StyleCI](https://github.styleci.io/repos/5709354/shield?branch=master)](https://github.styleci.io/repos/5709354) [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://opensource.org/licenses/MIT) From 07ea495aa066bea125ea74b26da086d5d30627a4 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 20 Mar 2020 10:30:17 +0000 Subject: [PATCH 020/150] Update .gitignore (#170) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index abb9c75a..bc959c53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -/vendor /composer.lock /phpunit.xml +/vendor From 5448d79b30fe162fb4540ce61d346668f7987c0f Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 20 Mar 2020 15:04:44 +0000 Subject: [PATCH 021/150] Get real commit for annotated tag (#171) --- src/Gitonomy/Git/Reference/Tag.php | 28 ++++++++++++++++++++++ tests/Gitonomy/Git/Tests/ReferenceTest.php | 7 ++++++ 2 files changed, 35 insertions(+) diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index e80fde00..f8461e1b 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -14,6 +14,7 @@ use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Parser\ReferenceParser; use Gitonomy\Git\Parser\TagParser; use Gitonomy\Git\Reference; @@ -21,6 +22,7 @@ * Representation of a tag reference. * * @author Alexandre Salomé + * @author Bruce Wells */ class Tag extends Reference { @@ -49,6 +51,32 @@ public function isAnnotated() return true; } + /** + * Returns the actual commit associated with the tag, and not the hash of the tag if annotated. + * + * @return Commit + */ + public function getCommit() + { + if ($this->isAnnotated()) { + try { + $output = $this->repository->run('show-ref', ['-d', '--tag', $this->revision]); + $parser = new ReferenceParser(); + $parser->parse($output); + + foreach ($parser->references as $row) { + list($commitHash, $fullname) = $row; + } + + return $this->repository->getCommit($commitHash); + } catch (ProcessException $e) { + // ignore the exception + } + } + + return parent::getCommit(); + } + /** * Returns the tagger name. * diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index d1c00fc6..d24a5e64 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -97,6 +97,13 @@ public function testAnnotatedTag($repository) $this->assertEquals('heading', $tag->getSubjectMessage(), 'Message heading is correct'); $this->assertEquals("body\nbody", $tag->getBodyMessage(), 'Message body is correct'); + + $closure = function () { + return parent::getCommit(); + }; + $parentCommit = $closure->bindTo($tag, Tag::class); + $this->assertNotEquals($parentCommit()->getHash(), $tag->getCommit()->getHash(), 'Tag commit is not the same as main commit'); + $this->assertEquals('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommit()->getHash(), 'Tag commit is correct'); } /** From e3b26db65f303e6421e4dbf60aa4b0c2ea2a2506 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Sat, 21 Mar 2020 11:35:57 +0000 Subject: [PATCH 022/150] Configurable inheritance of environment variables (#173) --- src/Gitonomy/Git/Repository.php | 37 ++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 36e35850..fc65c865 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -86,6 +86,11 @@ class Repository */ protected $environmentVariables; + /** + * @var bool + */ + protected $inheritEnvironmentVariables; + /** * Timeout that should be set for every running process. * @@ -113,14 +118,14 @@ class Repository */ public function __construct($dir, $options = []) { - $is_windows = defined('PHP_WINDOWS_VERSION_BUILD'); $options = array_merge([ - 'working_dir' => null, - 'debug' => true, - 'logger' => null, - 'environment_variables' => $is_windows ? ['PATH' => getenv('path')] : [], - 'command' => 'git', - 'process_timeout' => 3600, + 'working_dir' => null, + 'debug' => true, + 'logger' => null, + 'command' => 'git', + 'environment_variables' => [], + 'inherit_environment_variables' => false, + 'process_timeout' => 3600, ], $options); if (null !== $options['logger'] && !$options['logger'] instanceof LoggerInterface) { @@ -131,10 +136,16 @@ public function __construct($dir, $options = []) $this->initDir($dir, $options['working_dir']); $this->objects = []; + $this->command = $options['command']; $this->debug = (bool) $options['debug']; - $this->environmentVariables = $options['environment_variables']; $this->processTimeout = $options['process_timeout']; - $this->command = $options['command']; + + if (defined('PHP_WINDOWS_VERSION_BUILD') && isset($_SERVER['PATH']) && !isset($options['environment_variables']['PATH'])) { + $options['environment_variables']['PATH'] = $_SERVER['PATH']; + } + + $this->environmentVariables = $options['environment_variables']; + $this->inheritEnvironmentVariables = $options['inherit_environment_variables']; if (true === $this->debug && null !== $this->logger) { $this->logger->debug(sprintf('Repository created (git dir: "%s", working dir: "%s")', $this->gitDir, $this->workingDir ?: 'none')); @@ -620,7 +631,13 @@ private function getProcess($command, $args = []) $base[] = $command; $process = new Process(array_merge($base, $args)); - $process->setEnv($this->environmentVariables); + + if ($this->inheritEnvironmentVariables) { + $process->setEnv(array_replace($_SERVER, $this->environmentVariables)); + } else { + $process->setEnv($this->environmentVariables); + } + $process->setTimeout($this->processTimeout); $process->setIdleTimeout($this->processTimeout); From 2d18d67b45afec54311d5fad6d72c2ee94431d87 Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Sat, 21 Mar 2020 07:36:55 -0400 Subject: [PATCH 023/150] Php only Repository::getsize (#174) Co-authored-by: Graham Campbell --- src/Gitonomy/Git/Repository.php | 23 ++++++--------------- tests/Gitonomy/Git/Tests/RepositoryTest.php | 3 ++- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index fc65c865..ae019793 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -422,30 +422,19 @@ public function getDiff($revisions) /** * Returns the size of repository, in kilobytes. * - * @throws RuntimeException An error occurred while computing size - * * @return int A sum, in kilobytes */ public function getSize() { - $process = new Process(['du', '-skc', $this->gitDir]); - $process->run(); - - if (!preg_match('/(\d+)\s+total$/', trim($process->getOutput()), $vars)) { - $message = sprintf("Unable to parse process output\ncommand: %s\noutput: %s", $process->getCommandLine(), $process->getOutput()); - - if (null !== $this->logger) { - $this->logger->error($message); + $totalBytes = 0; + $path = realpath($this->gitDir); + if ($path && file_exists($path)) { + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)) as $object) { + $totalBytes += $object->getSize(); } - - if (true === $this->debug) { - throw new RuntimeException('unable to parse repository size output'); - } - - return; } - return $vars[1]; + return (int) ($totalBytes / 1000 + 0.5); } /** diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 6eee0e60..2cafdd22 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -35,7 +35,8 @@ public function testGetBlob_WithExisting_Works($repository) public function testGetSize($repository) { $size = $repository->getSize(); - $this->assertGreaterThan(70, $size, 'Repository is greater than 70KB'); + $this->assertGreaterThanOrEqual(69, $size, 'Repository is at least 69KB'); + $this->assertLessThan(80, $size, 'Repository is less than 80KB'); } public function testIsBare() From 0cbac31d7a0c62310feefc807cbb26297c7d2f19 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Mon, 23 Mar 2020 10:58:55 +0000 Subject: [PATCH 024/150] Added missing mbstring soft requirement (#175) --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index aa6c2f13..0e5f1723 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ }, "require": { "php": "^5.6 || ^7.0", + "symfony/polyfill-mbstring": "^1.7", "symfony/process": "^3.4|^4.0|^5.0" }, "require-dev": { From 718ca021c67f3ea8f6a5fa5d231ec49675068868 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Mon, 23 Mar 2020 12:43:44 +0000 Subject: [PATCH 025/150] Added remaining missing requirements (#177) --- composer.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/composer.json b/composer.json index 0e5f1723..805fbff3 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ }, "require": { "php": "^5.6 || ^7.0", + "ext-pcre": "*", "symfony/polyfill-mbstring": "^1.7", "symfony/process": "^3.4|^4.0|^5.0" }, @@ -40,6 +41,7 @@ "psr/log": "^1.0" }, "suggest": { + "ext-fileinfo": "Required to determine the mimetype of a blob", "psr/log": "Required to use loggers for reporting of execution" }, "extra": { From f0fa7829b4d9541c16178dfcec01e4243260dbb3 Mon Sep 17 00:00:00 2001 From: Gilles Doge Date: Sat, 28 Mar 2020 17:07:20 +0100 Subject: [PATCH 026/150] [doc] Fix small typo (#178) --- doc/admin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/admin.md b/doc/admin.md index ee2596ec..d4776804 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -43,7 +43,7 @@ non-bare repository, this branch will be checked out: $repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch'); // Clone to a non-bare repository -$repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch' false); +$repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch', false); ``` Clone a Repository object From 5ffc63876d39531f05dfbc74d59aaae46ae7df2c Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Mon, 13 Apr 2020 12:37:29 +0100 Subject: [PATCH 027/150] Update .travis.yml --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 93aa0fff..db3bdc7a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -42,8 +42,8 @@ matrix: dist: bionic env: SYMFONY_VERSION=^5.0 -before_install: composer require "symfony/process:${SYMFONY_VERSION}" --no-update +before_install: travis_retry composer require "symfony/process:${SYMFONY_VERSION}" --no-update -install: travis_retry composer install --no-interaction --no-progress --no-suggest +install: travis_retry composer install --prefer-dist --no-progress -n -o script: vendor/bin/phpunit From ce2f9b77f0739bb343d4b6b802561e02b8a6fbc4 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Mon, 13 Apr 2020 12:39:22 +0100 Subject: [PATCH 028/150] Added missing dev extension requirement --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 805fbff3..6c57fc47 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "symfony/process": "^3.4|^4.0|^5.0" }, "require-dev": { + "ext-fileinfo": "*", "phpunit/phpunit": "^5.7|^6.5|^7.0", "psr/log": "^1.0" }, From 3d70f3504ee0d3b687917312af96a22f649bcf3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1=20Pereira?= Date: Tue, 14 Apr 2020 09:47:33 +0100 Subject: [PATCH 029/150] Allow reload references from filesystem (#179) --- src/Gitonomy/Git/Repository.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index ae019793..3920616b 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -315,11 +315,13 @@ public function getWorkingCopy() /** * Returns the reference list associated to the repository. * + * @param bool $reload Reload references from the filesystem + * * @return ReferenceBag */ - public function getReferences() + public function getReferences($reload = false) { - if (null === $this->referenceBag) { + if (null === $this->referenceBag || $reload) { $this->referenceBag = new ReferenceBag($this); } From c6ba917bcf09bf56a9e839dfa9c4d7942a062751 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 30 Jul 2020 15:39:09 +0100 Subject: [PATCH 030/150] Added support for PHP 8.0 (#180) --- .gitignore | 1 + .travis.yml | 22 +++++++++++++--- composer.json | 6 ++--- phpunit.xml.dist | 2 +- tests/Gitonomy/Git/Tests/AbstractTest.php | 2 +- tests/Gitonomy/Git/Tests/AdminTest.php | 12 ++++++--- tests/Gitonomy/Git/Tests/BlobTest.php | 13 ++++++++-- tests/Gitonomy/Git/Tests/HooksTest.php | 28 ++++++++++++++++++--- tests/Gitonomy/Git/Tests/RepositoryTest.php | 9 +++++-- 9 files changed, 75 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index bc959c53..376a2cd1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.phpunit.result.cache /composer.lock /phpunit.xml /vendor diff --git a/.travis.yml b/.travis.yml index db3bdc7a..6b17e263 100644 --- a/.travis.yml +++ b/.travis.yml @@ -41,9 +41,23 @@ matrix: - php: 7.4 dist: bionic env: SYMFONY_VERSION=^5.0 + - php: nightly + dist: bionic + env: SYMFONY_VERSION=^3.4 + - php: nightly + dist: bionic + env: SYMFONY_VERSION=^4.0 + - php: nightly + dist: bionic + env: SYMFONY_VERSION=^5.0 -before_install: travis_retry composer require "symfony/process:${SYMFONY_VERSION}" --no-update - -install: travis_retry composer install --prefer-dist --no-progress -n -o +before_install: + - composer self-update --2 + - travis_retry composer require "symfony/process:${SYMFONY_VERSION}" --no-update -script: vendor/bin/phpunit +install: + - if [[ "$TRAVIS_PHP_VERSION" != 'nightly' ]]; then travis_retry composer update --prefer-dist --no-progress -n -o; fi + - if [[ "$TRAVIS_PHP_VERSION" = 'nightly' ]]; then travis_retry composer update --ignore-platform-req=php --prefer-dist --no-progress -n -o; fi + +script: + - vendor/bin/phpunit diff --git a/composer.json b/composer.json index 6c57fc47..92116201 100644 --- a/composer.json +++ b/composer.json @@ -31,14 +31,14 @@ } }, "require": { - "php": "^5.6 || ^7.0", + "php": "^5.6 || ^7.0 || ^8.0", "ext-pcre": "*", "symfony/polyfill-mbstring": "^1.7", - "symfony/process": "^3.4|^4.0|^5.0" + "symfony/process": "^3.4 || ^4.0 || ^5.0" }, "require-dev": { "ext-fileinfo": "*", - "phpunit/phpunit": "^5.7|^6.5|^7.0", + "phpunit/phpunit": "^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0", "psr/log": "^1.0" }, "suggest": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 333343d5..fd8be6ab 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,7 +9,7 @@ convertNoticesToExceptions="true" convertWarningsToExceptions="true" failOnRisky="true" - failOnWarning="true" + failOnWarning="false" processIsolation="false" stopOnError="false" stopOnFailure="false" diff --git a/tests/Gitonomy/Git/Tests/AbstractTest.php b/tests/Gitonomy/Git/Tests/AbstractTest.php index 13f40da9..d586eda6 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTest.php +++ b/tests/Gitonomy/Git/Tests/AbstractTest.php @@ -117,7 +117,7 @@ public static function createTempDir() * * @param string $dir directory to delete */ - public static function deleteDir($dir) + protected static function deleteDir($dir) { $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS); $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::CHILD_FIRST); diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index 7234bdff..3bd29f50 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -21,14 +21,20 @@ class AdminTest extends AbstractTest { private $tmpDir; - protected function setUp() + /** + * @before + */ + public function setUpTmpDir() { $this->tmpDir = self::createTempDir(); } - protected function tearDown() + /** + * @after + */ + public function tearDownTmpDir() { - $this->deleteDir(self::createTempDir()); + self::deleteDir(self::createTempDir()); } public function testBare() diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 36b1d58d..4b288626 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -30,7 +30,11 @@ public function testGetContent($repository) { $blob = $this->getReadmeBlob($repository); - $this->assertContains(self::README_FRAGMENT, $blob->getContent()); + if (method_exists($this, 'assertStringContainsString')) { + $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); + } else { + $this->assertContains(self::README_FRAGMENT, $blob->getContent()); + } } /** @@ -50,7 +54,12 @@ public function testNotExisting($repository) public function testGetMimetype($repository) { $blob = $this->getReadmeBlob($repository); - $this->assertRegexp('#text/plain#', $blob->getMimetype()); + + if (method_exists($this, 'assertMatchesRegularExpression')) { + $this->assertMatchesRegularExpression('#text/plain#', $blob->getMimetype()); + } else { + $this->assertRegExp('#text/plain#', $blob->getMimetype()); + } } /** diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 597a0b4b..1b09fe55 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -19,7 +19,10 @@ class HooksTest extends AbstractTest { private static $symlinkOnWindows = null; - public static function setUpBeforeClass() + /** + * @beforeClass + */ + public static function setUpWindows() { if (defined('PHP_WINDOWS_VERSION_MAJOR')) { self::$symlinkOnWindows = true; @@ -52,6 +55,7 @@ public function assertHasHook($repository, $hook) $file = $this->hookPath($repository, $hook); $this->assertTrue($repository->getHooks()->has($hook), "hook $hook in repository"); + $this->assertFileExists($file, "Hook $hook is present"); } @@ -60,7 +64,12 @@ public function assertNoHook($repository, $hook) $file = $this->hookPath($repository, $hook); $this->assertFalse($repository->getHooks()->has($hook), "No hook $hook in repository"); - $this->assertFileNotExists($file, "Hook $hook is not present"); + + if (method_exists($this, 'assertFileDoesNotExist')) { + $this->assertFileDoesNotExist($file, "Hook $hook is not present"); + } else { + $this->assertFileNotExists($file, "Hook $hook is not present"); + } } /** @@ -104,7 +113,12 @@ public function testSymlink($repository) $repository->getHooks()->setSymlink('foo', $file); $this->assertTrue(is_link($this->hookPath($repository, 'foo')), 'foo hook is a symlink'); - $this->assertEquals(str_replace('\\', '/', $file), str_replace('\\', '/', readlink($this->hookPath($repository, 'foo'))), 'target of symlink is correct'); + + $this->assertEquals( + str_replace('\\', '/', $file), + str_replace('\\', '/', readlink($this->hookPath($repository, 'foo'))), + 'target of symlink is correct' + ); } /** @@ -147,6 +161,7 @@ public function testSet_Existing_ThrowsLogicException($repository) $repository->getHooks()->set('foo', 'bar'); $this->expectException(LogicException::class); + $repository->getHooks()->set('foo', 'bar'); } @@ -159,7 +174,12 @@ public function testRemove($repository) touch($file); $repository->getHooks()->remove('foo'); - $this->assertFileNotExists($file); + + if (method_exists($this, 'assertFileDoesNotExist')) { + $this->assertFileDoesNotExist($file); + } else { + $this->assertFileNotExists($file); + } } /** diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 2cafdd22..7ff6a195 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -21,12 +21,17 @@ class RepositoryTest extends AbstractTest /** * @dataProvider provideFoobar */ - public function testGetBlob_WithExisting_Works($repository) + public function testGetBlobWithExistingWorks($repository) { $blob = $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('README.md'); $this->assertInstanceOf(Blob::class, $blob, 'getBlob() returns a Blob object'); - $this->assertContains('Foo Bar project', $blob->getContent(), 'file is correct'); + + if (method_exists($this, 'assertStringContainsString')) { + $this->assertStringContainsString('Foo Bar project', $blob->getContent(), 'file is correct'); + } else { + $this->assertContains('Foo Bar project', $blob->getContent(), 'file is correct'); + } } /** From 0c6af28c7f4133f5c36274b6459048086a16d571 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 30 Jul 2020 15:42:51 +0100 Subject: [PATCH 031/150] Don't need a branch alias now --- composer.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/composer.json b/composer.json index 92116201..38108cb6 100644 --- a/composer.json +++ b/composer.json @@ -44,10 +44,5 @@ "suggest": { "ext-fileinfo": "Required to determine the mimetype of a blob", "psr/log": "Required to use loggers for reporting of execution" - }, - "extra": { - "branch-alias": { - "dev-master": "1.2-dev" - } } } From b4c46a9a945b878f0ff9ce7422332417e7323b04 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 30 Jul 2020 15:45:19 +0100 Subject: [PATCH 032/150] Tweaked README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7aae7fca..861dcdbe 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ Gitlib for Gitonomy =================== -[![Build Status](https://img.shields.io/travis/com/gitonomy/gitlib/master.svg?style=flat-square)](https://travis-ci.com/gitonomy/gitlib) -[![StyleCI](https://github.styleci.io/repos/5709354/shield?branch=master)](https://github.styleci.io/repos/5709354) +[![Build Status](https://img.shields.io/travis/com/gitonomy/gitlib/1.2.svg?style=flat-square)](https://travis-ci.com/gitonomy/gitlib) +[![StyleCI](https://github.styleci.io/repos/5709354/shield?branch=1.2)](https://github.styleci.io/repos/5709354?branch=1.2) [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://opensource.org/licenses/MIT) -This library provides methods to access Git repository from PHP 5.6 or 7. +This library provides methods to access Git repository from PHP 5.6+. It makes shell calls, which makes it less performant than any solution. From d1fe4676bf1347c08dec84a14a4c5e7110740d72 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 30 Jul 2020 15:54:11 +0100 Subject: [PATCH 033/150] Removed old method that was never implemented --- src/Gitonomy/Git/WorkingCopy.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index 057253b5..aed3b85c 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -35,11 +35,6 @@ public function __construct(Repository $repository) } } - public function getStatus() - { - return WorkingStatus::parseOutput(); - } - public function getUntrackedFiles() { $lines = explode("\0", $this->run('status', ['--porcelain', '--untracked-files=all', '-z'])); From e7a9ddd044933d6280996b4142e79a623771ae1e Mon Sep 17 00:00:00 2001 From: "Chun-Sheng, Li" Date: Tue, 29 Dec 2020 22:08:16 +0800 Subject: [PATCH 034/150] Fix the bug for counter variable type on PR #183 (#184) Co-authored-by: Graham Campbell --- src/Gitonomy/Git/Diff/FileChange.php | 35 ++++++++++++++++++++++++++ src/Gitonomy/Git/Parser/DiffParser.php | 8 +++--- tests/Gitonomy/Git/Tests/DiffTest.php | 8 +++--- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/Gitonomy/Git/Diff/FileChange.php b/src/Gitonomy/Git/Diff/FileChange.php index 898bbb48..cca18dc6 100644 --- a/src/Gitonomy/Git/Diff/FileChange.php +++ b/src/Gitonomy/Git/Diff/FileChange.php @@ -24,6 +24,15 @@ class FileChange protected $rangeNewCount; protected $lines; + /** + * @param int $rangeOldStart + * @param int $rangeOldCount + * @param int $rangeNewStart + * @param int $rangeNewCount + * @param array $lines + * + * @return void + */ public function __construct($rangeOldStart, $rangeOldCount, $rangeNewStart, $rangeNewCount, $lines) { $this->rangeOldStart = $rangeOldStart; @@ -33,6 +42,9 @@ public function __construct($rangeOldStart, $rangeOldCount, $rangeNewStart, $ran $this->lines = $lines; } + /** + * @return int + */ public function getCount($type) { $result = 0; @@ -45,31 +57,49 @@ public function getCount($type) return $result; } + /** + * @return int + */ public function getRangeOldStart() { return $this->rangeOldStart; } + /** + * @return int + */ public function getRangeOldCount() { return $this->rangeOldCount; } + /** + * @return int + */ public function getRangeNewStart() { return $this->rangeNewStart; } + /** + * @return int + */ public function getRangeNewCount() { return $this->rangeNewCount; } + /** + * @return array + */ public function getLines() { return $this->lines; } + /** + * @return array + */ public function toArray() { return [ @@ -81,6 +111,11 @@ public function toArray() ]; } + /** + * @param array $array + * + * @return self + */ public static function fromArray(array $array) { return new self($array['range_old_start'], $array['range_old_count'], $array['range_new_start'], $array['range_new_count'], $array['lines']); diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index 2b84df8b..d58016e3 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -97,10 +97,10 @@ protected function doParse() // 5. Diff while ($this->expects('@@ ')) { $vars = $this->consumeRegexp('/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?/'); - $rangeOldStart = $vars[1]; - $rangeOldCount = $vars[2]; - $rangeNewStart = $vars[3]; - $rangeNewCount = isset($vars[4]) ? $vars[4] : $vars[2]; // @todo Ici, t'as pris un gros raccourci mon loulou + $rangeOldStart = (int) $vars[1]; + $rangeOldCount = (int) $vars[2]; + $rangeNewStart = (int) $vars[3]; + $rangeNewCount = isset($vars[4]) ? (int) $vars[4] : (int) $vars[2]; // @todo Ici, t'as pris un gros raccourci mon loulou $this->consume(' @@'); $this->consumeTo("\n"); $this->consumeNewLine(); diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index ec0eebe7..08f2e943 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -134,10 +134,10 @@ public function testDiffRangeParse($repository) $changes = $files[0]->getChanges(); - $this->assertEquals(0, $changes[0]->getRangeOldStart()); - $this->assertEquals(0, $changes[0]->getRangeOldCount()); + $this->assertSame(0, $changes[0]->getRangeOldStart()); + $this->assertSame(0, $changes[0]->getRangeOldCount()); - $this->assertEquals(1, $changes[0]->getRangeNewStart()); - $this->assertEquals(0, $changes[0]->getRangeNewCount()); + $this->assertSame(1, $changes[0]->getRangeNewStart()); + $this->assertSame(0, $changes[0]->getRangeNewCount()); } } From 69c50da21415ff9cfa73ad796032d39ecf6a1170 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Tue, 29 Dec 2020 16:46:31 +0000 Subject: [PATCH 035/150] Switch to GitHub Actions --- .github/workflows/tests.yml | 74 +++++++++++++++++++++++++++++++++++++ .travis.yml | 63 ------------------------------- README.md | 3 +- 3 files changed, 76 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..6acf5b98 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,74 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + tests: + name: PHP ${{ matrix.php }}; Symfony ${{ matrix.laravel }} + runs-on: ubuntu-20.04 + + strategy: + matrix: + php: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0'] + symfony: ['3', '4', '5'] + exclude: + - php: '5.6' + symfony: '4' + - php: '5.6' + symfony: '5' + - php: '7.0' + symfony: '4' + - php: '7.0' + symfony: '5' + - php: '7.1' + symfony: '5' + + steps: + - name: Checkout Code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: none + + - name: Setup Problem Matchers + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Select Symfony 3 + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer require "symfony/process:^3.4" --no-update --no-interaction + if: "matrix.symfony == '3'" + + - name: Select Symfony 4 + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer require "symfony/process:^4.4" --no-update --no-interaction + if: "matrix.symfony == '4'" + + - name: Select Symfony 5 + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer require "symfony/process:^5.2" --no-update --no-interaction + if: "matrix.symfony == '5'" + + - name: Install PHP Dependencies + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --no-interaction --no-progress + + - name: Execute PHPUnit + run: vendor/bin/phpunit diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6b17e263..00000000 --- a/.travis.yml +++ /dev/null @@ -1,63 +0,0 @@ -language: php - -matrix: - include: - - php: 5.6 - dist: xenial - env: SYMFONY_VERSION=^3.4 - - php: 7.0 - dist: xenial - env: SYMFONY_VERSION=^3.4 - - php: 7.1 - dist: bionic - env: SYMFONY_VERSION=^3.4 - - php: 7.1 - dist: bionic - env: SYMFONY_VERSION=^4.0 - - php: 7.2 - dist: bionic - env: SYMFONY_VERSION=^3.4 - - php: 7.2 - dist: bionic - env: SYMFONY_VERSION=^4.0 - - php: 7.2 - dist: bionic - env: SYMFONY_VERSION=^5.0 - - php: 7.3 - dist: bionic - env: SYMFONY_VERSION=^3.4 - - php: 7.3 - dist: bionic - env: SYMFONY_VERSION=^4.0 - - php: 7.3 - dist: bionic - env: SYMFONY_VERSION=^5.0 - - php: 7.4 - dist: bionic - env: SYMFONY_VERSION=^3.4 - - php: 7.4 - dist: bionic - env: SYMFONY_VERSION=^4.0 - - php: 7.4 - dist: bionic - env: SYMFONY_VERSION=^5.0 - - php: nightly - dist: bionic - env: SYMFONY_VERSION=^3.4 - - php: nightly - dist: bionic - env: SYMFONY_VERSION=^4.0 - - php: nightly - dist: bionic - env: SYMFONY_VERSION=^5.0 - -before_install: - - composer self-update --2 - - travis_retry composer require "symfony/process:${SYMFONY_VERSION}" --no-update - -install: - - if [[ "$TRAVIS_PHP_VERSION" != 'nightly' ]]; then travis_retry composer update --prefer-dist --no-progress -n -o; fi - - if [[ "$TRAVIS_PHP_VERSION" = 'nightly' ]]; then travis_retry composer update --ignore-platform-req=php --prefer-dist --no-progress -n -o; fi - -script: - - vendor/bin/phpunit diff --git a/README.md b/README.md index 861dcdbe..ddb7135c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ Gitlib for Gitonomy =================== -[![Build Status](https://img.shields.io/travis/com/gitonomy/gitlib/1.2.svg?style=flat-square)](https://travis-ci.com/gitonomy/gitlib) +[![Build Status](https://img.shields.io/github/workflow/status/gitonomy/gitlib/Tests/1.2?label=Tests&style=flat-square)](https://github.com/gitonomy/gitlib/actions?query=workflow%3ATests+branch%3A1.2) [![StyleCI](https://github.styleci.io/repos/5709354/shield?branch=1.2)](https://github.styleci.io/repos/5709354?branch=1.2) [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://opensource.org/licenses/MIT) +[![Downloads](https://img.shields.io/packagist/dt/gitonomy/gitlib?style=flat-square)](https://packagist.org/packages/gitonomy/gitlib) This library provides methods to access Git repository from PHP 5.6+. From d22f212b97fdb631ac73dfae65c194dc4cb0d227 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Tue, 29 Dec 2020 16:48:45 +0000 Subject: [PATCH 036/150] Updated docs --- .github/CONTRIBUTING.md | 2 +- .github/workflows/tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7c8267dc..30aa95b9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -28,5 +28,5 @@ $ vendor/bin/phpunit ``` * A script `test-git-version.sh` is available in repository to test gitlib against many git versions. -* The tests will be automatically run by [Travis CI](https://travis-ci.org/) against pull requests. +* The tests will be automatically run by [GitHub Actions](https://github.com/features/actions) against pull requests. * We also have [StyleCI](https://styleci.io/) setup to automatically fix any code style issues. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6acf5b98..ac7e4e47 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ on: jobs: tests: - name: PHP ${{ matrix.php }}; Symfony ${{ matrix.laravel }} + name: PHP ${{ matrix.php }}; Symfony ${{ matrix.symfony }} runs-on: ubuntu-20.04 strategy: From e096edbdb88ac7cf2e27113f9b9b1f410a2891a8 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Sat, 4 Sep 2021 21:05:52 +0100 Subject: [PATCH 037/150] Support Symfony 6 and PHP 8.1 --- .github/CODE_OF_CONDUCT.md | 152 ++++++++++++++++++++++++------------ .github/CONTRIBUTING.md | 2 +- .github/SECURITY.md | 19 ++--- .github/workflows/tests.yml | 28 ++++++- README.md | 6 +- composer.json | 13 ++- src/Gitonomy/Git/Blame.php | 1 + src/Gitonomy/Git/Log.php | 2 + 8 files changed, 151 insertions(+), 72 deletions(-) diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 72084f84..590c9593 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -2,75 +2,131 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, sex characteristics, gender identity and expression, -level of experience, education, socio-economic status, nationality, personal -appearance, race, religion, or sexual identity and orientation. +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to creating a positive environment -include: +Examples of behavior that contributes to a positive environment for our +community include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community -Examples of unacceptable behavior by participants include: +Examples of unacceptable behavior include: -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission +* Publishing others' private information, such as a physical or email + address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a - professional setting + professional setting -## Our Responsibilities +## Enforcement Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. ## Scope -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at graham@alt-three.com. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. +reported to the community leaders responsible for enforcement at +hello@gjcampbell.co.uk. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. -[homepage]: https://www.contributor-covenant.org +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. -For answers to common questions about this code of conduct, see -https://www.contributor-covenant.org/faq +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 30aa95b9..4e3d0d6e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,7 +6,7 @@ We accept contributions via pull requests on Github. Please review these guideli ## Guidelines -* Please follow the [PSR-2 Coding Style Guide](https://www.php-fig.org/psr/psr-2/), enforced by [StyleCI](https://styleci.io/). +* Please follow the [PSR-12 Coding Style Guide](https://www.php-fig.org/psr/psr-12/), enforced by [StyleCI](https://styleci.io/). * Ensure that the current tests pass, and if you've added something new, add the tests where relevant. * Send a coherent commit history, making sure each individual commit in your pull request is meaningful. * You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts. diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 79a128e7..8f66b915 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,20 +2,13 @@ ## Supported Versions -After each new major (minor) release, the previous release will be supported -for no less than 12 (6) months, unless explictly otherwise. This may mean that -there are multiple supported versions at any given time. +After each new major release, the previous release will be supported for no +less than 1 year, unless explictly stated otherwise. This may mean that there +are multiple supported versions at any given time. ## Reporting a Vulnerability If you discover a security vulnerability within this package, please send an -email to one the security contacts. All security vulnerabilities will be -promptly addressed. Please do not disclose security-related issues publicly -until a fix has been announced. - -### Security Contacts - -* Graham Campbell (graham@alt-three.com) -* Julien Didier (genzo.wm@gmail.com) -* Grégoire Pineau (lyrixx@lyrixx.info) -* Alexandre Salomé (alexandre.salome@gmail.com) +email to security@tidelift.com. All security vulnerabilities will be promptly +addressed. Please do not disclose security-related issues publicly until a fix +has been announced. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ac7e4e47..34cc6607 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,19 +11,33 @@ jobs: strategy: matrix: - php: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0'] - symfony: ['3', '4', '5'] + php: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1'] + symfony: ['3', '4', '5', '6'] exclude: - php: '5.6' symfony: '4' - php: '5.6' symfony: '5' + - php: '5.6' + symfony: '6' - php: '7.0' symfony: '4' - php: '7.0' symfony: '5' + - php: '7.0' + symfony: '6' - php: '7.1' symfony: '5' + - php: '7.1' + symfony: '6' + - php: '7.2' + symfony: '6' + - php: '7.3' + symfony: '6' + - php: '7.4' + symfony: '6' + - php: '8.1' + symfony: '3' steps: - name: Checkout Code @@ -60,9 +74,17 @@ jobs: with: timeout_minutes: 5 max_attempts: 5 - command: composer require "symfony/process:^5.2" --no-update --no-interaction + command: composer require "symfony/process:^5.3" --no-update --no-interaction if: "matrix.symfony == '5'" + - name: Select Symfony 6 + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer require "symfony/process:^6.0" --no-update --no-interaction + if: "matrix.symfony == '6'" + - name: Install PHP Dependencies uses: nick-invision/retry@v1 with: diff --git a/README.md b/README.md index ddb7135c..333cc6d2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ Gitlib for Gitonomy =================== -[![Build Status](https://img.shields.io/github/workflow/status/gitonomy/gitlib/Tests/1.2?label=Tests&style=flat-square)](https://github.com/gitonomy/gitlib/actions?query=workflow%3ATests+branch%3A1.2) -[![StyleCI](https://github.styleci.io/repos/5709354/shield?branch=1.2)](https://github.styleci.io/repos/5709354?branch=1.2) +[![Build Status](https://img.shields.io/github/workflow/status/gitonomy/gitlib/Tests/1.3?label=Tests&style=flat-square)](https://github.com/gitonomy/gitlib/actions?query=workflow%3ATests+branch%3A1.3) +[![StyleCI](https://github.styleci.io/repos/5709354/shield?branch=1.3)](https://github.styleci.io/repos/5709354?branch=1.3) [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://opensource.org/licenses/MIT) [![Downloads](https://img.shields.io/packagist/dt/gitonomy/gitlib?style=flat-square)](https://packagist.org/packages/gitonomy/gitlib) @@ -26,7 +26,7 @@ or edit your `composer.json` file by hand: ```json { "require": { - "gitonomy/gitlib": "^1.2" + "gitonomy/gitlib": "^1.3" } } ``` diff --git a/composer.json b/composer.json index 38108cb6..db5efe83 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "authors": [ { "name": "Graham Campbell", - "email": "graham@alt-three.com" + "email": "hello@gjcampbell.co.uk" }, { "name": "Julien Didier", @@ -34,15 +34,20 @@ "php": "^5.6 || ^7.0 || ^8.0", "ext-pcre": "*", "symfony/polyfill-mbstring": "^1.7", - "symfony/process": "^3.4 || ^4.0 || ^5.0" + "symfony/process": "^3.4 || ^4.4 || ^5.0 || ^6.0" }, "require-dev": { "ext-fileinfo": "*", - "phpunit/phpunit": "^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0", + "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.20 || ^9.5.9", "psr/log": "^1.0" }, "suggest": { "ext-fileinfo": "Required to determine the mimetype of a blob", "psr/log": "Required to use loggers for reporting of execution" - } + }, + "config": { + "preferred-install": "dist" + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index 17d8bfe7..a9667cb4 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -139,6 +139,7 @@ public function getLines() /** * @return int */ + #[\ReturnTypeWillChange] public function count() { return count($this->getLines()); diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index 13f1fb0f..84b8bca7 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -202,6 +202,7 @@ public function getCommits() /** * @see Countable */ + #[\ReturnTypeWillChange] public function count() { return $this->countCommits(); @@ -210,6 +211,7 @@ public function count() /** * @see IteratorAggregate */ + #[\ReturnTypeWillChange] public function getIterator() { return new \ArrayIterator($this->getCommits()); From ed15dba0aa042bad925a4b26655f57d398d3f4aa Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Mon, 6 Sep 2021 20:45:36 +0100 Subject: [PATCH 038/150] Added missing `ReturnTypeWillChange` annotations --- src/Gitonomy/Git/ReferenceBag.php | 2 ++ src/Gitonomy/Git/RevisionList.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 57aec259..35fd710e 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -375,6 +375,7 @@ protected function initialize() * * @see Countable */ + #[\ReturnTypeWillChange] public function count() { $this->initialize(); @@ -385,6 +386,7 @@ public function count() /** * @see IteratorAggregate */ + #[\ReturnTypeWillChange] public function getIterator() { $this->initialize(); diff --git a/src/Gitonomy/Git/RevisionList.php b/src/Gitonomy/Git/RevisionList.php index 760abb67..a3dfe592 100644 --- a/src/Gitonomy/Git/RevisionList.php +++ b/src/Gitonomy/Git/RevisionList.php @@ -54,11 +54,13 @@ public function getAll() return $this->revisions; } + #[\ReturnTypeWillChange] public function getIterator() { return new \ArrayIterator($this->revisions); } + #[\ReturnTypeWillChange] public function count() { return count($this->revisions); From e73e439590b194b0b250b516b22a68c7116e2f21 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Mon, 6 Sep 2021 21:30:10 +0100 Subject: [PATCH 039/150] Fixed passing null to integer param --- src/Gitonomy/Git/Parser/ParserBase.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index 94155ce1..7ce1eae2 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -59,7 +59,7 @@ protected function expects($expected) protected function consumeShortHash() { - if (!preg_match('/([A-Za-z0-9]{7,40})/A', $this->content, $vars, null, $this->cursor)) { + if (!preg_match('/([A-Za-z0-9]{7,40})/A', $this->content, $vars, 0, $this->cursor)) { throw new RuntimeException('No short hash found: '.substr($this->content, $this->cursor, 7)); } @@ -70,7 +70,7 @@ protected function consumeShortHash() protected function consumeHash() { - if (!preg_match('/([A-Za-z0-9]{40})/A', $this->content, $vars, null, $this->cursor)) { + if (!preg_match('/([A-Za-z0-9]{40})/A', $this->content, $vars, 0, $this->cursor)) { throw new RuntimeException('No hash found: '.substr($this->content, $this->cursor, 40)); } @@ -81,7 +81,7 @@ protected function consumeHash() protected function consumeRegexp($regexp) { - if (!preg_match($regexp.'A', $this->content, $vars, null, $this->cursor)) { + if (!preg_match($regexp.'A', $this->content, $vars, 0, $this->cursor)) { throw new RuntimeException('No match for regexp '.$regexp.' Upcoming: '.substr($this->content, $this->cursor, 30)); } From 13cd2b134c5371bfc8ab051628193607ce4d6f30 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Mon, 18 Oct 2021 00:26:47 +0100 Subject: [PATCH 040/150] Updated contribution docs (#187) --- .github/CONTRIBUTING.md | 10 +++++----- .github/SECURITY.md | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4e3d0d6e..d33db7e8 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -2,16 +2,16 @@ Contributions are **welcome** and will be fully **credited**. -We accept contributions via pull requests on Github. Please review these guidelines before continuing. +We accept contributions via pull requests on GitHub. Please review these guidelines before continuing. ## Guidelines * Please follow the [PSR-12 Coding Style Guide](https://www.php-fig.org/psr/psr-12/), enforced by [StyleCI](https://styleci.io/). * Ensure that the current tests pass, and if you've added something new, add the tests where relevant. -* Send a coherent commit history, making sure each individual commit in your pull request is meaningful. +* Send a coherent commit history, making sure each commit in your pull request is meaningful. * You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts. -* If you are changing or adding to the behaviour or public api, you may need to update the docs. -* Please remember that we follow [SemVer](https://semver.org/). +* If you are changing or adding to the behaviour or public API, you may need to update the docs. +* Please remember that we follow [Semantic Versioning](https://semver.org/). ## Running Tests @@ -29,4 +29,4 @@ $ vendor/bin/phpunit * A script `test-git-version.sh` is available in repository to test gitlib against many git versions. * The tests will be automatically run by [GitHub Actions](https://github.com/features/actions) against pull requests. -* We also have [StyleCI](https://styleci.io/) setup to automatically fix any code style issues. +* We also have [StyleCI](https://styleci.io/) set up to automatically fix any code style issues. diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 8f66b915..0c3e8a24 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -3,8 +3,8 @@ ## Supported Versions After each new major release, the previous release will be supported for no -less than 1 year, unless explictly stated otherwise. This may mean that there -are multiple supported versions at any given time. +less than 12 months, unless explictly stated otherwise. This may mean that +there are multiple supported versions at any given time. ## Reporting a Vulnerability From 1238d31d07a9fac94547c5a5a07efafce9eaf03d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Salom=C3=A9?= Date: Fri, 10 Dec 2021 12:12:26 +0100 Subject: [PATCH 041/150] Add missing use statement (#188) --- src/Gitonomy/Git/Reference/Tag.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index f8461e1b..988ac1e3 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git\Reference; +use Gitonomy\Git\Commit; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Parser\ReferenceParser; From 2c72667295954dfcdeffb32024d00e1be8631c73 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Sun, 23 Jan 2022 20:23:58 +0000 Subject: [PATCH 042/150] `convertDeprecationsToExceptions="true"` (#189) --- phpunit.xml.dist | 1 + 1 file changed, 1 insertion(+) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index fd8be6ab..77fe6238 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,6 +5,7 @@ beStrictAboutOutputDuringTests="true" bootstrap="tests/bootstrap.php" colors="true" + convertDeprecationsToExceptions="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" From 5035f69c06df74ab7ee8ec14da9016b71ff3ed50 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Sun, 23 Jan 2022 20:26:42 +0000 Subject: [PATCH 043/150] Update composer.json --- composer.json | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index db5efe83..417c7631 100644 --- a/composer.json +++ b/composer.json @@ -5,19 +5,23 @@ "authors": [ { "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk" + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" }, { "name": "Julien Didier", - "email": "genzo.wm@gmail.com" + "email": "genzo.wm@gmail.com", + "homepage": "https://github.com/juliendidier" }, { "name": "Grégoire Pineau", - "email": "lyrixx@lyrixx.info" + "email": "lyrixx@lyrixx.info", + "homepage": "https://github.com/lyrixx" }, { "name": "Alexandre Salomé", - "email": "alexandre.salome@gmail.com" + "email": "alexandre.salome@gmail.com", + "homepage": "https://github.com/alexandresalome" } ], "autoload": { From 3c2f67a2fa6e7b26dbc9b9d0ca272ed33ba5895e Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Mon, 14 Feb 2022 02:07:07 -0700 Subject: [PATCH 044/150] Don't pass null as second parameter to preg_match (#190) --- src/Gitonomy/Git/Parser/DiffParser.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index d58016e3..edaea1e2 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -90,6 +90,8 @@ protected function doParse() $oldName = $oldName === '/dev/null' ? null : substr($oldName, 2); $newName = $newName === '/dev/null' ? null : substr($newName, 2); + $oldIndex !== null ?: ''; + $newIndex !== null ?: ''; $oldIndex = preg_match('/^0+$/', $oldIndex) ? null : $oldIndex; $newIndex = preg_match('/^0+$/', $newIndex) ? null : $newIndex; $file = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary); From 793ffe5826c30e64ea499ea9e4bb353dd0892bef Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Tue, 1 Mar 2022 05:56:00 -0500 Subject: [PATCH 045/150] Assign to variable to prevent null value (#192) --- src/Gitonomy/Git/Parser/DiffParser.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index edaea1e2..3fa9e2e6 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -90,8 +90,8 @@ protected function doParse() $oldName = $oldName === '/dev/null' ? null : substr($oldName, 2); $newName = $newName === '/dev/null' ? null : substr($newName, 2); - $oldIndex !== null ?: ''; - $newIndex !== null ?: ''; + $oldIndex = $oldIndex !== null ?: ''; + $newIndex = $newIndex !== null ?: ''; $oldIndex = preg_match('/^0+$/', $oldIndex) ? null : $oldIndex; $newIndex = preg_match('/^0+$/', $newIndex) ? null : $newIndex; $file = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary); From 33ae0a2e469accc19d1a06bed63ed93dbf368ae2 Mon Sep 17 00:00:00 2001 From: Anatolij Vasilev <3026792+tolik518@users.noreply.github.com> Date: Fri, 5 Aug 2022 11:17:13 +0200 Subject: [PATCH 046/150] Added more type hinting & Fixed bug when parsing renamed files with spaces and/or non english-symbols (#194) * changed repo name in the composer * added path to the symlink RuntimeException exception-message * added more and better typehints into PHPDocs above methods * Regex didn't count for the rare case when the diff has quotation marks around the filenames (file names with non-english symbols for example) * replaced uninitialized $entry variable with $element * removed getRevisions() since revisions isn't a part of the Diff object * removed unused $fullname in the getCommit() method * - added more type hinting - introduced data property to Tag - added .idea to .gitignore * - changes that were requested by " continuous-integration/styleci/pr" - fixed composer.json * upped the number of characters that are shown to get a more verbose exception * added type annotation for file in the Diff class * - fixed parser to properly work with files - with umlauts added a test - made changes @lyrixx suggested * - made fixes for @continuous-integration/styleci/pr * - made one more fix for @continuous-integration/styleci/pr * - switches quotes for @continuous-integration/styleci/pr * added trailing \n to the gitignore --- src/Gitonomy/Git/Blame.php | 4 +- src/Gitonomy/Git/Blob.php | 4 +- src/Gitonomy/Git/Commit.php | 18 ++++++--- src/Gitonomy/Git/CommitReference.php | 3 ++ src/Gitonomy/Git/Diff/Diff.php | 12 +----- src/Gitonomy/Git/Diff/File.php | 8 +++- src/Gitonomy/Git/Hooks.php | 8 +++- src/Gitonomy/Git/Log.php | 6 ++- src/Gitonomy/Git/Parser/DiffParser.php | 10 +++-- src/Gitonomy/Git/Parser/ParserBase.php | 2 +- src/Gitonomy/Git/PushReference.php | 9 ++++- src/Gitonomy/Git/Reference.php | 19 ++++++++-- src/Gitonomy/Git/Reference/Tag.php | 8 ++-- src/Gitonomy/Git/ReferenceBag.php | 51 ++++++++++++++++---------- src/Gitonomy/Git/Repository.php | 3 ++ src/Gitonomy/Git/RevisionList.php | 3 ++ src/Gitonomy/Git/Tree.php | 2 +- tests/Gitonomy/Git/Tests/DiffTest.php | 10 +++++ 18 files changed, 123 insertions(+), 57 deletions(-) diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index a9667cb4..dec0773b 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -108,9 +108,7 @@ public function getGroupedLines() } /** - * Returns all lines of the blame. - * - * @return array + * @return Line[] All lines of the blame. */ public function getLines() { diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index 10150935..e455fe2a 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -58,9 +58,9 @@ public function getHash() } /** - * Returns content of the blob. - * * @throws ProcessException Error occurred while getting content of blob + * + * @return string Content of the blob. */ public function getContent() { diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 10141bec..2008f7a3 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -16,6 +16,7 @@ use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Util\StringHelper; /** @@ -35,8 +36,8 @@ class Commit extends Revision /** * Constructor. * - * @param Gitonomy\Git\Repository $repository Repository of the commit - * @param string $hash Hash of the commit + * @param Repository $repository Repository of the commit + * @param string $hash Hash of the commit */ public function __construct(Repository $repository, $hash, array $data = []) { @@ -91,6 +92,8 @@ public function getShortHash() /** * Returns a fixed-with short hash. + * + * @return string Short hash */ public function getFixedShortHash($length = 6) { @@ -100,7 +103,7 @@ public function getFixedShortHash($length = 6) /** * Returns parent hashes. * - * @return array An array of SHA1 hashes + * @return string[] An array of SHA1 hashes */ public function getParentHashes() { @@ -110,7 +113,7 @@ public function getParentHashes() /** * Returns the parent commits. * - * @return array An array of Commit objects + * @return Commit[] An array of Commit objects */ public function getParents() { @@ -132,6 +135,9 @@ public function getTreeHash() return $this->getData('treeHash'); } + /** + * @return Tree + */ public function getTree() { return $this->getData('tree'); @@ -184,7 +190,7 @@ public function getShortMessage($length = 50, $preserve = false, $separator = '. /** * Resolves all references associated to this commit. * - * @return array An array of references (Branch, Tag, Squash) + * @return Reference[] An array of references (Branch, Tag, Squash) */ public function resolveReferences() { @@ -197,7 +203,7 @@ public function resolveReferences() * @param bool $local set true to try to locate a commit on local repository * @param bool $remote set true to try to locate a commit on remote repository * - * @return array An array of Reference\Branch + * @return Reference[]|Branch[] An array of Reference\Branch */ public function getIncludingBranches($local = true, $remote = true) { diff --git a/src/Gitonomy/Git/CommitReference.php b/src/Gitonomy/Git/CommitReference.php index bb20bc3c..7be3d89c 100644 --- a/src/Gitonomy/Git/CommitReference.php +++ b/src/Gitonomy/Git/CommitReference.php @@ -14,6 +14,9 @@ class CommitReference { + /** + * @var string + */ private $hash; public function __construct($hash) diff --git a/src/Gitonomy/Git/Diff/Diff.php b/src/Gitonomy/Git/Diff/Diff.php index faafbcba..7737ee8f 100644 --- a/src/Gitonomy/Git/Diff/Diff.php +++ b/src/Gitonomy/Git/Diff/Diff.php @@ -23,7 +23,7 @@ class Diff { /** - * @var array + * @var File[] */ protected $files; @@ -62,18 +62,10 @@ public function setRepository(Repository $repository) } } - /** - * @return array - */ - public function getRevisions() - { - return $this->revisions; - } - /** * Get list of files modified in the diff's revision. * - * @return array An array of Diff\File objects + * @return File[] An array of Diff\File objects */ public function getFiles() { diff --git a/src/Gitonomy/Git/Diff/File.php b/src/Gitonomy/Git/Diff/File.php index f7099769..d3da24bb 100644 --- a/src/Gitonomy/Git/Diff/File.php +++ b/src/Gitonomy/Git/Diff/File.php @@ -55,7 +55,7 @@ class File protected $isBinary; /** - * @var array An array of FileChange objects + * @var FileChange[] An array of FileChange objects */ protected $changes; @@ -215,6 +215,9 @@ public function isBinary() return $this->isBinary; } + /** + * @return FileChange[] + */ public function getChanges() { return $this->changes; @@ -236,6 +239,9 @@ public function toArray() ]; } + /** + * @return File + */ public static function fromArray(array $array) { $file = new self($array['old_name'], $array['new_name'], $array['old_mode'], $array['new_mode'], $array['old_index'], $array['new_index'], $array['is_binary']); diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index 3c19d2c4..dd0adbe8 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -14,6 +14,7 @@ use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\RuntimeException; /** * Hooks collection, aggregated by repository. @@ -23,7 +24,7 @@ class Hooks { /** - * @var Gitonomy\Git\Repository + * @var \Gitonomy\Git\Repository */ protected $repository; @@ -82,7 +83,7 @@ public function setSymlink($name, $file) $path = $this->getPath($name); if (false === symlink($file, $path)) { - throw new RuntimeException(sprintf('Unable to create hook "%s"', $name, $path)); + throw new RuntimeException(sprintf('Unable to create hook "%s" (%s)', $name, $path)); } } @@ -121,6 +122,9 @@ public function remove($name) unlink($this->getPath($name)); } + /** + * @return string + */ protected function getPath($name) { return $this->repository->getGitDir().'/hooks/'.$name; diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index 84b8bca7..47665359 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Util\StringHelper; @@ -136,6 +137,9 @@ public function setLimit($limit) return $this; } + /** + * @return Commit + */ public function getSingleCommit() { $limit = $this->limit; @@ -151,7 +155,7 @@ public function getSingleCommit() } /** - * @return array + * @return Commit[] */ public function getCommits() { diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index 3fa9e2e6..e14f879e 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -25,7 +25,7 @@ protected function doParse() while (!$this->isFinished()) { // 1. title - $vars = $this->consumeRegexp('/diff --git (a\/.*) (b\/.*)\n/'); + $vars = $this->consumeRegexp("/diff --git \"?(a\/.*?)\"? \"?(b\/.*?)\"?\n/"); $oldName = $vars[1]; $newName = $vars[2]; $oldIndex = null; @@ -74,14 +74,15 @@ protected function doParse() } $this->consumeNewLine(); + //verifying if the file was deleted or created if ($this->expects('--- ')) { - $oldName = $this->consumeTo("\n"); + $oldName = $this->consumeTo("\n") === '/dev/null' ? '/dev/null' : $oldName; $this->consumeNewLine(); $this->consume('+++ '); - $newName = $this->consumeTo("\n"); + $newName = $this->consumeTo("\n") === '/dev/null' ? '/dev/null' : $newName; $this->consumeNewLine(); } elseif ($this->expects('Binary files ')) { - $vars = $this->consumeRegexp('/(.*) and (.*) differ\n/'); + $vars = $this->consumeRegexp('/"?(.*?)"? and "?(.*?)"? differ\n/'); $isBinary = true; $oldName = $vars[1]; $newName = $vars[2]; @@ -90,6 +91,7 @@ protected function doParse() $oldName = $oldName === '/dev/null' ? null : substr($oldName, 2); $newName = $newName === '/dev/null' ? null : substr($newName, 2); + $oldIndex = $oldIndex !== null ?: ''; $newIndex = $newIndex !== null ?: ''; $oldIndex = preg_match('/^0+$/', $oldIndex) ? null : $oldIndex; diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index 7ce1eae2..7cd37e75 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -82,7 +82,7 @@ protected function consumeHash() protected function consumeRegexp($regexp) { if (!preg_match($regexp.'A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('No match for regexp '.$regexp.' Upcoming: '.substr($this->content, $this->cursor, 30)); + throw new RuntimeException('No match for regexp '.$regexp.' Upcoming: '.substr($this->content, $this->cursor, 500)); } $this->cursor += strlen($vars[0]); diff --git a/src/Gitonomy/Git/PushReference.php b/src/Gitonomy/Git/PushReference.php index 3911bd2e..dea10823 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -24,6 +24,10 @@ class PushReference { const ZERO = '0000000000000000000000000000000000000000'; + /** + * @var Repository + */ + protected $repository; /** * @var string */ @@ -86,7 +90,7 @@ public function getAfter() } /** - * @return array + * @return Log */ public function getLog($excludes = []) { @@ -98,6 +102,9 @@ public function getLog($excludes = []) )); } + /** + * @return string + */ public function getRevision() { if ($this->isDelete()) { diff --git a/src/Gitonomy/Git/Reference.php b/src/Gitonomy/Git/Reference.php index 23f1cb75..96b8fa12 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; + /** * Reference in a Git repository. * @@ -29,16 +32,25 @@ public function __construct(Repository $repository, $revision, $commitHash = nul $this->commitHash = $commitHash; } + /** + * @return string + */ public function getFullname() { return $this->revision; } + /** + * @return void + */ public function delete() { $this->repository->getReferences()->delete($this->getFullname()); } + /** + * @return string + */ public function getCommitHash() { if (null !== $this->commitHash) { @@ -55,15 +67,16 @@ public function getCommitHash() } /** - * Returns the commit associated to the reference. - * - * @return Commit + * @return Commit Commit associated to the reference. */ public function getCommit() { return $this->repository->getCommit($this->getCommitHash()); } + /** + * @return Commit + */ public function getLastModification($path = null) { return $this->getCommit()->getLastModification($path); diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index 988ac1e3..bca6ca49 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -27,6 +27,8 @@ */ class Tag extends Reference { + protected $data; + public function getName() { if (!preg_match('#^refs/tags/(.*)$#', $this->revision, $vars)) { @@ -65,8 +67,8 @@ public function getCommit() $parser = new ReferenceParser(); $parser->parse($output); - foreach ($parser->references as $row) { - list($commitHash, $fullname) = $row; + foreach ($parser->references as list($row)) { + $commitHash = $row; } return $this->repository->getCommit($commitHash); @@ -101,7 +103,7 @@ public function getTaggerEmail() /** * Returns the authoring date. * - * @return DateTime A time object + * @return \DateTime A time object */ public function getTaggerDate() { diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 35fd710e..4b79fc66 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -29,7 +29,7 @@ class ReferenceBag implements \Countable, \IteratorAggregate /** * Repository object. * - * @var Gitonomy\Git\Repository + * @var Repository */ protected $repository; @@ -43,14 +43,14 @@ class ReferenceBag implements \Countable, \IteratorAggregate /** * List with all tags. * - * @var array + * @var Tag[] */ protected $tags; /** * List with all branches. * - * @var array + * @var Branch[] */ protected $branches; @@ -64,7 +64,7 @@ class ReferenceBag implements \Countable, \IteratorAggregate /** * Constructor. * - * @param Gitonomy\Git\Repository $repository The repository + * @param Repository $repository The repository */ public function __construct($repository) { @@ -92,6 +92,9 @@ public function get($fullname) return $this->references[$fullname]; } + /** + * @return bool + */ public function has($fullname) { $this->initialize(); @@ -99,6 +102,9 @@ public function has($fullname) return isset($this->references[$fullname]); } + /** + * @return Reference + */ public function update(Reference $reference) { $fullname = $reference->getFullname(); @@ -111,6 +117,9 @@ public function update(Reference $reference) return $reference; } + /** + * @return Reference + */ public function createBranch($name, $commitHash) { $branch = new Branch($this->repository, 'refs/heads/'.$name, $commitHash); @@ -118,6 +127,9 @@ public function createBranch($name, $commitHash) return $this->update($branch); } + /** + * @return Reference + */ public function createTag($name, $commitHash) { $tag = new Tag($this->repository, 'refs/tags/'.$name, $commitHash); @@ -125,6 +137,9 @@ public function createTag($name, $commitHash) return $this->update($tag); } + /** + * @return void + */ public function delete($fullname) { $this->repository->run('update-ref', ['-d', $fullname]); @@ -132,6 +147,9 @@ public function delete($fullname) unset($this->references[$fullname]); } + /** + * @return bool + */ public function hasBranches() { $this->initialize(); @@ -154,6 +172,9 @@ public function hasTag($name) return $this->has('refs/tags/'.$name); } + /** + * @return Branch + */ public function getFirstBranch() { $this->initialize(); @@ -163,7 +184,7 @@ public function getFirstBranch() } /** - * @return array An array of Tag objects + * @return Tag[] An array of Tag objects */ public function resolveTags($hash) { @@ -184,7 +205,7 @@ public function resolveTags($hash) } /** - * @return array An array of Branch objects + * @return Branch[] An array of Branch objects */ public function resolveBranches($hash) { @@ -205,7 +226,7 @@ public function resolveBranches($hash) } /** - * @return array An array of references + * @return Reference[] An array of references */ public function resolve($hash) { @@ -226,9 +247,7 @@ public function resolve($hash) } /** - * Returns all tags. - * - * @return array + * @return Tag[] All tags. */ public function getTags() { @@ -238,9 +257,7 @@ public function getTags() } /** - * Returns all branches. - * - * @return array + * @return Branch[] All branches. */ public function getBranches() { @@ -257,9 +274,7 @@ public function getBranches() } /** - * Returns all locales branches. - * - * @return array + * @return Branch[] All local branches. */ public function getLocalBranches() { @@ -274,9 +289,7 @@ public function getLocalBranches() } /** - * Returns all remote branches. - * - * @return array + * @return Branch[] All remote branches. */ public function getRemoteBranches() { diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 3920616b..fe77a888 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -376,6 +376,9 @@ public function getBlob($hash) return $this->objects[$hash]; } + /** + * @return Blame + */ public function getBlame($revision, $file, $lineRange = null) { if (is_string($revision)) { diff --git a/src/Gitonomy/Git/RevisionList.php b/src/Gitonomy/Git/RevisionList.php index a3dfe592..1c1a1dcd 100644 --- a/src/Gitonomy/Git/RevisionList.php +++ b/src/Gitonomy/Git/RevisionList.php @@ -49,6 +49,9 @@ public function __construct(Repository $repository, $revisions) $this->revisions = $revisions; } + /** + * @return Revision[] + */ public function getAll() { return $this->revisions; diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index d3179837..570c8ff8 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -96,7 +96,7 @@ public function resolvePath($path) foreach ($segments as $segment) { if ($element instanceof self) { $element = $element->getEntry($segment); - } elseif ($entry instanceof Blob) { + } elseif ($element instanceof Blob) { throw new InvalidArgumentException('Unresolvable path'); } else { throw new UnexpectedValueException('Unknow type of element'); diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 08f2e943..e251d72e 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -20,6 +20,7 @@ class DiffTest extends AbstractTest const CREATE_COMMIT = 'e6fa3c792facc06faa049a6938c84c411954deb5'; const RENAME_COMMIT = '6640e0ef31518054847a1876328e26ee64083e0a'; const CHANGEMODE_COMMIT = '93da965f58170f13017477b9a608657e87e23230'; + const FILE_WITH_UMLAUTS_COMMIT = '8defb9217692dc1f4c18e05e343ca91cf5047702'; /** * @dataProvider provideFoobar @@ -140,4 +141,13 @@ public function testDiffRangeParse($repository) $this->assertSame(1, $changes[0]->getRangeNewStart()); $this->assertSame(0, $changes[0]->getRangeNewCount()); } + + /** + * @dataProvider provideFoobar + */ + public function testWorksWithUmlauts($repository) + { + $files = $repository->getCommit(self::FILE_WITH_UMLAUTS_COMMIT)->getDiff()->getFiles(); + $this->assertSame('file_with_umlauts_\303\244\303\266\303\274', $files[0]->getNewName()); + } } From 00b57b79f02396aa4c7c163f76fe2bc48faebbb7 Mon Sep 17 00:00:00 2001 From: Anatolij Vasilev <3026792+tolik518@users.noreply.github.com> Date: Tue, 4 Oct 2022 16:20:15 +0200 Subject: [PATCH 047/150] Added tests for empty commit messages (#196) * added tests for empty commit messages * cosmetic changes for continuous-integration/styleci/pr * added phpspec/prophecy to composer-json, so the tests pass * added phpspec/prophecy compatibility with php 5.6 * sorted composer.json alphabetically and removed "^1.15" --- composer.json | 1 + tests/Gitonomy/Git/Tests/AbstractTest.php | 1 + tests/Gitonomy/Git/Tests/CommitTest.php | 26 +++++++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/composer.json b/composer.json index 417c7631..86735c14 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ }, "require-dev": { "ext-fileinfo": "*", + "phpspec/prophecy": "^1.10.2", "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.20 || ^9.5.9", "psr/log": "^1.0" }, diff --git a/tests/Gitonomy/Git/Tests/AbstractTest.php b/tests/Gitonomy/Git/Tests/AbstractTest.php index d586eda6..0b9a51b6 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTest.php +++ b/tests/Gitonomy/Git/Tests/AbstractTest.php @@ -20,6 +20,7 @@ abstract class AbstractTest extends TestCase { const REPOSITORY_URL = 'https://github.com/gitonomy/foobar.git'; + const NO_MESSAGE_COMMIT = '011cd0c1625190d2959ee9a8f9f822006d94b661'; const LONGFILE_COMMIT = '4f17752acc9b7c54ba679291bf24cb7d354f0f4f'; const BEFORE_LONGFILE_COMMIT = 'e0ec50e2af75fa35485513f60b2e658e245227e9'; const LONGMESSAGE_COMMIT = '3febd664b6886344a9b32d70657687ea4b1b4fab'; diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 8dca9c40..7d094648 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -16,6 +16,7 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Repository; use Gitonomy\Git\Tree; class CommitTest extends AbstractTest @@ -189,6 +190,31 @@ public function testGetMessage($repository) $this->assertEquals('add a long file'."\n", $commit->getMessage()); } + /** + * @dataProvider provideFoobar + * + * @param $repository Repository + */ + public function testGetEmptyMessage($repository) + { + $commit = $repository->getCommit(self::NO_MESSAGE_COMMIT); + + $this->assertEquals('', $commit->getMessage()); + } + + /** + * @dataProvider provideFoobar + * + * @param $repository Repository + */ + public function testGetEmptyMessageFromLog($repository) + { + $commit = $repository->getCommit(self::NO_MESSAGE_COMMIT); + $commitMessageFromLog = $commit->getLog()->getCommits()[0]->getMessage(); + + $this->assertEquals('', $commitMessageFromLog); + } + /** * This test ensures that GPG signed commits does not break the reading of a commit * message. From f9b36538fc784782f3b960655722c996b55624de Mon Sep 17 00:00:00 2001 From: Anatolij Vasilev <3026792+tolik518@users.noreply.github.com> Date: Tue, 8 Nov 2022 07:21:31 +0100 Subject: [PATCH 048/150] Method to check if a branch is merged (added code from PR #151) (#197) * added code from PR #151 * added user credentials which are needed for the next commands * stylistic changes to satisfy style-ci * moved user credentials to the top * stylistic changes to satisfy style-ci --- src/Gitonomy/Git/Reference/Branch.php | 45 ++++++++++++++++++++++ tests/Gitonomy/Git/Tests/ReferenceTest.php | 28 ++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/Gitonomy/Git/Reference/Branch.php b/src/Gitonomy/Git/Reference/Branch.php index 0f263a76..0d27fb88 100644 --- a/src/Gitonomy/Git/Reference/Branch.php +++ b/src/Gitonomy/Git/Reference/Branch.php @@ -12,8 +12,10 @@ namespace Gitonomy\Git\Reference; +use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference; +use Gitonomy\Git\Util\StringHelper; /** * Representation of a branch reference. @@ -53,6 +55,49 @@ public function isLocal() return $this->local; } + /** + * Check if this branch is merged to a destination branch + * Optionally, check only with remote branches. + * + * @param string $destinationBranchName + * @param bool $compareOnlyWithRemote + * + * @return null|bool + */ + public function isMergedTo($destinationBranchName = 'master', $compareOnlyWithRemote = false) + { + $arguments = ['-a']; + + if ($compareOnlyWithRemote) { + $arguments = ['-r']; + } + + $arguments[] = '--merged'; + $arguments[] = $destinationBranchName; + + try { + $result = $this->repository->run('branch', $arguments); + } catch (ProcessException $e) { + throw new RuntimeException( + sprintf('Cannot determine if merged to the branch "%s"', $destinationBranchName), + $e->getCode(), + $e + ); + } + + if (!$result) { + return false; + } + + $output = explode("\n", trim(str_replace(['*', 'remotes/'], '', $result))); + $filtered_output = array_filter($output, static function ($v) { + return false === StringHelper::strpos($v, '->'); + }); + $trimmed_output = array_map('trim', $filtered_output); + + return in_array($this->getName(), $trimmed_output, true); + } + private function detectBranchType() { if (null === $this->local) { diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index d24a5e64..0f975577 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -209,4 +209,32 @@ public function testCreateAndDeleteBranch($repository) $branch->delete(); $this->assertFalse($references->hasBranch('foobar'), 'Branch foobar removed'); } + + /** + * @dataProvider provideFoobar + */ + public function testIsBranchMergedToMaster() + { + $repository = self::createFoobarRepository(false); + + $repository->run('config', ['--local', 'user.name', '"Unit Test"']); + $repository->run('config', ['--local', 'user.email', '"unit_test@unit-test.com"']); + + $master = $repository->getReferences()->getBranch('master'); + $references = $repository->getReferences(); + + $branch = $references->createBranch('foobar-new', $master->getCommit()->getHash()); + + $this->assertTrue($branch->isMergedTo('master')); + + $wc = $repository->getWorkingCopy(); + $wc->checkout('foobar-new'); + + $file = $repository->getWorkingDir().'/foobar-test.txt'; + file_put_contents($file, 'test'); + $repository->run('add', [$file]); + $repository->run('commit', ['-m', 'foobar-test.txt updated']); + + $this->assertFalse($branch->isMergedTo('master')); + } } From 11a3dfc80a031ed646ba38b4a45e54cb883e57c1 Mon Sep 17 00:00:00 2001 From: Anatolij Vasilev <3026792+tolik518@users.noreply.github.com> Date: Tue, 3 Jan 2023 14:04:26 +0100 Subject: [PATCH 049/150] fixed github test badge: https://github.com/badges/shields/issues/8671 (#199) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 333cc6d2..e0dcade7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Gitlib for Gitonomy =================== -[![Build Status](https://img.shields.io/github/workflow/status/gitonomy/gitlib/Tests/1.3?label=Tests&style=flat-square)](https://github.com/gitonomy/gitlib/actions?query=workflow%3ATests+branch%3A1.3) +[![Build Status](https://img.shields.io/github/actions/workflow/status/gitonomy/gitlib/tests.yml?label=Tests&style=flat-square&branch=1.3)](https://github.com/gitonomy/gitlib/actions?query=workflow%3ATests+branch%3A1.3) [![StyleCI](https://github.styleci.io/repos/5709354/shield?branch=1.3)](https://github.styleci.io/repos/5709354?branch=1.3) [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://opensource.org/licenses/MIT) [![Downloads](https://img.shields.io/packagist/dt/gitonomy/gitlib?style=flat-square)](https://packagist.org/packages/gitonomy/gitlib) From 9fea656e75ad6e3452feb2cac46a6c1239cd7f74 Mon Sep 17 00:00:00 2001 From: Pieter Hoste Date: Thu, 11 May 2023 10:29:06 +0200 Subject: [PATCH 050/150] Adds feature to check if a branch name exists in a repository without cloning it. (#202) --- src/Gitonomy/Git/Admin.php | 24 ++++++++++++++++++++++++ tests/Gitonomy/Git/Tests/AdminTest.php | 18 ++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 7b64cd77..7b32794c 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -67,6 +67,30 @@ public static function isValidRepository($url, array $options = []) return $process->isSuccessFul(); } + /** + * Checks the validity of a git repository url without cloning it and + * check if a certain branch exists in that repository. + * + * This will use the `ls-remote` command of git against the given url. + * Usually, this command returns 0 when successful, and 128 when the + * repository is not found. + * + * @param string $url url of repository to check + * @param string $branchName name of branch to check + * @param array $options options for Repository creation + * + * @return bool true if url is valid and branch exists + */ + public static function isValidRepositoryAndBranch($url, $branchName, array $options = []) + { + $process = static::getProcess('ls-remote', ['--heads', $url, $branchName], $options); + + $process->run(); + $processOutput = $process->getOutput(); + + return $process->isSuccessFul() && strpos($processOutput, $branchName) !== false; + } + /** * Clone a repository to a local path. * diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index 3bd29f50..0f7460fa 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -155,6 +155,24 @@ public function testCheckInvalidRepository() $this->assertFalse(Admin::isValidRepository($url)); } + /** + * @dataProvider provideFoobar + */ + public function testCheckValidRepositoryAndBranch($repository) + { + $url = $repository->getGitDir(); + $this->assertTrue(Admin::isValidRepositoryAndBranch($url, 'master')); + } + + /** + * @dataProvider provideFoobar + */ + public function testCheckInvalidRepositoryAndBranch($repository) + { + $url = $repository->getGitDir(); + $this->assertFalse(Admin::isValidRepositoryAndBranch($url, 'invalid-branch-name')); + } + public function testExistingFile() { $this->expectException(RuntimeException::class); From e4e06bb9db45728a3a9f05499cb4295fd6f3bdba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 22 May 2023 17:00:21 +0200 Subject: [PATCH 051/150] Bump minimal PHP version to 8.0 (#204) * Bump minimal PHP version to 8.0 * Fixed ci when pushing on origin repo --- .github/workflows/tests.yml | 84 ++++----------------- composer.json | 15 ++-- tests/Gitonomy/Git/Tests/RepositoryTest.php | 5 +- 3 files changed, 24 insertions(+), 80 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 34cc6607..79d8bf10 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,42 +2,23 @@ name: Tests on: push: + branches: [ main ] pull_request: jobs: tests: - name: PHP ${{ matrix.php }}; Symfony ${{ matrix.symfony }} - runs-on: ubuntu-20.04 - + name: Test PHP ${{ matrix.php-version }} ${{ matrix.name }} + runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - php: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1'] - symfony: ['3', '4', '5', '6'] - exclude: - - php: '5.6' - symfony: '4' - - php: '5.6' - symfony: '5' - - php: '5.6' - symfony: '6' - - php: '7.0' - symfony: '4' - - php: '7.0' - symfony: '5' - - php: '7.0' - symfony: '6' - - php: '7.1' - symfony: '5' - - php: '7.1' - symfony: '6' - - php: '7.2' - symfony: '6' - - php: '7.3' - symfony: '6' - - php: '7.4' - symfony: '6' - - php: '8.1' - symfony: '3' + php-version: ['8.1', '8.2'] + composer-flags: [''] + name: [''] + include: + - php: 8.0 + composer-flags: '--prefer-lowest' + name: '(prefer lowest dependencies)' steps: - name: Checkout Code @@ -47,50 +28,13 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - tools: composer:v2 - coverage: none - name: Setup Problem Matchers run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - name: Select Symfony 3 - uses: nick-invision/retry@v1 - with: - timeout_minutes: 5 - max_attempts: 5 - command: composer require "symfony/process:^3.4" --no-update --no-interaction - if: "matrix.symfony == '3'" - - - name: Select Symfony 4 - uses: nick-invision/retry@v1 - with: - timeout_minutes: 5 - max_attempts: 5 - command: composer require "symfony/process:^4.4" --no-update --no-interaction - if: "matrix.symfony == '4'" - - - name: Select Symfony 5 - uses: nick-invision/retry@v1 - with: - timeout_minutes: 5 - max_attempts: 5 - command: composer require "symfony/process:^5.3" --no-update --no-interaction - if: "matrix.symfony == '5'" - - - name: Select Symfony 6 - uses: nick-invision/retry@v1 - with: - timeout_minutes: 5 - max_attempts: 5 - command: composer require "symfony/process:^6.0" --no-update --no-interaction - if: "matrix.symfony == '6'" - - - name: Install PHP Dependencies - uses: nick-invision/retry@v1 - with: - timeout_minutes: 5 - max_attempts: 5 - command: composer update --no-interaction --no-progress + - name: Install Composer dependencies + run: | + composer update --prefer-dist --no-interaction ${{ matrix.composer-flags }} - name: Execute PHPUnit run: vendor/bin/phpunit diff --git a/composer.json b/composer.json index 86735c14..fe0dd35c 100644 --- a/composer.json +++ b/composer.json @@ -35,23 +35,20 @@ } }, "require": { - "php": "^5.6 || ^7.0 || ^8.0", + "php": "^8.0", "ext-pcre": "*", "symfony/polyfill-mbstring": "^1.7", - "symfony/process": "^3.4 || ^4.4 || ^5.0 || ^6.0" + "symfony/process": "^5.4 || ^6.0" }, "require-dev": { "ext-fileinfo": "*", - "phpspec/prophecy": "^1.10.2", - "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.20 || ^9.5.9", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.20 || ^9.5.9", "psr/log": "^1.0" }, - "suggest": { - "ext-fileinfo": "Required to determine the mimetype of a blob", - "psr/log": "Required to use loggers for reporting of execution" - }, "config": { - "preferred-install": "dist" + "preferred-install": "dist", + "sort-packages": true }, "minimum-stability": "dev", "prefer-stable": true diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 7ff6a195..de433dff 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -15,9 +15,12 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\Exception\RuntimeException; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; class RepositoryTest extends AbstractTest { + use ProphecyTrait; + /** * @dataProvider provideFoobar */ @@ -40,7 +43,7 @@ public function testGetBlobWithExistingWorks($repository) public function testGetSize($repository) { $size = $repository->getSize(); - $this->assertGreaterThanOrEqual(69, $size, 'Repository is at least 69KB'); + $this->assertGreaterThanOrEqual(53, $size, 'Repository is at least 53KB'); $this->assertLessThan(80, $size, 'Repository is less than 80KB'); } From 2c7fbbd9814178474d0bb1b6292701cb4ab508f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Mokr=C3=BD?= <31004308+MokryPatrik@users.noreply.github.com> Date: Wed, 20 Dec 2023 14:02:08 +0100 Subject: [PATCH 052/150] Add support for symfony 7 (#213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Patrik Mokrý --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index fe0dd35c..a3de0536 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ "php": "^8.0", "ext-pcre": "*", "symfony/polyfill-mbstring": "^1.7", - "symfony/process": "^5.4 || ^6.0" + "symfony/process": "^5.4 || ^6.0 || ^7.0" }, "require-dev": { "ext-fileinfo": "*", From ba22bb2ebfdfdfae9d11134072eb04cb81d3fc9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C3=87al=C4=B1=C5=9Fkan?= <13554944+thecaliskan@users.noreply.github.com> Date: Fri, 5 Jan 2024 00:38:31 +0300 Subject: [PATCH 053/150] Added PHP 8.3 Test (#215) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 79d8bf10..e40ff9a6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.1', '8.2'] + php-version: ['8.1', '8.2', '8.3'] composer-flags: [''] name: [''] include: From 640068c25297fcc3b7b8948a8421d9f21fc5e0ba Mon Sep 17 00:00:00 2001 From: Patrick-Beuks Date: Wed, 24 Apr 2024 12:18:59 +0200 Subject: [PATCH 054/150] Detect binary file by NULL byte (#219) * Detect binary file by NULL byte * Add description where the binary check and values come from --------- Co-authored-by: Patrick Beuks --- src/Gitonomy/Git/Blob.php | 22 +++++++++++++++++++++- tests/Gitonomy/Git/Tests/BlobTest.php | 17 +++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index e455fe2a..dfe885fe 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -19,6 +19,11 @@ */ class Blob { + /** + * @var int Size that git uses to look for NULL byte: https://git.kernel.org/pub/scm/git/git.git/tree/xdiff-interface.c?h=v2.44.0#n193 + */ + private const FIRST_FEW_BYTES = 8000; + /** * @var Repository */ @@ -39,6 +44,11 @@ class Blob */ protected $mimetype; + /** + * @var bool + */ + protected $text; + /** * @param Repository $repository Repository where the blob is located * @param string $hash Hash of the blob @@ -89,6 +99,9 @@ public function getMimetype() /** * Determines if file is binary. * + * Uses the same check that git uses to determine if a file is binary or not + * https://git.kernel.org/pub/scm/git/git.git/tree/xdiff-interface.c?h=v2.44.0#n193 + * * @return bool */ public function isBinary() @@ -99,10 +112,17 @@ public function isBinary() /** * Determines if file is text. * + * Uses the same check that git uses to determine if a file is binary or not + * https://git.kernel.org/pub/scm/git/git.git/tree/xdiff-interface.c?h=v2.44.0#n193 + * * @return bool */ public function isText() { - return (bool) preg_match('#^text/|^application/xml#', $this->getMimetype()); + if (null === $this->text) { + $this->text = !str_contains(substr($this->getContent(), 0, self::FIRST_FEW_BYTES), chr(0)); + } + + return $this->text; } } diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 4b288626..431a0a46 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -23,6 +23,11 @@ public function getReadmeBlob($repository) return $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('README.md'); } + public function getImageBlob($repository) + { + return $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('image.jpg'); + } + /** * @dataProvider provideFoobar */ @@ -67,8 +72,10 @@ public function testGetMimetype($repository) */ public function testIsText($repository) { - $blob = $this->getReadmeBlob($repository); - $this->assertTrue($blob->isText()); + $readmeBlob = $this->getReadmeBlob($repository); + $this->assertTrue($readmeBlob->isText()); + $imageBlob = $this->getImageBlob($repository); + $this->assertFalse($imageBlob->isText()); } /** @@ -76,7 +83,9 @@ public function testIsText($repository) */ public function testIsBinary($repository) { - $blob = $this->getReadmeBlob($repository); - $this->assertFalse($blob->isBinary()); + $readmeBlob = $this->getReadmeBlob($repository); + $this->assertFalse($readmeBlob->isBinary()); + $imageBlob = $this->getImageBlob($repository); + $this->assertTrue($imageBlob->isBinary()); } } From a8bfcd173aafc7cc97f6a15bf75370f4bfdc7660 Mon Sep 17 00:00:00 2001 From: Patrick-Beuks Date: Fri, 3 May 2024 09:22:05 +0200 Subject: [PATCH 055/150] Update tests for new git version (#223) --- tests/Gitonomy/Git/Tests/RepositoryTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index de433dff..fc5c7deb 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -43,8 +43,8 @@ public function testGetBlobWithExistingWorks($repository) public function testGetSize($repository) { $size = $repository->getSize(); - $this->assertGreaterThanOrEqual(53, $size, 'Repository is at least 53KB'); - $this->assertLessThan(80, $size, 'Repository is less than 80KB'); + $this->assertGreaterThanOrEqual(57, $size, 'Repository is at least 57KB'); + $this->assertLessThan(84, $size, 'Repository is less than 84KB'); } public function testIsBare() From de32cc09c5f1ac82ad836aefb87e29db3cfb6596 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 3 May 2024 08:23:26 +0100 Subject: [PATCH 056/150] Set default range counts (#211) * Set default range counts * Force ints --- src/Gitonomy/Git/Parser/DiffParser.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index e14f879e..28f6c18d 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -102,9 +102,9 @@ protected function doParse() while ($this->expects('@@ ')) { $vars = $this->consumeRegexp('/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?/'); $rangeOldStart = (int) $vars[1]; - $rangeOldCount = (int) $vars[2]; + $rangeOldCount = (int) ($vars[2] ?? 1); $rangeNewStart = (int) $vars[3]; - $rangeNewCount = isset($vars[4]) ? (int) $vars[4] : (int) $vars[2]; // @todo Ici, t'as pris un gros raccourci mon loulou + $rangeNewCount = (int) ($vars[4] ?? 1); $this->consume(' @@'); $this->consumeTo("\n"); $this->consumeNewLine(); From 5a47e03f8f76f40693c8ebbc5ca6a7de48e176a0 Mon Sep 17 00:00:00 2001 From: Patrick-Beuks Date: Mon, 6 May 2024 22:30:56 +0200 Subject: [PATCH 057/150] Add optional entry type option for tree entries (#221) * Add optional entry type option for tree entries * Fix cs problems * Update test for diff range counts * Make each entry type a different function --- src/Gitonomy/Git/Tree.php | 48 ++++++++++++++++++++++++--- tests/Gitonomy/Git/Tests/DiffTest.php | 2 +- tests/Gitonomy/Git/Tests/TreeTest.php | 41 ++++++++++++++++++++++- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index 570c8ff8..7830cfa4 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -24,6 +24,7 @@ class Tree protected $hash; protected $isInitialized = false; protected $entries; + protected $entriesByType; public function __construct(Repository $repository, $hash) { @@ -47,31 +48,68 @@ protected function initialize() $parser->parse($output); $this->entries = []; + $this->entriesByType = [ + 'blob' => [], + 'tree' => [], + 'commit' => [], + ]; foreach ($parser->entries as $entry) { list($mode, $type, $hash, $name) = $entry; if ($type == 'blob') { - $this->entries[$name] = [$mode, $this->repository->getBlob($hash)]; + $treeEntry = [$mode, $this->repository->getBlob($hash)]; } elseif ($type == 'tree') { - $this->entries[$name] = [$mode, $this->repository->getTree($hash)]; + $treeEntry = [$mode, $this->repository->getTree($hash)]; } else { - $this->entries[$name] = [$mode, new CommitReference($hash)]; + $treeEntry = [$mode, new CommitReference($hash)]; } + $this->entries[$name] = $treeEntry; + $this->entriesByType[$type][$name] = $treeEntry; } $this->isInitialized = true; } /** - * @return array An associative array name => $object + * @return array An associative array name => $object */ - public function getEntries() + public function getEntries(): array { $this->initialize(); return $this->entries; } + /** + * @return array An associative array of name => [mode, commit reference] + */ + public function getCommitReferenceEntries(): array + { + $this->initialize(); + + return $this->entriesByType['commit']; + } + + /** + * @return array An associative array of name => [mode, tree] + */ + public function getTreeEntries(): array + { + $this->initialize(); + + return $this->entriesByType['tree']; + } + + /** + * @return array An associative array of name => [mode, blob] + */ + public function getBlobEntries(): array + { + $this->initialize(); + + return $this->entriesByType['blob']; + } + public function getEntry($name) { $this->initialize(); diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index e251d72e..2232345f 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -139,7 +139,7 @@ public function testDiffRangeParse($repository) $this->assertSame(0, $changes[0]->getRangeOldCount()); $this->assertSame(1, $changes[0]->getRangeNewStart()); - $this->assertSame(0, $changes[0]->getRangeNewCount()); + $this->assertSame(1, $changes[0]->getRangeNewCount()); } /** diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index 4c0476de..b7ba7d32 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Blob; +use Gitonomy\Git\CommitReference; class TreeTest extends AbstractTest { @@ -21,7 +22,7 @@ class TreeTest extends AbstractTest /** * @dataProvider provideFooBar */ - public function testCase($repository) + public function testGetEntries($repository) { $tree = $repository->getCommit(self::LONGFILE_COMMIT)->getTree(); @@ -34,6 +35,44 @@ public function testCase($repository) $this->assertTrue($entries['README.md'][1] instanceof Blob, 'README.md is a Blob'); } + /** + * @dataProvider provideFooBar + */ + public function testGetCommitReferenceEntries($repository) + { + $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $commits = $tree->getCommitReferenceEntries(); + + $this->assertNotEmpty($commits['barbaz'], 'barbaz is present'); + $this->assertTrue($commits['barbaz'][1] instanceof CommitReference, 'barbaz is a Commit'); + } + + /** + * @dataProvider provideFooBar + */ + public function testGetTreeEntries($repository) + { + $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $trees = $tree->getTreeEntries(); + + $this->assertEmpty($trees); + } + + /** + * @dataProvider provideFooBar + */ + public function testGetBlobEntries($repository) + { + $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $blobs = $tree->getBlobEntries(); + + $this->assertNotEmpty($blobs['README.md'], 'README.md is present'); + $this->assertTrue($blobs['README.md'][1] instanceof Blob, 'README.md is a blob'); + } + /** * @dataProvider provideFooBar */ From 0853cb9f840c2bacd38e2f79c4928e60e1d2b152 Mon Sep 17 00:00:00 2001 From: Claudiu Cristea Date: Tue, 7 May 2024 18:05:13 +0300 Subject: [PATCH 058/150] Edge case: Empty message in the first commit (#217) * Edge case: Empty message in the first commit (#217) Don't try to consume a 2nd new line when the first repository commit has an empty message. * Ensure author identity --- src/Gitonomy/Git/Parser/LogParser.php | 4 +++- tests/Gitonomy/Git/Tests/LogTest.php | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Gitonomy/Git/Parser/LogParser.php b/src/Gitonomy/Git/Parser/LogParser.php index 2672186e..4397a460 100644 --- a/src/Gitonomy/Git/Parser/LogParser.php +++ b/src/Gitonomy/Git/Parser/LogParser.php @@ -49,7 +49,9 @@ protected function doParse() $this->consumeGPGSignature(); $this->consumeNewLine(); - $this->consumeNewLine(); + if ($this->cursor < strlen($this->content)) { + $this->consumeNewLine(); + } $message = ''; if ($this->expects(' ')) { diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index cdf15630..970e55c2 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -76,4 +76,19 @@ public function testIterable($repository) } } } + + public function testFirstMessageEmpty() + { + $repository = $this->createEmptyRepository(false); + $repository->run('config', ['--local', 'user.name', '"Unit Test"']); + $repository->run('config', ['--local', 'user.email', '"unit_test@unit-test.com"']); + + // Edge case: first commit lacks a message. + file_put_contents($repository->getWorkingDir().'/file', 'foo'); + $repository->run('add', ['.']); + $repository->run('commit', ['--allow-empty-message', '--no-edit']); + + $commits = $repository->getLog()->getCommits(); + $this->assertCount(1, $commits); + } } From 5fa8b857b9dc61217575190228f66abbe34055ec Mon Sep 17 00:00:00 2001 From: Patrick-Beuks Date: Tue, 21 May 2024 11:10:28 +0200 Subject: [PATCH 059/150] Fix computed data not assinged to cache (#224) Fix assinging two data types to the same variable Fix calling of removed functions that would just return null --- src/Gitonomy/Git/Commit.php | 4 ++-- src/Gitonomy/Git/Parser/CommitParser.php | 4 ++-- src/Gitonomy/Git/Parser/LogParser.php | 8 ++++---- src/Gitonomy/Git/Parser/TagParser.php | 4 ++-- src/Gitonomy/Git/Reference/Tag.php | 4 ++-- src/Gitonomy/Git/ReferenceBag.php | 6 +----- 6 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 2008f7a3..005dab38 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -380,9 +380,9 @@ private function getData($name) array_shift($lines); array_shift($lines); - $data['bodyMessage'] = implode("\n", $lines); + $this->data['bodyMessage'] = implode("\n", $lines); - return $data['bodyMessage']; + return $this->data['bodyMessage']; } $parser = new Parser\CommitParser(); diff --git a/src/Gitonomy/Git/Parser/CommitParser.php b/src/Gitonomy/Git/Parser/CommitParser.php index 98234f6d..58b97131 100644 --- a/src/Gitonomy/Git/Parser/CommitParser.php +++ b/src/Gitonomy/Git/Parser/CommitParser.php @@ -44,8 +44,8 @@ protected function doParse() $this->consumeNewLine(); $this->consume('committer '); - list($this->committerName, $this->committerEmail, $this->committerDate) = $this->consumeNameEmailDate(); - $this->committerDate = $this->parseDate($this->committerDate); + list($this->committerName, $this->committerEmail, $committerDate) = $this->consumeNameEmailDate(); + $this->committerDate = $this->parseDate($committerDate); // will consume an GPG signed commit if there is one $this->consumeGPGSignature(); diff --git a/src/Gitonomy/Git/Parser/LogParser.php b/src/Gitonomy/Git/Parser/LogParser.php index 4397a460..ad41ce43 100644 --- a/src/Gitonomy/Git/Parser/LogParser.php +++ b/src/Gitonomy/Git/Parser/LogParser.php @@ -37,13 +37,13 @@ protected function doParse() } $this->consume('author '); - list($commit['authorName'], $commit['authorEmail'], $commit['authorDate']) = $this->consumeNameEmailDate(); - $commit['authorDate'] = $this->parseDate($commit['authorDate']); + list($commit['authorName'], $commit['authorEmail'], $authorDate) = $this->consumeNameEmailDate(); + $commit['authorDate'] = $this->parseDate($authorDate); $this->consumeNewLine(); $this->consume('committer '); - list($commit['committerName'], $commit['committerEmail'], $commit['committerDate']) = $this->consumeNameEmailDate(); - $commit['committerDate'] = $this->parseDate($commit['committerDate']); + list($commit['committerName'], $commit['committerEmail'], $committerDate) = $this->consumeNameEmailDate(); + $commit['committerDate'] = $this->parseDate($committerDate); // will consume an GPG signed commit if there is one $this->consumeGPGSignature(); diff --git a/src/Gitonomy/Git/Parser/TagParser.php b/src/Gitonomy/Git/Parser/TagParser.php index 659be18a..1a1c084c 100644 --- a/src/Gitonomy/Git/Parser/TagParser.php +++ b/src/Gitonomy/Git/Parser/TagParser.php @@ -40,8 +40,8 @@ protected function doParse() $this->consumeNewLine(); $this->consume('tagger '); - list($this->taggerName, $this->taggerEmail, $this->taggerDate) = $this->consumeNameEmailDate(); - $this->taggerDate = $this->parseDate($this->taggerDate); + list($this->taggerName, $this->taggerEmail, $taggerDate) = $this->consumeNameEmailDate(); + $this->taggerDate = $this->parseDate($taggerDate); $this->consumeNewLine(); $this->consumeNewLine(); diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index bca6ca49..78c43b25 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -191,9 +191,9 @@ private function getData($name) array_shift($lines); array_pop($lines); - $data['bodyMessage'] = implode("\n", $lines); + $this->data['bodyMessage'] = implode("\n", $lines); - return $data['bodyMessage']; + return $this->data['bodyMessage']; } $parser = new TagParser(); diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 4b79fc66..adde82c4 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -354,11 +354,7 @@ protected function initialize() $parser = new Parser\ReferenceParser(); $output = $this->repository->run('show-ref'); } catch (RuntimeException $e) { - $output = $e->getOutput(); - $error = $e->getErrorOutput(); - if ($error) { - throw new RuntimeException('Error while getting list of references: '.$error); - } + $output = null; } $parser->parse($output); From d1c05fdad6c6ae75a7c594daa31a215a9e1e0ce6 Mon Sep 17 00:00:00 2001 From: codebymikey <9484406+codebymikey@users.noreply.github.com> Date: Sun, 3 Nov 2024 15:54:15 +0000 Subject: [PATCH 060/150] Avoid unintentional conversion of index to boolean (#208) Avoid unintentional conversion of index to boolean. --- src/Gitonomy/Git/Parser/DiffParser.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index 28f6c18d..ad22bea8 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -92,8 +92,8 @@ protected function doParse() $oldName = $oldName === '/dev/null' ? null : substr($oldName, 2); $newName = $newName === '/dev/null' ? null : substr($newName, 2); - $oldIndex = $oldIndex !== null ?: ''; - $newIndex = $newIndex !== null ?: ''; + $oldIndex = $oldIndex === null ? '' : $oldIndex; + $newIndex = $newIndex === null ? '' : $newIndex; $oldIndex = preg_match('/^0+$/', $oldIndex) ? null : $oldIndex; $newIndex = preg_match('/^0+$/', $newIndex) ? null : $newIndex; $file = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary); From ac17834888bf399a4ecae5e108be52c8c5f93958 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Sun, 3 Nov 2024 10:59:21 -0500 Subject: [PATCH 061/150] PHP 8.4 support (#225) * PHP 8.4 support * Update tests.yml * Update tests.yml --- .github/workflows/tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e40ff9a6..4e611ce8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,22 +7,22 @@ on: jobs: tests: - name: Test PHP ${{ matrix.php-version }} ${{ matrix.name }} - runs-on: ubuntu-latest + name: Test PHP ${{ matrix.php }} ${{ matrix.name }} + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - php-version: ['8.1', '8.2', '8.3'] + php: ['8.1', '8.2', '8.3', '8.4'] composer-flags: [''] name: [''] include: - - php: 8.0 + - php: '8.0' composer-flags: '--prefer-lowest' name: '(prefer lowest dependencies)' steps: - name: Checkout Code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 From 16214e3a57856e685e000b526414926d29990f54 Mon Sep 17 00:00:00 2001 From: Patrick-Beuks Date: Mon, 27 Jan 2025 10:58:38 +0100 Subject: [PATCH 062/150] Fix incorrect file name on new or deleted empty files (#226) * Fix incorrect file name on new or deleted empty files * Add test for empty file diff --- src/Gitonomy/Git/Parser/DiffParser.php | 2 ++ tests/Gitonomy/Git/Tests/DiffTest.php | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index ad22bea8..eec47ca5 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -38,6 +38,7 @@ protected function doParse() $newMode = $this->consumeTo("\n"); $this->consumeNewLine(); $oldMode = null; + $oldName = '/dev/null'; } if ($this->expects('old mode ')) { $oldMode = $this->consumeTo("\n"); @@ -49,6 +50,7 @@ protected function doParse() if ($this->expects('deleted file mode ')) { $oldMode = $this->consumeTo("\n"); $newMode = null; + $newName = '/dev/null'; $this->consumeNewLine(); } diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 2232345f..c82061b1 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -150,4 +150,26 @@ public function testWorksWithUmlauts($repository) $files = $repository->getCommit(self::FILE_WITH_UMLAUTS_COMMIT)->getDiff()->getFiles(); $this->assertSame('file_with_umlauts_\303\244\303\266\303\274', $files[0]->getNewName()); } + + public function testEmptyNewFile() + { + $diff = Diff::parse("diff --git a/test b/test\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n"); + $firstFile = $diff->getFiles()[0]; + + $this->assertTrue($firstFile->isCreation()); + $this->assertFalse($firstFile->isDeletion()); + $this->assertSame('test', $firstFile->getNewName()); + $this->assertNull($firstFile->getOldName()); + } + + public function testEmptyOldFile() + { + $diff = Diff::parse("diff --git a/test b/test\ndeleted file mode 100644\nindex e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000\n"); + $firstFile = $diff->getFiles()[0]; + + $this->assertFalse($firstFile->isCreation()); + $this->assertTrue($firstFile->isDeletion()); + $this->assertNull($firstFile->getNewName()); + $this->assertSame('test', $firstFile->getOldName()); + } } From 64d8e79dd4c4cf488dad3a3503f1b0ace0a82759 Mon Sep 17 00:00:00 2001 From: Patrick-Beuks Date: Wed, 29 Jan 2025 14:38:58 +0100 Subject: [PATCH 063/150] fix error with empty index by including raw information in diff (#229) * fix error with empty index by including raw information in diff * fix style error --- src/Gitonomy/Git/Commit.php | 2 +- src/Gitonomy/Git/Diff/File.php | 8 ++ src/Gitonomy/Git/Parser/DiffParser.php | 21 ++++- src/Gitonomy/Git/Repository.php | 2 +- src/Gitonomy/Git/WorkingCopy.php | 4 +- tests/Gitonomy/Git/Tests/DiffTest.php | 109 +++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 6 deletions(-) diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 005dab38..573d3b52 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -62,7 +62,7 @@ public function setData(array $data) */ public function getDiff() { - $args = ['-r', '-p', '-m', '-M', '--no-commit-id', '--full-index', $this->revision]; + $args = ['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index', $this->revision]; $diff = Diff::parse($this->repository->run('diff-tree', $args)); $diff->setRepository($this->repository); diff --git a/src/Gitonomy/Git/Diff/File.php b/src/Gitonomy/Git/Diff/File.php index d3da24bb..bfca155c 100644 --- a/src/Gitonomy/Git/Diff/File.php +++ b/src/Gitonomy/Git/Diff/File.php @@ -278,6 +278,10 @@ public function getOldBlob() throw new \LogicException('Can\'t return old Blob on a creation'); } + if ($this->oldIndex === '') { + throw new \RuntimeException('Index is missing to return Blob object.'); + } + return $this->repository->getBlob($this->oldIndex); } @@ -291,6 +295,10 @@ public function getNewBlob() throw new \LogicException('Can\'t return new Blob on a deletion'); } + if ($this->newIndex === '') { + throw new \RuntimeException('Index is missing to return Blob object.'); + } + return $this->repository->getBlob($this->newIndex); } } diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index eec47ca5..15e6c037 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -22,14 +22,31 @@ class DiffParser extends ParserBase protected function doParse() { $this->files = []; + $indexes = []; + // Diff contains raw information + if (str_starts_with($this->content, ':')) { + while ($this->expects(':')) { + $this->consumeRegexp('/\d{6} \d{6} /'); + $oldIndex = $this->consumeShortHash(); + $this->consume(' '); + $newIndex = $this->consumeShortHash(); + $this->consumeTo("\n"); + $this->consumeNewLine(); + $indexes[] = [$oldIndex, $newIndex]; + } + $this->consumeNewLine(); + } elseif (!$this->isFinished()) { + trigger_error('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', E_USER_DEPRECATED); + } + $fileIndex = 0; while (!$this->isFinished()) { // 1. title $vars = $this->consumeRegexp("/diff --git \"?(a\/.*?)\"? \"?(b\/.*?)\"?\n/"); $oldName = $vars[1]; $newName = $vars[2]; - $oldIndex = null; - $newIndex = null; + $oldIndex = isset($indexes[$fileIndex]) ? $indexes[$fileIndex][0] : null; + $newIndex = isset($indexes[$fileIndex]) ? $indexes[$fileIndex][1] : null; $oldMode = null; $newMode = null; diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index fe77a888..296496ae 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -416,7 +416,7 @@ public function getDiff($revisions) $revisions = new RevisionList($this, $revisions); } - $args = array_merge(['-r', '-p', '-m', '-M', '--no-commit-id', '--full-index'], $revisions->getAsTextArray()); + $args = array_merge(['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index'], $revisions->getAsTextArray()); $diff = Diff::parse($this->run('diff', $args)); $diff->setRepository($this); diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index aed3b85c..a94fbbb9 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -50,7 +50,7 @@ public function getUntrackedFiles() public function getDiffPending() { - $diff = Diff::parse($this->run('diff', ['-r', '-p', '-m', '-M', '--full-index'])); + $diff = Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index'])); $diff->setRepository($this->repository); return $diff; @@ -58,7 +58,7 @@ public function getDiffPending() public function getDiffStaged() { - $diff = Diff::parse($this->run('diff', ['-r', '-p', '-m', '-M', '--full-index', '--staged'])); + $diff = Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--staged'])); $diff->setRepository($this->repository); return $diff; diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index c82061b1..e51fc7fc 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -13,6 +13,8 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Repository; class DiffTest extends AbstractTest { @@ -151,6 +153,113 @@ public function testWorksWithUmlauts($repository) $this->assertSame('file_with_umlauts_\303\244\303\266\303\274', $files[0]->getNewName()); } + public function testDeleteFileWithoutRaw() + { + $deprecationCalled = false; + $self = $this; + set_error_handler(function (int $errno, string $errstr) use ($self, &$deprecationCalled): void { + $deprecationCalled = true; + $self->assertSame('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', $errstr); + }, E_USER_DEPRECATED); + + $diff = Diff::parse(<<<'DIFF' + diff --git a/test b/test + deleted file mode 100644 + index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 + + DIFF); + $firstFile = $diff->getFiles()[0]; + + restore_exception_handler(); + + $this->assertTrue($deprecationCalled); + $this->assertFalse($firstFile->isCreation()); + // TODO: Enable after #226 is merged + //$this->assertTrue($firstFile->isDeletion()); + //$this->assertFalse($firstFile->isChangeMode()); + $this->assertSame('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', $firstFile->getOldIndex()); + $this->assertNull($firstFile->getNewIndex()); + } + + public function testModeChangeFileWithoutRaw() + { + $deprecationCalled = false; + $self = $this; + set_error_handler(function (int $errno, string $errstr) use ($self, &$deprecationCalled): void { + $deprecationCalled = true; + $self->assertSame('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', $errstr); + }, E_USER_DEPRECATED); + + $diff = Diff::parse(<<<'DIFF' + diff --git a/a.out b/a.out + old mode 100755 + new mode 100644 + + DIFF); + $firstFile = $diff->getFiles()[0]; + + restore_exception_handler(); + + $this->assertTrue($deprecationCalled); + $this->assertFalse($firstFile->isCreation()); + $this->assertFalse($firstFile->isDeletion()); + $this->assertTrue($firstFile->isChangeMode()); + $this->assertSame('', $firstFile->getOldIndex()); + $this->assertSame('', $firstFile->getNewIndex()); + } + + public function testModeChangeFileWithRaw() + { + $deprecationCalled = false; + set_error_handler(function (int $errno, string $errstr) use (&$deprecationCalled): void { + $deprecationCalled = true; + }, E_USER_DEPRECATED); + + $diff = Diff::parse(<<<'DIFF' + :100755 100644 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M a.out + + diff --git a/a.out b/a.out + old mode 100755 + new mode 100644 + + DIFF); + $firstFile = $diff->getFiles()[0]; + + restore_exception_handler(); + + $this->assertFalse($deprecationCalled); + $this->assertFalse($firstFile->isCreation()); + $this->assertFalse($firstFile->isDeletion()); + $this->assertTrue($firstFile->isChangeMode()); + $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getOldIndex()); + $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getNewIndex()); + } + + public function testThrowErrorOnBlobGetWithoutIndex() + { + $repository = $this->getMockBuilder(Repository::class)->disableOriginalConstructor()->getMock(); + $file = new File('oldName', 'newName', '100755', '100644', '', '', false); + $file->setRepository($repository); + + try { + $file->getOldBlob(); + } catch(\RuntimeException $exception) { + $this->assertSame('Index is missing to return Blob object.', $exception->getMessage()); + } + + try { + $file->getNewBlob(); + } catch(\RuntimeException $exception) { + $this->assertSame('Index is missing to return Blob object.', $exception->getMessage()); + } + + $this->assertFalse($file->isCreation()); + $this->assertFalse($file->isDeletion()); + $this->assertTrue($file->isChangeMode()); + $this->assertSame('', $file->getOldIndex()); + $this->assertSame('', $file->getNewIndex()); + } + public function testEmptyNewFile() { $diff = Diff::parse("diff --git a/test b/test\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n"); From de03597af9dcac5d8a024ee33f5a1b77f68547b2 Mon Sep 17 00:00:00 2001 From: Adrian Lorenc <101017881+adrian-lorenc@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:43:33 +0100 Subject: [PATCH 064/150] Consume merge tag (#231) --- src/Gitonomy/Git/Parser/CommitParser.php | 2 ++ src/Gitonomy/Git/Parser/LogParser.php | 2 ++ src/Gitonomy/Git/Parser/ParserBase.php | 14 ++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/src/Gitonomy/Git/Parser/CommitParser.php b/src/Gitonomy/Git/Parser/CommitParser.php index 58b97131..6fff1ea4 100644 --- a/src/Gitonomy/Git/Parser/CommitParser.php +++ b/src/Gitonomy/Git/Parser/CommitParser.php @@ -47,6 +47,8 @@ protected function doParse() list($this->committerName, $this->committerEmail, $committerDate) = $this->consumeNameEmailDate(); $this->committerDate = $this->parseDate($committerDate); + $this->consumeMergeTag(); + // will consume an GPG signed commit if there is one $this->consumeGPGSignature(); diff --git a/src/Gitonomy/Git/Parser/LogParser.php b/src/Gitonomy/Git/Parser/LogParser.php index ad41ce43..53e5b167 100644 --- a/src/Gitonomy/Git/Parser/LogParser.php +++ b/src/Gitonomy/Git/Parser/LogParser.php @@ -45,6 +45,8 @@ protected function doParse() list($commit['committerName'], $commit['committerEmail'], $committerDate) = $this->consumeNameEmailDate(); $commit['committerDate'] = $this->parseDate($committerDate); + $this->consumeMergeTag(); + // will consume an GPG signed commit if there is one $this->consumeGPGSignature(); diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index 7cd37e75..be149bf0 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -136,4 +136,18 @@ protected function consumeGPGSignature() return $this->consumeTo("\n\n"); } + + protected function consumeMergeTag() + { + $expected = "\nmergetag "; + $length = strlen($expected); + $actual = substr($this->content, $this->cursor, $length); + if ($actual != $expected) { + return ''; + } + $this->cursor += $length; + + $this->consumeTo('-----END PGP SIGNATURE-----'); + $this->consume('-----END PGP SIGNATURE-----'); + } } From fae99117474658bd6af131f82d063150a75e6329 Mon Sep 17 00:00:00 2001 From: Ilias Dimopoulos Date: Wed, 4 Jun 2025 17:55:21 +0300 Subject: [PATCH 065/150] Skip third party unsupported headers. (#232) * Skip third party unsupported headers. * Fix sniff remarks. * Sniffs fix v2. --- src/Gitonomy/Git/Parser/LogParser.php | 12 ++++++++ tests/Gitonomy/Git/Tests/LogTest.php | 42 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/Gitonomy/Git/Parser/LogParser.php b/src/Gitonomy/Git/Parser/LogParser.php index 53e5b167..67b86687 100644 --- a/src/Gitonomy/Git/Parser/LogParser.php +++ b/src/Gitonomy/Git/Parser/LogParser.php @@ -51,6 +51,7 @@ protected function doParse() $this->consumeGPGSignature(); $this->consumeNewLine(); + $this->consumeUnsupportedLinesToNewLine(); if ($this->cursor < strlen($this->content)) { $this->consumeNewLine(); } @@ -76,4 +77,15 @@ protected function doParse() $this->log[] = $commit; } } + + protected function consumeUnsupportedLinesToNewLine() + { + // Consume any unsupported lines that may appear in the log output. For + // example, gitbutler headers or other custom metadata but this should + // work regardless of the content. + while (!$this->isFinished() && substr($this->content, $this->cursor, 1) !== "\n") { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + } } diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 970e55c2..a02184dd 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -12,6 +12,8 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Parser\LogParser; + class LogTest extends AbstractTest { /** @@ -91,4 +93,44 @@ public function testFirstMessageEmpty() $commits = $repository->getLog()->getCommits(); $this->assertCount(1, $commits); } + + public function testParsesCommitsWithAndWithoutGitButlerHeaders(): void + { + $logContent = <<<'EOT' + commit 1111111111111111111111111111111111111111 + tree abcdefabcdefabcdefabcdefabcdefabcdefabcd + author John Doe 1620000000 +0000 + committer John Doe 1620000000 +0000 + + First commit message + + commit 2222222222222222222222222222222222222222 + tree abcdefabcdefabcdefabcdefabcdefabcdefabcd + parent 1111111111111111111111111111111111111111 + author Jane Smith 1620003600 +0000 + committer Jane Smith 1620003600 +0000 + gitbutler-headers-version: 2 + gitbutler-change-id: a7bd485c-bae6-45b2-910f-163c78aace81 + + Commit with GitButler headers + + commit 3333333333333333333333333333333333333333 + tree abcdefabcdefabcdefabcdefabcdefabcdefabcd + author John Doe 1620007200 +0000 + committer Jane Smith 1620007200 +0000 + + Another commit without GitButler headers + + EOT; + + $parser = new LogParser(); + $parser->parse($logContent); + + $log = $parser->log; + $this->assertCount(3, $log); + + $this->assertEquals("First commit message\n", $log[0]['message']); + $this->assertEquals("Commit with GitButler headers\n", $log[1]['message']); + $this->assertEquals("Another commit without GitButler headers\n", $log[2]['message']); + } } From e6e9da54bf035f4637322c97ef02cd0de0e91454 Mon Sep 17 00:00:00 2001 From: Patrick-Beuks Date: Thu, 26 Jun 2025 16:46:39 +0200 Subject: [PATCH 066/150] Fix null passed to strlen in parsers (#234) When a command is run and fails it returns null. This is then passed to the parser and it handles it as an empty string Since PHP 8.0 it is deprecated to pass null to `strlen` so instead make this an empty String. --- src/Gitonomy/Git/Parser/ParserBase.php | 2 +- src/Gitonomy/Git/ReferenceBag.php | 2 +- tests/Gitonomy/Git/Tests/ReferenceBagTest.php | 9 +++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index be149bf0..45a80020 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -25,7 +25,7 @@ abstract protected function doParse(); public function parse($content) { $this->cursor = 0; - $this->content = $content; + $this->content = $content ?? ''; $this->length = strlen($this->content); $this->doParse(); diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index adde82c4..71ef9ae4 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -354,7 +354,7 @@ protected function initialize() $parser = new Parser\ReferenceParser(); $output = $this->repository->run('show-ref'); } catch (RuntimeException $e) { - $output = null; + return; } $parser->parse($output); diff --git a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php index 35756bf0..f9b9c82c 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php @@ -44,4 +44,13 @@ public function testUnknownReference(Repository $repository) $this->assertArrayNotHasKey('refs/pull/1/head', $refs); $this->assertArrayNotHasKey('refs/notes/gtm-data', $refs); } + + /** + * @dataProvider provideEmpty + */ + public function testEmptyRepo(Repository $repository) + { + $refs = $repository->getReferences()->getAll(); + $this->assertSame([], $refs); + } } From d07fac405bbc3857fe69192ef58554e0669dbfaf Mon Sep 17 00:00:00 2001 From: Arne De Smedt Date: Mon, 1 Dec 2025 13:42:50 +0100 Subject: [PATCH 067/150] Update symfony/process version constraint to include 8.0 (#235) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a3de0536..6979e26f 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ "php": "^8.0", "ext-pcre": "*", "symfony/polyfill-mbstring": "^1.7", - "symfony/process": "^5.4 || ^6.0 || ^7.0" + "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "ext-fileinfo": "*", From c1476cb0fd317fc512971e926b40ebcdb666e263 Mon Sep 17 00:00:00 2001 From: Patrick-Beuks Date: Tue, 9 Dec 2025 21:06:56 +0100 Subject: [PATCH 068/150] fix: add forgotten increment (#236) --- src/Gitonomy/Git/Parser/DiffParser.php | 2 ++ tests/Gitonomy/Git/Tests/DiffTest.php | 28 ++++++++++++++++++-------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index 15e6c037..b6386ee9 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -45,8 +45,10 @@ protected function doParse() $vars = $this->consumeRegexp("/diff --git \"?(a\/.*?)\"? \"?(b\/.*?)\"?\n/"); $oldName = $vars[1]; $newName = $vars[2]; + // Get indexes from raw if it exists $oldIndex = isset($indexes[$fileIndex]) ? $indexes[$fileIndex][0] : null; $newIndex = isset($indexes[$fileIndex]) ? $indexes[$fileIndex][1] : null; + $fileIndex++; $oldMode = null; $newMode = null; diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index e51fc7fc..9703bdde 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -174,9 +174,8 @@ public function testDeleteFileWithoutRaw() $this->assertTrue($deprecationCalled); $this->assertFalse($firstFile->isCreation()); - // TODO: Enable after #226 is merged - //$this->assertTrue($firstFile->isDeletion()); - //$this->assertFalse($firstFile->isChangeMode()); + $this->assertTrue($firstFile->isDeletion()); + $this->assertFalse($firstFile->isChangeMode()); $this->assertSame('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', $firstFile->getOldIndex()); $this->assertNull($firstFile->getNewIndex()); } @@ -216,23 +215,36 @@ public function testModeChangeFileWithRaw() }, E_USER_DEPRECATED); $diff = Diff::parse(<<<'DIFF' - :100755 100644 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M a.out + :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M testfile + :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 M testfile2 - diff --git a/a.out b/a.out - old mode 100755 - new mode 100644 + diff --git a/testfile b/testfile + old mode 100644 + new mode 100755 + diff --git a/testfile2 b/testfile2 + old mode 100644 + new mode 100755 DIFF); - $firstFile = $diff->getFiles()[0]; + $files = $diff->getFiles(); + $firstFile = $files[0]; + $secondFile = $files[1]; restore_exception_handler(); $this->assertFalse($deprecationCalled); + $this->assertFalse($firstFile->isCreation()); $this->assertFalse($firstFile->isDeletion()); $this->assertTrue($firstFile->isChangeMode()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getOldIndex()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getNewIndex()); + + $this->assertFalse($secondFile->isCreation()); + $this->assertFalse($secondFile->isDeletion()); + $this->assertTrue($secondFile->isChangeMode()); + $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getOldIndex()); + $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getNewIndex()); } public function testThrowErrorOnBlobGetWithoutIndex() From 9fd35df8faaac1285b2584399436e61fff0cbd97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 11:21:41 +0200 Subject: [PATCH 069/150] Modernize for PHP 8.4+, Symfony 6.4/7.4/8.1+, PHPUnit 12 (#238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Modernize for PHP 8.4+, Symfony 6.4/7.4/8.1+, PHPUnit 12 - composer.json: require php ^8.4, symfony/process ^6.4 || ^7.4 || ^8.1, bump phpunit/phpunit to ^12.0 and psr/log to ^3.0, drop phpspec/prophecy-phpunit (its one use site now uses native PHPUnit mocks) - CI matrix now runs PHP 8.4/8.5 plus a prefer-lowest job; phpunit.xml.dist rewritten for the PHPUnit 12 schema - README/CONTRIBUTING updated for the new PHP baseline and a couple of stale references - Full property/parameter/return type coverage across src/, with readonly applied wherever a property is genuinely write-once (mostly via constructor promotion) - Classes marked final wherever nothing extends them, and final readonly where every property is readonly; left plain where they're actually extended or inherit mutable state (Revision, Reference, ParserBase, CommitParser, Exception\RuntimeException) - Tests converted from doc-comment metadata (@dataProvider/@before/@after) to PHPUnit 12 attributes, since PHPUnit 12 no longer reads the old annotations; AbstractTest renamed to AbstractTestCase so PHPUnit doesn't mistake it for a test case via the *Test.php glob - Fixed bugs surfaced along the way: Log's ReferenceNotFoundException call silently dropped the previous exception (constructor didn't accept it); DiffTest called restore_exception_handler() instead of restore_error_handler(), leaking handlers between tests; a dead @dataProvider on ReferenceTest::testIsBranchMergedToMaster wastefully cloned repos it never used * Drop StyleCI references and README badges StyleCI never had a .styleci.yml or a workflow step in this repo — it only ran as a separate GitHub App integration. Removing it here means dropping every textual reference; actually uninstalling the app is a repo-settings action outside of this checkout. * Add php-cs-fixer, PHPStan and Castor tooling Mirrors JoliCode's own JoliNotif project setup: - php-cs-fixer and phpstan each live in tools// with their own isolated composer.json/lock (avoids dependency conflicts with the library's own require-dev), wired together via a root castor.php that imports each tool's own castor.php. `castor install`, `castor cs`, `castor phpstan` and `castor phpunit` all work end to end. - .php-cs-fixer.php: @PHP84Migration + @PhpCsFixer + @Symfony(:risky), gitlib's own license header enforced via header_comment. Ran it for real across the codebase (58 files reformatted, all mechanical); it also filled in the license header on two files that were missing it entirely (RuntimeException.php, tests/bootstrap.php). - phpstan.neon: level 9 on src/. Fixed the ~20 findings that were real bugs or design smells rather than annotation gaps: - Reference\Tag::getCommit() could pass an undefined $commitHash to getCommit() when show-ref returned no matching row - Repository::getDiff(null) and Log::getCommits()'s catch block would fatal-error calling a method on null on a reachable path (a Log built with no revision filter) - Repository's $gitDir/$workingDir were readonly-assigned outside the constructor; moved to a static resolveDir() helper - Repository's single $objects cache (typed Commit|Tree|Blob) split into three properly-typed caches - RevisionList/Tree array-shape narrowing fixed by restructuring the loops that build them - ReferenceBag::update() and a new getAs() helper made generic so createBranch()/getTag()/etc. return their real narrow type - CommitReference was missing a getHash() getter entirely - Blob::getMimetype() honestly typed string|false (finfo::buffer() can fail) The remaining ~94 findings are baselined in phpstan-baseline.neon: mostly Commit/Reference\Tag's untyped getData(): mixed internals, and Repository::run()'s ?string return propagating into code that assumes a string. Fixing those properly means redesigning that data-caching layer or auditing every run() call site for the debug=false null case, which is bigger than "add phpstan". - CI: check-cs and phpstan jobs pinned to PHP 8.4 (our floor) using castor-php/setup-castor@v1.1.0. - CONTRIBUTING.md: Castor install step, "Standard code" and "Static analysis" sections. * Remove redundant readonly on properties of readonly classes A class-level `readonly` modifier already makes every declared property readonly, so repeating the keyword on each promoted constructor property was dead weight. --- .github/CONTRIBUTING.md | 33 +- .github/workflows/tests.yml | 46 +- .gitignore | 5 +- .php-cs-fixer.php | 48 + README.md | 7 +- castor.php | 37 + composer.json | 9 +- phpstan-baseline.neon | 535 ++++ phpstan.neon | 11 + phpunit.xml.dist | 27 +- src/Gitonomy/Git/Admin.php | 56 +- src/Gitonomy/Git/Blame.php | 70 +- src/Gitonomy/Git/Blame/Line.php | 33 +- src/Gitonomy/Git/Blob.php | 68 +- src/Gitonomy/Git/Commit.php | 122 +- src/Gitonomy/Git/CommitReference.php | 15 +- src/Gitonomy/Git/Diff/Diff.php | 56 +- src/Gitonomy/Git/Diff/File.php | 148 +- src/Gitonomy/Git/Diff/FileChange.php | 83 +- .../Git/Exception/GitExceptionInterface.php | 2 +- .../Exception/InvalidArgumentException.php | 12 +- src/Gitonomy/Git/Exception/LogicException.php | 12 +- .../Git/Exception/ProcessException.php | 22 +- .../Exception/ReferenceNotFoundException.php | 8 +- .../Git/Exception/RuntimeException.php | 10 + .../Exception/UnexpectedValueException.php | 12 +- src/Gitonomy/Git/Hooks.php | 51 +- src/Gitonomy/Git/Log.php | 119 +- src/Gitonomy/Git/Parser/BlameParser.php | 23 +- src/Gitonomy/Git/Parser/CommitParser.php | 46 +- src/Gitonomy/Git/Parser/DiffParser.php | 33 +- src/Gitonomy/Git/Parser/LogParser.php | 25 +- src/Gitonomy/Git/Parser/ParserBase.php | 61 +- src/Gitonomy/Git/Parser/ReferenceParser.php | 11 +- src/Gitonomy/Git/Parser/TagParser.php | 41 +- src/Gitonomy/Git/Parser/TreeParser.php | 11 +- src/Gitonomy/Git/PushReference.php | 103 +- src/Gitonomy/Git/Reference.php | 36 +- src/Gitonomy/Git/Reference/Branch.php | 31 +- src/Gitonomy/Git/Reference/Stash.php | 6 +- src/Gitonomy/Git/Reference/Tag.php | 68 +- src/Gitonomy/Git/ReferenceBag.php | 200 +- src/Gitonomy/Git/Repository.php | 318 +- src/Gitonomy/Git/Revision.php | 39 +- src/Gitonomy/Git/RevisionList.php | 51 +- src/Gitonomy/Git/Tree.php | 104 +- src/Gitonomy/Git/Util/StringHelper.php | 28 +- src/Gitonomy/Git/WorkingCopy.php | 44 +- ...{AbstractTest.php => AbstractTestCase.php} | 60 +- tests/Gitonomy/Git/Tests/AdminTest.php | 77 +- tests/Gitonomy/Git/Tests/BlameTest.php | 19 +- tests/Gitonomy/Git/Tests/BlobTest.php | 55 +- tests/Gitonomy/Git/Tests/CommitTest.php | 172 +- tests/Gitonomy/Git/Tests/DiffTest.php | 181 +- tests/Gitonomy/Git/Tests/HooksTest.php | 108 +- tests/Gitonomy/Git/Tests/LogTest.php | 96 +- .../Gitonomy/Git/Tests/PushReferenceTest.php | 55 +- tests/Gitonomy/Git/Tests/ReferenceBagTest.php | 23 +- tests/Gitonomy/Git/Tests/ReferenceTest.php | 105 +- tests/Gitonomy/Git/Tests/RepositoryTest.php | 108 +- tests/Gitonomy/Git/Tests/RevisionTest.php | 24 +- tests/Gitonomy/Git/Tests/TreeTest.php | 38 +- tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 20 +- tests/bootstrap.php | 10 + tools/php-cs-fixer/.gitignore | 1 + tools/php-cs-fixer/castor.php | 50 + tools/php-cs-fixer/composer.json | 11 + tools/php-cs-fixer/composer.lock | 2766 +++++++++++++++++ tools/phpstan/.gitignore | 1 + tools/phpstan/castor.php | 50 + tools/phpstan/composer.json | 11 + tools/phpstan/composer.lock | 86 + 72 files changed, 5067 insertions(+), 1996 deletions(-) create mode 100644 .php-cs-fixer.php create mode 100644 castor.php create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon rename tests/Gitonomy/Git/Tests/{AbstractTest.php => AbstractTestCase.php} (64%) create mode 100644 tools/php-cs-fixer/.gitignore create mode 100644 tools/php-cs-fixer/castor.php create mode 100644 tools/php-cs-fixer/composer.json create mode 100644 tools/php-cs-fixer/composer.lock create mode 100644 tools/phpstan/.gitignore create mode 100644 tools/phpstan/castor.php create mode 100644 tools/phpstan/composer.json create mode 100644 tools/phpstan/composer.lock diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index d33db7e8..28cea4c3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,13 +6,23 @@ We accept contributions via pull requests on GitHub. Please review these guideli ## Guidelines -* Please follow the [PSR-12 Coding Style Guide](https://www.php-fig.org/psr/psr-12/), enforced by [StyleCI](https://styleci.io/). +* Please follow the [PSR-12 Coding Style Guide](https://www.php-fig.org/psr/psr-12/). * Ensure that the current tests pass, and if you've added something new, add the tests where relevant. * Send a coherent commit history, making sure each commit in your pull request is meaningful. * You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts. * If you are changing or adding to the behaviour or public API, you may need to update the docs. * Please remember that we follow [Semantic Versioning](https://semver.org/). +You will need [Castor](https://castor.jolicode.com/) to run the tests, fix CS +violations and run the static analysis. See [Castor's documentation](https://castor.jolicode.com/getting-started/installation/) +for installation instructions. + +To install all the dependencies and tools, run: + +```bash +$ castor install +``` + ## Running Tests First, install the dependencies using [Composer](https://getcomposer.org/): @@ -27,6 +37,23 @@ Then run [PHPUnit](https://phpunit.de/): $ vendor/bin/phpunit ``` -* A script `test-git-version.sh` is available in repository to test gitlib against many git versions. +* A script `test-git-versions.sh` is available in repository to test gitlib against many git versions. * The tests will be automatically run by [GitHub Actions](https://github.com/features/actions) against pull requests. -* We also have [StyleCI](https://styleci.io/) set up to automatically fix any code style issues. + +## Standard code + +Use PHP-CS-Fixer to make your code compliant with gitlib's coding standards: + +```bash +$ castor cs +``` + +## Static analysis + +Use PHPStan to ensure the code is free of errors: + +```bash +$ castor phpstan +``` + +Both checks run automatically via GitHub Actions against pull requests. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4e611ce8..a599f8c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,17 +6,56 @@ on: pull_request: jobs: + check-cs: + name: Check Coding Standards + runs-on: ubuntu-24.04 + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + + - name: Setup Castor + uses: castor-php/setup-castor@2a495b8c91f00be6768ad8a040ba8634c797d386 # v1.1.0 + + - name: Run CS check + run: castor cs --dry-run + + phpstan: + name: Static Analysis + runs-on: ubuntu-24.04 + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + + - name: Setup Castor + uses: castor-php/setup-castor@2a495b8c91f00be6768ad8a040ba8634c797d386 # v1.1.0 + + - name: Install dependencies + run: castor install + + - name: Run PHPStan + run: castor phpstan + tests: name: Test PHP ${{ matrix.php }} ${{ matrix.name }} runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - php: ['8.1', '8.2', '8.3', '8.4'] + php: ['8.4', '8.5'] composer-flags: [''] name: [''] include: - - php: '8.0' + - php: '8.4' composer-flags: '--prefer-lowest' name: '(prefer lowest dependencies)' @@ -32,6 +71,9 @@ jobs: - name: Setup Problem Matchers run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + - name: Validate composer.json + run: composer validate --strict + - name: Install Composer dependencies run: | composer update --prefer-dist --no-interaction ${{ matrix.composer-flags }} diff --git a/.gitignore b/.gitignore index 376a2cd1..6c80d033 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ -/.phpunit.result.cache +/.castor.stub.php +/.php-cs-fixer.cache +/.phpunit.cache /composer.lock /phpunit.xml +/var/ /vendor diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 00000000..e9dffd22 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,48 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +$fileHeaderComment = <<<'EOF' + This file is part of Gitonomy. + + (c) Alexandre Salomé + (c) Julien DIDIER + + This source file is subject to the MIT license that is bundled + with this source code in the file LICENSE. + EOF; + +$finder = PhpCsFixer\Finder::create() + ->in(__DIR__) + ->append([ + __FILE__, + 'castor.php', + ]) + ->notPath('var') +; + +return new PhpCsFixer\Config() + ->setRiskyAllowed(true) + ->setUnsupportedPhpVersionAllowed(true) + ->setRules([ + '@PHP84Migration' => true, + '@PhpCsFixer' => true, + '@Symfony' => true, + '@Symfony:risky' => true, + 'php_unit_internal_class' => false, // From @PhpCsFixer but we don't want it + 'php_unit_test_class_requires_covers' => false, // From @PhpCsFixer but we don't want it + 'phpdoc_add_missing_param_annotation' => false, // From @PhpCsFixer but we don't want it + 'header_comment' => ['header' => $fileHeaderComment], + 'ordered_class_elements' => true, // Symfony(PSR12) override the default value, but we don't want + 'blank_line_before_statement' => true, // Symfony(PSR12) override the default value, but we don't want + ]) + ->setFinder($finder) +; diff --git a/README.md b/README.md index e0dcade7..24b1c76e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,7 @@ Gitlib for Gitonomy =================== -[![Build Status](https://img.shields.io/github/actions/workflow/status/gitonomy/gitlib/tests.yml?label=Tests&style=flat-square&branch=1.3)](https://github.com/gitonomy/gitlib/actions?query=workflow%3ATests+branch%3A1.3) -[![StyleCI](https://github.styleci.io/repos/5709354/shield?branch=1.3)](https://github.styleci.io/repos/5709354?branch=1.3) -[![License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://opensource.org/licenses/MIT) -[![Downloads](https://img.shields.io/packagist/dt/gitonomy/gitlib?style=flat-square)](https://packagist.org/packages/gitonomy/gitlib) - -This library provides methods to access Git repository from PHP 5.6+. +This library provides methods to access Git repository from PHP 8.4+. It makes shell calls, which makes it less performant than any solution. diff --git a/castor.php b/castor.php new file mode 100644 index 00000000..61fc7a88 --- /dev/null +++ b/castor.php @@ -0,0 +1,37 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +use Castor\Attribute\AsRawTokens; +use Castor\Attribute\AsTask; + +use function Castor\guard_min_version; +use function Castor\import; +use function Castor\run; + +guard_min_version('1.0.0'); + +import(__DIR__.'/tools/php-cs-fixer/castor.php'); +import(__DIR__.'/tools/phpstan/castor.php'); + +#[AsTask(description: 'Install dependencies')] +function install(): void +{ + run(['composer', 'install']); + qa\cs\install(); + qa\phpstan\install(); +} + +#[AsTask(description: 'Run PHPUnit', ignoreValidationErrors: true)] +function phpunit(#[AsRawTokens] array $rawTokens): void +{ + run(['vendor/bin/phpunit', ...$rawTokens]); +} diff --git a/composer.json b/composer.json index 6979e26f..b85a2405 100644 --- a/composer.json +++ b/composer.json @@ -35,16 +35,15 @@ } }, "require": { - "php": "^8.0", + "php": "^8.4", "ext-pcre": "*", "symfony/polyfill-mbstring": "^1.7", - "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0" + "symfony/process": "^6.4 || ^7.4 || ^8.1" }, "require-dev": { "ext-fileinfo": "*", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^7.5.20 || ^8.5.20 || ^9.5.9", - "psr/log": "^1.0" + "phpunit/phpunit": "^12.0", + "psr/log": "^3.0" }, "config": { "preferred-install": "dist", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000..1f818dbb --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,535 @@ +parameters: + ignoreErrors: + - + message: '#^Method Gitonomy\\Git\\Admin\:\:cloneBranchTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Admin.php + + - + message: '#^Method Gitonomy\\Git\\Admin\:\:cloneRepository\(\) has parameter \$args with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Admin.php + + - + message: '#^Method Gitonomy\\Git\\Admin\:\:cloneRepository\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Admin.php + + - + message: '#^Method Gitonomy\\Git\\Admin\:\:cloneTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Admin.php + + - + message: '#^Method Gitonomy\\Git\\Admin\:\:getProcess\(\) has parameter \$args with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Admin.php + + - + message: '#^Method Gitonomy\\Git\\Admin\:\:getProcess\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Admin.php + + - + message: '#^Method Gitonomy\\Git\\Admin\:\:init\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Admin.php + + - + message: '#^Method Gitonomy\\Git\\Admin\:\:isValidRepository\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Admin.php + + - + message: '#^Method Gitonomy\\Git\\Admin\:\:isValidRepositoryAndBranch\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Admin.php + + - + message: '#^Method Gitonomy\\Git\\Admin\:\:mirrorTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Admin.php + + - + message: '#^Method Gitonomy\\Git\\Blame\:\:getGroupedLines\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Blame.php + + - + message: '#^Property Gitonomy\\Git\\Blame\\Line\:\:\$blockLine is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Gitonomy/Git/Blame/Line.php + + - + message: '#^Property Gitonomy\\Git\\Blame\\Line\:\:\$targetLine is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Gitonomy/Git/Blame/Line.php + + - + message: '#^Method Gitonomy\\Git\\Blob\:\:getContent\(\) should return string but returns string\|null\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Blob.php + + - + message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' + identifier: foreach.nonIterable + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:__construct\(\) has parameter \$data with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getAuthorDate\(\) should return DateTime but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getAuthorEmail\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getAuthorName\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getBodyMessage\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getCommitterDate\(\) should return DateTime but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getCommitterEmail\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getCommitterName\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getMessage\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getParentHashes\(\) should return array\ but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getShortHash\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getShortMessage\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getSubjectMessage\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getTree\(\) should return Gitonomy\\Git\\Tree but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:getTreeHash\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Commit\:\:setData\(\) has parameter \$data with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Parameter \#1 \$hash of method Gitonomy\\Git\\Repository\:\:getCommit\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Parameter \#1 \$hash of method Gitonomy\\Git\\Repository\:\:getTree\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Parameter \#1 \$haystack of static method Gitonomy\\Git\\Util\\StringHelper\:\:strpos\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Parameter \#1 \$rawDiff of static method Gitonomy\\Git\\Diff\\Diff\:\:parse\(\) expects string, string\|null given\.$#' + identifier: argument.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Parameter \#1 \$string of function trim expects string, string\|null given\.$#' + identifier: argument.type + count: 2 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Parameter \#1 \$string of static method Gitonomy\\Git\\Util\\StringHelper\:\:strlen\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Parameter \#1 \$string of static method Gitonomy\\Git\\Util\\StringHelper\:\:substr\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Parameter \#2 \$string of function explode expects string, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Gitonomy/Git/Commit.php + + - + message: '#^Method Gitonomy\\Git\\Diff\\Diff\:\:fromArray\(\) has parameter \$array with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Diff/Diff.php + + - + message: '#^Method Gitonomy\\Git\\Diff\\Diff\:\:toArray\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Diff/Diff.php + + - + message: '#^Method Gitonomy\\Git\\Diff\\File\:\:fromArray\(\) has parameter \$array with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Diff/File.php + + - + message: '#^Method Gitonomy\\Git\\Diff\\File\:\:toArray\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Diff/File.php + + - + message: '#^Parameter \#1 \$hash of method Gitonomy\\Git\\Repository\:\:getBlob\(\) expects string, string\|null given\.$#' + identifier: argument.type + count: 2 + path: src/Gitonomy/Git/Diff/File.php + + - + message: '#^Method Gitonomy\\Git\\Diff\\FileChange\:\:fromArray\(\) has parameter \$array with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Diff/FileChange.php + + - + message: '#^Method Gitonomy\\Git\\Diff\\FileChange\:\:toArray\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Diff/FileChange.php + + - + message: '#^Method Gitonomy\\Git\\Hooks\:\:get\(\) should return string but returns string\|false\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Hooks.php + + - + message: '#^Class Gitonomy\\Git\\Log implements generic interface IteratorAggregate but does not specify its types\: TKey, TValue$#' + identifier: missingType.generics + count: 1 + path: src/Gitonomy/Git/Log.php + + - + message: '#^Method Gitonomy\\Git\\Log\:\:__construct\(\) has parameter \$paths with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Log.php + + - + message: '#^Method Gitonomy\\Git\\Log\:\:__construct\(\) has parameter \$revisions with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Log.php + + - + message: '#^Method Gitonomy\\Git\\Log\:\:count\(\) should return int\<0, max\> but returns int\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Log.php + + - + message: '#^Method Gitonomy\\Git\\Log\:\:getIterator\(\) return type with generic class ArrayIterator does not specify its types\: TKey, TValue$#' + identifier: missingType.generics + count: 1 + path: src/Gitonomy/Git/Log.php + + - + message: '#^Method Gitonomy\\Git\\Log\:\:getPaths\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Log.php + + - + message: '#^Property Gitonomy\\Git\\Log\:\:\$paths type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Log.php + + - + message: '#^Property Gitonomy\\Git\\Parser\\LogParser\:\:\$log type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Parser/LogParser.php + + - + message: '#^Method Gitonomy\\Git\\Parser\\ParserBase\:\:consumeRegexp\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Parser/ParserBase.php + + - + message: '#^Parameter \#1 \$string of function trim expects string, string\|null given\.$#' + identifier: argument.type + count: 1 + path: src/Gitonomy/Git/PushReference.php + + - + message: '#^Parameter \#1 \$string of function trim expects string, string\|null given\.$#' + identifier: argument.type + count: 1 + path: src/Gitonomy/Git/Reference.php + + - + message: '#^Method Gitonomy\\Git\\Reference\\Branch\:\:isLocal\(\) should return bool but returns bool\|null\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Reference/Branch.php + + - + message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getBodyMessage\(\) should return string\|false but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Reference/Tag.php + + - + message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getGPGSignature\(\) should return string\|false but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Reference/Tag.php + + - + message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getMessage\(\) should return string\|false but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Reference/Tag.php + + - + message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getSubjectMessage\(\) should return string\|false but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Reference/Tag.php + + - + message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getTaggerDate\(\) should return DateTime\|false but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Reference/Tag.php + + - + message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getTaggerEmail\(\) should return string\|false but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Reference/Tag.php + + - + message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getTaggerName\(\) should return string\|false but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Reference/Tag.php + + - + message: '#^Parameter \#2 \$string of function explode expects string, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Gitonomy/Git/Reference/Tag.php + + - + message: '#^Class Gitonomy\\Git\\ReferenceBag implements generic interface IteratorAggregate but does not specify its types\: TKey, TValue$#' + identifier: missingType.generics + count: 1 + path: src/Gitonomy/Git/ReferenceBag.php + + - + message: '#^Method Gitonomy\\Git\\ReferenceBag\:\:getIterator\(\) return type with generic class ArrayIterator does not specify its types\: TKey, TValue$#' + identifier: missingType.generics + count: 1 + path: src/Gitonomy/Git/ReferenceBag.php + + - + message: '#^Method Gitonomy\\Git\\Repository\:\:__construct\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Repository.php + + - + message: '#^Method Gitonomy\\Git\\Repository\:\:cloneTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Repository.php + + - + message: '#^Method Gitonomy\\Git\\Repository\:\:getDescription\(\) should return string but returns string\|false\.$#' + identifier: return.type + count: 1 + path: src/Gitonomy/Git/Repository.php + + - + message: '#^Method Gitonomy\\Git\\Repository\:\:getDiff\(\) has parameter \$revisions with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Repository.php + + - + message: '#^Method Gitonomy\\Git\\Repository\:\:getLog\(\) has parameter \$paths with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Repository.php + + - + message: '#^Method Gitonomy\\Git\\Repository\:\:getLog\(\) has parameter \$revisions with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Repository.php + + - + message: '#^Method Gitonomy\\Git\\Repository\:\:getProcess\(\) has parameter \$args with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Repository.php + + - + message: '#^Method Gitonomy\\Git\\Repository\:\:run\(\) has parameter \$args with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Repository.php + + - + message: '#^Method Gitonomy\\Git\\Repository\:\:shell\(\) has parameter \$env with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Repository.php + + - + message: '#^Parameter \#1 \$rawDiff of static method Gitonomy\\Git\\Diff\\Diff\:\:parse\(\) expects string, string\|null given\.$#' + identifier: argument.type + count: 1 + path: src/Gitonomy/Git/Repository.php + + - + message: '#^Parameter \#1 \$string of function trim expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: src/Gitonomy/Git/Repository.php + + - + message: '#^Property Gitonomy\\Git\\Repository\:\:\$environmentVariables type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Repository.php + + - + message: '#^Method Gitonomy\\Git\\Revision\:\:getLog\(\) has parameter \$paths with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Revision.php + + - + message: '#^Class Gitonomy\\Git\\RevisionList implements generic interface IteratorAggregate but does not specify its types\: TKey, TValue$#' + identifier: missingType.generics + count: 1 + path: src/Gitonomy/Git/RevisionList.php + + - + message: '#^Method Gitonomy\\Git\\RevisionList\:\:__construct\(\) has parameter \$revisions with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/RevisionList.php + + - + message: '#^Method Gitonomy\\Git\\RevisionList\:\:getIterator\(\) return type with generic class ArrayIterator does not specify its types\: TKey, TValue$#' + identifier: missingType.generics + count: 1 + path: src/Gitonomy/Git/RevisionList.php + + - + message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' + identifier: argument.type + count: 1 + path: src/Gitonomy/Git/Tree.php + + - + message: '#^Method Gitonomy\\Git\\WorkingCopy\:\:run\(\) has parameter \$args with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/WorkingCopy.php + + - + message: '#^Parameter \#1 \$rawDiff of static method Gitonomy\\Git\\Diff\\Diff\:\:parse\(\) expects string, string\|null given\.$#' + identifier: argument.type + count: 2 + path: src/Gitonomy/Git/WorkingCopy.php + + - + message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' + identifier: argument.type + count: 1 + path: src/Gitonomy/Git/WorkingCopy.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..6d1c7337 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,11 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 9 + paths: + - src + tmpDir: 'var/phpstan/tmp' + inferPrivatePropertyTypeFromConstructor: true + excludePaths: + analyse: [] diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 77fe6238..b6df36eb 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,29 +1,22 @@ - ./tests/Gitonomy/Git/Tests - - + + ./src/Gitonomy/Git - - + + diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 7b32794c..024acf41 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -1,6 +1,6 @@ @@ -20,7 +20,7 @@ * * @author Alexandre Salomé */ -class Admin +final class Admin { /** * Initializes a repository and returns the instance. @@ -30,17 +30,15 @@ class Admin * @param array $options options for Repository creation * * @throws RuntimeException Directory exists or not writable (only if debug=true) - * - * @return Repository */ - public static function init($path, $bare = true, array $options = []) + public static function init(string $path, bool $bare = true, array $options = []): Repository { - $process = static::getProcess('init', array_merge(['-q'], $bare ? ['--bare'] : [], [$path]), $options); + $process = self::getProcess('init', array_merge(['-q'], $bare ? ['--bare'] : [], [$path]), $options); $process->run(); if (!$process->isSuccessFul()) { - throw new RuntimeException(sprintf("Error on repository initialization, command wasn't successful (%s). Error output:\n%s", $process->getCommandLine(), $process->getErrorOutput())); + throw new RuntimeException(\sprintf("Error on repository initialization, command wasn't successful (%s). Error output:\n%s", $process->getCommandLine(), $process->getErrorOutput())); } return new Repository($path, $options); @@ -58,9 +56,9 @@ public static function init($path, $bare = true, array $options = []) * * @return bool true if url is valid */ - public static function isValidRepository($url, array $options = []) + public static function isValidRepository(string $url, array $options = []): bool { - $process = static::getProcess('ls-remote', [$url], $options); + $process = self::getProcess('ls-remote', [$url], $options); $process->run(); @@ -81,14 +79,14 @@ public static function isValidRepository($url, array $options = []) * * @return bool true if url is valid and branch exists */ - public static function isValidRepositoryAndBranch($url, $branchName, array $options = []) + public static function isValidRepositoryAndBranch(string $url, string $branchName, array $options = []): bool { - $process = static::getProcess('ls-remote', ['--heads', $url, $branchName], $options); + $process = self::getProcess('ls-remote', ['--heads', $url, $branchName], $options); $process->run(); $processOutput = $process->getOutput(); - return $process->isSuccessFul() && strpos($processOutput, $branchName) !== false; + return $process->isSuccessFul() && str_contains($processOutput, $branchName); } /** @@ -98,14 +96,12 @@ public static function isValidRepositoryAndBranch($url, $branchName, array $opti * @param string $url url of repository to clone * @param bool $bare indicates if repository should be bare or have a working copy * @param array $options options for Repository creation - * - * @return Repository */ - public static function cloneTo($path, $url, $bare = true, array $options = []) + public static function cloneTo(string $path, string $url, bool $bare = true, array $options = []): Repository { $args = $bare ? ['--bare'] : []; - return static::cloneRepository($path, $url, $args, $options); + return self::cloneRepository($path, $url, $args, $options); } /** @@ -116,17 +112,15 @@ public static function cloneTo($path, $url, $bare = true, array $options = []) * @param string $branch branch to clone * @param bool $bare indicates if repository should be bare or have a working copy * @param array $options options for Repository creation - * - * @return Repository */ - public static function cloneBranchTo($path, $url, $branch, $bare = true, $options = []) + public static function cloneBranchTo(string $path, string $url, string $branch, bool $bare = true, array $options = []): Repository { $args = ['--branch', $branch]; if ($bare) { $args[] = '--bare'; } - return static::cloneRepository($path, $url, $args, $options); + return self::cloneRepository($path, $url, $args, $options); } /** @@ -135,12 +129,10 @@ public static function cloneBranchTo($path, $url, $branch, $bare = true, $option * @param string $path indicates where to clone repository * @param string $url url of repository to clone * @param array $options options for Repository creation - * - * @return Repository */ - public static function mirrorTo($path, $url, array $options = []) + public static function mirrorTo(string $path, string $url, array $options = []): Repository { - return static::cloneRepository($path, $url, ['--mirror'], $options); + return self::cloneRepository($path, $url, ['--mirror'], $options); } /** @@ -150,17 +142,15 @@ public static function mirrorTo($path, $url, array $options = []) * @param string $url url of repository to clone * @param array $args arguments to be added to the command-line * @param array $options options for Repository creation - * - * @return Repository */ - public static function cloneRepository($path, $url, array $args = [], array $options = []) + public static function cloneRepository(string $path, string $url, array $args = [], array $options = []): Repository { - $process = static::getProcess('clone', array_merge(['-q'], $args, [$url, $path]), $options); + $process = self::getProcess('clone', array_merge(['-q'], $args, [$url, $path]), $options); $process->run(); if (!$process->isSuccessFul()) { - throw new RuntimeException(sprintf('Error while initializing repository: %s', $process->getErrorOutput())); + throw new RuntimeException(\sprintf('Error while initializing repository: %s', $process->getErrorOutput())); } return new Repository($path, $options); @@ -169,13 +159,13 @@ public static function cloneRepository($path, $url, array $args = [], array $opt /** * This internal method is used to create a process object. */ - private static function getProcess($command, array $args = [], array $options = []) + private static function getProcess(string $command, array $args = [], array $options = []): Process { - $is_windows = defined('PHP_WINDOWS_VERSION_BUILD'); + $is_windows = \defined('PHP_WINDOWS_VERSION_BUILD'); $options = array_merge([ 'environment_variables' => $is_windows ? ['PATH' => getenv('PATH')] : [], - 'command' => 'git', - 'process_timeout' => 3600, + 'command' => 'git', + 'process_timeout' => 3600, ], $options); $process = new Process(array_merge([$options['command'], $command], $args)); diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index dec0773b..35dc2e8f 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -1,6 +1,6 @@ @@ -19,50 +19,28 @@ /** * @author Alexandre Salomé */ -class Blame implements \Countable +final class Blame implements \Countable { /** - * @var Repository - */ - protected $repository; - - /** - * @var Revision - */ - protected $revision; - - /** - * @var string - */ - protected $file; - - /** - * @var string|null + * @var Line[]|null */ - protected $lineRange; + private ?array $lines = null; /** - * @var array|null + * @param string $file the file the blame is about + * @param string|null $lineRange argument to pass to git blame (-L). + * Can be a line range (40,60 or 40,+21) + * or a regexp ('/^function$/') */ - protected $lines; - - /** - * @param string $lineRange Argument to pass to git blame (-L). - * Can be a line range (40,60 or 40,+21) - * or a regexp ('/^function$/') - */ - public function __construct(Repository $repository, Revision $revision, $file, $lineRange = null) - { - $this->repository = $repository; - $this->revision = $revision; - $this->lineRange = $lineRange; - $this->file = $file; + public function __construct( + private readonly Repository $repository, + private readonly Revision $revision, + private readonly string $file, + private readonly ?string $lineRange = null, + ) { } - /** - * @return Line - */ - public function getLine($number) + public function getLine(int $number): Line { if ($number < 1) { throw new InvalidArgumentException('Line number should be at least 1'); @@ -82,7 +60,7 @@ public function getLine($number) * * @return array a list of two-elements array (commit, lines) */ - public function getGroupedLines() + public function getGroupedLines(): array { $result = []; $commit = null; @@ -90,7 +68,7 @@ public function getGroupedLines() foreach ($this->getLines() as $lineNumber => $line) { if ($commit !== $line->getCommit()) { - if (count($current)) { + if (\count($current)) { $result[] = [$commit, $current]; } $commit = $line->getCommit(); @@ -100,7 +78,7 @@ public function getGroupedLines() $current[$lineNumber] = $line; } - if (count($current)) { + if (\count($current)) { $result[] = [$commit, $current]; } @@ -108,9 +86,9 @@ public function getGroupedLines() } /** - * @return Line[] All lines of the blame. + * @return Line[] all lines of the blame */ - public function getLines() + public function getLines(): array { if (null !== $this->lines) { return $this->lines; @@ -134,12 +112,8 @@ public function getLines() return $this->lines; } - /** - * @return int - */ - #[\ReturnTypeWillChange] - public function count() + public function count(): int { - return count($this->getLines()); + return \count($this->getLines()); } } diff --git a/src/Gitonomy/Git/Blame/Line.php b/src/Gitonomy/Git/Blame/Line.php index 83a7a912..d252e3ea 100644 --- a/src/Gitonomy/Git/Blame/Line.php +++ b/src/Gitonomy/Git/Blame/Line.php @@ -1,6 +1,6 @@ @@ -17,40 +17,31 @@ /** * @author Alexandre Salomé */ -class Line +final readonly class Line { - /** - * @var Commit - */ - protected $commit; - protected $sourceLine; - protected $targetLine; - protected $blockLine; - protected $content; - /** * Instanciates a new Line object. */ - public function __construct(Commit $commit, $sourceLine, $targetLine, $blockLine, $content) - { - $this->commit = $commit; - $this->sourceLine = $sourceLine; - $this->targetLine = $targetLine; - $this->blockLine = $blockLine; - $this->content = $content; + public function __construct( + private Commit $commit, + private string $sourceLine, + private string $targetLine, + private ?string $blockLine, + private string $content, + ) { } - public function getContent() + public function getContent(): string { return $this->content; } - public function getLine() + public function getLine(): string { return $this->sourceLine; } - public function getCommit() + public function getCommit(): Commit { return $this->commit; } diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index dfe885fe..54a9c22b 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -1,6 +1,6 @@ @@ -17,62 +17,40 @@ * * @author Alexandre Salomé */ -class Blob +final class Blob { /** - * @var int Size that git uses to look for NULL byte: https://git.kernel.org/pub/scm/git/git.git/tree/xdiff-interface.c?h=v2.44.0#n193 + * Size that git uses to look for NULL byte: https://git.kernel.org/pub/scm/git/git.git/tree/xdiff-interface.c?h=v2.44.0#n193. */ - private const FIRST_FEW_BYTES = 8000; + private const int FIRST_FEW_BYTES = 8000; - /** - * @var Repository - */ - protected $repository; - - /** - * @var string - */ - protected $hash; + private ?string $content = null; - /** - * @var string - */ - protected $content; + private string|false|null $mimetype = null; - /** - * @var string - */ - protected $mimetype; - - /** - * @var bool - */ - protected $text; + private ?bool $text = null; /** * @param Repository $repository Repository where the blob is located * @param string $hash Hash of the blob */ - public function __construct(Repository $repository, $hash) - { - $this->repository = $repository; - $this->hash = $hash; + public function __construct( + private readonly Repository $repository, + private readonly string $hash, + ) { } - /** - * @return string - */ - public function getHash() + public function getHash(): string { return $this->hash; } /** - * @throws ProcessException Error occurred while getting content of blob + * @return string content of the blob * - * @return string Content of the blob. + * @throws Exception\ProcessException Error occurred while getting content of blob */ - public function getContent() + public function getContent(): string { if (null === $this->content) { $this->content = $this->repository->run('cat-file', ['-p', $this->hash]); @@ -83,13 +61,11 @@ public function getContent() /** * Determine the mimetype of the blob. - * - * @return string A mimetype */ - public function getMimetype() + public function getMimetype(): string|false { if (null === $this->mimetype) { - $finfo = new \finfo(FILEINFO_MIME); + $finfo = new \finfo(\FILEINFO_MIME); $this->mimetype = $finfo->buffer($this->getContent()); } @@ -101,10 +77,8 @@ public function getMimetype() * * Uses the same check that git uses to determine if a file is binary or not * https://git.kernel.org/pub/scm/git/git.git/tree/xdiff-interface.c?h=v2.44.0#n193 - * - * @return bool */ - public function isBinary() + public function isBinary(): bool { return !$this->isText(); } @@ -114,13 +88,11 @@ public function isBinary() * * Uses the same check that git uses to determine if a file is binary or not * https://git.kernel.org/pub/scm/git/git.git/tree/xdiff-interface.c?h=v2.44.0#n193 - * - * @return bool */ - public function isText() + public function isText(): bool { if (null === $this->text) { - $this->text = !str_contains(substr($this->getContent(), 0, self::FIRST_FEW_BYTES), chr(0)); + $this->text = !str_contains(substr($this->getContent(), 0, self::FIRST_FEW_BYTES), \chr(0)); } return $this->text; diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 573d3b52..671f467d 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -1,6 +1,6 @@ @@ -24,22 +24,20 @@ * * @author Alexandre Salomé */ -class Commit extends Revision +final class Commit extends Revision { /** * Associative array of commit data. * - * @var array + * @var array */ - private $data = []; + private array $data = []; /** - * Constructor. - * * @param Repository $repository Repository of the commit * @param string $hash Hash of the commit */ - public function __construct(Repository $repository, $hash, array $data = []) + public function __construct(Repository $repository, string $hash, array $data = []) { if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { throw new ReferenceNotFoundException($hash); @@ -50,17 +48,14 @@ public function __construct(Repository $repository, $hash, array $data = []) $this->setData($data); } - public function setData(array $data) + public function setData(array $data): void { foreach ($data as $name => $value) { $this->data[$name] = $value; } } - /** - * @return Diff - */ - public function getDiff() + public function getDiff(): Diff { $args = ['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index', $this->revision]; @@ -72,30 +67,24 @@ public function getDiff() /** * Returns the commit hash. - * - * @return string A SHA1 hash */ - public function getHash() + public function getHash(): string { return $this->revision; } /** * Returns the short commit hash. - * - * @return string A SHA1 hash */ - public function getShortHash() + public function getShortHash(): string { return $this->getData('shortHash'); } /** * Returns a fixed-with short hash. - * - * @return string Short hash */ - public function getFixedShortHash($length = 6) + public function getFixedShortHash(int $length = 6): string { return StringHelper::substr($this->revision, 0, $length); } @@ -105,7 +94,7 @@ public function getFixedShortHash($length = 6) * * @return string[] An array of SHA1 hashes */ - public function getParentHashes() + public function getParentHashes(): array { return $this->getData('parentHashes'); } @@ -115,7 +104,7 @@ public function getParentHashes() * * @return Commit[] An array of Commit objects */ - public function getParents() + public function getParents(): array { $result = []; foreach ($this->getData('parentHashes') as $parentHash) { @@ -127,28 +116,20 @@ public function getParents() /** * Returns the tree hash. - * - * @return string A SHA1 hash */ - public function getTreeHash() + public function getTreeHash(): string { return $this->getData('treeHash'); } - /** - * @return Tree - */ - public function getTree() + public function getTree(): Tree { return $this->getData('tree'); } - /** - * @return Commit - */ - public function getLastModification($path = null) + public function getLastModification(?string $path = null): self { - if (0 === strpos($path, '/')) { + if (null !== $path && str_starts_with($path, '/')) { $path = StringHelper::substr($path, 1); } @@ -165,14 +146,8 @@ public function getLastModification($path = null) * Returns the first line of the commit, and the first 50 characters. * * Ported from https://github.com/fabpot/Twig-extensions/blob/d67bc7e69788795d7905b52d31188bbc1d390e01/lib/Twig/Extensions/Extension/Text.php#L52-L109 - * - * @param int $length - * @param bool $preserve - * @param string $separator - * - * @return string */ - public function getShortMessage($length = 50, $preserve = false, $separator = '...') + public function getShortMessage(int $length = 50, bool $preserve = false, string $separator = '...'): string { $message = $this->getData('subjectMessage'); @@ -192,7 +167,7 @@ public function getShortMessage($length = 50, $preserve = false, $separator = '. * * @return Reference[] An array of references (Branch, Tag, Squash) */ - public function resolveReferences() + public function resolveReferences(): array { return $this->repository->getReferences()->resolve($this); } @@ -205,7 +180,7 @@ public function resolveReferences() * * @return Reference[]|Branch[] An array of Reference\Branch */ - public function getIncludingBranches($local = true, $remote = true) + public function getIncludingBranches(bool $local = true, bool $remote = true): array { $arguments = ['--contains', $this->revision]; @@ -213,7 +188,7 @@ public function getIncludingBranches($local = true, $remote = true) $arguments[] = '-a'; } elseif (!$local && $remote) { $arguments[] = '-r'; - } elseif (!$local && !$remote) { + } elseif (!$local) { throw new InvalidArgumentException('You should a least set one argument to true'); } @@ -228,7 +203,7 @@ public function getIncludingBranches($local = true, $remote = true) } $branchesName = explode("\n", trim(str_replace('*', '', $result))); - $branchesName = array_filter($branchesName, function ($v) { + $branchesName = array_filter($branchesName, static function ($v) { return false === StringHelper::strpos($v, '->'); }); $branchesName = array_map('trim', $branchesName); @@ -251,128 +226,107 @@ public function getIncludingBranches($local = true, $remote = true) /** * Returns the author name. - * - * @return string A name */ - public function getAuthorName() + public function getAuthorName(): string { return $this->getData('authorName'); } /** * Returns the author email. - * - * @return string An email */ - public function getAuthorEmail() + public function getAuthorEmail(): string { return $this->getData('authorEmail'); } /** * Returns the authoring date. - * - * @return \DateTime A time object */ - public function getAuthorDate() + public function getAuthorDate(): \DateTime { return $this->getData('authorDate'); } /** * Returns the committer name. - * - * @return string A name */ - public function getCommitterName() + public function getCommitterName(): string { return $this->getData('committerName'); } /** * Returns the comitter email. - * - * @return string An email */ - public function getCommitterEmail() + public function getCommitterEmail(): string { return $this->getData('committerEmail'); } /** * Returns the authoring date. - * - * @return \DateTime A time object */ - public function getCommitterDate() + public function getCommitterDate(): \DateTime { return $this->getData('committerDate'); } /** * Returns the message of the commit. - * - * @return string A commit message */ - public function getMessage() + public function getMessage(): string { return $this->getData('message'); } /** * Returns the subject message (the first line). - * - * @return string The subject message */ - public function getSubjectMessage() + public function getSubjectMessage(): string { return $this->getData('subjectMessage'); } /** * Return the body message. - * - * @return string The body message */ - public function getBodyMessage() + public function getBodyMessage(): string { return $this->getData('bodyMessage'); } - /** - * {@inheritdoc} - */ - public function getCommit() + public function getCommit(): self { return $this; } - private function getData($name) + private function getData(string $name): mixed { if (isset($this->data[$name])) { return $this->data[$name]; } - if ($name === 'shortHash') { + if ('shortHash' === $name) { $this->data['shortHash'] = trim($this->repository->run('log', ['--abbrev-commit', '--format=%h', '-n', 1, $this->revision])); return $this->data['shortHash']; } - if ($name === 'tree') { + if ('tree' === $name) { $this->data['tree'] = $this->repository->getTree($this->getData('treeHash')); return $this->data['tree']; } - if ($name === 'subjectMessage') { + if ('subjectMessage' === $name) { $lines = explode("\n", $this->getData('message')); $this->data['subjectMessage'] = reset($lines); return $this->data['subjectMessage']; } - if ($name === 'bodyMessage') { + if ('bodyMessage' === $name) { $message = $this->getData('message'); $lines = explode("\n", $message); @@ -390,7 +344,7 @@ private function getData($name) try { $result = $this->repository->run('cat-file', ['commit', $this->revision]); } catch (ProcessException $e) { - throw new ReferenceNotFoundException(sprintf('Can not find reference "%s"', $this->revision)); + throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision)); } $parser->parse($result); @@ -406,7 +360,7 @@ private function getData($name) $this->data['message'] = $parser->message; if (!isset($this->data[$name])) { - throw new \InvalidArgumentException(sprintf('No data named "%s" in Commit.', $name)); + throw new \InvalidArgumentException(\sprintf('No data named "%s" in Commit.', $name)); } return $this->data[$name]; diff --git a/src/Gitonomy/Git/CommitReference.php b/src/Gitonomy/Git/CommitReference.php index 7be3d89c..6233e2b4 100644 --- a/src/Gitonomy/Git/CommitReference.php +++ b/src/Gitonomy/Git/CommitReference.php @@ -1,6 +1,6 @@ @@ -12,15 +12,14 @@ namespace Gitonomy\Git; -class CommitReference +final readonly class CommitReference { - /** - * @var string - */ - private $hash; + public function __construct(private string $hash) + { + } - public function __construct($hash) + public function getHash(): string { - $this->hash = $hash; + return $this->hash; } } diff --git a/src/Gitonomy/Git/Diff/Diff.php b/src/Gitonomy/Git/Diff/Diff.php index 7737ee8f..c94dc427 100644 --- a/src/Gitonomy/Git/Diff/Diff.php +++ b/src/Gitonomy/Git/Diff/Diff.php @@ -1,6 +1,6 @@ @@ -20,34 +20,20 @@ * * @author Alexandre Salomé */ -class Diff +final readonly class Diff { - /** - * @var File[] - */ - protected $files; - - /** - * @var string - */ - protected $rawDiff; - /** * Constructs a new diff for a given revision. * - * @param array $files The files - * @param string $rawDiff The raw diff + * @param File[] $files The files */ - public function __construct(array $files, $rawDiff) - { - $this->files = $files; - $this->rawDiff = $rawDiff; + public function __construct( + private array $files, + private string $rawDiff, + ) { } - /** - * @return Diff - */ - public static function parse($rawDiff) + public static function parse(string $rawDiff): self { $parser = new DiffParser(); $parser->parse($rawDiff); @@ -55,7 +41,7 @@ public static function parse($rawDiff) return new self($parser->files, $rawDiff); } - public function setRepository(Repository $repository) + public function setRepository(Repository $repository): void { foreach ($this->files as $file) { $file->setRepository($repository); @@ -67,32 +53,28 @@ public function setRepository(Repository $repository) * * @return File[] An array of Diff\File objects */ - public function getFiles() + public function getFiles(): array { return $this->files; } /** * Returns the raw diff. - * - * @return string The raw diff */ - public function getRawDiff() + public function getRawDiff(): string { return $this->rawDiff; } /** * Export a diff as array. - * - * @return array The array */ - public function toArray() + public function toArray(): array { return [ 'rawDiff' => $this->rawDiff, - 'files' => array_map( - function (File $file) { + 'files' => array_map( + static function (File $file) { return $file->toArray(); }, $this->files @@ -102,16 +84,12 @@ function (File $file) { /** * Create a new instance of Diff from an array. - * - * @param array $array The array - * - * @return Diff The new instance */ - public static function fromArray(array $array) + public static function fromArray(array $array): self { - return new static( + return new self( array_map( - function ($array) { + static function ($array) { return File::fromArray($array); }, $array['files'] diff --git a/src/Gitonomy/Git/Diff/File.php b/src/Gitonomy/Git/Diff/File.php index bfca155c..4ef2e0a2 100644 --- a/src/Gitonomy/Git/Diff/File.php +++ b/src/Gitonomy/Git/Diff/File.php @@ -1,6 +1,6 @@ @@ -12,95 +12,52 @@ namespace Gitonomy\Git\Diff; +use Gitonomy\Git\Blob; use Gitonomy\Git\Repository; /** * @author Alexandre Salomé */ -class File +final class File { /** - * @var string - */ - protected $oldName; - - /** - * @var string - */ - protected $newName; - - /** - * @var string - */ - protected $oldMode; - - /** - * @var string - */ - protected $newMode; - - /** - * @var string - */ - protected $oldIndex; - - /** - * @var string + * @var FileChange[] */ - protected $newIndex; + private array $changes = []; - /** - * @var bool - */ - protected $isBinary; - - /** - * @var FileChange[] An array of FileChange objects - */ - protected $changes; - - /** - * @var Repository - */ - protected $repository; + private ?Repository $repository = null; /** * Instanciates a new File object. */ - public function __construct($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary) - { - $this->oldName = $oldName; - $this->newName = $newName; - $this->oldMode = $oldMode; - $this->newMode = $newMode; - $this->oldIndex = $oldIndex; - $this->newIndex = $newIndex; - $this->isBinary = $isBinary; - - $this->changes = []; + public function __construct( + private readonly ?string $oldName, + private readonly ?string $newName, + private readonly ?string $oldMode, + private readonly ?string $newMode, + private readonly ?string $oldIndex, + private readonly ?string $newIndex, + private readonly bool $isBinary, + ) { } - public function addChange(FileChange $change) + public function addChange(FileChange $change): void { $this->changes[] = $change; } /** * Indicates if this diff file is a creation. - * - * @return bool */ - public function isCreation() + public function isCreation(): bool { return null === $this->oldName; } /** * Indicates if this diff file is a modification. - * - * @return bool */ - public function isModification() + public function isModification(): bool { return null !== $this->oldName && null !== $this->newName; } @@ -109,10 +66,8 @@ public function isModification() * Indicates if it's a rename. * * A rename can only occurs if it's a modification (not a creation or a deletion). - * - * @return bool */ - public function isRename() + public function isRename(): bool { return $this->isModification() && $this->oldName !== $this->newName; } @@ -120,27 +75,23 @@ public function isRename() /** * Indicates if the file mode has changed. */ - public function isChangeMode() + public function isChangeMode(): bool { return $this->isModification() && $this->oldMode !== $this->newMode; } /** * Indicates if this diff file is a deletion. - * - * @return bool */ - public function isDeletion() + public function isDeletion(): bool { return null === $this->newName; } /** * Indicates if this diff file is a deletion. - * - * @return bool */ - public function isDelete() + public function isDelete(): bool { return null === $this->newName; } @@ -148,7 +99,7 @@ public function isDelete() /** * @return int Number of added lines */ - public function getAdditions() + public function getAdditions(): int { $result = 0; foreach ($this->changes as $change) { @@ -161,7 +112,7 @@ public function getAdditions() /** * @return int Number of deleted lines */ - public function getDeletions() + public function getDeletions(): int { $result = 0; foreach ($this->changes as $change) { @@ -171,17 +122,17 @@ public function getDeletions() return $result; } - public function getOldName() + public function getOldName(): ?string { return $this->oldName; } - public function getNewName() + public function getNewName(): ?string { return $this->newName; } - public function getName() + public function getName(): ?string { if (null === $this->newName) { return $this->oldName; @@ -190,27 +141,27 @@ public function getName() return $this->newName; } - public function getOldMode() + public function getOldMode(): ?string { return $this->oldMode; } - public function getNewMode() + public function getNewMode(): ?string { return $this->newMode; } - public function getOldIndex() + public function getOldIndex(): ?string { return $this->oldIndex; } - public function getNewIndex() + public function getNewIndex(): ?string { return $this->newIndex; } - public function isBinary() + public function isBinary(): bool { return $this->isBinary; } @@ -218,31 +169,28 @@ public function isBinary() /** * @return FileChange[] */ - public function getChanges() + public function getChanges(): array { return $this->changes; } - public function toArray() + public function toArray(): array { return [ - 'old_name' => $this->oldName, - 'new_name' => $this->newName, - 'old_mode' => $this->oldMode, - 'new_mode' => $this->newMode, + 'old_name' => $this->oldName, + 'new_name' => $this->newName, + 'old_mode' => $this->oldMode, + 'new_mode' => $this->newMode, 'old_index' => $this->oldIndex, 'new_index' => $this->newIndex, 'is_binary' => $this->isBinary, - 'changes' => array_map(function (FileChange $change) { + 'changes' => array_map(static function (FileChange $change) { return $change->toArray(); }, $this->changes), ]; } - /** - * @return File - */ - public static function fromArray(array $array) + public static function fromArray(array $array): self { $file = new self($array['old_name'], $array['new_name'], $array['old_mode'], $array['new_mode'], $array['old_index'], $array['new_index'], $array['is_binary']); @@ -253,22 +201,22 @@ public static function fromArray(array $array) return $file; } - public function getAnchor() + public function getAnchor(): string { - return substr($this->newIndex, 0, 12); + return substr($this->newIndex ?? '', 0, 12); } - public function getRepository() + public function getRepository(): ?Repository { return $this->repository; } - public function setRepository(Repository $repository) + public function setRepository(Repository $repository): void { $this->repository = $repository; } - public function getOldBlob() + public function getOldBlob(): Blob { if (null === $this->repository) { throw new \RuntimeException('Repository is missing to return Blob object.'); @@ -278,14 +226,14 @@ public function getOldBlob() throw new \LogicException('Can\'t return old Blob on a creation'); } - if ($this->oldIndex === '') { + if ('' === $this->oldIndex) { throw new \RuntimeException('Index is missing to return Blob object.'); } return $this->repository->getBlob($this->oldIndex); } - public function getNewBlob() + public function getNewBlob(): Blob { if (null === $this->repository) { throw new \RuntimeException('Repository is missing to return Blob object.'); @@ -295,7 +243,7 @@ public function getNewBlob() throw new \LogicException('Can\'t return new Blob on a deletion'); } - if ($this->newIndex === '') { + if ('' === $this->newIndex) { throw new \RuntimeException('Index is missing to return Blob object.'); } diff --git a/src/Gitonomy/Git/Diff/FileChange.php b/src/Gitonomy/Git/Diff/FileChange.php index cca18dc6..1e7bc914 100644 --- a/src/Gitonomy/Git/Diff/FileChange.php +++ b/src/Gitonomy/Git/Diff/FileChange.php @@ -1,6 +1,6 @@ @@ -12,111 +12,76 @@ namespace Gitonomy\Git\Diff; -class FileChange +final readonly class FileChange { - const LINE_CONTEXT = 0; - const LINE_REMOVE = -1; - const LINE_ADD = 1; - - protected $rangeOldStart; - protected $rangeOldCount; - protected $rangeNewStart; - protected $rangeNewCount; - protected $lines; + public const int LINE_CONTEXT = 0; + public const int LINE_REMOVE = -1; + public const int LINE_ADD = 1; /** - * @param int $rangeOldStart - * @param int $rangeOldCount - * @param int $rangeNewStart - * @param int $rangeNewCount - * @param array $lines - * - * @return void + * @param array $lines */ - public function __construct($rangeOldStart, $rangeOldCount, $rangeNewStart, $rangeNewCount, $lines) - { - $this->rangeOldStart = $rangeOldStart; - $this->rangeOldCount = $rangeOldCount; - $this->rangeNewStart = $rangeNewStart; - $this->rangeNewCount = $rangeNewCount; - $this->lines = $lines; + public function __construct( + private int $rangeOldStart, + private int $rangeOldCount, + private int $rangeNewStart, + private int $rangeNewCount, + private array $lines, + ) { } - /** - * @return int - */ - public function getCount($type) + public function getCount(int $type): int { $result = 0; foreach ($this->lines as $line) { if ($line[0] === $type) { - $result++; + ++$result; } } return $result; } - /** - * @return int - */ - public function getRangeOldStart() + public function getRangeOldStart(): int { return $this->rangeOldStart; } - /** - * @return int - */ - public function getRangeOldCount() + public function getRangeOldCount(): int { return $this->rangeOldCount; } - /** - * @return int - */ - public function getRangeNewStart() + public function getRangeNewStart(): int { return $this->rangeNewStart; } - /** - * @return int - */ - public function getRangeNewCount() + public function getRangeNewCount(): int { return $this->rangeNewCount; } /** - * @return array + * @return array */ - public function getLines() + public function getLines(): array { return $this->lines; } - /** - * @return array - */ - public function toArray() + public function toArray(): array { return [ 'range_old_start' => $this->rangeOldStart, 'range_old_count' => $this->rangeOldCount, 'range_new_start' => $this->rangeNewStart, 'range_new_count' => $this->rangeNewCount, - 'lines' => $this->lines, + 'lines' => $this->lines, ]; } - /** - * @param array $array - * - * @return self - */ - public static function fromArray(array $array) + public static function fromArray(array $array): self { return new self($array['range_old_start'], $array['range_old_count'], $array['range_new_start'], $array['range_new_count'], $array['lines']); } diff --git a/src/Gitonomy/Git/Exception/GitExceptionInterface.php b/src/Gitonomy/Git/Exception/GitExceptionInterface.php index 6e7e3898..febbd51f 100644 --- a/src/Gitonomy/Git/Exception/GitExceptionInterface.php +++ b/src/Gitonomy/Git/Exception/GitExceptionInterface.php @@ -1,6 +1,6 @@ diff --git a/src/Gitonomy/Git/Exception/InvalidArgumentException.php b/src/Gitonomy/Git/Exception/InvalidArgumentException.php index 32b01b6f..119aad2b 100644 --- a/src/Gitonomy/Git/Exception/InvalidArgumentException.php +++ b/src/Gitonomy/Git/Exception/InvalidArgumentException.php @@ -1,7 +1,17 @@ + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + namespace Gitonomy\Git\Exception; -class InvalidArgumentException extends \InvalidArgumentException implements GitExceptionInterface +final class InvalidArgumentException extends \InvalidArgumentException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Exception/LogicException.php b/src/Gitonomy/Git/Exception/LogicException.php index 8d299890..13b2e29c 100644 --- a/src/Gitonomy/Git/Exception/LogicException.php +++ b/src/Gitonomy/Git/Exception/LogicException.php @@ -1,7 +1,17 @@ + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + namespace Gitonomy\Git\Exception; -class LogicException extends \LogicException implements GitExceptionInterface +final class LogicException extends \LogicException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index 6d6b6912..9d76688f 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -1,14 +1,22 @@ + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + namespace Gitonomy\Git\Exception; use Symfony\Component\Process\Process; -class ProcessException extends RuntimeException implements GitExceptionInterface +final class ProcessException extends RuntimeException implements GitExceptionInterface { - protected $process; - - public function __construct(Process $process) + public function __construct(private readonly Process $process) { parent::__construct( "Error while running git command:\n". @@ -18,16 +26,14 @@ public function __construct(Process $process) "\n". $process->getOutput() ); - - $this->process = $process; } - public function getErrorOutput() + public function getErrorOutput(): string { return $this->process->getErrorOutput(); } - public function getOutput() + public function getOutput(): string { return $this->process->getOutput(); } diff --git a/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php b/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php index dd9ae98d..134c17e2 100644 --- a/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php +++ b/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php @@ -1,6 +1,6 @@ @@ -12,10 +12,10 @@ namespace Gitonomy\Git\Exception; -class ReferenceNotFoundException extends \InvalidArgumentException implements GitExceptionInterface +final class ReferenceNotFoundException extends \InvalidArgumentException implements GitExceptionInterface { - public function __construct($reference) + public function __construct(string $reference, int $code = 0, ?\Throwable $previous = null) { - parent::__construct(sprintf('Reference not found: "%s"', $reference)); + parent::__construct(\sprintf('Reference not found: "%s"', $reference), $code, $previous); } } diff --git a/src/Gitonomy/Git/Exception/RuntimeException.php b/src/Gitonomy/Git/Exception/RuntimeException.php index 06a76d8d..2886429b 100644 --- a/src/Gitonomy/Git/Exception/RuntimeException.php +++ b/src/Gitonomy/Git/Exception/RuntimeException.php @@ -1,5 +1,15 @@ + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + namespace Gitonomy\Git\Exception; class RuntimeException extends \RuntimeException implements GitExceptionInterface diff --git a/src/Gitonomy/Git/Exception/UnexpectedValueException.php b/src/Gitonomy/Git/Exception/UnexpectedValueException.php index 83b3bebb..c88d8ed3 100644 --- a/src/Gitonomy/Git/Exception/UnexpectedValueException.php +++ b/src/Gitonomy/Git/Exception/UnexpectedValueException.php @@ -1,7 +1,17 @@ + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + namespace Gitonomy\Git\Exception; -class UnexpectedValueException extends \UnexpectedValueException implements GitExceptionInterface +final class UnexpectedValueException extends \UnexpectedValueException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index dd0adbe8..f07bf3b0 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -1,6 +1,6 @@ @@ -21,29 +21,19 @@ * * @author Alexandre Salomé */ -class Hooks +final readonly class Hooks { - /** - * @var \Gitonomy\Git\Repository - */ - protected $repository; - - /** - * @var Repository - */ - public function __construct(Repository $repository) - { - $this->repository = $repository; + public function __construct( + private Repository $repository, + ) { } /** * Tests if repository has a given hook. * * @param string $name Name of the hook - * - * @return bool */ - public function has($name) + public function has(string $name): bool { return file_exists($this->getPath($name)); } @@ -53,14 +43,14 @@ public function has($name) * * @param string $name Name of the hook * - * @throws InvalidArgumentException Hook does not exist - * * @return string Content of the hook + * + * @throws InvalidArgumentException Hook does not exist */ - public function get($name) + public function get(string $name): string { if (!$this->has($name)) { - throw new InvalidArgumentException(sprintf('Hook named "%s" is not present', $name)); + throw new InvalidArgumentException(\sprintf('Hook named "%s" is not present', $name)); } return file_get_contents($this->getPath($name)); @@ -75,15 +65,15 @@ public function get($name) * @throws LogicException Hook is already present * @throws RuntimeException Error on symlink creation */ - public function setSymlink($name, $file) + public function setSymlink(string $name, string $file): void { if ($this->has($name)) { - throw new LogicException(sprintf('A hook "%s" is already defined', $name)); + throw new LogicException(\sprintf('A hook "%s" is already defined', $name)); } $path = $this->getPath($name); if (false === symlink($file, $path)) { - throw new RuntimeException(sprintf('Unable to create hook "%s" (%s)', $name, $path)); + throw new RuntimeException(\sprintf('Unable to create hook "%s" (%s)', $name, $path)); } } @@ -95,15 +85,15 @@ public function setSymlink($name, $file) * * @throws LogicException The hook is already defined */ - public function set($name, $content) + public function set(string $name, string $content): void { if ($this->has($name)) { - throw new LogicException(sprintf('A hook "%s" is already defined', $name)); + throw new LogicException(\sprintf('A hook "%s" is already defined', $name)); } $path = $this->getPath($name); file_put_contents($path, $content); - chmod($path, 0777); + chmod($path, 0o777); } /** @@ -113,19 +103,16 @@ public function set($name, $content) * * @throws LogicException The hook is not present */ - public function remove($name) + public function remove(string $name): void { if (!$this->has($name)) { - throw new LogicException(sprintf('The hook "%s" was not found', $name)); + throw new LogicException(\sprintf('The hook "%s" was not found', $name)); } unlink($this->getPath($name)); } - /** - * @return string - */ - protected function getPath($name) + private function getPath(string $name): string { return $this->repository->getGitDir().'/hooks/'.$name; } diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index 47665359..bc4c43d7 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -1,6 +1,6 @@ @@ -20,134 +20,95 @@ /** * @author Alexandre Salomé */ -class Log implements \Countable, \IteratorAggregate +final class Log implements \Countable, \IteratorAggregate { - /** - * @var Repository - */ - protected $repository; - - /** - * @var null|RevisionList - */ - protected $revisions; + private ?RevisionList $revisions; - /** - * @var array - */ - protected $paths; + private array $paths; - /** - * @var int - */ - protected $offset; + private ?int $offset; - /** - * @var int - */ - protected $limit; + private ?int $limit; /** * Instanciates a git log object. * - * @param Repository $repository the repository where log occurs - * @param RevisionList|Revision|array|null $revisions a list of revisions or null if you want all history - * @param array $paths paths to filter on - * @param int|null $offset start list from a given position - * @param int|null $limit limit number of fetched elements - */ - public function __construct(Repository $repository, $revisions = null, $paths = null, $offset = null, $limit = null) - { + * @param Repository $repository the repository where log occurs + * @param RevisionList|Revision|string|array|null $revisions a list of revisions or null if you want all history + * @param array|string|null $paths paths to filter on + * @param int|null $offset start list from a given position + * @param int|null $limit limit number of fetched elements + */ + public function __construct( + private readonly Repository $repository, + RevisionList|Revision|string|array|null $revisions = null, + array|string|null $paths = null, + ?int $offset = null, + ?int $limit = null, + ) { if (null !== $revisions && !$revisions instanceof RevisionList) { $revisions = new RevisionList($repository, $revisions); } if (null === $paths) { $paths = []; - } elseif (is_string($paths)) { + } elseif (\is_string($paths)) { $paths = [$paths]; - } elseif (!is_array($paths)) { - throw new \InvalidArgumentException(sprintf('Expected a string or an array, got a "%s".', is_object($paths) ? get_class($paths) : gettype($paths))); } - $this->repository = $repository; $this->revisions = $revisions; $this->paths = $paths; $this->offset = $offset; $this->limit = $limit; } - /** - * @return Diff - */ - public function getDiff() + public function getDiff(): Diff { return $this->repository->getDiff($this->revisions); } - /** - * @return RevisionList - */ - public function getRevisions() + public function getRevisions(): ?RevisionList { return $this->revisions; } - /** - * @return array - */ - public function getPaths() + public function getPaths(): array { return $this->paths; } - /** - * @return int - */ - public function getOffset() + public function getOffset(): ?int { return $this->offset; } - /** - * @param int $offset - */ - public function setOffset($offset) + public function setOffset(?int $offset): static { $this->offset = $offset; return $this; } - /** - * @return int - */ - public function getLimit() + public function getLimit(): ?int { return $this->limit; } - /** - * @param int $limit - */ - public function setLimit($limit) + public function setLimit(?int $limit): static { $this->limit = $limit; return $this; } - /** - * @return Commit - */ - public function getSingleCommit() + public function getSingleCommit(): Commit { $limit = $this->limit; $this->limit = 1; $commits = $this->getCommits(); $this->setLimit($limit); - if (count($commits) === 0) { + if (0 === \count($commits)) { throw new ReferenceNotFoundException('The log is empty'); } @@ -157,17 +118,17 @@ public function getSingleCommit() /** * @return Commit[] */ - public function getCommits() + public function getCommits(): array { $args = ['--encoding='.StringHelper::getEncoding(), '--format=raw']; if (null !== $this->offset) { - $args[] = '--skip='.((int) $this->offset); + $args[] = '--skip='.$this->offset; } if (null !== $this->limit) { $args[] = '-n'; - $args[] = (int) $this->limit; + $args[] = $this->limit; } if (null !== $this->revisions) { @@ -183,7 +144,9 @@ public function getCommits() try { $output = $this->repository->run('log', $args); } catch (ProcessException $e) { - throw new ReferenceNotFoundException(sprintf('Can not find revision "%s"', implode(' ', $this->revisions->getAsTextArray())), null, $e); + $revisionsText = null !== $this->revisions ? implode(' ', $this->revisions->getAsTextArray()) : '--all'; + + throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $revisionsText), 0, $e); } $parser = new Parser\LogParser(); @@ -206,8 +169,7 @@ public function getCommits() /** * @see Countable */ - #[\ReturnTypeWillChange] - public function count() + public function count(): int { return $this->countCommits(); } @@ -215,20 +177,17 @@ public function count() /** * @see IteratorAggregate */ - #[\ReturnTypeWillChange] - public function getIterator() + public function getIterator(): \ArrayIterator { return new \ArrayIterator($this->getCommits()); } /** * Count commits, without offset or limit. - * - * @return int */ - public function countCommits() + public function countCommits(): int { - if (null !== $this->revisions && count($this->revisions)) { + if (null !== $this->revisions && \count($this->revisions)) { $output = $this->repository->run('rev-list', array_merge(['--count'], $this->revisions->getAsTextArray(), ['--'], $this->paths)); } else { $output = $this->repository->run('rev-list', array_merge(['--count', '--all', '--'], $this->paths)); diff --git a/src/Gitonomy/Git/Parser/BlameParser.php b/src/Gitonomy/Git/Parser/BlameParser.php index 8dd6c570..1dd35d83 100644 --- a/src/Gitonomy/Git/Parser/BlameParser.php +++ b/src/Gitonomy/Git/Parser/BlameParser.php @@ -1,6 +1,6 @@ @@ -15,18 +15,19 @@ use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Repository; -class BlameParser extends ParserBase +final class BlameParser extends ParserBase { - public $lines; + /** + * @var Line[] + */ + public array $lines = []; - protected $repository; - - public function __construct(Repository $repository) - { - $this->repository = $repository; + public function __construct( + protected readonly Repository $repository, + ) { } - protected function doParse() + protected function doParse(): void { $this->lines = []; @@ -39,7 +40,7 @@ protected function doParse() $vars = $this->consumeRegexp('/(\d+) (\d+)( (\d+))?/A'); $sourceLine = $vars[1]; $targetLine = $vars[2]; - $blockLine = isset($vars[4]) ? $vars[4] : null; + $blockLine = $vars[4] ?? null; $this->consumeTo("\n"); $this->consumeNewLine(); @@ -69,7 +70,7 @@ protected function doParse() $this->consumeNewLine(); $this->lines[$line] = new Line($memory[$hash], $sourceLine, $targetLine, $blockLine, $content); - $line++; + ++$line; } } } diff --git a/src/Gitonomy/Git/Parser/CommitParser.php b/src/Gitonomy/Git/Parser/CommitParser.php index 6fff1ea4..c9300e1b 100644 --- a/src/Gitonomy/Git/Parser/CommitParser.php +++ b/src/Gitonomy/Git/Parser/CommitParser.php @@ -1,6 +1,6 @@ @@ -16,17 +16,22 @@ class CommitParser extends ParserBase { - public $tree; - public $parents; - public $authorName; - public $authorEmail; - public $authorDate; - public $committerName; - public $committerEmail; - public $committerDate; - public $message; - - protected function doParse() + public string $tree; + + /** + * @var string[] + */ + public array $parents; + + public string $authorName; + public string $authorEmail; + public \DateTime $authorDate; + public string $committerName; + public string $committerEmail; + public \DateTime $committerDate; + public string $message; + + protected function doParse(): void { $this->consume('tree '); $this->tree = $this->consumeHash(); @@ -39,12 +44,12 @@ protected function doParse() } $this->consume('author '); - list($this->authorName, $this->authorEmail, $this->authorDate) = $this->consumeNameEmailDate(); - $this->authorDate = $this->parseDate($this->authorDate); + [$this->authorName, $this->authorEmail, $authorDate] = $this->consumeNameEmailDate(); + $this->authorDate = $this->parseDate($authorDate); $this->consumeNewLine(); $this->consume('committer '); - list($this->committerName, $this->committerEmail, $committerDate) = $this->consumeNameEmailDate(); + [$this->committerName, $this->committerEmail, $committerDate] = $this->consumeNameEmailDate(); $this->committerDate = $this->parseDate($committerDate); $this->consumeMergeTag(); @@ -58,23 +63,26 @@ protected function doParse() $this->message = $this->consumeAll(); } - protected function consumeNameEmailDate() + /** + * @return string[] A tuple of [name, email, date] + */ + protected function consumeNameEmailDate(): array { if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+ [+-]\d{4}))/A', $this->content, $vars, 0, $this->cursor)) { throw new RuntimeException('Unable to parse name, email and date'); } - $this->cursor += strlen($vars[1]); + $this->cursor += \strlen($vars[1]); return [$vars[2], $vars[3], $vars[4]]; } - protected function parseDate($text) + protected function parseDate(string $text): \DateTime { $date = \DateTime::createFromFormat('U e O', $text.' UTC'); if (!$date instanceof \DateTime) { - throw new RuntimeException(sprintf('Unable to convert "%s" to datetime', $text)); + throw new RuntimeException(\sprintf('Unable to convert "%s" to datetime', $text)); } return $date; diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index b6386ee9..3e95d1a8 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -1,6 +1,6 @@ @@ -15,11 +15,14 @@ use Gitonomy\Git\Diff\File; use Gitonomy\Git\Diff\FileChange; -class DiffParser extends ParserBase +final class DiffParser extends ParserBase { - public $files; + /** + * @var File[] + */ + public array $files = []; - protected function doParse() + protected function doParse(): void { $this->files = []; $indexes = []; @@ -36,19 +39,19 @@ protected function doParse() } $this->consumeNewLine(); } elseif (!$this->isFinished()) { - trigger_error('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', E_USER_DEPRECATED); + @trigger_error('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', \E_USER_DEPRECATED); } $fileIndex = 0; while (!$this->isFinished()) { // 1. title - $vars = $this->consumeRegexp("/diff --git \"?(a\/.*?)\"? \"?(b\/.*?)\"?\n/"); + $vars = $this->consumeRegexp("/diff --git \"?(a\\/.*?)\"? \"?(b\\/.*?)\"?\n/"); $oldName = $vars[1]; $newName = $vars[2]; // Get indexes from raw if it exists $oldIndex = isset($indexes[$fileIndex]) ? $indexes[$fileIndex][0] : null; $newIndex = isset($indexes[$fileIndex]) ? $indexes[$fileIndex][1] : null; - $fileIndex++; + ++$fileIndex; $oldMode = null; $newMode = null; @@ -95,12 +98,12 @@ protected function doParse() } $this->consumeNewLine(); - //verifying if the file was deleted or created + // verifying if the file was deleted or created if ($this->expects('--- ')) { - $oldName = $this->consumeTo("\n") === '/dev/null' ? '/dev/null' : $oldName; + $oldName = '/dev/null' === $this->consumeTo("\n") ? '/dev/null' : $oldName; $this->consumeNewLine(); $this->consume('+++ '); - $newName = $this->consumeTo("\n") === '/dev/null' ? '/dev/null' : $newName; + $newName = '/dev/null' === $this->consumeTo("\n") ? '/dev/null' : $newName; $this->consumeNewLine(); } elseif ($this->expects('Binary files ')) { $vars = $this->consumeRegexp('/"?(.*?)"? and "?(.*?)"? differ\n/'); @@ -110,11 +113,11 @@ protected function doParse() } } - $oldName = $oldName === '/dev/null' ? null : substr($oldName, 2); - $newName = $newName === '/dev/null' ? null : substr($newName, 2); + $oldName = '/dev/null' === $oldName ? null : substr($oldName, 2); + $newName = '/dev/null' === $newName ? null : substr($newName, 2); - $oldIndex = $oldIndex === null ? '' : $oldIndex; - $newIndex = $newIndex === null ? '' : $newIndex; + $oldIndex = null === $oldIndex ? '' : $oldIndex; + $newIndex = null === $newIndex ? '' : $newIndex; $oldIndex = preg_match('/^0+$/', $oldIndex) ? null : $oldIndex; $newIndex = preg_match('/^0+$/', $newIndex) ? null : $newIndex; $file = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary); @@ -139,7 +142,7 @@ protected function doParse() $lines[] = [FileChange::LINE_ADD, $this->consumeTo("\n")]; } elseif ($this->expects('-')) { $lines[] = [FileChange::LINE_REMOVE, $this->consumeTo("\n")]; - } elseif ($this->expects("\ No newline at end of file")) { + } elseif ($this->expects('\ No newline at end of file')) { // Ignore this case... } else { break; diff --git a/src/Gitonomy/Git/Parser/LogParser.php b/src/Gitonomy/Git/Parser/LogParser.php index 67b86687..693abfc4 100644 --- a/src/Gitonomy/Git/Parser/LogParser.php +++ b/src/Gitonomy/Git/Parser/LogParser.php @@ -1,6 +1,6 @@ @@ -12,11 +12,14 @@ namespace Gitonomy\Git\Parser; -class LogParser extends CommitParser +final class LogParser extends CommitParser { - public $log = []; + /** + * @var array[] + */ + public array $log = []; - protected function doParse() + protected function doParse(): void { $this->log = []; @@ -37,12 +40,12 @@ protected function doParse() } $this->consume('author '); - list($commit['authorName'], $commit['authorEmail'], $authorDate) = $this->consumeNameEmailDate(); + [$commit['authorName'], $commit['authorEmail'], $authorDate] = $this->consumeNameEmailDate(); $commit['authorDate'] = $this->parseDate($authorDate); $this->consumeNewLine(); $this->consume('committer '); - list($commit['committerName'], $commit['committerEmail'], $committerDate) = $this->consumeNameEmailDate(); + [$commit['committerName'], $commit['committerEmail'], $committerDate] = $this->consumeNameEmailDate(); $commit['committerDate'] = $this->parseDate($committerDate); $this->consumeMergeTag(); @@ -52,20 +55,20 @@ protected function doParse() $this->consumeNewLine(); $this->consumeUnsupportedLinesToNewLine(); - if ($this->cursor < strlen($this->content)) { + if ($this->cursor < \strlen($this->content)) { $this->consumeNewLine(); } $message = ''; if ($this->expects(' ')) { - $this->cursor -= strlen(' '); + $this->cursor -= \strlen(' '); while ($this->expects(' ')) { $message .= $this->consumeTo("\n")."\n"; $this->consumeNewLine(); } } else { - $this->cursor--; + --$this->cursor; } if (!$this->isFinished()) { @@ -78,12 +81,12 @@ protected function doParse() } } - protected function consumeUnsupportedLinesToNewLine() + protected function consumeUnsupportedLinesToNewLine(): void { // Consume any unsupported lines that may appear in the log output. For // example, gitbutler headers or other custom metadata but this should // work regardless of the content. - while (!$this->isFinished() && substr($this->content, $this->cursor, 1) !== "\n") { + while (!$this->isFinished() && "\n" !== substr($this->content, $this->cursor, 1)) { $this->consumeTo("\n"); $this->consumeNewLine(); } diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index 45a80020..70a7a81e 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -1,6 +1,6 @@ @@ -16,37 +16,37 @@ abstract class ParserBase { - protected $cursor; - protected $content; - protected $length; + protected int $cursor; + protected string $content; + protected int $length; - abstract protected function doParse(); - - public function parse($content) + public function parse(?string $content): void { $this->cursor = 0; $this->content = $content ?? ''; - $this->length = strlen($this->content); + $this->length = \strlen($this->content); $this->doParse(); } - protected function isFinished() + abstract protected function doParse(): void; + + protected function isFinished(): bool { return $this->cursor === $this->length; } - protected function consumeAll() + protected function consumeAll(): string { $rest = substr($this->content, $this->cursor); - $this->cursor += strlen($rest); + $this->cursor += \strlen($rest); return $rest; } - protected function expects($expected) + protected function expects(string $expected): bool { - $length = strlen($expected); + $length = \strlen($expected); $actual = substr($this->content, $this->cursor, $length); if ($actual !== $expected) { return false; @@ -57,18 +57,18 @@ protected function expects($expected) return true; } - protected function consumeShortHash() + protected function consumeShortHash(): string { if (!preg_match('/([A-Za-z0-9]{7,40})/A', $this->content, $vars, 0, $this->cursor)) { throw new RuntimeException('No short hash found: '.substr($this->content, $this->cursor, 7)); } - $this->cursor += strlen($vars[1]); + $this->cursor += \strlen($vars[1]); return $vars[1]; } - protected function consumeHash() + protected function consumeHash(): string { if (!preg_match('/([A-Za-z0-9]{40})/A', $this->content, $vars, 0, $this->cursor)) { throw new RuntimeException('No hash found: '.substr($this->content, $this->cursor, 40)); @@ -79,23 +79,23 @@ protected function consumeHash() return $vars[1]; } - protected function consumeRegexp($regexp) + protected function consumeRegexp(string $regexp): array { if (!preg_match($regexp.'A', $this->content, $vars, 0, $this->cursor)) { throw new RuntimeException('No match for regexp '.$regexp.' Upcoming: '.substr($this->content, $this->cursor, 500)); } - $this->cursor += strlen($vars[0]); + $this->cursor += \strlen($vars[0]); return $vars; } - protected function consumeTo($text) + protected function consumeTo(string $text): string { $pos = strpos($this->content, $text, $this->cursor); if (false === $pos) { - throw new RuntimeException(sprintf('Unable to find "%s"', $text)); + throw new RuntimeException(\sprintf('Unable to find "%s"', $text)); } $result = substr($this->content, $this->cursor, $pos - $this->cursor); @@ -104,30 +104,27 @@ protected function consumeTo($text) return $result; } - protected function consume($expected) + protected function consume(string $expected): string { - $length = strlen($expected); + $length = \strlen($expected); $actual = substr($this->content, $this->cursor, $length); if ($actual !== $expected) { - throw new RuntimeException(sprintf('Expected "%s", but got "%s" (%s)', $expected, $actual, substr($this->content, $this->cursor, 10))); + throw new RuntimeException(\sprintf('Expected "%s", but got "%s" (%s)', $expected, $actual, substr($this->content, $this->cursor, 10))); } $this->cursor += $length; return $expected; } - protected function consumeNewLine() + protected function consumeNewLine(): string { return $this->consume("\n"); } - /** - * @return string - */ - protected function consumeGPGSignature() + protected function consumeGPGSignature(): string { $expected = "\ngpgsig "; - $length = strlen($expected); + $length = \strlen($expected); $actual = substr($this->content, $this->cursor, $length); if ($actual != $expected) { return ''; @@ -137,13 +134,13 @@ protected function consumeGPGSignature() return $this->consumeTo("\n\n"); } - protected function consumeMergeTag() + protected function consumeMergeTag(): void { $expected = "\nmergetag "; - $length = strlen($expected); + $length = \strlen($expected); $actual = substr($this->content, $this->cursor, $length); if ($actual != $expected) { - return ''; + return; } $this->cursor += $length; diff --git a/src/Gitonomy/Git/Parser/ReferenceParser.php b/src/Gitonomy/Git/Parser/ReferenceParser.php index 82e3a8b5..aae23af7 100644 --- a/src/Gitonomy/Git/Parser/ReferenceParser.php +++ b/src/Gitonomy/Git/Parser/ReferenceParser.php @@ -1,6 +1,6 @@ @@ -12,11 +12,14 @@ namespace Gitonomy\Git\Parser; -class ReferenceParser extends ParserBase +final class ReferenceParser extends ParserBase { - public $references; + /** + * @var array + */ + public array $references = []; - protected function doParse() + protected function doParse(): void { $this->references = []; diff --git a/src/Gitonomy/Git/Parser/TagParser.php b/src/Gitonomy/Git/Parser/TagParser.php index 1a1c084c..b517f4e0 100644 --- a/src/Gitonomy/Git/Parser/TagParser.php +++ b/src/Gitonomy/Git/Parser/TagParser.php @@ -1,6 +1,6 @@ @@ -14,18 +14,18 @@ use Gitonomy\Git\Exception\RuntimeException; -class TagParser extends ParserBase +final class TagParser extends ParserBase { - public $object; - public $type; - public $tag; - public $taggerName; - public $taggerEmail; - public $taggerDate; - public $gpgSignature; - public $message; - - protected function doParse() + public string $object; + public string $type; + public string $tag; + public string $taggerName; + public string $taggerEmail; + public \DateTime $taggerDate; + public ?string $gpgSignature = null; + public string $message; + + protected function doParse(): void { $this->consume('object '); $this->object = $this->consumeHash(); @@ -40,7 +40,7 @@ protected function doParse() $this->consumeNewLine(); $this->consume('tagger '); - list($this->taggerName, $this->taggerEmail, $taggerDate) = $this->consumeNameEmailDate(); + [$this->taggerName, $this->taggerEmail, $taggerDate] = $this->consumeNameEmailDate(); $this->taggerDate = $this->parseDate($taggerDate); $this->consumeNewLine(); @@ -54,10 +54,10 @@ protected function doParse() } } - protected function consumeGPGSignature() + protected function consumeGPGSignature(): string { $expected = '-----BEGIN PGP SIGNATURE-----'; - $length = strlen($expected); + $length = \strlen($expected); $actual = substr($this->content, $this->cursor, $length); if ($actual != $expected) { return ''; @@ -67,23 +67,26 @@ protected function consumeGPGSignature() return $this->consumeTo('-----END PGP SIGNATURE-----'); } - protected function consumeNameEmailDate() + /** + * @return string[] A tuple of [name, email, date] + */ + protected function consumeNameEmailDate(): array { if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+ [+-]\d{4}))/A', $this->content, $vars, 0, $this->cursor)) { throw new RuntimeException('Unable to parse name, email and date'); } - $this->cursor += strlen($vars[1]); + $this->cursor += \strlen($vars[1]); return [$vars[2], $vars[3], $vars[4]]; } - protected function parseDate($text) + protected function parseDate(string $text): \DateTime { $date = \DateTime::createFromFormat('U e O', $text.' UTC'); if (!$date instanceof \DateTime) { - throw new RuntimeException(sprintf('Unable to convert "%s" to datetime', $text)); + throw new RuntimeException(\sprintf('Unable to convert "%s" to datetime', $text)); } return $date; diff --git a/src/Gitonomy/Git/Parser/TreeParser.php b/src/Gitonomy/Git/Parser/TreeParser.php index d01f4b28..0dbb7db6 100644 --- a/src/Gitonomy/Git/Parser/TreeParser.php +++ b/src/Gitonomy/Git/Parser/TreeParser.php @@ -1,6 +1,6 @@ @@ -12,11 +12,14 @@ namespace Gitonomy\Git\Parser; -class TreeParser extends ParserBase +final class TreeParser extends ParserBase { - public $entries = []; + /** + * @var array + */ + public array $entries = []; - protected function doParse() + protected function doParse(): void { while (!$this->isFinished()) { $vars = $this->consumeRegexp('/\d{6}/A'); diff --git a/src/Gitonomy/Git/PushReference.php b/src/Gitonomy/Git/PushReference.php index dea10823..dc3086e4 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -1,6 +1,6 @@ @@ -20,92 +20,55 @@ * * @author Julien DIDIER */ -class PushReference +final readonly class PushReference { - const ZERO = '0000000000000000000000000000000000000000'; - - /** - * @var Repository - */ - protected $repository; - /** - * @var string - */ - protected $reference; + public const string ZERO = '0000000000000000000000000000000000000000'; - /** - * @var string - */ - protected $before; + private bool $isForce; - /** - * @var string - */ - protected $after; - - /** - * @var bool - */ - protected $isForce; - - public function __construct(Repository $repository, $reference, $before, $after) - { - $this->repository = $repository; - $this->reference = $reference; - $this->before = $before; - $this->after = $after; + public function __construct( + private Repository $repository, + private string $reference, + private string $before, + private string $after, + ) { $this->isForce = $this->getForce(); } - /** - * @return Repository - */ - public function getRepository() + public function getRepository(): Repository { return $this->repository; } - /** - * @return string - */ - public function getReference() + public function getReference(): string { return $this->reference; } - /** - * @return string - */ - public function getBefore() + public function getBefore(): string { return $this->before; } - /** - * @return string - */ - public function getAfter() + public function getAfter(): string { return $this->after; } /** - * @return Log + * @param string[] $excludes */ - public function getLog($excludes = []) + public function getLog(array $excludes = []): Log { return $this->repository->getLog(array_merge( [$this->getRevision()], - array_map(function ($e) { + array_map(static function ($e) { return '^'.$e; }, $excludes) )); } - /** - * @return string - */ - public function getRevision() + public function getRevision(): string { if ($this->isDelete()) { throw new LogicException('No revision for deletion'); @@ -118,50 +81,32 @@ public function getRevision() return $this->getBefore().'..'.$this->getAfter(); } - /** - * @return bool - */ - public function isCreate() + public function isCreate(): bool { return $this->isZero($this->before); } - /** - * @return bool - */ - public function isDelete() + public function isDelete(): bool { return $this->isZero($this->after); } - /** - * @return bool - */ - public function isForce() + public function isForce(): bool { return $this->isForce; } - /** - * @return bool - */ - public function isFastForward() + public function isFastForward(): bool { return !$this->isDelete() && !$this->isCreate() && !$this->isForce(); } - /** - * @return bool - */ - protected function isZero($reference) + private function isZero(string $reference): bool { return self::ZERO === $reference; } - /** - * @return bool - */ - protected function getForce() + private function getForce(): bool { if ($this->isDelete() || $this->isCreate()) { return false; diff --git a/src/Gitonomy/Git/Reference.php b/src/Gitonomy/Git/Reference.php index 96b8fa12..885335a4 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -1,6 +1,6 @@ @@ -23,35 +23,26 @@ */ abstract class Reference extends Revision { - protected $commitHash; + protected ?string $commitHash; - public function __construct(Repository $repository, $revision, $commitHash = null) + public function __construct(Repository $repository, string $revision, ?string $commitHash = null) { - $this->repository = $repository; - $this->revision = $revision; + parent::__construct($repository, $revision); + $this->commitHash = $commitHash; } - /** - * @return string - */ - public function getFullname() + public function getFullname(): string { return $this->revision; } - /** - * @return void - */ - public function delete() + public function delete(): void { $this->repository->getReferences()->delete($this->getFullname()); } - /** - * @return string - */ - public function getCommitHash() + public function getCommitHash(): string { if (null !== $this->commitHash) { return $this->commitHash; @@ -60,24 +51,21 @@ public function getCommitHash() try { $result = $this->repository->run('rev-parse', ['--verify', $this->revision]); } catch (ProcessException $e) { - throw new ReferenceNotFoundException(sprintf('Can not find revision "%s"', $this->revision)); + throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision)); } return $this->commitHash = trim($result); } /** - * @return Commit Commit associated to the reference. + * Returns the commit associated to the reference. */ - public function getCommit() + public function getCommit(): Commit { return $this->repository->getCommit($this->getCommitHash()); } - /** - * @return Commit - */ - public function getLastModification($path = null) + public function getLastModification(?string $path = null): Commit { return $this->getCommit()->getLastModification($path); } diff --git a/src/Gitonomy/Git/Reference/Branch.php b/src/Gitonomy/Git/Reference/Branch.php index 0d27fb88..f4edbf51 100644 --- a/src/Gitonomy/Git/Reference/Branch.php +++ b/src/Gitonomy/Git/Reference/Branch.php @@ -1,6 +1,6 @@ @@ -22,11 +22,11 @@ * * @author Alexandre Salomé */ -class Branch extends Reference +final class Branch extends Reference { - private $local = null; + private ?bool $local = null; - public function getName() + public function getName(): string { $fullname = $this->getFullname(); @@ -38,17 +38,17 @@ public function getName() return $vars['remote'].'/'.$vars['name']; } - throw new RuntimeException(sprintf('Cannot extract branch name from "%s"', $fullname)); + throw new RuntimeException(\sprintf('Cannot extract branch name from "%s"', $fullname)); } - public function isRemote() + public function isRemote(): bool { $this->detectBranchType(); return !$this->local; } - public function isLocal() + public function isLocal(): bool { $this->detectBranchType(); @@ -58,13 +58,8 @@ public function isLocal() /** * Check if this branch is merged to a destination branch * Optionally, check only with remote branches. - * - * @param string $destinationBranchName - * @param bool $compareOnlyWithRemote - * - * @return null|bool */ - public function isMergedTo($destinationBranchName = 'master', $compareOnlyWithRemote = false) + public function isMergedTo(string $destinationBranchName = 'master', bool $compareOnlyWithRemote = false): bool { $arguments = ['-a']; @@ -78,11 +73,7 @@ public function isMergedTo($destinationBranchName = 'master', $compareOnlyWithRe try { $result = $this->repository->run('branch', $arguments); } catch (ProcessException $e) { - throw new RuntimeException( - sprintf('Cannot determine if merged to the branch "%s"', $destinationBranchName), - $e->getCode(), - $e - ); + throw new RuntimeException(\sprintf('Cannot determine if merged to the branch "%s"', $destinationBranchName), $e->getCode(), $e); } if (!$result) { @@ -95,10 +86,10 @@ public function isMergedTo($destinationBranchName = 'master', $compareOnlyWithRe }); $trimmed_output = array_map('trim', $filtered_output); - return in_array($this->getName(), $trimmed_output, true); + return \in_array($this->getName(), $trimmed_output, true); } - private function detectBranchType() + private function detectBranchType(): void { if (null === $this->local) { $this->local = !preg_match('#^refs/remotes/(?[^/]*)/(?.*)$#', $this->getFullname()); diff --git a/src/Gitonomy/Git/Reference/Stash.php b/src/Gitonomy/Git/Reference/Stash.php index 9f26802f..ff6854bc 100644 --- a/src/Gitonomy/Git/Reference/Stash.php +++ b/src/Gitonomy/Git/Reference/Stash.php @@ -1,6 +1,6 @@ @@ -17,9 +17,9 @@ /** * @author Alexandre Salomé */ -class Stash extends Reference +final class Stash extends Reference { - public function getName() + public function getName(): string { return 'stash'; } diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index 78c43b25..29539380 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -1,6 +1,6 @@ @@ -25,14 +25,17 @@ * @author Alexandre Salomé * @author Bruce Wells */ -class Tag extends Reference +final class Tag extends Reference { - protected $data; + /** + * @var array|null + */ + private ?array $data = null; - public function getName() + public function getName(): string { if (!preg_match('#^refs/tags/(.*)$#', $this->revision, $vars)) { - throw new RuntimeException(sprintf('Cannot extract tag name from "%s"', $this->revision)); + throw new RuntimeException(\sprintf('Cannot extract tag name from "%s"', $this->revision)); } return $vars[1]; @@ -40,10 +43,8 @@ public function getName() /** * Check if tag is annotated. - * - * @return bool */ - public function isAnnotated() + public function isAnnotated(): bool { try { $this->repository->run('cat-file', ['tag', $this->revision]); @@ -56,10 +57,8 @@ public function isAnnotated() /** * Returns the actual commit associated with the tag, and not the hash of the tag if annotated. - * - * @return Commit */ - public function getCommit() + public function getCommit(): Commit { if ($this->isAnnotated()) { try { @@ -67,11 +66,14 @@ public function getCommit() $parser = new ReferenceParser(); $parser->parse($output); - foreach ($parser->references as list($row)) { + $commitHash = null; + foreach ($parser->references as [$row]) { $commitHash = $row; } - return $this->repository->getCommit($commitHash); + if (null !== $commitHash) { + return $this->repository->getCommit($commitHash); + } } catch (ProcessException $e) { // ignore the exception } @@ -82,80 +84,64 @@ public function getCommit() /** * Returns the tagger name. - * - * @return string A name */ - public function getTaggerName() + public function getTaggerName(): string|false { return $this->getData('taggerName'); } /** * Returns the comitter email. - * - * @return string An email */ - public function getTaggerEmail() + public function getTaggerEmail(): string|false { return $this->getData('taggerEmail'); } /** * Returns the authoring date. - * - * @return \DateTime A time object */ - public function getTaggerDate() + public function getTaggerDate(): \DateTime|false { return $this->getData('taggerDate'); } /** * Returns the message of the commit. - * - * @return string A tag message */ - public function getMessage() + public function getMessage(): string|false { return $this->getData('message'); } /** * Returns the subject message (the first line). - * - * @return string The subject message */ - public function getSubjectMessage() + public function getSubjectMessage(): string|false { return $this->getData('subjectMessage'); } /** * Return the body message. - * - * @return string The body message */ - public function getBodyMessage() + public function getBodyMessage(): string|false { return $this->getData('bodyMessage'); } /** * Return the GPG signature. - * - * @return string The GPG signature */ - public function getGPGSignature() + public function getGPGSignature(): string|false { return $this->getData('gpgSignature'); } /** * Check whether tag is signed. - * - * @return bool */ - public function isSigned() + public function isSigned(): bool { try { $this->getGPGSignature(); @@ -166,7 +152,7 @@ public function isSigned() } } - private function getData($name) + private function getData(string $name): mixed { if (!$this->isAnnotated()) { return false; @@ -176,14 +162,14 @@ private function getData($name) return $this->data[$name]; } - if ($name === 'subjectMessage') { + if ('subjectMessage' === $name) { $lines = explode("\n", $this->getData('message')); $this->data['subjectMessage'] = reset($lines); return $this->data['subjectMessage']; } - if ($name === 'bodyMessage') { + if ('bodyMessage' === $name) { $message = $this->getData('message'); $lines = explode("\n", $message); @@ -208,7 +194,7 @@ private function getData($name) $this->data['gpgSignature'] = $parser->gpgSignature; if (!isset($this->data[$name])) { - throw new \InvalidArgumentException(sprintf('No data named "%s" in Tag.', $name)); + throw new \InvalidArgumentException(\sprintf('No data named "%s" in Tag.', $name)); } return $this->data[$name]; diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 71ef9ae4..b1833b3e 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -1,6 +1,6 @@ @@ -24,64 +24,45 @@ * @author Alexandre Salomé * @author Julien DIDIER */ -class ReferenceBag implements \Countable, \IteratorAggregate +final class ReferenceBag implements \Countable, \IteratorAggregate { - /** - * Repository object. - * - * @var Repository - */ - protected $repository; - /** * Associative array of fullname references. * - * @var array + * @var array */ - protected $references; + private array $references = []; /** * List with all tags. * * @var Tag[] */ - protected $tags; + private array $tags = []; /** * List with all branches. * * @var Branch[] */ - protected $branches; + private array $branches = []; /** * A boolean indicating if the bag is already initialized. - * - * @var bool */ - protected $initialized = false; + private bool $initialized = false; - /** - * Constructor. - * - * @param Repository $repository The repository - */ - public function __construct($repository) - { - $this->repository = $repository; - $this->references = []; - $this->tags = []; - $this->branches = []; + public function __construct( + private readonly Repository $repository, + ) { } /** * Returns a reference, by name. * - * @param string $fullname Fullname of the reference (refs/heads/master, for example). - * - * @return Reference A reference object. + * @param string $fullname fullname of the reference (refs/heads/master, for example) */ - public function get($fullname) + public function get(string $fullname): Reference { $this->initialize(); @@ -92,10 +73,7 @@ public function get($fullname) return $this->references[$fullname]; } - /** - * @return bool - */ - public function has($fullname) + public function has(string $fullname): bool { $this->initialize(); @@ -103,9 +81,13 @@ public function has($fullname) } /** - * @return Reference + * @template T of Reference + * + * @param T $reference + * + * @return T */ - public function update(Reference $reference) + public function update(Reference $reference): Reference { $fullname = $reference->getFullname(); @@ -117,65 +99,50 @@ public function update(Reference $reference) return $reference; } - /** - * @return Reference - */ - public function createBranch($name, $commitHash) + public function createBranch(string $name, string $commitHash): Branch { $branch = new Branch($this->repository, 'refs/heads/'.$name, $commitHash); return $this->update($branch); } - /** - * @return Reference - */ - public function createTag($name, $commitHash) + public function createTag(string $name, string $commitHash): Tag { $tag = new Tag($this->repository, 'refs/tags/'.$name, $commitHash); return $this->update($tag); } - /** - * @return void - */ - public function delete($fullname) + public function delete(string $fullname): void { $this->repository->run('update-ref', ['-d', $fullname]); unset($this->references[$fullname]); } - /** - * @return bool - */ - public function hasBranches() + public function hasBranches(): bool { $this->initialize(); - return count($this->branches) > 0; + return \count($this->branches) > 0; } - public function hasBranch($name) + public function hasBranch(string $name): bool { return $this->has('refs/heads/'.$name); } - public function hasRemoteBranch($name) + public function hasRemoteBranch(string $name): bool { return $this->has('refs/remotes/'.$name); } - public function hasTag($name) + public function hasTag(string $name): bool { return $this->has('refs/tags/'.$name); } - /** - * @return Branch - */ - public function getFirstBranch() + public function getFirstBranch(): Reference|false { $this->initialize(); reset($this->branches); @@ -186,7 +153,7 @@ public function getFirstBranch() /** * @return Tag[] An array of Tag objects */ - public function resolveTags($hash) + public function resolveTags(Commit|string $hash): array { $this->initialize(); @@ -196,7 +163,7 @@ public function resolveTags($hash) $tags = []; foreach ($this->references as $reference) { - if ($reference instanceof Reference\Tag && $reference->getCommitHash() === $hash) { + if ($reference instanceof Tag && $reference->getCommitHash() === $hash) { $tags[] = $reference; } } @@ -207,7 +174,7 @@ public function resolveTags($hash) /** * @return Branch[] An array of Branch objects */ - public function resolveBranches($hash) + public function resolveBranches(Commit|string $hash): array { $this->initialize(); @@ -217,7 +184,7 @@ public function resolveBranches($hash) $branches = []; foreach ($this->references as $reference) { - if ($reference instanceof Reference\Branch && $reference->getCommitHash() === $hash) { + if ($reference instanceof Branch && $reference->getCommitHash() === $hash) { $branches[] = $reference; } } @@ -228,7 +195,7 @@ public function resolveBranches($hash) /** * @return Reference[] An array of references */ - public function resolve($hash) + public function resolve(Commit|string $hash): array { $this->initialize(); @@ -247,9 +214,9 @@ public function resolve($hash) } /** - * @return Tag[] All tags. + * @return Tag[] all tags */ - public function getTags() + public function getTags(): array { $this->initialize(); @@ -257,15 +224,15 @@ public function getTags() } /** - * @return Branch[] All branches. + * @return Branch[] all branches */ - public function getBranches() + public function getBranches(): array { $this->initialize(); $result = []; foreach ($this->references as $reference) { - if ($reference instanceof Reference\Branch) { + if ($reference instanceof Branch) { $result[] = $reference; } } @@ -274,9 +241,9 @@ public function getBranches() } /** - * @return Branch[] All local branches. + * @return Branch[] all local branches */ - public function getLocalBranches() + public function getLocalBranches(): array { $result = []; foreach ($this->getBranches() as $branch) { @@ -289,9 +256,9 @@ public function getLocalBranches() } /** - * @return Branch[] All remote branches. + * @return Branch[] all remote branches */ - public function getRemoteBranches() + public function getRemoteBranches(): array { $result = []; foreach ($this->getBranches() as $branch) { @@ -304,46 +271,75 @@ public function getRemoteBranches() } /** - * @return array An associative array with fullname as key (refs/heads/master, refs/tags/0.1) + * @return array An associative array with fullname as key (refs/heads/master, refs/tags/0.1) */ - public function getAll() + public function getAll(): array { $this->initialize(); return $this->references; } + public function getTag(string $name): Tag + { + $this->initialize(); + + return $this->getAs('refs/tags/'.$name, Tag::class); + } + + public function getBranch(string $name): Branch + { + $this->initialize(); + + return $this->getAs('refs/heads/'.$name, Branch::class); + } + + public function getRemoteBranch(string $name): Branch + { + $this->initialize(); + + return $this->getAs('refs/remotes/'.$name, Branch::class); + } + /** - * @return Tag + * @see Countable */ - public function getTag($name) + public function count(): int { $this->initialize(); - return $this->get('refs/tags/'.$name); + return \count($this->references); } /** - * @return Branch + * @see IteratorAggregate */ - public function getBranch($name) + public function getIterator(): \ArrayIterator { $this->initialize(); - return $this->get('refs/heads/'.$name); + return new \ArrayIterator($this->references); } /** - * @return Branch + * @template T of Reference + * + * @param class-string $class + * + * @return T */ - public function getRemoteBranch($name) + private function getAs(string $fullname, string $class): Reference { - $this->initialize(); + $reference = $this->get($fullname); + + if (!$reference instanceof $class) { + throw new ReferenceNotFoundException($fullname); + } - return $this->get('refs/remotes/'.$name); + return $reference; } - protected function initialize() + private function initialize(): void { if (true === $this->initialized) { return; @@ -359,7 +355,7 @@ protected function initialize() $parser->parse($output); foreach ($parser->references as $row) { - list($commitHash, $fullname) = $row; + [$commitHash, $fullname] = $row; if (preg_match('#^refs/(heads|remotes)/(.*)$#', $fullname)) { if (preg_match('#.*HEAD$#', $fullname)) { @@ -372,34 +368,10 @@ protected function initialize() $reference = new Tag($this->repository, $fullname, $commitHash); $this->references[$fullname] = $reference; $this->tags[] = $reference; - } elseif ($fullname === 'refs/stash') { + } elseif ('refs/stash' === $fullname) { $reference = new Stash($this->repository, $fullname, $commitHash); $this->references[$fullname] = $reference; } } } - - /** - * @return int - * - * @see Countable - */ - #[\ReturnTypeWillChange] - public function count() - { - $this->initialize(); - - return count($this->references); - } - - /** - * @see IteratorAggregate - */ - #[\ReturnTypeWillChange] - public function getIterator() - { - $this->initialize(); - - return new \ArrayIterator($this->references); - } } diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 296496ae..ca480f33 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -1,6 +1,6 @@ @@ -26,77 +26,72 @@ * * @author Alexandre Salomé */ -class Repository +final class Repository { - const DEFAULT_DESCRIPTION = "Unnamed repository; edit this file 'description' to name the repository.\n"; + public const string DEFAULT_DESCRIPTION = "Unnamed repository; edit this file 'description' to name the repository.\n"; /** * Directory containing git files. - * - * @var string */ - protected $gitDir; + private readonly string $gitDir; /** * Working directory. + */ + private readonly ?string $workingDir; + + /** + * Cache of commit objects, indexed by hash. * - * @var string + * @var array */ - protected $workingDir; + private array $commits = []; /** - * Cache containing all objects of the repository. + * Cache of tree objects, indexed by hash. * - * Associative array, indexed by object hash + * @var array + */ + private array $trees = []; + + /** + * Cache of blob objects, indexed by hash. * - * @var array + * @var array */ - protected $objects; + private array $blobs = []; /** * Reference bag associated to this repository. - * - * @var ReferenceBag */ - protected $referenceBag; + private ?ReferenceBag $referenceBag = null; /** * Logger (can be null). - * - * @var LoggerInterface */ - protected $logger; + private ?LoggerInterface $logger; /** * Path to git command. */ - protected $command; + private readonly string $command; /** * Debug flag, indicating if errors should be thrown. - * - * @var bool */ - protected $debug; + private readonly bool $debug; /** * Environment variables that should be set for every running process. - * - * @var array */ - protected $environmentVariables; + private readonly array $environmentVariables; - /** - * @var bool - */ - protected $inheritEnvironmentVariables; + private readonly bool $inheritEnvironmentVariables; /** * Timeout that should be set for every running process. - * - * @var int */ - protected $processTimeout; + private readonly int $processTimeout; /** * Constructs a new repository. @@ -116,31 +111,30 @@ class Repository * * @throws InvalidArgumentException The folder does not exists */ - public function __construct($dir, $options = []) + public function __construct(string $dir, array $options = []) { $options = array_merge([ - 'working_dir' => null, - 'debug' => true, - 'logger' => null, - 'command' => 'git', - 'environment_variables' => [], + 'working_dir' => null, + 'debug' => true, + 'logger' => null, + 'command' => 'git', + 'environment_variables' => [], 'inherit_environment_variables' => false, - 'process_timeout' => 3600, + 'process_timeout' => 3600, ], $options); if (null !== $options['logger'] && !$options['logger'] instanceof LoggerInterface) { - throw new InvalidArgumentException(sprintf('Argument "logger" passed to Repository should be a Psr\Log\LoggerInterface. A %s was provided', is_object($options['logger']) ? get_class($options['logger']) : gettype($options['logger']))); + throw new InvalidArgumentException(\sprintf('Argument "logger" passed to Repository should be a Psr\Log\LoggerInterface. A %s was provided', \is_object($options['logger']) ? \get_class($options['logger']) : \gettype($options['logger']))); } $this->logger = $options['logger']; - $this->initDir($dir, $options['working_dir']); + [$this->gitDir, $this->workingDir] = self::resolveDir($dir, $options['working_dir']); - $this->objects = []; $this->command = $options['command']; $this->debug = (bool) $options['debug']; $this->processTimeout = $options['process_timeout']; - if (defined('PHP_WINDOWS_VERSION_BUILD') && isset($_SERVER['PATH']) && !isset($options['environment_variables']['PATH'])) { + if (\defined('PHP_WINDOWS_VERSION_BUILD') && isset($_SERVER['PATH']) && !isset($options['environment_variables']['PATH'])) { $options['environment_variables']['PATH'] = $_SERVER['PATH']; } @@ -148,39 +142,14 @@ public function __construct($dir, $options = []) $this->inheritEnvironmentVariables = $options['inherit_environment_variables']; if (true === $this->debug && null !== $this->logger) { - $this->logger->debug(sprintf('Repository created (git dir: "%s", working dir: "%s")', $this->gitDir, $this->workingDir ?: 'none')); - } - } - - /** - * Initializes directory attributes on repository:. - * - * @param string $gitDir directory of a working copy with files checked out - * @param string $workingDir directory containing git files (objects, config...) - */ - private function initDir($gitDir, $workingDir = null) - { - $realGitDir = realpath($gitDir); - - if (false === $realGitDir) { - throw new InvalidArgumentException(sprintf('Directory "%s" does not exist or is not a directory', $gitDir)); - } elseif (!is_dir($realGitDir)) { - throw new InvalidArgumentException(sprintf('Directory "%s" does not exist or is not a directory', $realGitDir)); - } elseif (null === $workingDir && is_dir($realGitDir.'/.git')) { - $workingDir = $realGitDir; - $realGitDir = $realGitDir.'/.git'; + $this->logger->debug(\sprintf('Repository created (git dir: "%s", working dir: "%s")', $this->gitDir, $this->workingDir ?: 'none')); } - - $this->gitDir = $realGitDir; - $this->workingDir = $workingDir; } /** * Tests if repository is a bare repository. - * - * @return bool */ - public function isBare() + public function isBare(): bool { return null === $this->workingDir; } @@ -190,7 +159,7 @@ public function isBare() * * @return Commit|null returns a Commit or ``null`` if repository is empty */ - public function getHeadCommit() + public function getHeadCommit(): ?Commit { $head = $this->getHead(); @@ -202,16 +171,16 @@ public function getHeadCommit() } /** - * @throws RuntimeException Unable to find file HEAD (debug-mode only) - * * @return Reference|Commit|null current HEAD object or null if error occurs + * + * @throws RuntimeException Unable to find file HEAD (debug-mode only) */ - public function getHead() + public function getHead(): Reference|Commit|null { $file = $this->gitDir.'/HEAD'; if (!file_exists($file)) { - $message = sprintf('Unable to find HEAD file ("%s")', $file); + $message = \sprintf('Unable to find HEAD file ("%s")', $file); if (null !== $this->logger) { $this->logger->error($message); @@ -230,11 +199,12 @@ public function getHead() if (preg_match('/^ref: (.+)$/', $content, $vars)) { return $this->getReferences()->get($vars[1]); - } elseif (preg_match('/^[0-9a-f]{40}$/', $content)) { + } + if (preg_match('/^[0-9a-f]{40}$/', $content)) { return $this->getCommit($content); } - $message = sprintf('Unexpected HEAD file content (file: %s). Content of file: %s', $file, $content); + $message = \sprintf('Unexpected HEAD file content (file: %s). Content of file: %s', $file, $content); if (null !== $this->logger) { $this->logger->error($message); @@ -243,40 +213,32 @@ public function getHead() if (true === $this->debug) { throw new RuntimeException($message); } + + return null; } - /** - * @return bool - */ - public function isHeadDetached() + public function isHeadDetached(): bool { return !$this->isHeadAttached(); } - /** - * @return bool - */ - public function isHeadAttached() + public function isHeadAttached(): bool { return $this->getHead() instanceof Reference; } /** * Returns the path to the git repository. - * - * @return string A directory path */ - public function getPath() + public function getPath(): string { - return $this->workingDir === null ? $this->gitDir : $this->workingDir; + return null === $this->workingDir ? $this->gitDir : $this->workingDir; } /** * Returns the directory containing git files (git-dir). - * - * @return string */ - public function getGitDir() + public function getGitDir(): string { return $this->gitDir; } @@ -284,10 +246,8 @@ public function getGitDir() /** * Returns the work-tree directory. This may be null if repository is * bare. - * - * @return string path to repository or null if repository is bare */ - public function getWorkingDir() + public function getWorkingDir(): ?string { return $this->workingDir; } @@ -296,18 +256,13 @@ public function getWorkingDir() * Instanciates a revision. * * @param string $name Name of the revision - * - * @return Revision */ - public function getRevision($name) + public function getRevision(string $name): Revision { return new Revision($this, $name); } - /** - * @return WorkingCopy - */ - public function getWorkingCopy() + public function getWorkingCopy(): WorkingCopy { return new WorkingCopy($this); } @@ -316,10 +271,8 @@ public function getWorkingCopy() * Returns the reference list associated to the repository. * * @param bool $reload Reload references from the filesystem - * - * @return ReferenceBag */ - public function getReferences($reload = false) + public function getReferences(bool $reload = false): ReferenceBag { if (null === $this->referenceBag || $reload) { $this->referenceBag = new ReferenceBag($this); @@ -332,56 +285,47 @@ public function getReferences($reload = false) * Instanciates a commit object or fetches one from the cache. * * @param string $hash A commit hash, with a length of 40 - * - * @return Commit */ - public function getCommit($hash) + public function getCommit(string $hash): Commit { - if (!isset($this->objects[$hash])) { - $this->objects[$hash] = new Commit($this, $hash); + if (!isset($this->commits[$hash])) { + $this->commits[$hash] = new Commit($this, $hash); } - return $this->objects[$hash]; + return $this->commits[$hash]; } /** * Instanciates a tree object or fetches one from the cache. * * @param string $hash A tree hash, with a length of 40 - * - * @return Tree */ - public function getTree($hash) + public function getTree(string $hash): Tree { - if (!isset($this->objects[$hash])) { - $this->objects[$hash] = new Tree($this, $hash); + if (!isset($this->trees[$hash])) { + $this->trees[$hash] = new Tree($this, $hash); } - return $this->objects[$hash]; + return $this->trees[$hash]; } /** * Instanciates a blob object or fetches one from the cache. * * @param string $hash A blob hash, with a length of 40 - * - * @return Blob */ - public function getBlob($hash) + public function getBlob(string $hash): Blob { - if (!isset($this->objects[$hash])) { - $this->objects[$hash] = new Blob($this, $hash); + if (!isset($this->blobs[$hash])) { + $this->blobs[$hash] = new Blob($this, $hash); } - return $this->objects[$hash]; + return $this->blobs[$hash]; } - /** - * @return Blame - */ - public function getBlame($revision, $file, $lineRange = null) + public function getBlame(string|Revision $revision, string $file, ?string $lineRange = null): Blame { - if (is_string($revision)) { + if (\is_string($revision)) { $revision = $this->getRevision($revision); } @@ -393,26 +337,25 @@ public function getBlame($revision, $file, $lineRange = null) * * All those values can be null, meaning everything. * - * @param array $revisions An array of revisions to show logs from. Can be - * any text value type - * @param array $paths Restrict log to modifications occurring on given - * paths. - * @param int $offset Start from a given offset in results. - * @param int $limit Limit number of total results. - * - * @return Log + * @param RevisionList|Revision|string|array|null $revisions An array of revisions to show logs from. Can be + * any text value type + * @param array|string|null $paths restrict log to modifications occurring on given + * paths + * @param int|null $offset start from a given offset in results + * @param int|null $limit limit number of total results */ - public function getLog($revisions = null, $paths = null, $offset = null, $limit = null) + public function getLog(RevisionList|Revision|string|array|null $revisions = null, array|string|null $paths = null, ?int $offset = null, ?int $limit = null): Log { return new Log($this, $revisions, $paths, $offset, $limit); } - /** - * @return Diff - */ - public function getDiff($revisions) + public function getDiff(RevisionList|Revision|string|array|null $revisions): Diff { - if (null !== $revisions && !$revisions instanceof RevisionList) { + if (null === $revisions) { + throw new InvalidArgumentException('Cannot compute a diff without revisions.'); + } + + if (!$revisions instanceof RevisionList) { $revisions = new RevisionList($this, $revisions); } @@ -429,11 +372,12 @@ public function getDiff($revisions) * * @return int A sum, in kilobytes */ - public function getSize() + public function getSize(): int { $totalBytes = 0; $path = realpath($this->gitDir); if ($path && file_exists($path)) { + /** @var \SplFileInfo $object */ foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)) as $object) { $totalBytes += $object->getSize(); } @@ -447,24 +391,22 @@ public function getSize() * * @param string $command The command to execute */ - public function shell($command, array $env = []) + public function shell(string $command, array $env = []): void { - $argument = sprintf('%s \'%s\'', $command, $this->gitDir); + $argument = \sprintf('%s \'%s\'', $command, $this->gitDir); $prefix = ''; foreach ($env as $name => $value) { - $prefix .= sprintf('export %s=%s;', escapeshellarg($name), escapeshellarg($value)); + $prefix .= \sprintf('export %s=%s;', escapeshellarg($name), escapeshellarg($value)); } - proc_open($prefix.'git shell -c '.escapeshellarg($argument), [STDIN, STDOUT, STDERR], $pipes); + proc_open($prefix.'git shell -c '.escapeshellarg($argument), [\STDIN, \STDOUT, \STDERR], $pipes); } /** * Returns the hooks object. - * - * @return Hooks */ - public function getHooks() + public function getHooks(): Hooks { return new Hooks($this); } @@ -474,21 +416,21 @@ public function getHooks() * * @return string The description */ - public function getDescription() + public function getDescription(): string { $file = $this->gitDir.'/description'; $exists = is_file($file); if (null !== $this->logger && true === $this->debug) { if (false === $exists) { - $this->logger->debug(sprintf('no description file in repository ("%s")', $file)); + $this->logger->debug(\sprintf('no description file in repository ("%s")', $file)); } else { - $this->logger->debug(sprintf('reading description file in repository ("%s")', $file)); + $this->logger->debug(\sprintf('reading description file in repository ("%s")', $file)); } } if (false === $exists) { - return static::DEFAULT_DESCRIPTION; + return self::DEFAULT_DESCRIPTION; } return file_get_contents($this->gitDir.'/description'); @@ -496,25 +438,23 @@ public function getDescription() /** * Tests if repository has a custom set description. - * - * @return bool */ - public function hasDescription() + public function hasDescription(): bool { - return static::DEFAULT_DESCRIPTION !== $this->getDescription(); + return self::DEFAULT_DESCRIPTION !== $this->getDescription(); } /** * Changes the repository description (file description in git-directory). * - * @return Repository the current repository + * @return $this */ - public function setDescription($description) + public function setDescription(string $description): static { $file = $this->gitDir.'/description'; if (null !== $this->logger && true === $this->debug) { - $this->logger->debug(sprintf('change description file content to "%s" (file: %s)', $description, $file)); + $this->logger->debug(\sprintf('change description file content to "%s" (file: %s)', $description, $file)); } file_put_contents($file, $description); @@ -528,16 +468,16 @@ public function setDescription($description) * @param string $command Git command to run (checkout, branch, tag) * @param array $args Arguments of git command * - * @throws RuntimeException Error while executing git command (debug-mode only) + * @return string|null output of a successful process or null if execution failed and debug-mode is disabled * - * @return string Output of a successful process or null if execution failed and debug-mode is disabled. + * @throws RuntimeException Error while executing git command (debug-mode only) */ - public function run($command, $args = []) + public function run(string $command, array $args = []): ?string { $process = $this->getProcess($command, $args); if ($this->logger) { - $this->logger->info(sprintf('run command: %s "%s" ', $command, implode(' ', $args))); + $this->logger->info(\sprintf('run command: %s "%s" ', $command, implode(' ', $args))); $before = microtime(true); } @@ -547,13 +487,13 @@ public function run($command, $args = []) if ($this->logger && $this->debug) { $duration = microtime(true) - $before; - $this->logger->debug(sprintf('last command (%s) duration: %sms', $command, sprintf('%.2f', $duration * 1000))); - $this->logger->debug(sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); - $this->logger->debug(sprintf('last command (%s) output: %s', $command, $output)); + $this->logger->debug(\sprintf('last command (%s) duration: %sms', $command, \sprintf('%.2f', $duration * 1000))); + $this->logger->debug(\sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); + $this->logger->debug(\sprintf('last command (%s) output: %s', $command, $output)); } if (!$process->isSuccessful()) { - $error = sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput()); + $error = \sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput()); if ($this->logger) { $this->logger->error($error); @@ -563,7 +503,7 @@ public function run($command, $args = []) throw new ProcessException($process); } - return; + return null; } return $output; @@ -574,9 +514,9 @@ public function run($command, $args = []) * * @param LoggerInterface $logger A logger * - * @return Repository The current repository + * @return $this */ - public function setLogger(LoggerInterface $logger) + public function setLogger(LoggerInterface $logger): static { $this->logger = $logger; @@ -585,10 +525,8 @@ public function setLogger(LoggerInterface $logger) /** * Returns repository logger. - * - * @return LoggerInterface the logger or null */ - public function getLogger() + public function getLogger(): ?LoggerInterface { return $this->logger; } @@ -601,11 +539,37 @@ public function getLogger() * * @return Repository the newly created repository */ - public function cloneTo($path, $bare = true, array $options = []) + public function cloneTo(string $path, bool $bare = true, array $options = []): self { return Admin::cloneTo($path, $this->gitDir, $bare, $options); } + /** + * Resolves directory attributes for the repository. + * + * @param string $gitDir directory of a working copy with files checked out + * @param string|null $workingDir directory containing git files (objects, config...) + * + * @return array{string, string|null} + */ + private static function resolveDir(string $gitDir, ?string $workingDir = null): array + { + $realGitDir = realpath($gitDir); + + if (false === $realGitDir) { + throw new InvalidArgumentException(\sprintf('Directory "%s" does not exist or is not a directory', $gitDir)); + } + if (!is_dir($realGitDir)) { + throw new InvalidArgumentException(\sprintf('Directory "%s" does not exist or is not a directory', $realGitDir)); + } + if (null === $workingDir && is_dir($realGitDir.'/.git')) { + $workingDir = $realGitDir; + $realGitDir .= '/.git'; + } + + return [$realGitDir, $workingDir]; + } + /** * This internal method is used to create a process object. * @@ -614,7 +578,7 @@ public function cloneTo($path, $bare = true, array $options = []) * * @see self::run */ - private function getProcess($command, $args = []) + private function getProcess(string $command, array $args = []): Process { $base = [$this->command, '--git-dir', $this->gitDir]; diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index 9e1921fb..b6c831b6 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -1,6 +1,6 @@ @@ -17,52 +17,31 @@ */ class Revision { - /** - * @var Repository - */ - protected $repository; - - /** - * @var string - */ - protected $revision; - - public function __construct(Repository $repository, $revision) - { - $this->repository = $repository; - $this->revision = $revision; + public function __construct( + protected readonly Repository $repository, + protected readonly string $revision, + ) { } - /** - * @return Log - */ - public function getLog($paths = null, $offset = null, $limit = null) + public function getLog(array|string|null $paths = null, ?int $offset = null, ?int $limit = null): Log { return $this->repository->getLog($this, $paths, $offset, $limit); } /** * Returns the last modification date of the reference. - * - * @return Commit */ - public function getCommit() + public function getCommit(): Commit { return $this->getLog()->getSingleCommit(); } - /** - * @return string - */ - public function getRevision() + public function getRevision(): string { return $this->revision; } - /** - * @return Repository - */ - public function getRepository() + public function getRepository(): Repository { return $this->repository; } diff --git a/src/Gitonomy/Git/RevisionList.php b/src/Gitonomy/Git/RevisionList.php index 1c1a1dcd..609d354d 100644 --- a/src/Gitonomy/Git/RevisionList.php +++ b/src/Gitonomy/Git/RevisionList.php @@ -1,6 +1,6 @@ @@ -15,63 +15,68 @@ /** * @author Alexandre Salomé */ -class RevisionList implements \IteratorAggregate, \Countable +final readonly class RevisionList implements \IteratorAggregate, \Countable { - protected $revisions; + /** + * @var Revision[] + */ + private array $revisions; /** * Constructs a revision list from a variety of types. * - * @param mixed $revisions can be a string, an array of strings or an array of Revision, Branch, Tag, Commit + * @param string|Revision|array $revisions can be a string, a Revision, an array of strings or an array of Revision, Branch, Tag, Commit */ - public function __construct(Repository $repository, $revisions) + public function __construct(Repository $repository, string|Revision|array $revisions) { - if (is_string($revisions)) { + if (\is_string($revisions)) { $revisions = [$repository->getRevision($revisions)]; } elseif ($revisions instanceof Revision) { $revisions = [$revisions]; - } elseif (!is_array($revisions)) { - throw new \InvalidArgumentException(sprintf('Expected a string, a Revision or an array, got a "%s".', is_object($revisions) ? get_class($revisions) : gettype($revisions))); } - if (count($revisions) == 0) { - throw new \InvalidArgumentException(sprintf('Empty revision list not allowed')); + if (0 == \count($revisions)) { + throw new \InvalidArgumentException('Empty revision list not allowed'); } - foreach ($revisions as $i => $revision) { - if (is_string($revision)) { - $revisions[$i] = new Revision($repository, $revision); + $normalizedRevisions = []; + foreach ($revisions as $revision) { + if (\is_string($revision)) { + $revision = new Revision($repository, $revision); } elseif (!$revision instanceof Revision) { - throw new \InvalidArgumentException(sprintf('Expected a "Revision", got a "%s".', is_object($revision) ? get_class($revision) : gettype($revision))); + throw new \InvalidArgumentException(\sprintf('Expected a "Revision", got a "%s".', \is_object($revision) ? $revision::class : \gettype($revision))); } + + $normalizedRevisions[] = $revision; } - $this->revisions = $revisions; + $this->revisions = $normalizedRevisions; } /** * @return Revision[] */ - public function getAll() + public function getAll(): array { return $this->revisions; } - #[\ReturnTypeWillChange] - public function getIterator() + public function getIterator(): \ArrayIterator { return new \ArrayIterator($this->revisions); } - #[\ReturnTypeWillChange] - public function count() + public function count(): int { - return count($this->revisions); + return \count($this->revisions); } - public function getAsTextArray() + /** + * @return string[] + */ + public function getAsTextArray(): array { - return array_map(function ($revision) { + return array_map(static function (Revision $revision) { return $revision->getRevision(); }, $this->revisions); } diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index 7830cfa4..43f9f706 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -1,6 +1,6 @@ @@ -18,56 +18,29 @@ /** * @author Alexandre Salomé */ -class Tree +final class Tree { - protected $repository; - protected $hash; - protected $isInitialized = false; - protected $entries; - protected $entriesByType; + private bool $isInitialized = false; - public function __construct(Repository $repository, $hash) - { - $this->repository = $repository; - $this->hash = $hash; - } + /** + * @var array + */ + private array $entries; - public function getHash() - { - return $this->hash; + /** + * @var array{blob: array, tree: array, commit: array} + */ + private array $entriesByType; + + public function __construct( + private readonly Repository $repository, + private readonly string $hash, + ) { } - protected function initialize() + public function getHash(): string { - if (true === $this->isInitialized) { - return; - } - - $output = $this->repository->run('cat-file', ['-p', $this->hash]); - $parser = new Parser\TreeParser(); - $parser->parse($output); - - $this->entries = []; - $this->entriesByType = [ - 'blob' => [], - 'tree' => [], - 'commit' => [], - ]; - - foreach ($parser->entries as $entry) { - list($mode, $type, $hash, $name) = $entry; - if ($type == 'blob') { - $treeEntry = [$mode, $this->repository->getBlob($hash)]; - } elseif ($type == 'tree') { - $treeEntry = [$mode, $this->repository->getTree($hash)]; - } else { - $treeEntry = [$mode, new CommitReference($hash)]; - } - $this->entries[$name] = $treeEntry; - $this->entriesByType[$type][$name] = $treeEntry; - } - - $this->isInitialized = true; + return $this->hash; } /** @@ -110,7 +83,7 @@ public function getBlobEntries(): array return $this->entriesByType['blob']; } - public function getEntry($name) + public function getEntry(string $name): CommitReference|self|Blob { $this->initialize(); @@ -121,9 +94,9 @@ public function getEntry($name) return $this->entries[$name][1]; } - public function resolvePath($path) + public function resolvePath(string $path): CommitReference|self|Blob { - if ($path == '') { + if ('' == $path) { return $this; } @@ -143,4 +116,39 @@ public function resolvePath($path) return $element; } + + private function initialize(): void + { + if (true === $this->isInitialized) { + return; + } + + $output = $this->repository->run('cat-file', ['-p', $this->hash]); + $parser = new Parser\TreeParser(); + $parser->parse($output); + + $this->entries = []; + $this->entriesByType = [ + 'blob' => [], + 'tree' => [], + 'commit' => [], + ]; + + foreach ($parser->entries as $entry) { + [$mode, $type, $hash, $name] = $entry; + if ('blob' == $type) { + $treeEntry = [$mode, $this->repository->getBlob($hash)]; + $this->entriesByType['blob'][$name] = $treeEntry; + } elseif ('tree' == $type) { + $treeEntry = [$mode, $this->repository->getTree($hash)]; + $this->entriesByType['tree'][$name] = $treeEntry; + } else { + $treeEntry = [$mode, new CommitReference($hash)]; + $this->entriesByType['commit'][$name] = $treeEntry; + } + $this->entries[$name] = $treeEntry; + } + + $this->isInitialized = true; + } } diff --git a/src/Gitonomy/Git/Util/StringHelper.php b/src/Gitonomy/Git/Util/StringHelper.php index d784b66b..9b9db38b 100644 --- a/src/Gitonomy/Git/Util/StringHelper.php +++ b/src/Gitonomy/Git/Util/StringHelper.php @@ -1,6 +1,6 @@ @@ -17,41 +17,41 @@ * * @author Alexandre Salomé */ -class StringHelper +final class StringHelper { - private static $encoding = 'utf-8'; + private static string $encoding = 'utf-8'; - public static function getEncoding() + public static function getEncoding(): string { return self::$encoding; } - public static function setEncoding($encoding) + public static function setEncoding(string $encoding): void { self::$encoding = $encoding; } - public static function strlen($string) + public static function strlen(string $string): int { - return function_exists('mb_strlen') ? mb_strlen($string, self::$encoding) : strlen($string); + return \function_exists('mb_strlen') ? mb_strlen($string, self::$encoding) : \strlen($string); } - public static function substr($string, $start, $length = false) + public static function substr(string $string, int $start, ?int $length = null): string { - if (false === $length) { + if (null === $length) { $length = self::strlen($string); } - return function_exists('mb_substr') ? mb_substr($string, $start, $length, self::$encoding) : substr($string, $start, $length); + return \function_exists('mb_substr') ? mb_substr($string, $start, $length, self::$encoding) : substr($string, $start, $length); } - public static function strpos($haystack, $needle, $offset = 0) + public static function strpos(string $haystack, string $needle, int $offset = 0): int|false { - return function_exists('mb_strpos') ? mb_strpos($haystack, $needle, $offset, self::$encoding) : strpos($haystack, $needle, $offset); + return \function_exists('mb_strpos') ? mb_strpos($haystack, $needle, $offset, self::$encoding) : strpos($haystack, $needle, $offset); } - public static function strrpos($haystack, $needle, $offset = 0) + public static function strrpos(string $haystack, string $needle, int $offset = 0): int|false { - return function_exists('mb_strrpos') ? mb_strrpos($haystack, $needle, $offset, self::$encoding) : strrpos($haystack, $needle, $offset); + return \function_exists('mb_strrpos') ? mb_strrpos($haystack, $needle, $offset, self::$encoding) : strrpos($haystack, $needle, $offset); } } diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index a94fbbb9..e48acd97 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -1,6 +1,6 @@ @@ -19,36 +19,32 @@ /** * @author Alexandre Salomé */ -class WorkingCopy +final readonly class WorkingCopy { - /** - * @var Repository - */ - protected $repository; - - public function __construct(Repository $repository) - { - $this->repository = $repository; - + public function __construct( + private Repository $repository, + ) { if ($this->repository->isBare()) { throw new LogicException('Can\'t create a working copy on a bare repository'); } } - public function getUntrackedFiles() + /** + * @return string[] + */ + public function getUntrackedFiles(): array { $lines = explode("\0", $this->run('status', ['--porcelain', '--untracked-files=all', '-z'])); - $lines = array_filter($lines, function ($l) { - return substr($l, 0, 3) === '?? '; + $lines = array_filter($lines, static function ($l) { + return '?? ' === substr($l, 0, 3); }); - $lines = array_map(function ($l) { + + return array_map(static function ($l) { return substr($l, 3); }, $lines); - - return $lines; } - public function getDiffPending() + public function getDiffPending(): Diff { $diff = Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index'])); $diff->setRepository($this->repository); @@ -56,7 +52,7 @@ public function getDiffPending() return $diff; } - public function getDiffStaged() + public function getDiffStaged(): Diff { $diff = Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--staged'])); $diff->setRepository($this->repository); @@ -65,19 +61,19 @@ public function getDiffStaged() } /** - * @return WorkingCopy + * @param Commit|Reference|string $revision */ - public function checkout($revision, $branch = null) + public function checkout($revision, ?string $branch = null): static { $args = []; if ($revision instanceof Commit) { $args[] = $revision->getHash(); } elseif ($revision instanceof Reference) { $args[] = $revision->getFullname(); - } elseif (is_string($revision)) { + } elseif (\is_string($revision)) { $args[] = $revision; } else { - throw new InvalidArgumentException(sprintf('Unknown type "%s"', gettype($revision))); + throw new InvalidArgumentException(\sprintf('Unknown type "%s"', \gettype($revision))); } if (null !== $branch) { @@ -89,7 +85,7 @@ public function checkout($revision, $branch = null) return $this; } - protected function run($command, array $args = []) + private function run(string $command, array $args = []): ?string { return $this->repository->run($command, $args); } diff --git a/tests/Gitonomy/Git/Tests/AbstractTest.php b/tests/Gitonomy/Git/Tests/AbstractTestCase.php similarity index 64% rename from tests/Gitonomy/Git/Tests/AbstractTest.php rename to tests/Gitonomy/Git/Tests/AbstractTestCase.php index 0b9a51b6..e56bc35c 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTest.php +++ b/tests/Gitonomy/Git/Tests/AbstractTestCase.php @@ -1,6 +1,6 @@ @@ -16,30 +16,28 @@ use Gitonomy\Git\Repository; use PHPUnit\Framework\TestCase; -abstract class AbstractTest extends TestCase +abstract class AbstractTestCase extends TestCase { - const REPOSITORY_URL = 'https://github.com/gitonomy/foobar.git'; + public const REPOSITORY_URL = 'https://github.com/gitonomy/foobar.git'; - const NO_MESSAGE_COMMIT = '011cd0c1625190d2959ee9a8f9f822006d94b661'; - const LONGFILE_COMMIT = '4f17752acc9b7c54ba679291bf24cb7d354f0f4f'; - const BEFORE_LONGFILE_COMMIT = 'e0ec50e2af75fa35485513f60b2e658e245227e9'; - const LONGMESSAGE_COMMIT = '3febd664b6886344a9b32d70657687ea4b1b4fab'; - const INITIAL_COMMIT = '74acd054c8ec873ae6be044041d3a85a4f890ba5'; - const MERGE_COMMIT = '2f5b9d0a4e6e7173d7816e417805709c708674f8'; - const ENCODING_COMMIT = '779420b9b936f18a0b6579e1499a85b14270802e'; - const SIGNED_COMMIT = 'e1a83f16ed61ae3807e5652c7ef894692c813513'; + public const NO_MESSAGE_COMMIT = '011cd0c1625190d2959ee9a8f9f822006d94b661'; + public const LONGFILE_COMMIT = '4f17752acc9b7c54ba679291bf24cb7d354f0f4f'; + public const BEFORE_LONGFILE_COMMIT = 'e0ec50e2af75fa35485513f60b2e658e245227e9'; + public const LONGMESSAGE_COMMIT = '3febd664b6886344a9b32d70657687ea4b1b4fab'; + public const INITIAL_COMMIT = '74acd054c8ec873ae6be044041d3a85a4f890ba5'; + public const MERGE_COMMIT = '2f5b9d0a4e6e7173d7816e417805709c708674f8'; + public const ENCODING_COMMIT = '779420b9b936f18a0b6579e1499a85b14270802e'; + public const SIGNED_COMMIT = 'e1a83f16ed61ae3807e5652c7ef894692c813513'; /** * Local clone of remote URL. Avoids network call on each test. */ - private static $localRepository; + private static ?Repository $localRepository = null; /** * Creates an empty git repository and returns instance. - * - * @return Repository */ - public static function createEmptyRepository($bare = true) + public static function createEmptyRepository(bool $bare = true): Repository { $dir = self::createTempDir(); $repository = Admin::init($dir, $bare, self::getOptions()); @@ -51,7 +49,7 @@ public static function createEmptyRepository($bare = true) /** * Can be used as data provider to get bare/not-bare repositories. */ - public static function provideFoobar() + public static function provideFoobar(): array { return [ [self::createFoobarRepository()], @@ -62,7 +60,7 @@ public static function provideFoobar() /** * Can be used as data provider to get bare/not-bare repositories. */ - public static function provideEmpty() + public static function provideEmpty(): array { return [ [self::createEmptyRepository()], @@ -72,10 +70,8 @@ public static function provideEmpty() /** * Creates a fixture test repository. - * - * @return Repository */ - public static function createFoobarRepository($bare = true) + public static function createFoobarRepository(bool $bare = true): Repository { if (null === self::$localRepository) { self::$localRepository = Admin::cloneTo(self::createTempDir(), self::REPOSITORY_URL, $bare, self::getOptions()); @@ -87,9 +83,9 @@ public static function createFoobarRepository($bare = true) return $repository; } - public static function registerDeletion(Repository $repository) + public static function registerDeletion(Repository $repository): void { - register_shutdown_function(function () use ($repository) { + register_shutdown_function(static function () use ($repository): void { if ($repository->getWorkingDir()) { $dir = $repository->getWorkingDir(); } else { @@ -101,10 +97,8 @@ public static function registerDeletion(Repository $repository) /** * Created an empty directory and return path to it. - * - * @return string a fullpath */ - public static function createTempDir() + public static function createTempDir(): string { $tmpDir = tempnam(sys_get_temp_dir(), 'gitlib_'); unlink($tmpDir); @@ -115,16 +109,14 @@ public static function createTempDir() /** * Deletes a directory recursively. - * - * @param string $dir directory to delete */ - protected static function deleteDir($dir) + protected static function deleteDir(string $dir): void { $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS); $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::CHILD_FIRST); foreach ($iterator as $file) { if (!is_link($file)) { - chmod($file, 0777); + chmod($file, 0o777); } if (is_dir($file)) { rmdir($file); @@ -133,19 +125,19 @@ protected static function deleteDir($dir) } } - chmod($dir, 0777); + chmod($dir, 0o777); rmdir($dir); } - protected static function getOptions() + protected static function getOptions(): array { - $command = isset($_SERVER['GIT_COMMAND']) ? $_SERVER['GIT_COMMAND'] : 'git'; + $command = $_SERVER['GIT_COMMAND'] ?? 'git'; $envs = isset($_SERVER['GIT_ENVS']) ? (array) $_SERVER['GIT_ENVS'] : []; return [ - 'command' => $command, + 'command' => $command, 'environment_variables' => $envs, - 'process_timeout' => 60, + 'process_timeout' => 60, ]; } } diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index 0f7460fa..f47702d8 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -1,6 +1,6 @@ @@ -16,28 +16,27 @@ use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\DataProvider; -class AdminTest extends AbstractTest +class AdminTest extends AbstractTestCase { - private $tmpDir; + private string $tmpDir; - /** - * @before - */ - public function setUpTmpDir() + #[Before] + public function setUpTmpDir(): void { $this->tmpDir = self::createTempDir(); } - /** - * @after - */ - public function tearDownTmpDir() + #[After] + public function tearDownTmpDir(): void { self::deleteDir(self::createTempDir()); } - public function testBare() + public function testBare(): void { $repository = Admin::init($this->tmpDir, true, self::getOptions()); @@ -50,7 +49,7 @@ public function testBare() $this->assertNull($repository->getWorkingDir(), 'No working dir in bare repository'); } - public function testNotBare() + public function testNotBare(): void { $repository = Admin::init($this->tmpDir, false, self::getOptions()); @@ -63,10 +62,8 @@ public function testNotBare() $this->assertEquals($this->tmpDir, $repository->getWorkingDir(), 'working dir present in bare repository'); } - /** - * @dataProvider provideFoobar - */ - public function testClone($repository) + #[DataProvider('provideFoobar')] + public function testClone(Repository $repository): void { $newDir = self::createTempDir(); $new = $repository->cloneTo($newDir, $repository->isBare(), self::getOptions()); @@ -86,11 +83,11 @@ public function testClone($repository) } } - public function testCloneBranchBare() + public function testCloneBranchBare(): void { - //we can't use AbstractText::createFoobarRepository() - //because it does not clone other branches than "master" - //so we test it directly against the remote repository + // we can't use AbstractTestCase::createFoobarRepository() + // because it does not clone other branches than "master" + // so we test it directly against the remote repository $newDir = self::createTempDir(); $new = Admin::cloneBranchTo($newDir, self::REPOSITORY_URL, 'new-feature'); @@ -101,11 +98,11 @@ public function testCloneBranchBare() $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); } - public function testCloneBranchNotBare() + public function testCloneBranchNotBare(): void { - //we can't use AbstractText::createFoobarRepository() - //because it does not clone other branches than "master" - //so we test it directly against remote repository + // we can't use AbstractTestCase::createFoobarRepository() + // because it does not clone other branches than "master" + // so we test it directly against remote repository $newDir = self::createTempDir(); $new = Admin::cloneBranchTo($newDir, self::REPOSITORY_URL, 'new-feature', false); @@ -116,10 +113,8 @@ public function testCloneBranchNotBare() $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); } - /** - * @dataProvider provideFoobar - */ - public function testMirror($repository) + #[DataProvider('provideFoobar')] + public function testMirror(Repository $repository): void { $newDir = self::createTempDir(); $new = Admin::mirrorTo($newDir, $repository->getGitDir(), self::getOptions()); @@ -138,16 +133,14 @@ public function testMirror($repository) } } - /** - * @dataProvider provideFoobar - */ - public function testCheckValidRepository($repository) + #[DataProvider('provideFoobar')] + public function testCheckValidRepository(Repository $repository): void { $url = $repository->getGitDir(); $this->assertTrue(Admin::isValidRepository($url)); } - public function testCheckInvalidRepository() + public function testCheckInvalidRepository(): void { $url = $this->tmpDir.'/invalid.git'; mkdir($url); @@ -155,25 +148,21 @@ public function testCheckInvalidRepository() $this->assertFalse(Admin::isValidRepository($url)); } - /** - * @dataProvider provideFoobar - */ - public function testCheckValidRepositoryAndBranch($repository) + #[DataProvider('provideFoobar')] + public function testCheckValidRepositoryAndBranch(Repository $repository): void { $url = $repository->getGitDir(); $this->assertTrue(Admin::isValidRepositoryAndBranch($url, 'master')); } - /** - * @dataProvider provideFoobar - */ - public function testCheckInvalidRepositoryAndBranch($repository) + #[DataProvider('provideFoobar')] + public function testCheckInvalidRepositoryAndBranch(Repository $repository): void { $url = $repository->getGitDir(); $this->assertFalse(Admin::isValidRepositoryAndBranch($url, 'invalid-branch-name')); } - public function testExistingFile() + public function testExistingFile(): void { $this->expectException(RuntimeException::class); @@ -183,7 +172,7 @@ public function testExistingFile() Admin::init($file, true, self::getOptions()); } - public function testCloneRepository() + public function testCloneRepository(): void { $newDir = self::createTempDir(); $args = []; diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index 6d92547d..3fe66c3f 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -1,6 +1,6 @@ @@ -12,12 +12,13 @@ namespace Gitonomy\Git\Tests; -class BlameTest extends AbstractTest +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; + +class BlameTest extends AbstractTestCase { - /** - * @dataProvider provideFoobar - */ - public function testBlame($repository) + #[DataProvider('provideFoobar')] + public function testBlame(Repository $repository): void { $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md'); @@ -30,10 +31,8 @@ public function testBlame($repository) $this->assertNotEquals(self::INITIAL_COMMIT, $blame->getLine(5)->getCommit()->getHash()); } - /** - * @dataProvider provideFoobar - */ - public function testGroupedBlame($repository) + #[DataProvider('provideFoobar')] + public function testGroupedBlame(Repository $repository): void { $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getGroupedLines(); diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 431a0a46..9c6fa44d 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -1,6 +1,6 @@ @@ -12,40 +12,35 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Blob; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; -class BlobTest extends AbstractTest +class BlobTest extends AbstractTestCase { - const README_FRAGMENT = 'Foo Bar project'; + public const README_FRAGMENT = 'Foo Bar project'; - public function getReadmeBlob($repository) + public function getReadmeBlob(Repository $repository): Blob { return $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('README.md'); } - public function getImageBlob($repository) + public function getImageBlob(Repository $repository): Blob { return $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('image.jpg'); } - /** - * @dataProvider provideFoobar - */ - public function testGetContent($repository) + #[DataProvider('provideFoobar')] + public function testGetContent(Repository $repository): void { $blob = $this->getReadmeBlob($repository); - if (method_exists($this, 'assertStringContainsString')) { - $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); - } else { - $this->assertContains(self::README_FRAGMENT, $blob->getContent()); - } + $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); } - /** - * @dataProvider provideFoobar - */ - public function testNotExisting($repository) + #[DataProvider('provideFoobar')] + public function testNotExisting(Repository $repository): void { $this->expectException(RuntimeException::class); @@ -53,24 +48,16 @@ public function testNotExisting($repository) $blob->getContent(); } - /** - * @dataProvider provideFoobar - */ - public function testGetMimetype($repository) + #[DataProvider('provideFoobar')] + public function testGetMimetype(Repository $repository): void { $blob = $this->getReadmeBlob($repository); - if (method_exists($this, 'assertMatchesRegularExpression')) { - $this->assertMatchesRegularExpression('#text/plain#', $blob->getMimetype()); - } else { - $this->assertRegExp('#text/plain#', $blob->getMimetype()); - } + $this->assertMatchesRegularExpression('#text/plain#', $blob->getMimetype()); } - /** - * @dataProvider provideFoobar - */ - public function testIsText($repository) + #[DataProvider('provideFoobar')] + public function testIsText(Repository $repository): void { $readmeBlob = $this->getReadmeBlob($repository); $this->assertTrue($readmeBlob->isText()); @@ -78,10 +65,8 @@ public function testIsText($repository) $this->assertFalse($imageBlob->isText()); } - /** - * @dataProvider provideFoobar - */ - public function testIsBinary($repository) + #[DataProvider('provideFoobar')] + public function testIsBinary(Repository $repository): void { $readmeBlob = $this->getReadmeBlob($repository); $this->assertFalse($readmeBlob->isBinary()); diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 7d094648..4fdf02dc 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -1,6 +1,6 @@ @@ -18,13 +18,12 @@ use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Repository; use Gitonomy\Git\Tree; +use PHPUnit\Framework\Attributes\DataProvider; -class CommitTest extends AbstractTest +class CommitTest extends AbstractTestCase { - /** - * @dataProvider provideFoobar - */ - public function testGetDiff($repository) + #[DataProvider('provideFoobar')] + public function testGetDiff(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); @@ -33,20 +32,16 @@ public function testGetDiff($repository) $this->assertInstanceOf(Diff::class, $diff, 'getDiff() returns a Diff object'); } - /** - * @dataProvider provideFoobar - */ - public function testGetHash($repository) + #[DataProvider('provideFoobar')] + public function testGetHash(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals(self::LONGFILE_COMMIT, $commit->getHash()); } - /** - * @dataProvider provideFoobar - */ - public function testInvalideHashThrowException($repository) + #[DataProvider('provideFoobar')] + public function testInvalideHashThrowException(Repository $repository): void { $this->expectException(ReferenceNotFoundException::class); $this->expectExceptionMessage('Reference not found: "that-hash-doest-not-exists"'); @@ -54,30 +49,24 @@ public function testInvalideHashThrowException($repository) new Commit($repository, 'that-hash-doest-not-exists'); } - /** - * @dataProvider provideFoobar - */ - public function testGetShortHash($repository) + #[DataProvider('provideFoobar')] + public function testGetShortHash(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('4f17752', $commit->getShortHash(), 'Short hash'); } - /** - * @dataProvider provideFoobar - */ - public function testGetParentHashes_WithNoParent($repository) + #[DataProvider('provideFoobar')] + public function testGetParentHashesWithNoParent(Repository $repository): void { $commit = $repository->getCommit(self::INITIAL_COMMIT); $this->assertCount(0, $commit->getParentHashes(), 'No parent on initial commit'); } - /** - * @dataProvider provideFoobar - */ - public function testGetParentHashes_WithOneParent($repository) + #[DataProvider('provideFoobar')] + public function testGetParentHashesWithOneParent(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); $parents = $commit->getParentHashes(); @@ -86,10 +75,8 @@ public function testGetParentHashes_WithOneParent($repository) $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $parents[0], 'Parent hash is correct'); } - /** - * @dataProvider provideFoobar - */ - public function testGetParents_WithOneParent($repository) + #[DataProvider('provideFoobar')] + public function testGetParentsWithOneParent(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); $parents = $commit->getParents(); @@ -99,20 +86,16 @@ public function testGetParents_WithOneParent($repository) $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $parents[0]->getHash(), "First parents's hash is correct"); } - /** - * @dataProvider provideFoobar - */ - public function testGetTreeHash($repository) + #[DataProvider('provideFoobar')] + public function testGetTreeHash(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTreeHash(), 'Tree hash is correct'); } - /** - * @dataProvider provideFoobar - */ - public function testGetTree($repository) + #[DataProvider('provideFoobar')] + public function testGetTree(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); @@ -120,94 +103,72 @@ public function testGetTree($repository) $this->assertEquals('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTree()->getHash(), 'Tree hash is correct'); } - /** - * @dataProvider provideFoobar - */ - public function testGetAuthorName($repository) + #[DataProvider('provideFoobar')] + public function testGetAuthorName(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('alice', $commit->getAuthorName(), 'Author name'); } - /** - * @dataProvider provideFoobar - */ - public function testGetAuthorEmail($repository) + #[DataProvider('provideFoobar')] + public function testGetAuthorEmail(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('alice@example.org', $commit->getAuthorEmail(), 'Author email'); } - /** - * @dataProvider provideFoobar - */ - public function testGetAuthorDate($repository) + #[DataProvider('provideFoobar')] + public function testGetAuthorDate(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('2012-12-31 14:21:03', $commit->getAuthorDate()->format('Y-m-d H:i:s'), 'Author date'); } - /** - * @dataProvider provideFoobar - */ - public function testGetCommitterName($repository) + #[DataProvider('provideFoobar')] + public function testGetCommitterName(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('alice', $commit->getCommitterName(), 'Committer name'); } - /** - * @dataProvider provideFoobar - */ - public function testGetCommitterEmail($repository) + #[DataProvider('provideFoobar')] + public function testGetCommitterEmail(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('alice@example.org', $commit->getCommitterEmail(), 'Committer email'); } - /** - * @dataProvider provideFoobar - */ - public function testGetCommitterDate($repository) + #[DataProvider('provideFoobar')] + public function testGetCommitterDate(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('2012-12-31 14:21:03', $commit->getCommitterDate()->format('Y-m-d H:i:s'), 'Committer date'); } - /** - * @dataProvider provideFoobar - */ - public function testGetMessage($repository) + #[DataProvider('provideFoobar')] + public function testGetMessage(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('add a long file'."\n", $commit->getMessage()); } - /** - * @dataProvider provideFoobar - * - * @param $repository Repository - */ - public function testGetEmptyMessage($repository) + #[DataProvider('provideFoobar')] + public function testGetEmptyMessage(Repository $repository): void { $commit = $repository->getCommit(self::NO_MESSAGE_COMMIT); $this->assertEquals('', $commit->getMessage()); } - /** - * @dataProvider provideFoobar - * - * @param $repository Repository - */ - public function testGetEmptyMessageFromLog($repository) + #[DataProvider('provideFoobar')] + public function testGetEmptyMessageFromLog(Repository $repository): void { $commit = $repository->getCommit(self::NO_MESSAGE_COMMIT); $commitMessageFromLog = $commit->getLog()->getCommits()[0]->getMessage(); @@ -218,19 +179,16 @@ public function testGetEmptyMessageFromLog($repository) /** * This test ensures that GPG signed commits does not break the reading of a commit * message. - * - * @dataProvider provideFoobar */ - public function testGetSignedMessage($repository) + #[DataProvider('provideFoobar')] + public function testGetSignedMessage(Repository $repository): void { $commit = $repository->getCommit(self::SIGNED_COMMIT); $this->assertEquals('signed commit'."\n", $commit->getMessage()); } - /** - * @dataProvider provideFoobar - */ - public function testGetShortMessage($repository) + #[DataProvider('provideFoobar')] + public function testGetShortMessage(Repository $repository): void { // tests with a multi-line message $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); @@ -247,13 +205,11 @@ public function testGetShortMessage($repository) $this->assertEquals('Add!!!', $commit->getShortMessage(1, true, '!!!')); } - /** - * @dataProvider provideFoobar - */ - public function testGetBodyMessage($repository) + #[DataProvider('provideFoobar')] + public function testGetBodyMessage(Repository $repository): void { $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); - $nl = chr(10); + $nl = \chr(10); $message = "If you want to know everything,{$nl}I ran something like `chmox +x test.sh`{$nl}{$nl}Hello and good bye.{$nl}"; $this->assertEquals($message, $commit->getBodyMessage()); @@ -262,10 +218,8 @@ public function testGetBodyMessage($repository) $this->assertEquals('', $commit->getBodyMessage()); } - /** - * @dataProvider provideFoobar - */ - public function testGetIncludingBranchesException($repository) + #[DataProvider('provideFoobar')] + public function testGetIncludingBranchesException(Repository $repository): void { $this->expectException(InvalidArgumentException::class); @@ -274,27 +228,23 @@ public function testGetIncludingBranchesException($repository) $commit->getIncludingBranches(false, false); } - /** - * @dataProvider provideFoobar - */ - public function testGetIncludingBranches($repository) + #[DataProvider('provideFoobar')] + public function testGetIncludingBranches(Repository $repository): void { $commit = $repository->getCommit(self::INITIAL_COMMIT); $branches = $commit->getIncludingBranches(true, false); - $this->assertCount(count($repository->getReferences()->getLocalBranches()), $branches); + $this->assertCount(\count($repository->getReferences()->getLocalBranches()), $branches); $branches = $commit->getIncludingBranches(true, true); - $this->assertCount(count($repository->getReferences()->getBranches()), $branches); + $this->assertCount(\count($repository->getReferences()->getBranches()), $branches); $branches = $commit->getIncludingBranches(false, true); - $this->assertCount(count($repository->getReferences()->getRemoteBranches()), $branches); + $this->assertCount(\count($repository->getReferences()->getRemoteBranches()), $branches); } - /** - * @dataProvider provideFoobar - */ - public function testGetLastModification($repository) + #[DataProvider('provideFoobar')] + public function testGetLastModification(Repository $repository): void { $commit = $repository->getCommit(self::LONGFILE_COMMIT); @@ -304,20 +254,16 @@ public function testGetLastModification($repository) $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $lastModification->getHash(), 'Last modification is current commit'); } - /** - * @dataProvider provideFoobar - */ - public function testMergeCommit($repository) + #[DataProvider('provideFoobar')] + public function testMergeCommit(Repository $repository): void { $commit = $repository->getCommit(self::MERGE_COMMIT); $this->assertEquals("Merge branch 'authors'", $commit->getSubjectMessage()); } - /** - * @dataProvider provideFoobar - */ - public function testEncoding($repository) + #[DataProvider('provideFoobar')] + public function testEncoding(Repository $repository): void { $commit = $repository->getCommit(self::ENCODING_COMMIT); diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 9703bdde..a79fd4bb 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -1,6 +1,6 @@ @@ -15,19 +15,18 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Diff\File; use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; -class DiffTest extends AbstractTest +class DiffTest extends AbstractTestCase { - const DELETE_COMMIT = '519d5693c72c925cd59205d9f11e9fa1d550028b'; - const CREATE_COMMIT = 'e6fa3c792facc06faa049a6938c84c411954deb5'; - const RENAME_COMMIT = '6640e0ef31518054847a1876328e26ee64083e0a'; - const CHANGEMODE_COMMIT = '93da965f58170f13017477b9a608657e87e23230'; - const FILE_WITH_UMLAUTS_COMMIT = '8defb9217692dc1f4c18e05e343ca91cf5047702'; - - /** - * @dataProvider provideFoobar - */ - public function testSerialization($repository) + public const DELETE_COMMIT = '519d5693c72c925cd59205d9f11e9fa1d550028b'; + public const CREATE_COMMIT = 'e6fa3c792facc06faa049a6938c84c411954deb5'; + public const RENAME_COMMIT = '6640e0ef31518054847a1876328e26ee64083e0a'; + public const CHANGEMODE_COMMIT = '93da965f58170f13017477b9a608657e87e23230'; + public const FILE_WITH_UMLAUTS_COMMIT = '8defb9217692dc1f4c18e05e343ca91cf5047702'; + + #[DataProvider('provideFoobar')] + public function testSerialization(Repository $repository): void { $data = $repository->getCommit(self::CREATE_COMMIT)->getDiff()->toArray(); $diff = Diff::fromArray($data); @@ -35,36 +34,15 @@ public function testSerialization($repository) $this->verifyCreateCommitDiff($diff); } - /** - * @dataProvider provideFoobar - */ - public function testGetFiles_Addition($repository) + #[DataProvider('provideFoobar')] + public function testGetFilesAddition(Repository $repository): void { $diff = $repository->getCommit(self::CREATE_COMMIT)->getDiff(); $this->verifyCreateCommitDiff($diff); } - protected function verifyCreateCommitDiff(Diff $diff) - { - $files = $diff->getFiles(); - - $this->assertCount(2, $files, '1 file in diff'); - - $this->assertTrue($files[0]->isCreation(), 'script_A.php created'); - - $this->assertEquals(null, $files[0]->getOldName(), 'First file name is a new file'); - $this->assertEquals('script_A.php', $files[0]->getNewName(), 'First file name is script_A.php'); - $this->assertEquals(null, $files[0]->getOldMode(), 'First file mode is a new file'); - $this->assertEquals('100644', $files[0]->getNewMode(), 'First file mode is correct'); - - $this->assertEquals(1, $files[0]->getAdditions(), '1 line added'); - $this->assertEquals(0, $files[0]->getDeletions(), '0 lines deleted'); - } - - /** - * @dataProvider provideFoobar - */ - public function testGetFiles_Modification($repository) + #[DataProvider('provideFoobar')] + public function testGetFilesModification(Repository $repository): void { $files = $repository->getCommit(self::BEFORE_LONGFILE_COMMIT)->getDiff()->getFiles(); @@ -82,10 +60,8 @@ public function testGetFiles_Modification($repository) $this->assertEquals(0, $files[0]->getDeletions(), '0 lines deleted'); } - /** - * @dataProvider provideFoobar - */ - public function testGetFiles_Deletion($repository) + #[DataProvider('provideFoobar')] + public function testGetFilesDeletion(Repository $repository): void { $files = $repository->getCommit(self::DELETE_COMMIT)->getDiff()->getFiles(); @@ -96,10 +72,8 @@ public function testGetFiles_Deletion($repository) $this->assertEquals(1, $files[0]->getDeletions(), '1 line deleted'); } - /** - * @dataProvider provideFoobar - */ - public function testGetFiles_Rename($repository) + #[DataProvider('provideFoobar')] + public function testGetFilesRename(Repository $repository): void { $files = $repository->getCommit(self::RENAME_COMMIT)->getDiff()->getFiles(); @@ -112,10 +86,8 @@ public function testGetFiles_Rename($repository) $this->assertFalse($files[0]->isChangeMode()); } - /** - * @dataProvider provideFoobar - */ - public function testGetFiles_Changemode($repository) + #[DataProvider('provideFoobar')] + public function testGetFilesChangemode(Repository $repository): void { $files = $repository->getCommit(self::CHANGEMODE_COMMIT)->getDiff()->getFiles(); @@ -128,10 +100,8 @@ public function testGetFiles_Changemode($repository) $this->assertFalse($files[0]->isRename()); } - /** - * @dataProvider provideFoobar - */ - public function testDiffRangeParse($repository) + #[DataProvider('provideFoobar')] + public function testDiffRangeParse(Repository $repository): void { $files = $repository->getCommit(self::CREATE_COMMIT)->getDiff()->getFiles(); @@ -144,35 +114,25 @@ public function testDiffRangeParse($repository) $this->assertSame(1, $changes[0]->getRangeNewCount()); } - /** - * @dataProvider provideFoobar - */ - public function testWorksWithUmlauts($repository) + #[DataProvider('provideFoobar')] + public function testWorksWithUmlauts(Repository $repository): void { $files = $repository->getCommit(self::FILE_WITH_UMLAUTS_COMMIT)->getDiff()->getFiles(); $this->assertSame('file_with_umlauts_\303\244\303\266\303\274', $files[0]->getNewName()); } - public function testDeleteFileWithoutRaw() + public function testDeleteFileWithoutRaw(): void { - $deprecationCalled = false; - $self = $this; - set_error_handler(function (int $errno, string $errstr) use ($self, &$deprecationCalled): void { - $deprecationCalled = true; - $self->assertSame('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', $errstr); - }, E_USER_DEPRECATED); + $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); $diff = Diff::parse(<<<'DIFF' - diff --git a/test b/test - deleted file mode 100644 - index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 + diff --git a/test b/test + deleted file mode 100644 + index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 - DIFF); + DIFF); $firstFile = $diff->getFiles()[0]; - restore_exception_handler(); - - $this->assertTrue($deprecationCalled); $this->assertFalse($firstFile->isCreation()); $this->assertTrue($firstFile->isDeletion()); $this->assertFalse($firstFile->isChangeMode()); @@ -180,26 +140,18 @@ public function testDeleteFileWithoutRaw() $this->assertNull($firstFile->getNewIndex()); } - public function testModeChangeFileWithoutRaw() + public function testModeChangeFileWithoutRaw(): void { - $deprecationCalled = false; - $self = $this; - set_error_handler(function (int $errno, string $errstr) use ($self, &$deprecationCalled): void { - $deprecationCalled = true; - $self->assertSame('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', $errstr); - }, E_USER_DEPRECATED); + $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); $diff = Diff::parse(<<<'DIFF' - diff --git a/a.out b/a.out - old mode 100755 - new mode 100644 + diff --git a/a.out b/a.out + old mode 100755 + new mode 100644 - DIFF); + DIFF); $firstFile = $diff->getFiles()[0]; - restore_exception_handler(); - - $this->assertTrue($deprecationCalled); $this->assertFalse($firstFile->isCreation()); $this->assertFalse($firstFile->isDeletion()); $this->assertTrue($firstFile->isChangeMode()); @@ -207,30 +159,30 @@ public function testModeChangeFileWithoutRaw() $this->assertSame('', $firstFile->getNewIndex()); } - public function testModeChangeFileWithRaw() + public function testModeChangeFileWithRaw(): void { $deprecationCalled = false; - set_error_handler(function (int $errno, string $errstr) use (&$deprecationCalled): void { + set_error_handler(static function (int $errno, string $errstr) use (&$deprecationCalled): void { $deprecationCalled = true; - }, E_USER_DEPRECATED); + }, \E_USER_DEPRECATED); $diff = Diff::parse(<<<'DIFF' - :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M testfile - :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 M testfile2 + :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M testfile + :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 M testfile2 - diff --git a/testfile b/testfile - old mode 100644 - new mode 100755 - diff --git a/testfile2 b/testfile2 - old mode 100644 - new mode 100755 + diff --git a/testfile b/testfile + old mode 100644 + new mode 100755 + diff --git a/testfile2 b/testfile2 + old mode 100644 + new mode 100755 - DIFF); + DIFF); $files = $diff->getFiles(); $firstFile = $files[0]; $secondFile = $files[1]; - restore_exception_handler(); + restore_error_handler(); $this->assertFalse($deprecationCalled); @@ -247,21 +199,21 @@ public function testModeChangeFileWithRaw() $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getNewIndex()); } - public function testThrowErrorOnBlobGetWithoutIndex() + public function testThrowErrorOnBlobGetWithoutIndex(): void { - $repository = $this->getMockBuilder(Repository::class)->disableOriginalConstructor()->getMock(); + $repository = self::createEmptyRepository(); $file = new File('oldName', 'newName', '100755', '100644', '', '', false); $file->setRepository($repository); try { $file->getOldBlob(); - } catch(\RuntimeException $exception) { + } catch (\RuntimeException $exception) { $this->assertSame('Index is missing to return Blob object.', $exception->getMessage()); } try { $file->getNewBlob(); - } catch(\RuntimeException $exception) { + } catch (\RuntimeException $exception) { $this->assertSame('Index is missing to return Blob object.', $exception->getMessage()); } @@ -272,8 +224,10 @@ public function testThrowErrorOnBlobGetWithoutIndex() $this->assertSame('', $file->getNewIndex()); } - public function testEmptyNewFile() + public function testEmptyNewFile(): void { + $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); + $diff = Diff::parse("diff --git a/test b/test\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n"); $firstFile = $diff->getFiles()[0]; @@ -283,8 +237,10 @@ public function testEmptyNewFile() $this->assertNull($firstFile->getOldName()); } - public function testEmptyOldFile() + public function testEmptyOldFile(): void { + $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); + $diff = Diff::parse("diff --git a/test b/test\ndeleted file mode 100644\nindex e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000\n"); $firstFile = $diff->getFiles()[0]; @@ -293,4 +249,21 @@ public function testEmptyOldFile() $this->assertNull($firstFile->getNewName()); $this->assertSame('test', $firstFile->getOldName()); } + + protected function verifyCreateCommitDiff(Diff $diff): void + { + $files = $diff->getFiles(); + + $this->assertCount(2, $files, '1 file in diff'); + + $this->assertTrue($files[0]->isCreation(), 'script_A.php created'); + + $this->assertNull($files[0]->getOldName(), 'First file name is a new file'); + $this->assertEquals('script_A.php', $files[0]->getNewName(), 'First file name is script_A.php'); + $this->assertNull($files[0]->getOldMode(), 'First file mode is a new file'); + $this->assertEquals('100644', $files[0]->getNewMode(), 'First file mode is correct'); + + $this->assertEquals(1, $files[0]->getAdditions(), '1 line added'); + $this->assertEquals(0, $files[0]->getDeletions(), '0 lines deleted'); + } } diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 1b09fe55..7256db1b 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -1,6 +1,6 @@ @@ -14,35 +14,36 @@ use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\BeforeClass; +use PHPUnit\Framework\Attributes\DataProvider; -class HooksTest extends AbstractTest +class HooksTest extends AbstractTestCase { - private static $symlinkOnWindows = null; + private static ?bool $symlinkOnWindows = null; - /** - * @beforeClass - */ - public static function setUpWindows() + #[BeforeClass] + public static function setUpWindows(): void { - if (defined('PHP_WINDOWS_VERSION_MAJOR')) { + if (\defined('PHP_WINDOWS_VERSION_MAJOR')) { self::$symlinkOnWindows = true; $originDir = tempnam(sys_get_temp_dir(), 'sl'); $targetDir = tempnam(sys_get_temp_dir(), 'sl'); if (true !== @symlink($originDir, $targetDir)) { $report = error_get_last(); - if (is_array($report) && false !== strpos($report['message'], 'error code(1314)')) { + if (\is_array($report) && str_contains($report['message'], 'error code(1314)')) { self::$symlinkOnWindows = false; } } } } - public function hookPath($repository, $hook) + public function hookPath(Repository $repository, string $hook): string { return $repository->getGitDir().'/hooks/'.$hook; } - public function touchHook($repository, $hook, $content = '') + public function touchHook(Repository $repository, string $hook, string $content = ''): string { $path = $this->hookPath($repository, $hook); file_put_contents($path, $content); @@ -50,62 +51,49 @@ public function touchHook($repository, $hook, $content = '') return $path; } - public function assertHasHook($repository, $hook) + public function assertHasHook(Repository $repository, string $hook): void { $file = $this->hookPath($repository, $hook); - $this->assertTrue($repository->getHooks()->has($hook), "hook $hook in repository"); + $this->assertTrue($repository->getHooks()->has($hook), "hook {$hook} in repository"); - $this->assertFileExists($file, "Hook $hook is present"); + $this->assertFileExists($file, "Hook {$hook} is present"); } - public function assertNoHook($repository, $hook) + public function assertNoHook(Repository $repository, string $hook): void { $file = $this->hookPath($repository, $hook); - $this->assertFalse($repository->getHooks()->has($hook), "No hook $hook in repository"); - - if (method_exists($this, 'assertFileDoesNotExist')) { - $this->assertFileDoesNotExist($file, "Hook $hook is not present"); - } else { - $this->assertFileNotExists($file, "Hook $hook is not present"); - } + $this->assertFalse($repository->getHooks()->has($hook), "No hook {$hook} in repository"); + $this->assertFileDoesNotExist($file, "Hook {$hook} is not present"); } - /** - * @dataProvider provideFoobar - */ - public function testHas($repository) + #[DataProvider('provideFoobar')] + public function testHas(Repository $repository): void { $this->assertNoHook($repository, 'foo'); $this->touchHook($repository, 'foo'); $this->assertHasHook($repository, 'foo'); } - /** - * @dataProvider provideFoobar - */ - public function testGet_InvalidName_ThrowsException($repository) + #[DataProvider('provideFoobar')] + public function testGetInvalidNameThrowsException(Repository $repository): void { $this->expectException(InvalidArgumentException::class); $repository->getHooks()->get('foo'); } - /** - * @dataProvider provideFoobar - */ - public function testGet($repository) + #[DataProvider('provideFoobar')] + public function testGet(Repository $repository): void { $this->touchHook($repository, 'foo', 'foobar'); $this->assertEquals('foobar', $repository->getHooks()->get('foo')); } - /** - * @dataProvider provideFoobar - */ - public function testSymlink($repository) + #[DataProvider('provideFoobar')] + public function testSymlink(Repository $repository): void { $this->markAsSkippedIfSymlinkIsMissing(); @@ -121,10 +109,8 @@ public function testSymlink($repository) ); } - /** - * @dataProvider provideFoobar - */ - public function testSymlink_WithExisting_ThrowsLogicException($repository) + #[DataProvider('provideFoobar')] + public function testSymlinkWithExistingThrowsLogicException(Repository $repository): void { $this->expectException(LogicException::class); @@ -139,10 +125,8 @@ public function testSymlink_WithExisting_ThrowsLogicException($repository) $repository->getHooks()->setSymlink('foo', $file); } - /** - * @dataProvider provideFoobar - */ - public function testSet($repository) + #[DataProvider('provideFoobar')] + public function testSet(Repository $repository): void { $file = $this->hookPath($repository, 'foo'); $repository->getHooks()->set('foo', 'bar'); @@ -150,13 +134,11 @@ public function testSet($repository) $this->assertEquals('bar', file_get_contents($file), 'Hook content is correct'); $perms = fileperms($file); - $this->assertEquals(defined('PHP_WINDOWS_VERSION_BUILD') ? 0666 : 0777, $perms & 0777, 'Hook permissions are correct'); + $this->assertEquals(\defined('PHP_WINDOWS_VERSION_BUILD') ? 0o666 : 0o777, $perms & 0o777, 'Hook permissions are correct'); } - /** - * @dataProvider provideFoobar - */ - public function testSet_Existing_ThrowsLogicException($repository) + #[DataProvider('provideFoobar')] + public function testSetExistingThrowsLogicException(Repository $repository): void { $repository->getHooks()->set('foo', 'bar'); @@ -165,40 +147,32 @@ public function testSet_Existing_ThrowsLogicException($repository) $repository->getHooks()->set('foo', 'bar'); } - /** - * @dataProvider provideFoobar - */ - public function testRemove($repository) + #[DataProvider('provideFoobar')] + public function testRemove(Repository $repository): void { $file = $this->hookPath($repository, 'foo'); touch($file); $repository->getHooks()->remove('foo'); - if (method_exists($this, 'assertFileDoesNotExist')) { - $this->assertFileDoesNotExist($file); - } else { - $this->assertFileNotExists($file); - } + $this->assertFileDoesNotExist($file); } - /** - * @dataProvider provideFoobar - */ - public function testRemove_NotExisting_ThrowsLogicException($repository) + #[DataProvider('provideFoobar')] + public function testRemoveNotExistingThrowsLogicException(Repository $repository): void { $this->expectException(LogicException::class); $repository->getHooks()->remove('foo'); } - private function markAsSkippedIfSymlinkIsMissing() + private function markAsSkippedIfSymlinkIsMissing(): void { - if (!function_exists('symlink')) { + if (!\function_exists('symlink')) { $this->markTestSkipped('symlink is not supported'); } - if (defined('PHP_WINDOWS_VERSION_MAJOR') && false === self::$symlinkOnWindows) { + if (\defined('PHP_WINDOWS_VERSION_MAJOR') && false === self::$symlinkOnWindows) { $this->markTestSkipped('symlink requires "Create symbolic links" privilege on windows'); } } diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index a02184dd..74b94eaf 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -1,6 +1,6 @@ @@ -13,13 +13,13 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Parser\LogParser; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; -class LogTest extends AbstractTest +class LogTest extends AbstractTestCase { - /** - * @dataProvider provideFoobar - */ - public function testRevisionAndPath($repository) + #[DataProvider('provideFoobar')] + public function testRevisionAndPath(Repository $repository): void { $logReadme = $repository->getLog(self::LONGFILE_COMMIT, 'README'); $logImage = $repository->getLog(self::LONGFILE_COMMIT, 'image.jpg'); @@ -28,10 +28,8 @@ public function testRevisionAndPath($repository) $this->assertCount(2, $logImage); } - /** - * @dataProvider provideFoobar - */ - public function testGetCommits($repository) + #[DataProvider('provideFoobar')] + public function testGetCommits(Repository $repository): void { $log = $repository->getLog(self::LONGFILE_COMMIT, null, null, 3); @@ -39,33 +37,27 @@ public function testGetCommits($repository) $this->assertCount(3, $commits, '3 commits in log'); $this->assertEquals(self::LONGFILE_COMMIT, $commits[0]->getHash(), 'First is requested one'); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commits[1]->getHash(), "Second is longfile parent\'s"); + $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commits[1]->getHash(), "Second is longfile parent\\'s"); } - /** - * @dataProvider provideFoobar - */ - public function testCountCommits($repository) + #[DataProvider('provideFoobar')] + public function testCountCommits(Repository $repository): void { $log = $repository->getLog(self::LONGFILE_COMMIT, null, 2, 3); $this->assertEquals(8, $log->countCommits(), '8 commits found in history'); } - /** - * @dataProvider provideFoobar - */ - public function testCountAllCommits($repository) + #[DataProvider('provideFoobar')] + public function testCountAllCommits(Repository $repository): void { - $log = $log = $repository->getLog(); + $log = $repository->getLog(); $this->assertGreaterThan(100, $log->countCommits(), 'Returns all commits from all branches'); } - /** - * @dataProvider provideFoobar - */ - public function testIterable($repository) + #[DataProvider('provideFoobar')] + public function testIterable(Repository $repository): void { $log = $repository->getLog(self::LONGFILE_COMMIT); @@ -73,15 +65,15 @@ public function testIterable($repository) foreach ($log as $entry) { $hash = array_shift($expectedHashes); $this->assertEquals($hash, $entry->getHash()); - if (count($expectedHashes) == 0) { + if (0 == \count($expectedHashes)) { break; } } } - public function testFirstMessageEmpty() + public function testFirstMessageEmpty(): void { - $repository = $this->createEmptyRepository(false); + $repository = self::createEmptyRepository(false); $repository->run('config', ['--local', 'user.name', '"Unit Test"']); $repository->run('config', ['--local', 'user.email', '"unit_test@unit-test.com"']); @@ -97,31 +89,31 @@ public function testFirstMessageEmpty() public function testParsesCommitsWithAndWithoutGitButlerHeaders(): void { $logContent = <<<'EOT' - commit 1111111111111111111111111111111111111111 - tree abcdefabcdefabcdefabcdefabcdefabcdefabcd - author John Doe 1620000000 +0000 - committer John Doe 1620000000 +0000 - - First commit message - - commit 2222222222222222222222222222222222222222 - tree abcdefabcdefabcdefabcdefabcdefabcdefabcd - parent 1111111111111111111111111111111111111111 - author Jane Smith 1620003600 +0000 - committer Jane Smith 1620003600 +0000 - gitbutler-headers-version: 2 - gitbutler-change-id: a7bd485c-bae6-45b2-910f-163c78aace81 - - Commit with GitButler headers - - commit 3333333333333333333333333333333333333333 - tree abcdefabcdefabcdefabcdefabcdefabcdefabcd - author John Doe 1620007200 +0000 - committer Jane Smith 1620007200 +0000 - - Another commit without GitButler headers - - EOT; + commit 1111111111111111111111111111111111111111 + tree abcdefabcdefabcdefabcdefabcdefabcdefabcd + author John Doe 1620000000 +0000 + committer John Doe 1620000000 +0000 + + First commit message + + commit 2222222222222222222222222222222222222222 + tree abcdefabcdefabcdefabcdefabcdefabcdefabcd + parent 1111111111111111111111111111111111111111 + author Jane Smith 1620003600 +0000 + committer Jane Smith 1620003600 +0000 + gitbutler-headers-version: 2 + gitbutler-change-id: a7bd485c-bae6-45b2-910f-163c78aace81 + + Commit with GitButler headers + + commit 3333333333333333333333333333333333333333 + tree abcdefabcdefabcdefabcdefabcdefabcdefabcd + author John Doe 1620007200 +0000 + committer Jane Smith 1620007200 +0000 + + Another commit without GitButler headers + + EOT; $parser = new LogParser(); $parser->parse($logContent); diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index 4490a3f9..dd115d0e 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -1,6 +1,6 @@ @@ -13,15 +13,27 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\PushReference; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; -class PushReferenceTest extends AbstractTest +class PushReferenceTest extends AbstractTestCase { - const CREATE = 1; - const DELETE = 2; - const FORCE = 4; - const FAST_FORWARD = 8; + public const CREATE = 1; + public const DELETE = 2; + public const FORCE = 4; + public const FAST_FORWARD = 8; - public function provideIsers() + #[DataProvider('provideIsers')] + public function testIsers(string $reference, string $before, string $after, int $mask): void + { + $reference = new PushReference(self::createFoobarRepository(), $reference, $before, $after); + $this->assertEquals($mask & self::CREATE, $reference->isCreate(), 'Create value is correct.'); + $this->assertEquals($mask & self::DELETE, $reference->isDelete(), 'Delete value is correct.'); + $this->assertEquals($mask & self::FORCE, $reference->isForce(), 'Force value is correct.'); + $this->assertEquals($mask & self::FAST_FORWARD, $reference->isFastForward(), 'FastForward value is correct.'); + } + + public static function provideIsers(): array { // mask: force fastforward create delete return [ @@ -32,22 +44,8 @@ public function provideIsers() ]; } - /** - * @dataProvider provideIsers - */ - public function testIsers($reference, $before, $after, $mask) - { - $reference = new PushReference(self::createFoobarRepository(), $reference, $before, $after); - $this->assertEquals($mask & self::CREATE, $reference->isCreate(), 'Create value is correct.'); - $this->assertEquals($mask & self::DELETE, $reference->isDelete(), 'Delete value is correct.'); - $this->assertEquals($mask & self::FORCE, $reference->isForce(), 'Force value is correct.'); - $this->assertEquals($mask & self::FAST_FORWARD, $reference->isFastForward(), 'FastForward value is correct.'); - } - - /** - * @dataProvider provideFoobar - */ - public function testLog($repository) + #[DataProvider('provideFoobar')] + public function testLog(Repository $repository): void { $ref = new PushReference($repository, 'foo', self::INITIAL_COMMIT, self::LONGFILE_COMMIT); @@ -58,10 +56,9 @@ public function testLog($repository) /** * This test ensures that GPG signed requests does not break the reading of commit logs. - * - * @dataProvider provideFoobar */ - public function testSignedLog($repository) + #[DataProvider('provideFoobar')] + public function testSignedLog(Repository $repository): void { $ref = new PushReference($repository, 'foo', self::INITIAL_COMMIT, self::SIGNED_COMMIT); $log = $ref->getLog()->getCommits(); @@ -69,10 +66,8 @@ public function testSignedLog($repository) $this->assertEquals('signed commit', $log[0]->getShortMessage(), 'Last commit is correct'); } - /** - * @dataProvider provideFoobar - */ - public function testLogWithExclude($repository) + #[DataProvider('provideFoobar')] + public function testLogWithExclude(Repository $repository): void { $ref = new PushReference($repository, 'foo', PushReference::ZERO, self::LONGFILE_COMMIT); diff --git a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php index f9b9c82c..a6266631 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php @@ -1,6 +1,6 @@ @@ -13,13 +13,12 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; -class ReferenceBagTest extends AbstractTest +class ReferenceBagTest extends AbstractTestCase { - /** - * @dataProvider provideFoobar - */ - public function testUnknownReference(Repository $repository) + #[DataProvider('provideFoobar')] + public function testUnknownReference(Repository $repository): void { $hash = $repository->getLog()->getSingleCommit()->getHash(); @@ -29,11 +28,7 @@ public function testUnknownReference(Repository $repository) $repository->run('update-ref', ['refs/notes/gtm-data', $hash]); $refs = $repository->getReferences()->getAll(); - if (method_exists($this, 'assertIsArray')) { - $this->assertIsArray($refs); - } else { - $this->assertInternalType('array', $refs); - } + $this->assertIsArray($refs); // Check that at least it has the master ref $this->assertArrayHasKey('refs/heads/master', $refs); @@ -45,10 +40,8 @@ public function testUnknownReference(Repository $repository) $this->assertArrayNotHasKey('refs/notes/gtm-data', $refs); } - /** - * @dataProvider provideEmpty - */ - public function testEmptyRepo(Repository $repository) + #[DataProvider('provideEmpty')] + public function testEmptyRepo(Repository $repository): void { $refs = $repository->getReferences()->getAll(); $this->assertSame([], $refs); diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 0f975577..f164c349 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -1,6 +1,6 @@ @@ -15,22 +15,20 @@ use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Tag; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; -class ReferenceTest extends AbstractTest +class ReferenceTest extends AbstractTestCase { - /** - * @dataProvider provideEmpty - */ - public function testEmptyRepository($repository) + #[DataProvider('provideEmpty')] + public function testEmptyRepository(Repository $repository): void { $this->assertCount(0, $repository->getReferences()); $this->assertEquals([], $repository->getReferences()->getAll()); } - /** - * @dataProvider provideFoobar - */ - public function testGetBranch($repository) + #[DataProvider('provideFoobar')] + public function testGetBranch(Repository $repository): void { $branch = $repository->getReferences()->getBranch('master'); @@ -38,38 +36,30 @@ public function testGetBranch($repository) $this->assertEquals($branch->getCommitHash(), $branch->getCommit()->getHash(), 'Hash is correctly resolved'); } - /** - * @dataProvider provideFoobar - */ - public function testHasBranch($repository) + #[DataProvider('provideFoobar')] + public function testHasBranch(Repository $repository): void { $this->assertTrue($repository->getReferences()->hasBranch('master'), 'Branch master exists'); $this->assertFalse($repository->getReferences()->hasBranch('foobar'), 'Branch foobar does not exists'); } - /** - * @dataProvider provideFoobar - */ - public function testHasTag($repository) + #[DataProvider('provideFoobar')] + public function testHasTag(Repository $repository): void { $this->assertTrue($repository->getReferences()->hasTag('0.1'), 'Tag 0.1 exists'); $this->assertFalse($repository->getReferences()->hasTag('foobar'), 'Tag foobar does not exists'); } - /** - * @dataProvider provideFoobar - */ - public function testGetBranch_NotExisting_Error($repository) + #[DataProvider('provideFoobar')] + public function testGetBranchNotExistingError(Repository $repository): void { $this->expectException(ReferenceNotFoundException::class); $repository->getReferences()->getBranch('notexisting'); } - /** - * @dataProvider provideFoobar - */ - public function testGetTag($repository) + #[DataProvider('provideFoobar')] + public function testGetTag(Repository $repository): void { $tag = $repository->getReferences()->getTag('0.1'); @@ -80,10 +70,8 @@ public function testGetTag($repository) $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommit()->getHash(), 'Commit hash is correct'); } - /** - * @dataProvider provideFoobar - */ - public function testAnnotatedTag($repository) + #[DataProvider('provideFoobar')] + public function testAnnotatedTag(Repository $repository): void { $tag = $repository->getReferences()->getTag('annotated'); @@ -106,20 +94,16 @@ public function testAnnotatedTag($repository) $this->assertEquals('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommit()->getHash(), 'Tag commit is correct'); } - /** - * @dataProvider provideFoobar - */ - public function testGetTag_NotExisting_Error($repository) + #[DataProvider('provideFoobar')] + public function testGetTagNotExistingError(Repository $repository): void { $this->expectException(ReferenceNotFoundException::class); $repository->getReferences()->getTag('notexisting'); } - /** - * @dataProvider provideFoobar - */ - public function testResolve($repository) + #[DataProvider('provideFoobar')] + public function testResolve(Repository $repository): void { $commit = $repository->getReferences()->getTag('0.1')->getCommit(); $resolved = $repository->getReferences()->resolve($commit->getHash()); @@ -128,10 +112,8 @@ public function testResolve($repository) $this->assertInstanceOf(Tag::class, reset($resolved), 'Resolved object is a tag'); } - /** - * @dataProvider provideFoobar - */ - public function testResolveTags($repository) + #[DataProvider('provideFoobar')] + public function testResolveTags(Repository $repository): void { $commit = $repository->getReferences()->getTag('0.1')->getCommit(); $resolved = $repository->getReferences()->resolveTags($commit->getHash()); @@ -140,10 +122,8 @@ public function testResolveTags($repository) $this->assertInstanceOf(Tag::class, reset($resolved), 'Resolved object is a tag'); } - /** - * @dataProvider provideFoobar - */ - public function testResolveBranches($repository) + #[DataProvider('provideFoobar')] + public function testResolveBranches(Repository $repository): void { $master = $repository->getReferences()->getBranch('master'); @@ -158,30 +138,24 @@ public function testResolveBranches($repository) $this->assertInstanceOf(Branch::class, reset($resolved), 'Resolved object is a branch'); } - /** - * @dataProvider provideFoobar - */ - public function testCountable($repository) + #[DataProvider('provideFoobar')] + public function testCountable(Repository $repository): void { - $this->assertGreaterThanOrEqual(2, count($repository->getReferences()), 'At least two references in repository'); + $this->assertGreaterThanOrEqual(2, \count($repository->getReferences()), 'At least two references in repository'); } - /** - * @dataProvider provideFoobar - */ - public function testIterable($repository) + #[DataProvider('provideFoobar')] + public function testIterable(Repository $repository): void { $i = 0; foreach ($repository->getReferences() as $ref) { - $i++; + ++$i; } $this->assertGreaterThanOrEqual(2, $i, 'At least two references in repository'); } - /** - * @dataProvider provideFoobar - */ - public function testCreateAndDeleteTag($repository) + #[DataProvider('provideFoobar')] + public function testCreateAndDeleteTag(Repository $repository): void { $references = $repository->getReferences(); $tag = $references->createTag('0.0', self::INITIAL_COMMIT); @@ -194,10 +168,8 @@ public function testCreateAndDeleteTag($repository) $this->assertFalse($references->hasTag('0.0'), 'Tag 0.0 removed'); } - /** - * @dataProvider provideFoobar - */ - public function testCreateAndDeleteBranch($repository) + #[DataProvider('provideFoobar')] + public function testCreateAndDeleteBranch(Repository $repository): void { $references = $repository->getReferences(); $branch = $references->createBranch('foobar', self::INITIAL_COMMIT); @@ -210,10 +182,7 @@ public function testCreateAndDeleteBranch($repository) $this->assertFalse($references->hasBranch('foobar'), 'Branch foobar removed'); } - /** - * @dataProvider provideFoobar - */ - public function testIsBranchMergedToMaster() + public function testIsBranchMergedToMaster(): void { $repository = self::createFoobarRepository(false); diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index fc5c7deb..c0fee88b 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -1,6 +1,6 @@ @@ -14,40 +14,31 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\Exception\RuntimeException; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; +use Psr\Log\LoggerInterface; -class RepositoryTest extends AbstractTest +class RepositoryTest extends AbstractTestCase { - use ProphecyTrait; - - /** - * @dataProvider provideFoobar - */ - public function testGetBlobWithExistingWorks($repository) + #[DataProvider('provideFoobar')] + public function testGetBlobWithExistingWorks(Repository $repository): void { $blob = $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('README.md'); $this->assertInstanceOf(Blob::class, $blob, 'getBlob() returns a Blob object'); - - if (method_exists($this, 'assertStringContainsString')) { - $this->assertStringContainsString('Foo Bar project', $blob->getContent(), 'file is correct'); - } else { - $this->assertContains('Foo Bar project', $blob->getContent(), 'file is correct'); - } + $this->assertStringContainsString('Foo Bar project', $blob->getContent(), 'file is correct'); } - /** - * @dataProvider provideFoobar - */ - public function testGetSize($repository) + #[DataProvider('provideFoobar')] + public function testGetSize(Repository $repository): void { $size = $repository->getSize(); - $this->assertGreaterThanOrEqual(57, $size, 'Repository is at least 57KB'); + // The exact figure depends on git's packing/compression, which varies across versions. + $this->assertGreaterThanOrEqual(40, $size, 'Repository is at least 40KB'); $this->assertLessThan(84, $size, 'Repository is less than 84KB'); } - public function testIsBare() + public function testIsBare(): void { $bare = self::createFoobarRepository(true); $this->assertTrue($bare->isBare(), 'Lib repository is bare'); @@ -56,59 +47,50 @@ public function testIsBare() $this->assertFalse($notBare->isBare(), 'Working copy is not bare'); } - /** - * @dataProvider provideFoobar - */ - public function testGetDescription($repository) + #[DataProvider('provideFoobar')] + public function testGetDescription(Repository $repository): void { $this->assertSame("Unnamed repository; edit this file 'description' to name the repository.\n", $repository->getDescription()); } - /** - * @dataProvider provideFoobar - */ - public function testLoggerOk($repository) + #[DataProvider('provideFoobar')] + public function testLoggerOk(Repository $repository): void { - if (!interface_exists('Psr\Log\LoggerInterface')) { - $this->markTestSkipped(); - } - - $loggerProphecy = $this->prophesize('Psr\Log\LoggerInterface'); - $loggerProphecy - ->info('run command: remote "" ') - ->shouldBeCalledTimes(1); - $loggerProphecy - ->debug(Argument::type('string')) // duration, return code and output - ->shouldBeCalledTimes(3); - - $repository->setLogger($loggerProphecy->reveal()); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('info') + ->with('run command: remote "" ') + ; + $logger->expects($this->exactly(3)) // duration, return code and output + ->method('debug') + ->with($this->isString()) + ; + + $repository->setLogger($logger); $repository->run('remote'); } - /** - * @dataProvider provideFoobar - */ - public function testLoggerNOk($repository) + #[DataProvider('provideFoobar')] + public function testLoggerNOk(Repository $repository): void { - if (!interface_exists('Psr\Log\LoggerInterface')) { - $this->markTestSkipped(); - } - $this->expectException(RuntimeException::class); - $loggerProphecy = $this->prophesize('Psr\Log\LoggerInterface'); - $loggerProphecy - ->info(Argument::type('string')) - ->shouldBeCalledTimes(1); - $loggerProphecy - ->debug(Argument::type('string')) // duration, return code and output - ->shouldBeCalledTimes(3); - $loggerProphecy - ->error(Argument::type('string')) - ->shouldBeCalledTimes(1); - - $repository->setLogger($loggerProphecy->reveal()); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('info') + ->with($this->isString()) + ; + $logger->expects($this->exactly(3)) // duration, return code and output + ->method('debug') + ->with($this->isString()) + ; + $logger->expects($this->once()) + ->method('error') + ->with($this->isString()) + ; + + $repository->setLogger($logger); $repository->run('not-work'); } diff --git a/tests/Gitonomy/Git/Tests/RevisionTest.php b/tests/Gitonomy/Git/Tests/RevisionTest.php index 84711843..1dbbbfc5 100644 --- a/tests/Gitonomy/Git/Tests/RevisionTest.php +++ b/tests/Gitonomy/Git/Tests/RevisionTest.php @@ -1,6 +1,6 @@ @@ -15,14 +15,14 @@ use Gitonomy\Git\Commit; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Log; +use Gitonomy\Git\Repository; use Gitonomy\Git\Revision; +use PHPUnit\Framework\Attributes\DataProvider; -class RevisionTest extends AbstractTest +class RevisionTest extends AbstractTestCase { - /** - * @dataProvider provideFoobar - */ - public function testGetCommit($repository) + #[DataProvider('provideFoobar')] + public function testGetCommit(Repository $repository): void { $revision = $repository->getRevision(self::LONGFILE_COMMIT.'^'); @@ -35,10 +35,8 @@ public function testGetCommit($repository) $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commit->getHash(), 'Resolution is correct'); } - /** - * @dataProvider provideFoobar - */ - public function testGetFailingReference($repository) + #[DataProvider('provideFoobar')] + public function testGetFailingReference(Repository $repository): void { $this->expectException(ReferenceNotFoundException::class); $this->expectExceptionMessage('Can not find revision "non-existent-commit"'); @@ -46,10 +44,8 @@ public function testGetFailingReference($repository) $repository->getRevision('non-existent-commit')->getCommit(); } - /** - * @dataProvider provideFoobar - */ - public function testGetLog($repository) + #[DataProvider('provideFoobar')] + public function testGetLog(Repository $repository): void { $revision = $repository->getRevision(self::LONGFILE_COMMIT); diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index b7ba7d32..fb8a4d6c 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -1,6 +1,6 @@ @@ -14,15 +14,15 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\CommitReference; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; -class TreeTest extends AbstractTest +class TreeTest extends AbstractTestCase { - const PATH_RESOLVING_COMMIT = 'cc06ac171d884282202dff88c1ded499a1f89420'; + public const PATH_RESOLVING_COMMIT = 'cc06ac171d884282202dff88c1ded499a1f89420'; - /** - * @dataProvider provideFooBar - */ - public function testGetEntries($repository) + #[DataProvider('provideFoobar')] + public function testGetEntries(Repository $repository): void { $tree = $repository->getCommit(self::LONGFILE_COMMIT)->getTree(); @@ -35,10 +35,8 @@ public function testGetEntries($repository) $this->assertTrue($entries['README.md'][1] instanceof Blob, 'README.md is a Blob'); } - /** - * @dataProvider provideFooBar - */ - public function testGetCommitReferenceEntries($repository) + #[DataProvider('provideFoobar')] + public function testGetCommitReferenceEntries(Repository $repository): void { $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); @@ -48,10 +46,8 @@ public function testGetCommitReferenceEntries($repository) $this->assertTrue($commits['barbaz'][1] instanceof CommitReference, 'barbaz is a Commit'); } - /** - * @dataProvider provideFooBar - */ - public function testGetTreeEntries($repository) + #[DataProvider('provideFoobar')] + public function testGetTreeEntries(Repository $repository): void { $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); @@ -60,10 +56,8 @@ public function testGetTreeEntries($repository) $this->assertEmpty($trees); } - /** - * @dataProvider provideFooBar - */ - public function testGetBlobEntries($repository) + #[DataProvider('provideFoobar')] + public function testGetBlobEntries(Repository $repository): void { $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); @@ -73,10 +67,8 @@ public function testGetBlobEntries($repository) $this->assertTrue($blobs['README.md'][1] instanceof Blob, 'README.md is a blob'); } - /** - * @dataProvider provideFooBar - */ - public function testResolvePath($repository) + #[DataProvider('provideFoobar')] + public function testResolvePath(Repository $repository): void { $tree = $repository->getCommit(self::PATH_RESOLVING_COMMIT)->getTree(); $path = 'test/a/b/c'; diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index dfa048f9..3e88ebca 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -1,6 +1,6 @@ @@ -17,9 +17,9 @@ use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; -class WorkingCopyTest extends AbstractTest +class WorkingCopyTest extends AbstractTestCase { - public function testNoWorkingCopyInBare() + public function testNoWorkingCopyInBare(): void { $this->expectException(LogicException::class); @@ -29,7 +29,7 @@ public function testNoWorkingCopyInBare() $repo->getWorkingCopy(); } - public function testCheckout() + public function testCheckout(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); @@ -40,7 +40,7 @@ public function testCheckout() $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); } - public function testDiffStaged() + public function testDiffStaged(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); @@ -56,7 +56,7 @@ public function testDiffStaged() $this->assertCount(1, $diffStaged->getFiles()); } - public function testDiffPending() + public function testDiffPending(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); @@ -71,14 +71,14 @@ public function testDiffPending() $this->assertCount(1, $diffPending->getFiles()); } - public function testCheckoutUnexisting() + public function testCheckoutUnexisting(): void { $this->expectException(RuntimeException::class); self::createFoobarRepository(false)->getWorkingCopy()->checkout('foobar'); } - public function testAttachedHead() + public function testAttachedHead(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); @@ -89,7 +89,7 @@ public function testAttachedHead() $this->assertFalse($repository->isHeadDetached(), 'HEAD is not detached'); } - public function testDetachedHead() + public function testDetachedHead(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); @@ -100,7 +100,7 @@ public function testDetachedHead() $this->assertTrue($repository->isHeadDetached(), 'HEAD is detached'); } - public function testGetUntracked() + public function testGetUntracked(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a988f4ab..589c0ffc 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,5 +1,15 @@ + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + require __DIR__.'/../vendor/autoload.php'; if (defined('PHP_WINDOWS_VERSION_BUILD')) { diff --git a/tools/php-cs-fixer/.gitignore b/tools/php-cs-fixer/.gitignore new file mode 100644 index 00000000..57872d0f --- /dev/null +++ b/tools/php-cs-fixer/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/tools/php-cs-fixer/castor.php b/tools/php-cs-fixer/castor.php new file mode 100644 index 00000000..d3a8346b --- /dev/null +++ b/tools/php-cs-fixer/castor.php @@ -0,0 +1,50 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace qa\cs; + +use Castor\Attribute\AsTask; + +use function Castor\context; +use function Castor\exit_code; +use function Castor\run; + +#[AsTask(description: 'Fix CS', aliases: ['cs'])] +function cs(bool $dryRun = false): int +{ + if (!file_exists(__DIR__.'/vendor/autoload.php')) { + install(); + } + + $command = [ + __DIR__.'/vendor/bin/php-cs-fixer', + 'fix', + ]; + + if ($dryRun) { + $command[] = '--dry-run'; + } + + return exit_code($command); +} + +#[AsTask(description: 'install dependencies')] +function install(): void +{ + run(['composer', 'install'], context()->withWorkingDirectory(__DIR__)); +} + +#[AsTask(description: 'Update dependencies')] +function update(): void +{ + run(['composer', 'update'], context()->withWorkingDirectory(__DIR__)); +} diff --git a/tools/php-cs-fixer/composer.json b/tools/php-cs-fixer/composer.json new file mode 100644 index 00000000..2eddc874 --- /dev/null +++ b/tools/php-cs-fixer/composer.json @@ -0,0 +1,11 @@ +{ + "require": { + "friendsofphp/php-cs-fixer": "^3.95.24" + }, + "config": { + "bump-after-update": true, + "platform": { + "php": "8.4" + } + } +} diff --git a/tools/php-cs-fixer/composer.lock b/tools/php-cs-fixer/composer.lock new file mode 100644 index 00000000..59437796 --- /dev/null +++ b/tools/php-cs-fixer/composer.lock @@ -0,0 +1,2766 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c092a0c10c3cc01eb773e9c4efe0ccbb", + "packages": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "ergebnis/agent-detector", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-05-07T08:19:07+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.95.24", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "9a4b8050f8768646bf3693ee7416978ceeca6206" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/9a4b8050f8768646bf3693ee7416978ceeca6206", + "reference": "9a4b8050f8768646bf3693ee7416978ceeca6206", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.24" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-08-31T15:31:12+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2df6626c1baf31d5a88674882a3072f151b5a26", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.1", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:38:55+00:00" + }, + { + "name": "symfony/console", + "version": "v8.0.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "7327288efcaf02a3838d2de0ae23915d64713e14" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/7327288efcaf02a3838d2de0ae23915d64713e14", + "reference": "7327288efcaf02a3838d2de0ae23915d64713e14", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4|^8.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.0.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T13:52:45+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.0.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "4ad08142e63219fdcb75ea1a0ac1c9e5c0fc7686" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4ad08142e63219fdcb75ea1a0ac1c9e5c0fc7686", + "reference": "4ad08142e63219fdcb75ea1a0ac1c9e5c0fc7686", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T15:20:43+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.0.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "3d190c51f717870eed9aa5e9c7cb927ffc911d05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/3d190c51f717870eed9aa5e9c7cb927ffc911d05", + "reference": "3d190c51f717870eed9aa5e9c7cb927ffc911d05", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T15:20:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.0.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "4a93f8c95fabc35fcfd2f5dbb29d138d3fe98f43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/4a93f8c95fabc35fcfd2f5dbb29d138d3fe98f43", + "reference": "4a93f8c95fabc35fcfd2f5dbb29d138d3fe98f43", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T08:56:37+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/4f23d9c7637ead9ed19f697fe93cb87fd9b379d4", + "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T18:05:53+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3", + "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "1a6a4245943af4dabe57d269bd0903f9d140a15e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/1a6a4245943af4dabe57d269bd0903f9d140a15e", + "reference": "1a6a4245943af4dabe57d269bd0903f9d140a15e", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.0.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:34:23+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "platform-overrides": { + "php": "8.4" + }, + "plugin-api-version": "2.9.0" +} diff --git a/tools/phpstan/.gitignore b/tools/phpstan/.gitignore new file mode 100644 index 00000000..57872d0f --- /dev/null +++ b/tools/phpstan/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/tools/phpstan/castor.php b/tools/phpstan/castor.php new file mode 100644 index 00000000..d862f1e1 --- /dev/null +++ b/tools/phpstan/castor.php @@ -0,0 +1,50 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace qa\phpstan; + +use Castor\Attribute\AsTask; + +use function Castor\context; +use function Castor\exit_code; +use function Castor\run; + +#[AsTask(description: 'Run PHPStan', aliases: ['phpstan'])] +function phpstan(bool $generateBaseline = false): int +{ + if (!file_exists(__DIR__.'/vendor/autoload.php')) { + install(); + } + + $command = [ + __DIR__.'/vendor/bin/phpstan', + '-v', + ]; + + if ($generateBaseline) { + $command[] = '-b'; + } + + return exit_code($command); +} + +#[AsTask(description: 'install dependencies')] +function install(): void +{ + run(['composer', 'install'], context()->withWorkingDirectory(__DIR__)); +} + +#[AsTask(description: 'update dependencies')] +function update(): void +{ + run(['composer', 'update'], context()->withWorkingDirectory(__DIR__)); +} diff --git a/tools/phpstan/composer.json b/tools/phpstan/composer.json new file mode 100644 index 00000000..33cce923 --- /dev/null +++ b/tools/phpstan/composer.json @@ -0,0 +1,11 @@ +{ + "require": { + "phpstan/phpstan": "^2.2.12" + }, + "config": { + "bump-after-update": true, + "platform": { + "php": "8.4" + } + } +} diff --git a/tools/phpstan/composer.lock b/tools/phpstan/composer.lock new file mode 100644 index 00000000..9c925845 --- /dev/null +++ b/tools/phpstan/composer.lock @@ -0,0 +1,86 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "ab3e41a1c8f6553bf1007f6afad3d08e", + "packages": [ + { + "name": "phpstan/phpstan", + "version": "2.2.12", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/174b0d88710f00a42598886504dd7a146f91ace5", + "reference": "174b0d88710f00a42598886504dd7a146f91ace5", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-08-31T19:09:43+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "platform-overrides": { + "php": "8.4" + }, + "plugin-api-version": "2.9.0" +} From 29ebc87fc5bc146f81cfe613c22f5a0cf3951f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 11:27:14 +0200 Subject: [PATCH 070/150] Fix bugs and hardening issues found by the code audit (#239) - ReferenceBag::getFirstBranch() mixed up two different arrays (reset($this->branches) then current($this->references)) and could return a non-branch reference, or even throw, when no branch exists. It now correctly returns the first branch or false. - Tag::getBodyMessage() used array_shift()+array_pop() instead of the two array_shift() used by Commit::getBodyMessage(), leaving a stray leading newline (or truncating the last line) for annotated tags using the standard "subject\n\nbody" convention. The body extraction now handles both the blank-line-separated and non-separated forms. - Repository::run() can return null instead of throwing when debug=false and the git command fails, but several call sites fed that null straight into trim()/explode()/Diff::parse() or a non-nullable return type. Depending on the call site this silently produced wrong data (Tag::isAnnotated() reporting true for a lightweight tag, PushReference::isForce() defaulting to a coin flip) or crashed with a TypeError (Blob::getContent()). All call sites in Commit, Tag, Blob, Reference, PushReference, Repository and WorkingCopy now check for null and fail predictably. - Repository::shell() interpolated the repository path into the shell command without escaping it (only the env vars were escaped); it is now passed through escapeshellarg(). - Hooks::set() created hook scripts as 0777 (world-writable); they are now created 0700, readable/writable/executable by the owner only. - Replaced the untyped array "data bag" backing Commit and Reference\Tag with real typed properties, validated at the one point external data enters (setData()/the parser). This removes the Commit.php and Reference\Tag.php entries from the PHPStan baseline (89 -> 51 ignored errors) without resorting to assert()/@var casts. - Minor: fixed the isSuccessFul() casing typo in Admin.php, and dropped composer.json's leftover minimum-stability: dev (dependency resolution is unaffected, verified with composer update --dry-run). Added regression tests for the two bugs, the debug=false fixes, and the new hook permissions. --- composer.json | 1 - phpstan-baseline.neon | 234 ------------------ src/Gitonomy/Git/Admin.php | 8 +- src/Gitonomy/Git/Blob.php | 9 +- src/Gitonomy/Git/Commit.php | 232 ++++++++++++----- src/Gitonomy/Git/Hooks.php | 2 +- src/Gitonomy/Git/PushReference.php | 5 + src/Gitonomy/Git/Reference.php | 4 + src/Gitonomy/Git/Reference/Tag.php | 141 +++++++---- src/Gitonomy/Git/ReferenceBag.php | 3 +- src/Gitonomy/Git/Repository.php | 9 +- src/Gitonomy/Git/WorkingCopy.php | 22 +- tests/Gitonomy/Git/Tests/HooksTest.php | 2 +- tests/Gitonomy/Git/Tests/ReferenceBagTest.php | 24 ++ tests/Gitonomy/Git/Tests/ReferenceTest.php | 25 ++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 22 ++ 16 files changed, 380 insertions(+), 363 deletions(-) diff --git a/composer.json b/composer.json index b85a2405..1d0afbc7 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,5 @@ "preferred-install": "dist", "sort-packages": true }, - "minimum-stability": "dev", "prefer-stable": true } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 1f818dbb..8b1e7216 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -78,162 +78,6 @@ parameters: count: 1 path: src/Gitonomy/Git/Blame/Line.php - - - message: '#^Method Gitonomy\\Git\\Blob\:\:getContent\(\) should return string but returns string\|null\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Blob.php - - - - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' - identifier: foreach.nonIterable - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:__construct\(\) has parameter \$data with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getAuthorDate\(\) should return DateTime but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getAuthorEmail\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getAuthorName\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getBodyMessage\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getCommitterDate\(\) should return DateTime but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getCommitterEmail\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getCommitterName\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getMessage\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getParentHashes\(\) should return array\ but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getShortHash\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getShortMessage\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getSubjectMessage\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getTree\(\) should return Gitonomy\\Git\\Tree but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getTreeHash\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:setData\(\) has parameter \$data with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$hash of method Gitonomy\\Git\\Repository\:\:getCommit\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$hash of method Gitonomy\\Git\\Repository\:\:getTree\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$haystack of static method Gitonomy\\Git\\Util\\StringHelper\:\:strpos\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$rawDiff of static method Gitonomy\\Git\\Diff\\Diff\:\:parse\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$string of function trim expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$string of static method Gitonomy\\Git\\Util\\StringHelper\:\:strlen\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$string of static method Gitonomy\\Git\\Util\\StringHelper\:\:substr\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#2 \$string of function explode expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Gitonomy/Git/Commit.php - - message: '#^Method Gitonomy\\Git\\Diff\\Diff\:\:fromArray\(\) has parameter \$array with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -336,72 +180,12 @@ parameters: count: 1 path: src/Gitonomy/Git/Parser/ParserBase.php - - - message: '#^Parameter \#1 \$string of function trim expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/PushReference.php - - - - message: '#^Parameter \#1 \$string of function trim expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Reference.php - - message: '#^Method Gitonomy\\Git\\Reference\\Branch\:\:isLocal\(\) should return bool but returns bool\|null\.$#' identifier: return.type count: 1 path: src/Gitonomy/Git/Reference/Branch.php - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getBodyMessage\(\) should return string\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getGPGSignature\(\) should return string\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getMessage\(\) should return string\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getSubjectMessage\(\) should return string\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getTaggerDate\(\) should return DateTime\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getTaggerEmail\(\) should return string\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getTaggerName\(\) should return string\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Parameter \#2 \$string of function explode expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Gitonomy/Git/Reference/Tag.php - - message: '#^Class Gitonomy\\Git\\ReferenceBag implements generic interface IteratorAggregate but does not specify its types\: TKey, TValue$#' identifier: missingType.generics @@ -468,12 +252,6 @@ parameters: count: 1 path: src/Gitonomy/Git/Repository.php - - - message: '#^Parameter \#1 \$rawDiff of static method Gitonomy\\Git\\Diff\\Diff\:\:parse\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Repository.php - - message: '#^Parameter \#1 \$string of function trim expects string, string\|false given\.$#' identifier: argument.type @@ -521,15 +299,3 @@ parameters: identifier: missingType.iterableValue count: 1 path: src/Gitonomy/Git/WorkingCopy.php - - - - message: '#^Parameter \#1 \$rawDiff of static method Gitonomy\\Git\\Diff\\Diff\:\:parse\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: src/Gitonomy/Git/WorkingCopy.php - - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/WorkingCopy.php diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 024acf41..35d4875d 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -37,7 +37,7 @@ public static function init(string $path, bool $bare = true, array $options = [] $process->run(); - if (!$process->isSuccessFul()) { + if (!$process->isSuccessful()) { throw new RuntimeException(\sprintf("Error on repository initialization, command wasn't successful (%s). Error output:\n%s", $process->getCommandLine(), $process->getErrorOutput())); } @@ -62,7 +62,7 @@ public static function isValidRepository(string $url, array $options = []): bool $process->run(); - return $process->isSuccessFul(); + return $process->isSuccessful(); } /** @@ -86,7 +86,7 @@ public static function isValidRepositoryAndBranch(string $url, string $branchNam $process->run(); $processOutput = $process->getOutput(); - return $process->isSuccessFul() && str_contains($processOutput, $branchName); + return $process->isSuccessful() && str_contains($processOutput, $branchName); } /** @@ -149,7 +149,7 @@ public static function cloneRepository(string $path, string $url, array $args = $process->run(); - if (!$process->isSuccessFul()) { + if (!$process->isSuccessful()) { throw new RuntimeException(\sprintf('Error while initializing repository: %s', $process->getErrorOutput())); } diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index 54a9c22b..60555cb4 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -12,6 +12,8 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\ReferenceNotFoundException; + /** * Representation of a Blob commit. * @@ -53,7 +55,12 @@ public function getHash(): string public function getContent(): string { if (null === $this->content) { - $this->content = $this->repository->run('cat-file', ['-p', $this->hash]); + $content = $this->repository->run('cat-file', ['-p', $this->hash]); + if (null === $content) { + throw new ReferenceNotFoundException($this->hash); + } + + $this->content = $content; } return $this->content; diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 671f467d..2e0215f0 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -27,15 +27,36 @@ final class Commit extends Revision { /** - * Associative array of commit data. - * - * @var array + * Whether the fields below have been populated (either lazily fetched + * from the repository, or provided upfront through setData()). + */ + private bool $loaded = false; + + private ?string $treeHash = null; + + /** + * @var string[]|null */ - private array $data = []; + private ?array $parentHashes = null; + + private ?string $authorName = null; + private ?string $authorEmail = null; + private ?\DateTime $authorDate = null; + private ?string $committerName = null; + private ?string $committerEmail = null; + private ?\DateTime $committerDate = null; + private ?string $message = null; + + // Values derived from the fields above, computed and cached independently. + private ?string $shortHash = null; + private ?Tree $tree = null; + private ?string $subjectMessage = null; + private ?string $bodyMessage = null; /** - * @param Repository $repository Repository of the commit - * @param string $hash Hash of the commit + * @param Repository $repository Repository of the commit + * @param string $hash Hash of the commit + * @param array $data */ public function __construct(Repository $repository, string $hash, array $data = []) { @@ -48,10 +69,28 @@ public function __construct(Repository $repository, string $hash, array $data = $this->setData($data); } + /** + * @param array $data + */ public function setData(array $data): void { foreach ($data as $name => $value) { - $this->data[$name] = $value; + match ($name) { + 'treeHash' => $this->treeHash = self::asString($name, $value), + 'parentHashes' => $this->parentHashes = self::asStringList($name, $value), + 'authorName' => $this->authorName = self::asString($name, $value), + 'authorEmail' => $this->authorEmail = self::asString($name, $value), + 'authorDate' => $this->authorDate = self::asDateTime($name, $value), + 'committerName' => $this->committerName = self::asString($name, $value), + 'committerEmail' => $this->committerEmail = self::asString($name, $value), + 'committerDate' => $this->committerDate = self::asDateTime($name, $value), + 'message' => $this->message = self::asString($name, $value), + default => throw new InvalidArgumentException(\sprintf('Unknown commit data "%s".', $name)), + }; + } + + if ([] !== $data) { + $this->loaded = true; } } @@ -59,7 +98,12 @@ public function getDiff(): Diff { $args = ['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index', $this->revision]; - $diff = Diff::parse($this->repository->run('diff-tree', $args)); + $result = $this->repository->run('diff-tree', $args); + if (null === $result) { + throw new ReferenceNotFoundException($this->revision); + } + + $diff = Diff::parse($result); $diff->setRepository($this->repository); return $diff; @@ -78,7 +122,16 @@ public function getHash(): string */ public function getShortHash(): string { - return $this->getData('shortHash'); + if (null === $this->shortHash) { + $result = $this->repository->run('log', ['--abbrev-commit', '--format=%h', '-n', 1, $this->revision]); + if (null === $result) { + throw new ReferenceNotFoundException($this->revision); + } + + $this->shortHash = trim($result); + } + + return $this->shortHash; } /** @@ -96,7 +149,9 @@ public function getFixedShortHash(int $length = 6): string */ public function getParentHashes(): array { - return $this->getData('parentHashes'); + $this->ensureLoaded(); + + return $this->parentHashes ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -107,7 +162,7 @@ public function getParentHashes(): array public function getParents(): array { $result = []; - foreach ($this->getData('parentHashes') as $parentHash) { + foreach ($this->getParentHashes() as $parentHash) { $result[] = $this->repository->getCommit($parentHash); } @@ -119,12 +174,18 @@ public function getParents(): array */ public function getTreeHash(): string { - return $this->getData('treeHash'); + $this->ensureLoaded(); + + return $this->treeHash ?? throw new ReferenceNotFoundException($this->revision); } public function getTree(): Tree { - return $this->getData('tree'); + if (null === $this->tree) { + $this->tree = $this->repository->getTree($this->getTreeHash()); + } + + return $this->tree; } public function getLastModification(?string $path = null): self @@ -139,6 +200,10 @@ public function getLastModification(?string $path = null): self $result = $this->repository->run('log', ['--format=%H', '-n', 1, $this->revision, '--', $path]); + if (null === $result) { + throw new ReferenceNotFoundException($this->revision); + } + return $this->repository->getCommit(trim($result)); } @@ -149,7 +214,7 @@ public function getLastModification(?string $path = null): self */ public function getShortMessage(int $length = 50, bool $preserve = false, string $separator = '...'): string { - $message = $this->getData('subjectMessage'); + $message = $this->getSubjectMessage(); if (StringHelper::strlen($message) > $length) { if ($preserve && false !== ($breakpoint = StringHelper::strpos($message, ' ', $length))) { @@ -229,7 +294,9 @@ public function getIncludingBranches(bool $local = true, bool $remote = true): a */ public function getAuthorName(): string { - return $this->getData('authorName'); + $this->ensureLoaded(); + + return $this->authorName ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -237,7 +304,9 @@ public function getAuthorName(): string */ public function getAuthorEmail(): string { - return $this->getData('authorEmail'); + $this->ensureLoaded(); + + return $this->authorEmail ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -245,7 +314,9 @@ public function getAuthorEmail(): string */ public function getAuthorDate(): \DateTime { - return $this->getData('authorDate'); + $this->ensureLoaded(); + + return $this->authorDate ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -253,7 +324,9 @@ public function getAuthorDate(): \DateTime */ public function getCommitterName(): string { - return $this->getData('committerName'); + $this->ensureLoaded(); + + return $this->committerName ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -261,7 +334,9 @@ public function getCommitterName(): string */ public function getCommitterEmail(): string { - return $this->getData('committerEmail'); + $this->ensureLoaded(); + + return $this->committerEmail ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -269,7 +344,9 @@ public function getCommitterEmail(): string */ public function getCommitterDate(): \DateTime { - return $this->getData('committerDate'); + $this->ensureLoaded(); + + return $this->committerDate ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -277,7 +354,9 @@ public function getCommitterDate(): \DateTime */ public function getMessage(): string { - return $this->getData('message'); + $this->ensureLoaded(); + + return $this->message ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -285,7 +364,12 @@ public function getMessage(): string */ public function getSubjectMessage(): string { - return $this->getData('subjectMessage'); + if (null === $this->subjectMessage) { + $lines = explode("\n", $this->getMessage()); + $this->subjectMessage = reset($lines); + } + + return $this->subjectMessage; } /** @@ -293,7 +377,16 @@ public function getSubjectMessage(): string */ public function getBodyMessage(): string { - return $this->getData('bodyMessage'); + if (null === $this->bodyMessage) { + $lines = explode("\n", $this->getMessage()); + + array_shift($lines); + array_shift($lines); + + $this->bodyMessage = implode("\n", $lines); + } + + return $this->bodyMessage; } public function getCommit(): self @@ -301,68 +394,75 @@ public function getCommit(): self return $this; } - private function getData(string $name): mixed + private function ensureLoaded(): void { - if (isset($this->data[$name])) { - return $this->data[$name]; - } - - if ('shortHash' === $name) { - $this->data['shortHash'] = trim($this->repository->run('log', ['--abbrev-commit', '--format=%h', '-n', 1, $this->revision])); - - return $this->data['shortHash']; + if ($this->loaded) { + return; } - if ('tree' === $name) { - $this->data['tree'] = $this->repository->getTree($this->getData('treeHash')); + $parser = new Parser\CommitParser(); - return $this->data['tree']; + try { + $result = $this->repository->run('cat-file', ['commit', $this->revision]); + } catch (ProcessException $e) { + throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision)); } - if ('subjectMessage' === $name) { - $lines = explode("\n", $this->getData('message')); - $this->data['subjectMessage'] = reset($lines); - - return $this->data['subjectMessage']; + if (null === $result) { + throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision)); } - if ('bodyMessage' === $name) { - $message = $this->getData('message'); + $parser->parse($result); - $lines = explode("\n", $message); + $this->treeHash = $parser->tree; + $this->parentHashes = $parser->parents; + $this->authorName = $parser->authorName; + $this->authorEmail = $parser->authorEmail; + $this->authorDate = $parser->authorDate; + $this->committerName = $parser->committerName; + $this->committerEmail = $parser->committerEmail; + $this->committerDate = $parser->committerDate; + $this->message = $parser->message; + + $this->loaded = true; + } - array_shift($lines); - array_shift($lines); + private static function asString(string $name, mixed $value): string + { + if (!\is_string($value)) { + throw new InvalidArgumentException(\sprintf('Commit data "%s" must be a string.', $name)); + } - $this->data['bodyMessage'] = implode("\n", $lines); + return $value; + } - return $this->data['bodyMessage']; + private static function asDateTime(string $name, mixed $value): \DateTime + { + if (!$value instanceof \DateTime) { + throw new InvalidArgumentException(\sprintf('Commit data "%s" must be a DateTime.', $name)); } - $parser = new Parser\CommitParser(); + return $value; + } - try { - $result = $this->repository->run('cat-file', ['commit', $this->revision]); - } catch (ProcessException $e) { - throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision)); + /** + * @return string[] + */ + private static function asStringList(string $name, mixed $value): array + { + if (!\is_array($value)) { + throw new InvalidArgumentException(\sprintf('Commit data "%s" must be an array.', $name)); } - $parser->parse($result); + $result = []; + foreach ($value as $item) { + if (!\is_string($item)) { + throw new InvalidArgumentException(\sprintf('Commit data "%s" must be an array of strings.', $name)); + } - $this->data['treeHash'] = $parser->tree; - $this->data['parentHashes'] = $parser->parents; - $this->data['authorName'] = $parser->authorName; - $this->data['authorEmail'] = $parser->authorEmail; - $this->data['authorDate'] = $parser->authorDate; - $this->data['committerName'] = $parser->committerName; - $this->data['committerEmail'] = $parser->committerEmail; - $this->data['committerDate'] = $parser->committerDate; - $this->data['message'] = $parser->message; - - if (!isset($this->data[$name])) { - throw new \InvalidArgumentException(\sprintf('No data named "%s" in Commit.', $name)); + $result[] = $item; } - return $this->data[$name]; + return $result; } } diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index f07bf3b0..6b6835c3 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -93,7 +93,7 @@ public function set(string $name, string $content): void $path = $this->getPath($name); file_put_contents($path, $content); - chmod($path, 0o777); + chmod($path, 0o700); } /** diff --git a/src/Gitonomy/Git/PushReference.php b/src/Gitonomy/Git/PushReference.php index dc3086e4..e7ca21ff 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -117,6 +117,11 @@ private function getForce(): bool $this->after, ]); + if (null === $result) { + // No common ancestor could be established: treat it as a force update. + return true; + } + return $this->before !== trim($result); } } diff --git a/src/Gitonomy/Git/Reference.php b/src/Gitonomy/Git/Reference.php index 885335a4..869e5b97 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -54,6 +54,10 @@ public function getCommitHash(): string throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision)); } + if (null === $result) { + throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision)); + } + return $this->commitHash = trim($result); } diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index 29539380..b07076de 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -27,10 +27,17 @@ */ final class Tag extends Reference { - /** - * @var array|null - */ - private ?array $data = null; + private bool $loaded = false; + + private ?string $taggerName = null; + private ?string $taggerEmail = null; + private ?\DateTime $taggerDate = null; + private ?string $message = null; + private ?string $gpgSignature = null; + + // Values derived from the fields above, computed and cached independently. + private ?string $subjectMessage = null; + private ?string $bodyMessage = null; public function getName(): string { @@ -47,12 +54,13 @@ public function getName(): string public function isAnnotated(): bool { try { - $this->repository->run('cat-file', ['tag', $this->revision]); + $result = $this->repository->run('cat-file', ['tag', $this->revision]); } catch (ProcessException $e) { return false; // Is not an annotated tag } - return true; + // In non-debug mode, a failed command returns null instead of throwing. + return null !== $result; } /** @@ -87,7 +95,12 @@ public function getCommit(): Commit */ public function getTaggerName(): string|false { - return $this->getData('taggerName'); + if (!$this->isAnnotated()) { + return false; + } + $this->ensureLoaded(); + + return $this->taggerName ?? throw new \InvalidArgumentException('No data named "taggerName" in Tag.'); } /** @@ -95,7 +108,12 @@ public function getTaggerName(): string|false */ public function getTaggerEmail(): string|false { - return $this->getData('taggerEmail'); + if (!$this->isAnnotated()) { + return false; + } + $this->ensureLoaded(); + + return $this->taggerEmail ?? throw new \InvalidArgumentException('No data named "taggerEmail" in Tag.'); } /** @@ -103,7 +121,12 @@ public function getTaggerEmail(): string|false */ public function getTaggerDate(): \DateTime|false { - return $this->getData('taggerDate'); + if (!$this->isAnnotated()) { + return false; + } + $this->ensureLoaded(); + + return $this->taggerDate ?? throw new \InvalidArgumentException('No data named "taggerDate" in Tag.'); } /** @@ -111,7 +134,12 @@ public function getTaggerDate(): \DateTime|false */ public function getMessage(): string|false { - return $this->getData('message'); + if (!$this->isAnnotated()) { + return false; + } + $this->ensureLoaded(); + + return $this->message ?? throw new \InvalidArgumentException('No data named "message" in Tag.'); } /** @@ -119,7 +147,16 @@ public function getMessage(): string|false */ public function getSubjectMessage(): string|false { - return $this->getData('subjectMessage'); + if (!$this->isAnnotated()) { + return false; + } + + if (null === $this->subjectMessage) { + $lines = explode("\n", $this->getMessageOrThrow()); + $this->subjectMessage = reset($lines); + } + + return $this->subjectMessage; } /** @@ -127,7 +164,27 @@ public function getSubjectMessage(): string|false */ public function getBodyMessage(): string|false { - return $this->getData('bodyMessage'); + if (!$this->isAnnotated()) { + return false; + } + + if (null === $this->bodyMessage) { + $lines = explode("\n", $this->getMessageOrThrow()); + + // Drop the subject line, then the blank separator line if the + // message follows the "subject\n\nbody" convention. + array_shift($lines); + if (isset($lines[0]) && '' === $lines[0]) { + array_shift($lines); + } + if ([] !== $lines && '' === end($lines)) { + array_pop($lines); + } + + $this->bodyMessage = implode("\n", $lines); + } + + return $this->bodyMessage; } /** @@ -135,7 +192,12 @@ public function getBodyMessage(): string|false */ public function getGPGSignature(): string|false { - return $this->getData('gpgSignature'); + if (!$this->isAnnotated()) { + return false; + } + $this->ensureLoaded(); + + return $this->gpgSignature ?? throw new \InvalidArgumentException('No data named "gpgSignature" in Tag.'); } /** @@ -152,51 +214,34 @@ public function isSigned(): bool } } - private function getData(string $name): mixed + private function getMessageOrThrow(): string { - if (!$this->isAnnotated()) { - return false; - } + $this->ensureLoaded(); - if (isset($this->data[$name])) { - return $this->data[$name]; - } - - if ('subjectMessage' === $name) { - $lines = explode("\n", $this->getData('message')); - $this->data['subjectMessage'] = reset($lines); - - return $this->data['subjectMessage']; - } - - if ('bodyMessage' === $name) { - $message = $this->getData('message'); - - $lines = explode("\n", $message); - - array_shift($lines); - array_pop($lines); - - $this->data['bodyMessage'] = implode("\n", $lines); + return $this->message ?? throw new \InvalidArgumentException('No data named "message" in Tag.'); + } - return $this->data['bodyMessage']; + private function ensureLoaded(): void + { + if ($this->loaded) { + return; } $parser = new TagParser(); $result = $this->repository->run('cat-file', ['tag', $this->revision]); - $parser->parse($result); + if (null === $result) { + throw new \InvalidArgumentException('Unable to read tag data.'); + } - $this->data['taggerName'] = $parser->taggerName; - $this->data['taggerEmail'] = $parser->taggerEmail; - $this->data['taggerDate'] = $parser->taggerDate; - $this->data['message'] = $parser->message; - $this->data['gpgSignature'] = $parser->gpgSignature; + $parser->parse($result); - if (!isset($this->data[$name])) { - throw new \InvalidArgumentException(\sprintf('No data named "%s" in Tag.', $name)); - } + $this->taggerName = $parser->taggerName; + $this->taggerEmail = $parser->taggerEmail; + $this->taggerDate = $parser->taggerDate; + $this->message = $parser->message; + $this->gpgSignature = $parser->gpgSignature; - return $this->data[$name]; + $this->loaded = true; } } diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index b1833b3e..e5408ad6 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -145,9 +145,8 @@ public function hasTag(string $name): bool public function getFirstBranch(): Reference|false { $this->initialize(); - reset($this->branches); - return current($this->references); + return reset($this->branches); } /** diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index ca480f33..3e499881 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -361,7 +361,12 @@ public function getDiff(RevisionList|Revision|string|array|null $revisions): Dif $args = array_merge(['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index'], $revisions->getAsTextArray()); - $diff = Diff::parse($this->run('diff', $args)); + $result = $this->run('diff', $args); + if (null === $result) { + throw new RuntimeException('Unable to compute diff.'); + } + + $diff = Diff::parse($result); $diff->setRepository($this); return $diff; @@ -393,7 +398,7 @@ public function getSize(): int */ public function shell(string $command, array $env = []): void { - $argument = \sprintf('%s \'%s\'', $command, $this->gitDir); + $argument = \sprintf('%s %s', $command, escapeshellarg($this->gitDir)); $prefix = ''; foreach ($env as $name => $value) { diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index e48acd97..aa96d524 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -15,6 +15,7 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\RuntimeException; /** * @author Alexandre Salomé @@ -34,7 +35,12 @@ public function __construct( */ public function getUntrackedFiles(): array { - $lines = explode("\0", $this->run('status', ['--porcelain', '--untracked-files=all', '-z'])); + $output = $this->run('status', ['--porcelain', '--untracked-files=all', '-z']); + if (null === $output) { + return []; + } + + $lines = explode("\0", $output); $lines = array_filter($lines, static function ($l) { return '?? ' === substr($l, 0, 3); }); @@ -46,7 +52,12 @@ public function getUntrackedFiles(): array public function getDiffPending(): Diff { - $diff = Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index'])); + $result = $this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index']); + if (null === $result) { + throw new RuntimeException('Unable to compute pending diff.'); + } + + $diff = Diff::parse($result); $diff->setRepository($this->repository); return $diff; @@ -54,7 +65,12 @@ public function getDiffPending(): Diff public function getDiffStaged(): Diff { - $diff = Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--staged'])); + $result = $this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--staged']); + if (null === $result) { + throw new RuntimeException('Unable to compute staged diff.'); + } + + $diff = Diff::parse($result); $diff->setRepository($this->repository); return $diff; diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 7256db1b..b1ab8bee 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -134,7 +134,7 @@ public function testSet(Repository $repository): void $this->assertEquals('bar', file_get_contents($file), 'Hook content is correct'); $perms = fileperms($file); - $this->assertEquals(\defined('PHP_WINDOWS_VERSION_BUILD') ? 0o666 : 0o777, $perms & 0o777, 'Hook permissions are correct'); + $this->assertEquals(\defined('PHP_WINDOWS_VERSION_BUILD') ? 0o666 : 0o700, $perms & 0o777, 'Hook permissions are correct'); } #[DataProvider('provideFoobar')] diff --git a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php index a6266631..7278b19b 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php @@ -12,11 +12,35 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; class ReferenceBagTest extends AbstractTestCase { + #[DataProvider('provideFoobar')] + public function testGetFirstBranch(Repository $repository): void + { + $branch = $repository->getReferences()->getFirstBranch(); + + $this->assertInstanceOf(Branch::class, $branch); + } + + public function testGetFirstBranchWithNoBranches(): void + { + $repository = self::createFoobarRepository(false); + $wc = $repository->getWorkingCopy(); + $wc->checkout('0.1'); + + foreach ($repository->getReferences()->getBranches() as $branch) { + $branch->delete(); + } + + $refs = $repository->getReferences(true); + $this->assertFalse($refs->hasBranches()); + $this->assertFalse($refs->getFirstBranch(), 'getFirstBranch() must not return a non-branch reference'); + } + #[DataProvider('provideFoobar')] public function testUnknownReference(Repository $repository): void { diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index f164c349..55da2a5d 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -70,6 +70,16 @@ public function testGetTag(Repository $repository): void $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommit()->getHash(), 'Commit hash is correct'); } + #[DataProvider('provideFoobar')] + public function testIsAnnotatedIsFalseForLightweightTagWhenDebugIsFalse(Repository $repository): void + { + $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); + + $tag = $repository->getReferences()->getTag('0.1'); + + $this->assertFalse($tag->isAnnotated(), 'Lightweight tag must not be reported as annotated'); + } + #[DataProvider('provideFoobar')] public function testAnnotatedTag(Repository $repository): void { @@ -94,6 +104,21 @@ public function testAnnotatedTag(Repository $repository): void $this->assertEquals('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommit()->getHash(), 'Tag commit is correct'); } + #[DataProvider('provideFoobar')] + public function testAnnotatedTagBodyMessageWithBlankLineSeparator(Repository $repository): void + { + $repository->run('config', ['user.email', 'test@example.com']); + $repository->run('config', ['user.name', 'Test']); + + $hash = $repository->getLog()->getSingleCommit()->getHash(); + $repository->run('tag', ['-a', 'blank-sep', '-m', 'Subject line', '-m', "Body line 1\nBody line 2", $hash]); + + $tag = $repository->getReferences()->getTag('blank-sep'); + + $this->assertEquals('Subject line', $tag->getSubjectMessage(), 'Message subject is correct'); + $this->assertEquals("Body line 1\nBody line 2", $tag->getBodyMessage(), 'Message body has no leading blank line'); + } + #[DataProvider('provideFoobar')] public function testGetTagNotExistingError(Repository $repository): void { diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index c0fee88b..e7bc480b 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -13,6 +13,8 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Blob; +use Gitonomy\Git\Commit; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; @@ -20,6 +22,26 @@ class RepositoryTest extends AbstractTestCase { + public function testRunReturnsNullInsteadOfThrowingWhenDebugIsFalse(): void + { + $repository = self::createFoobarRepository(true); + $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); + + $this->assertNull($repository->run('not-a-command')); + } + + public function testGetShortHashThrowsCleanExceptionWhenDebugIsFalse(): void + { + $repository = self::createFoobarRepository(true); + $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); + + $commit = new Commit($repository, str_repeat('a', 40)); + + $this->expectException(ReferenceNotFoundException::class); + + $commit->getShortHash(); + } + #[DataProvider('provideFoobar')] public function testGetBlobWithExistingWorks(Repository $repository): void { From b8ed9c0365a7f8443c9a234286e4938c282b9612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 11:38:28 +0200 Subject: [PATCH 071/150] ci: bump actions/checkout to v7 to fix Node 20 deprecation warning (#240) --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a599f8c1..ae4f7d70 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -61,7 +61,7 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 From 94cf24e73caa7502b084fb7cbfa4680ee0d66901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 16:50:33 +0200 Subject: [PATCH 072/150] Make the test fixture local and auditable (#241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Replace the network fixture clone with a local git bundle The test suite cloned https://github.com/gitonomy/foobar.git over the network on every run, and asserted on hardcoded commit SHAs from that external repository's history. This made the tests fail without network access, coupled them to a repository outside our control, and made it impossible for a contributor to add a new fixture scenario without push access to that separate repository. tests/fixtures/foobar.bundle is a git bundle of the same repository (all branches and tags, including the GPG-signed commit used by PushReferenceTest). Admin::cloneTo/cloneBranchTo/cloneRepository just shell out to `git clone `, so cloning from a local bundle file behaves identically to cloning the remote repository, but works offline and can't be affected by changes made upstream. * Guard the fixture bundle against unreviewable changes in CI Since tests/fixtures/foobar.bundle is a binary file, GitHub shows a PR touching it as an opaque "binary file not shown" diff: a contributor could regenerate it with extra refs or bloated content while keeping the commit SHAs the tests rely on intact, and a reviewer would have no way to see it from the diff alone. tests/fixtures/verify-bundle.sh runs `git bundle verify`, checks the ref list against an explicit allow-list of the 5 branches and 2 tags the fixture actually needs, and fails if the file grows past 200KB (current size: ~39KB). Any legitimate change to the fixture's shape or size requires editing this script in the same PR, making the change explicit and reviewable instead of silent. Wired into CI as a new verify-fixture job that runs on every push and pull request. * Document the test fixture bundle in CONTRIBUTING.md Explains what tests/fixtures/foobar.bundle is, how to regenerate it to add a new fixture scenario, and why tests/fixtures/verify-bundle.sh exists and needs updating alongside any intentional change to the bundle's refs or size. * Make the fixture regeneration recipe fully local The docs and comments still pointed at cloning gitonomy/foobar over the network to add a new fixture scenario, defeating the point of moving to a local bundle. The bundle already contains the full history (all branches and tags), so regenerating it only ever needs to clone tests/fixtures/foobar.bundle itself — verified this reproduces a byte-identical bundle with no network access. --- .gitattributes | 1 + .github/CONTRIBUTING.md | 29 ++++++++++ .github/workflows/tests.yml | 10 ++++ tests/Gitonomy/Git/Tests/AbstractTestCase.php | 6 +- tests/fixtures/foobar.bundle | Bin 0 -> 40744 bytes tests/fixtures/verify-bundle.sh | 53 ++++++++++++++++++ 6 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/foobar.bundle create mode 100755 tests/fixtures/verify-bundle.sh diff --git a/.gitattributes b/.gitattributes index 176a458f..20a19621 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text=auto +/tests/fixtures/*.bundle binary diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 28cea4c3..7486bbdd 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -39,6 +39,35 @@ $ vendor/bin/phpunit * A script `test-git-versions.sh` is available in repository to test gitlib against many git versions. * The tests will be automatically run by [GitHub Actions](https://github.com/features/actions) against pull requests. +* Tests run fully offline: no network access is required. + +## Test fixtures + +Most tests run against a fixture repository cloned from `tests/fixtures/foobar.bundle`, +a local git bundle. Using a bundle instead of a network clone keeps the tests fast and +fully offline. + +If you need a new fixture scenario (a specific merge, encoding, or signed-commit shape, +for example), regenerate the bundle locally, entirely from the one already in the repo: + +```bash +$ git clone tests/fixtures/foobar.bundle /tmp/foobar-fixture && cd /tmp/foobar-fixture +$ for b in $(git branch -r | grep -v HEAD | sed 's#origin/##'); do +$ git branch --track "$b" "origin/$b" +$ done +# ... add your commits, branches or tags ... +$ git bundle create foobar.bundle \ + HEAD refs/heads/master refs/heads/new-feature refs/heads/diff-features \ + refs/heads/pagination refs/heads/path-resolving refs/tags/0.1 refs/tags/annotated +$ cp foobar.bundle /path/to/gitlib/tests/fixtures/foobar.bundle +``` + +Then update the commit SHA constants in `AbstractTestCase` to match, and run +`tests/fixtures/verify-bundle.sh`. It checks the bundle's integrity, its ref list against +an allow-list, and its size, since GitHub renders any change to this binary file as an +opaque diff. If your change intentionally adds a ref or grows the file, update +`ALLOWED_REFS` or `MAX_SIZE_KB` in that script as part of the same pull request, so the +reason for the change is explicit and reviewable rather than a silent binary diff. ## Standard code diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ae4f7d70..73634aab 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,16 @@ on: pull_request: jobs: + verify-fixture: + name: Verify Test Fixture Bundle + runs-on: ubuntu-24.04 + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Verify fixture bundle integrity, refs and size + run: tests/fixtures/verify-bundle.sh + check-cs: name: Check Coding Standards runs-on: ubuntu-24.04 diff --git a/tests/Gitonomy/Git/Tests/AbstractTestCase.php b/tests/Gitonomy/Git/Tests/AbstractTestCase.php index e56bc35c..80cd16e9 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTestCase.php +++ b/tests/Gitonomy/Git/Tests/AbstractTestCase.php @@ -18,7 +18,11 @@ abstract class AbstractTestCase extends TestCase { - public const REPOSITORY_URL = 'https://github.com/gitonomy/foobar.git'; + /** + * Local git bundle used as a fixture repository: cloning from it behaves like + * cloning a remote repository, but requires no network access. + */ + public const REPOSITORY_URL = __DIR__.'/../../../fixtures/foobar.bundle'; public const NO_MESSAGE_COMMIT = '011cd0c1625190d2959ee9a8f9f822006d94b661'; public const LONGFILE_COMMIT = '4f17752acc9b7c54ba679291bf24cb7d354f0f4f'; diff --git a/tests/fixtures/foobar.bundle b/tests/fixtures/foobar.bundle new file mode 100644 index 0000000000000000000000000000000000000000..bbd71de5f6720e2a11367b67a1ff64cf78a3b66f GIT binary patch literal 40744 zcma%jWn9$T_ch%uEzQt5bPkPlcgFzIt+arGbPOO3(v8xsfJjIPA|N3GQj!XS0><;5 z8NJv0^Z(*`#|viIpMCb(d#$zCftBf&5R;2*0Fy(Y7u3TEPuxk;!5%CkDJIfB;6cQALN;-qVPLj^{V5q35prDY1!=Imrx;i`m{sMnI zu%Lq|R2(cMYA*`42a7s62#X4e2n#xhIST?mI6FEzI}3>czwqZb_}II+df5lKdVAqH zItq%}JA%c*PzebUAqgQNK_RHKvxI~r80rKSk(9ItJ4;B42nqi8;{iAM{G9x~J#M*r zx!{R7gT=)~h3p+2B^|^aMMWI!#l$6rB*6~OLL!b1;!t5x5obYX5ohFA1lYUy3kdRq z@f^f}zv(FCXzwU4VJ{*gEG{JJXb%>1w1CH#iGqTHhJu0`QY;cw;0YT~$(JWl`4Xn0X`AO`C|8T@A56P7rELODZJ=oJ^XDQ*m-cn)L?_;|j(_s=N7Iuyp6qv#>)q}5J*X5O zJ{wSBZLU>bZXS?tZQ(~JHmxzUUVXY#%GZ4`j`HosgN0yLzmFF$lQ?ECunsDveR6cg z2R@PO{#X@#7-cEU{+@~YzQSY1W2)>!wE%k=o6-#j?{~bSPcfrZwq(qhnh18AC41RN z3~Nqqwn4GmUZXr>bXh;|^KA!bhX^fd1l?Zq!te}<$kMhx-kl8+aSeQJ<)!|K)=wbG z?up5B$-eFI3RGV$t>F8yj29fb=FhR5lOs4(_V$;*P#zXv@e*x}u#Aads{DzpQ+AR%S5~ST&=r$1DD|A|i2IRJ%->c!uk+8|qxB++6u( zpJh>cid{aHHqD(i|IuYYGt(=c()ir%m_9j^a3|T9?r8Y|o=?4rx0Ofln(-}Na%spo zf}pqJ+wX7JVLe%W`&RX8)T256M{!QOGq=mbqZeeH=SxkMn8m#PS@rTfnx8hPSE~mn zr>V8^Ii1Murhk;+cu}t@T?6-<_Sbgz-A%m}l^{yUV08!Oo9RsR6Z(}{>>QH=ar*=u zz8vD9f`$ogyg@+{`Soz!^gccIj4{TzUA7h7S(^G*69@9%KI>h=l?WM|I+jWjr#c*! zQWh?PB6@xGT{CQIXvpWP+}T8K}J5GZpEeq$n=O_^~Lj$j0|BJ@s=1 zUMus#-Fd=tmF70iKQn&t+$el!5WL;}*~yw{I98stm-yzbNR+pg(8lSF= z2To50vf6{wsp#&!3fq$5_pWKxv(C4gzt%}D-8xWm|L)m*p7nyc@sCsIICE+vF-*RP z8S)ex#uM1I7bfU1Vfc-nOevKokNF~_!qy@pO#aj8m8`3tyo!qcaxuM}aeyncnI89( z8JUsZH-fXd*6${@y{06`CNC^fzyWE9DIBw#>)?3P zPJ5vlgNp|I(92vei)<#shz{f1mq(?gba&bM)|4Zu=6x@NdczwTKP%Q2%YzA_+}%!Q zb|}U;74_VjOYNP+4-d`C*R~9Nty+#69v}O6 z@#xN!T%f5XTKYr3*_GEjlG5H>Ar`|4?1JZ8!Q_QP`!w20F7lPhZEtM!lc>*Jwq#$G z`M${F&r!tfw6uv2DJt#@`bpqi#mbTR-7cXNdmL|WnVD>@dKb^-PG>3}Z))qhf#>Gp z$Kf6m+dhpo&CcC2{SDvZ~SsaWWlh_ zP01s3NuhhNy0|mtXQz(N1xGGZv8qWnuFsH+xI_#bg zuxjUtppr6vMQvQs6}m>NPOEPC{AtJ6&%{>N61S+~ zRW)DHG!OKw(_i;X6@u26j zPCp{=%XR&DVK`yCBIwAopf}E{r7F!zY0ut?!!_jNrrvk@Gwga=94^U5FmZ9sBcIH$ zskf-&(cN#G80bS{rmk{%o~fN-E>E$NLoB0P-+q^T{&lw1_bZmJiN{GMc~C)NvbF{W zm_+r9q)L>gf}wg9EHX~oG}?4jw_;N8EzPVeb?T;MZw}#IDxWjY)i0~N6KfUO{M(rh znKM=DS#;`L8QM4*lVYT7g;`|T)4}w11-IfP^qZd8@eZkp<#Tas)=yJ=>NyeIk#Y3t zX7Xhxs4r~w7_EkONyw#}j;;I7upGQE3k*7xd*@$QxZc-DcPmKh&RIy`la=j~#tPYI zGdJ$sEQk%QDJQSt)1<%qlaIuE#U*w;0;06@vakd7HcmwcX8VlJ>3g#FX97beJ6Xaj z2~STqouVEP1{GvYD&Re1B9WiztI7#4NphrP&3sj-6`+OAj@2~%zT-2N_4m2=sf~E} zCv@PL=MF<$u{kw9(0#Is%;zO)`VK->0kT+}ZYR|swy!Kxx-DPkDTZS*zt@Wdmp+^s zb(gWkuCX-e{C+asao{rNzQ0djfBG)n@s^K5;!T6)FP;S7@oC0rFx;d~I4zA&QDv+l zGZVN$1#F4q@Ro~me38(X*c|+(l8Ghgqx7u|(FPH1uFwzfuDS^(`b8qt4TAh0@LQaF zG0EYm9);=b^h3-}8i{ekhwiC3(llW4*|LVgw&x@l%wVZ1F@u2{>}pF!x?h6NYX_=5^6V3V*sd1@7)rye&X z1!$fMXQ@}{D=SlzrVr3hkG?}eBTUd`%&0kN9`BGb{xaVn#kdc{;Ore7Cry6Oxxvat zIUS?QqL{NGNDktDUP>*%skkC~0Lw$=6@cQDFxh~%(8>~XF<;?h9uI1au2B>L2T z>;27@==|e`kH_7YQi5Ohe<>1Gxjea``leMU_%kd%e-At8fg%XEx&JPWvdl|a*DT=& z-)uEQw69z)myZoa-##9&d569*?dkWKXo+zu%J9`$u;Ej*K8ABCYG!PSG--=^vQk*) zZf(o=>AnpuwU)VggWyk7bz`@arg;b*F#FueTCKu*rFW;Uf7tl+WEd6Ogo8S)J0dRW zt%w|M!u?OLI?nwEONmss&mNk`3JsJEzP9u(w0p7iu)x#%u(f`%$xK%K-u~v)$zoE$ z4FxnI8DDpvIT+D0C(Jl+^S2g^Juh zff$;1(v*YWlR5IyCLejKavMARsONUjB_zkG(&(1I4Wc}(ZVUGE0WY51Z?0SHKEoln z$tyN@u+C>~9PxM{#bj`PNMV7fLad}R7dCOcB^KM>Cw*8TPSlIbKG!9XoY!}mt$g&M zONaC8<@F;fQ;SXg0syvDOjyG!6=Us7fy29)<_mB6p*oMe21=OfD{`49r~~*CsqX}5 zuM4Zqa|Xt_Z^ZPexMb?Q5FjlRhN{*XQ(Al}$gWs(5f+t*qW~UIUvdGtS}WV#v;&I~Wxh z7$Y>A+AhwYQ1yMaJ+l$PnFGO*$E<&;XE|0J^T|h5UU}i^CnZ0tmb7-lhvU`1a4c7_ zit?dA<(4l?ai5CANQ#W#Ne)Z0foLMOINkS^jOam+v+BFFTxl-!y)Eqh5^EC! zicM>#w(c3d?{dAnF#>0rO zF-gK$O|EqY1DZ~9iY6W!m;@8(sOQlEQtz-xAH7nSk`nZdB^h`x9f$2>oNt<%r=*-s zTN8r!oGH}Q3cZ-xLjJS-u=91o1-FK!;8FiQ>M>^M3#gLthzK^LdJ^l#W{WR1Git)d zLYRlrX*c;ywt&~k_LE0`tHa-GCw&V&5^&FON#wH{zBuVzC~4$Gz@%zB6TQZ{_t2kr z$?RtV`6`^0hKCio9-#~FV-n8yV2_Twbt^g*9n^D;@IYx0$Hw(#6V>e0>XA1jf{#V- z`a64i&&0}4(L+RcnKQdfsK?myCDfA|Ltgw<|jl}xS3Wbb_ zU2yk)22-ewuk48J3q3nu2o1i!zBnGj{e^z|_Th|$$YVvt`XyKD=Gt##Pa|H#-fLn8 zL_a$Wktq~N^AniEMxoG6DI@{fcCtbO5UL80!CU-Tpu$=sGU_4xpt#!dSA1{Up)5gR z`83-ojjJ0!N3L7Xg=|lBx!v1j$JNWPRsJ}sQf?x1xPtA?uyO|B70J@Lr^rRh6d6_*=Mmg}Hr6*^q# zSl&1`jORK$Qyuc!FS5mN#=N@0+-l&n#;R>SvnKC2o`MQ&`OPt;OsOL*6tx zBkndXLv%*7-T@LZiGeQ$+?#BJaZb@sMU&d?4>s*I@mGAGnqISV>_-2tx4O6aek-Ps z$?+b{IRt~`Th^LKrDTJTS+S+d@Dt8^7@=HN2F-$AuOHi<3;D`q!DsC_V%DPc_dNP& zgVWgP6=QHoVSPJaem0JJ_Z0uTg06p_q%tr1NG&Y$am8?>K2hUW6B^cgdDC~vL`C`t zf^`$|lxL{6t>RhdVVqY4#|iH-4D%?c+;DrX{y|OOvY4}3xA`7jjg+S$CAQ8Im6-{%L~=zr zT|x^@xEf=Q`7%sNbgCITj#j)6X1DW>Svwg|w&|oVoN`=!^e`!Lb(EUCpd4n9+2rZT zcZrW%PeIZd7_AW?zx-4>q=IWT*+xk_^H;Xezyv^Kx-)%D>iM^5Nj`DAY6aR2)3{-# zJ_hXcccTn%u%9o|qh$)D^?U++*D>8{4r0+8Zi~=MH0*=goDT zQPhYl*mVt{=mruhEx@8@cm0g_v>dBe<--Qu0Sr$%Iz86fr4G;BjGuYrZCIys3J0O_OK* zGwV^M&xaC2PSQqRS1NNRj(C`!m`PL(*|0kM*Ce4QX7-S^=+R$gk&Ki8izM`f=GbL!_RHj6c~xn zr2x7ds*30l)844MV((9>jcyzo@nspYIe320$ik|PxlBEVcCAir<0UKhA-tW`oK0VZ zjJek&bunmp&>S;&^U-#!deawz7~a0(rY)Tj&b&uJYqL)*=hvFO+hetl;dEPqv(Ume0F@6OR`1h4sMb5dpMAH8^kh% z1O!^%&Jg`I8n*1T`?xU)4zC?BtO$Zrc)67dV9Vu;b)l%*Y{Hm1(J3f&=|0}gLbS;R zY9b&loz?G6VglnWIdb29no?hTHoadua!Ezsio+GWM8`leowq3NTeeN_sSUBLKk^q} zc}}H24Y`YG22+Tj;1@5wk10c|@fkGcr+6Lq6UxZSx0>Dr#X`m6OxQ zCy*1+7z|>b+*7Bla?WmiX)oz0p9JAnLM4P48fs^W^#U&biQUMQnD{z{To|+zT9CYobJv~3$i;JQ4Q6P0 z6hv64t;Lewm-$$_hmp(t1_LRtB#!p&Nb{{PL5&J`8!j>+Cu8`%DODG6Uz9>eE-SRC~w3A z16e9Pj(&1~3SAPeR|VE6RidC4|An2$6ELPEkW*w- z|MUWQncsOu2~U-)0ey~dtiiy0p4I+2%kYn?rftR|%uYBGS$lp2@lG09k?vCv1xk>Q zqVo=Lexkz763I1>^-ego^VL4jWk@hF8n_#B#oE4a3LGD|nk$Ov+Ra}GpLsuj?2-uC z1gdHYT>{0hBd`#0fC*;S0pF+I;^h*1&&UZQ4^R`1N97R*2+DW~Vp7p%$$%FXJXIY) z)AT-N=$%msXm)NSvKkWN*i2$3HB`7>xP_A&6NRI9_6cxsIB4!M_YilsP|x! zhR1u{`m-(gQ|GKs^eQMCsk`w~mSjRTvEb z752A9&lb!?HAR$p?3f>_8aX%6*b8aMSmT?3IUf1i_GEAAPcTMEE?O?Nzlk{PC>!Ad z4b!B3C&KB^YHJ&zKBw|?0W#Hgyuwy?g>vm4WZ-wEehp*b0=mAkC7H=kW1rmZe@&x- z#0TDI0|5AaKtkH76Q^ILMk7(?A4TRkS|ow1&o`4_*aNwbd4>`5{C@NSdjPMwOO0=e z8cP8VK6V1~{_V5;rz=RENOodOEu%9b5p z)C5#XUys|>1ZmcxUlWE;ngPb3T0?!&`*G~HVeMj@_YM>V?MkHNQzCN`#Ix>U`Wai& zp5*2AUn|1|JM<-bG$|iTpga1eM>F^OgR5VKLawG=trK=z=Q5%4&c4R?*1z>b1<|---Hg2MgY)Dr<T4meFBVS?+K`Uyla)k#Bz0z%I*PMx&e*o}O%ibvOQxo{zGf~3l zBMYiwN$*TIWKLmgLX&*=_(%_Y{dPFvb#kyy*1d{y9GZ06p-ub;1rqj?I2nq9-xyNc zEQ^Yb4?k2TMg%Q7%Qk+GXon~&6c=KL`V2@KE;t3Jl-yC-=5`32cPj45ff=2&9vurr79PYN)t@r}Emd zdcb4lOtfCrhlL5Jw8}!5#2`uXhB{4dDKU=IBbtcRoh3d2fBn!O>$Qwt%cUc2a{Tj3 zYP)5p0}Lyh1zhl%e3=dPVlkoeyjqbhxj>^fhZG}g>c8kE12K~`fSKIz-*rxWLnu8$E?6qddnPPDA2fh$QKV};7PO+Ye+Pliu5NXv~1TVDwBczYsb z+E!)Df4LoK`x!oxr3wsuFua&p( zs(gtS(O!;+wG8zQK<`+#SH%{tGbSmUh(d@NkLLf z6yxV~8MCzpEa07j95(6c%u&e}-|U6Q*AiY@=}Bi z6`Y&+=9JjqmS&Wd&LB<5{ zQ{+em1|&C244*R367lGJ;5aW?+{d_%+l@$`7L6m@lP@pFa13LMwAqv2ot>&y0B1g) zkFd8=D?!brN@-l|YbAGyDBg^Z^H&_t(M+;3wc`u3`I@@q=5CI` z$DE#93h6+y`{Z_pY+;@A+)WRU{l(`u@S!D;b7W9|aG4~jB^=T~n4U|0j3^a&_bcC9 zrl5~c7)NYTM6?=aO}h({j^r0cwziHb)w^fh9cxe7eD0Oya?2`qzj+O@fh{x}+z z-imWls+#Jxy8#r~ff0n9DTFFoVqoV9v7EIRA~WhDPsnJm)w6Q#$`*xUFU?Q*T&%xf zbQ>Ao>64?Bm(~6(d^f|$AfdOTJ!RmI%7zKv$6?Q@x8dZrKkq^m5iw0o;wb{0Z@o`< z9TKPSq8bwy0-N@|jB@|Nf+ZU|?d4=}oHakDzgU^+UgIl^?(AQ2DaoN#B5< zGpgcq^Y$8Q5^#9iub-w#b~p#l{3z@hfDMAfEf##6}b9d_Kec+jO-9MQGLxBp}fXrAU7r(Aj>n`Z-N02-bTWIzWd zvZuD=0MI<2KZIN=LDZ2^Spz}>dF*3TQ`!_SyF3-#)q7OY`slDiLVTkkfm)?u$o zqPWT!_;c4eDBe@g?KN4Ut`YSA7^B6kOc}}$q*FstPgrivcI)GmD2YbXs(1!ZsK~Wz zuB<2Skap(NZlh5ECG`5gGo|bBmD3awlfp}=m*H&4thPKJFZhZd&(kuL0MryF52UG8 zE*u$?0=&>8*ZZn`7>AOiYDn}s;(h6Og3cKnravWmP4C%;e|%ZuM#II8uO6j{Yw5l@ zGsmvM#+$! zNED^qDVLpGNSz5wx4?t_-aU4U9pi}9k@-NIVwKMy?k9%%Et^VueUF&GNgtdPsl#YD zRoC2hPN(!E;dPezIJaj}k4o8-*#HPGGAK(p??d_NpYRR8xo6T{A)!e5h@sZ&-wZ_QP>DIjd!F1ru6pO3^LsYI9Oo-b>L^ z?su@F^y?pnu9a`VdEfhP=u9?a<4L@y?N;yENWHJnKqX#eq_fj%!pt`C6yws2K-rAddQHXu^F|u|+1?Op8Eib-9!M0H9J&j3h2<78u|vQ;yT>p(MmDz|l}gonW`NeYQ72>ZXvCs|MXh zkzdq)_=IIMKG$$_LUr|h(=DFymTKAWJ~OGUo-^;40NQ>~I)XdB2oNj)PQ3(wel%+3 z-qw9{dB=-6S77%RLF^{AH8exgnC>y%+IPyseec2 zBzA0&93e7O1Mot}1XjZmCKKnkz_DuF!e7{-n%G2geyj4}`k{cu9}!1&ovHJHVj%?Gu>#DGj^ z%#|ryR;NHKIC*5=(>$B|{PVcVYQx3C=YBilH59G47M8QoB$Fb#B;k?zjaO-lJ-eKW zVGI*Rv_)@Y^_19mT8EfTxVcFvZxX2Zr>n=KI*WdoW3+5>&7}Z>!aQz0!cVd6DdtPn zVJ8}zVz|j9)ZMVZBK<>g+alq4^qKVSQx6E;g*^5Dktsw^7!#@at0%nB4+~U-WM{_b z!5+4fw@7H1OZxkuJU>`DldNYNe*IXF{52ZOn;?UeSDchl>Xv%PielerQyVO8wrk;u zde?iD$N*1N00dhra+M(xNECovlzuA08S)GeQ=%P23j#Sq!~|@Uk+l8!0cNhqQ!aqZ zKw%TrNX0Bs+~;(44-kSdqD{DI>(jc&jO*@!sH-fjV7c$<)G{0Z`n~1qkwGDMeaBA*Lrd!GoGCPJ+8VsJUY7TWHrqYDetEYjRuWLsS)`0` z>0AfNIzyPRH#CVjsn*a;+t+Vav$s_N*Y8srw+9NZxt14R77dv*_NK!WTT8{73UK|r ztg8aKN(JPswlbEUya?V;HNUi5!N ztULp*#Ile5C8h7VPBGvxT_l*f@HY-gX*apCnBs(|jhVJ+0$Tbau2LNCwK%ep$5m1; z_P)_fbY-g1(W*9pXw9<^Lx|RvOHcNE_rJ6R7CHD3q#U4<^l$za&A5=FncXQW2pdxK z_W_1gB51pobLqbmxg$shm^6wfMDbSXW;KFawVNU*AhBj47v*E!g4#vDg$0yBy|T6V zCeVw$I@Skioa&ovyjQLZ6LXuu_@h4&qKbi!KVa<;Vs-T%ZT>hn`6fDG`2kK)3=k7k zw%O6(Bl$=qS73Er?$;{hADDNH`5}x09hiO}jslEwB$VRTKG6evENo1bz8T{Zlh-eV z4C6#=LT(I9;qvvk-XD{`MW^1pSiSGwZ{??>t_af7(&4*i@L*%eWW7LN@grGT3D7_3 zZw4m}eh{bnu7~6^So=yTw-m3hMH0;bT7o~pcTJ%M19`ZXuw3*V?U+<<6nTJuBC4*j z$ucB&NX3KeF5?~la0fC=BIrPcj$!)EuxQ*&dw2Wmar9jWV>Q}RHcB^OViW>6V)qM1 zS(Ue6<2h6=6|_mmrDXPwI38MTyF@|0`wiT9;5qDj_@#w?WXw=x=+dqpTw6bWLI>v# z4aSbc%`e}|)Cn-PK*D|(I~;u?7Hl4Q+AXMXC0*<~#lISr5V~-8ZAF?0^tcmkzz9q+ zkQR51vYa5tZGs|eVi>?w+sZ~N{_$!>G-OjvrP(AD%9VmG2i?xkjz5|8Y;6Gx8ab=a z&pa(Zcb)@kP%60<#(nsL27oU+)R(3R(Yp1}zhiBZsyuGQ?DED7T zyyv-k7m!`BQU@G1hg!^z%l(+|MsbHS(pf6zbD`D91_l!r#``5C4S=9xdiY<-p4n3; z^1sZjlPCeSSO5~w{({8nNJw0s?h!zZ(g2A<)eCk9Hjw~MZy@KZYjr$2O1eaok&`w3 zD=O$ot`;1q!+!6w5DS`5K_6AAW)47618&;v^7nOWoo+-)kX_}lq6uo*9M+7x-;}OM zEA{ylEw=0t*`!*ou_i=*VpzTYn7ePpe_yIWlQjHdoxAw;x$%_coeA24DwLVX1rEn# zeWzbxB?I)~FsBDw&z9T-lJ$34i9cG!P!3@8%lI~j1nLyG*LIU25@qO1J)`611Ypng@A%GK0cv>IaNb2*0JsruI?6p?`e=+>_ad-d&y zX%sP1y#Y0EwX}Ba4C|(1rMy^$1z&%7 z9hJ#;t`(#Qnio0l+QRvR)IM+lS#kxq=OC?rr%G2!4i5EhzuM~`f~V?u4UjpsjXwc$ zI&%|F@R(@iW2M^MQNuBa@dk*$ZzHHeLbr62??P@xZpgsaXYZf+I*yDoeS(Dx4ZK3 z_weHsNg;n(P5FjdL{tZ(@h&jR_Le0P~#uUDIuf!lWVEx{UAWB{VJsd1OK~C z<{ia+&Z;!Jsaf8I6-P0r3zn>kw^B$zxB*J=?z7m}Hcc@=(e(W|O|E(#7@=IB+(6d) z?-e(;$40!HmQC)WuLnUq%i3;vY+M4%i&u7Hhe1S%Ig%|^Zw6mCzofCqbKf*zv(2*L z=4wfpS?GN9cJCYK;}P4bp#A2_H;=yGYTkTi&*DpdFxBK=3CtR5pey*-%sC976!u^N zhP!Y%Uu01s&JQ_jpnzb9*xEMN{jm>0%$hU(^ssah{Zigcbl?)lpjgM0QW%tIIKOXv zWFLRTi;@?{TcVndgF4=*cdsDJdYFrp1DG{dJzGwVf6SVZMYLEM1wWF5f#4l2Ys-9K z?al(xGPplsicT5)hEnx;*iV7 zFH#BF9MrrQg?`p|k#J;TELGQ$Jal`Up`T@4Jz5ZGNBKdv$THj2T4;0YJ`bekkO$t? zVM&B3y(~Ps8a!-p{RQhs!cYTwJO$9zEy-*Dmj*pVX>lm^O;QgUQfS6| zUafzPff56dKUZqA1NBd{-&q~9I@uof&W&s;rP84*$lr-3>i=N!;#J;v%q~lfRTG|( zuB-I#Xn3D~=jx9G^ytp_02n-(ddpv6fv*N?8_jC3pgO?S^qSU>1vfp;%!ql?c^m)> zwTb;glR5X4M%Of-TeGQXtoAVZHpbWsT>yB!BRH6{4QLCOW53Zt`7Zrk*3d}Y{>QR~0@`WPrf3x0$f6Th4jaNiQLudfe_3UdXG`9DbLAJbQ7vu8og zG%62bYgqv79E&gYP@T&MCx)n5IPo2rIA+vSs6nMUmjJU_?&hxtn2q_nw!=Dr*%a3k zr%$`=l*vl?Onrn4Xn@wJGPG9x$v=rVyS2sSbB{#aEJ90lX(+C_;;@7TrttpskBG!vC z8)`u;+59+}Knai=PZMFSD!qmj+UC}cD%JBh5lc21UF}0a|NONk^=(%}a=?DnoOb?k z5xCG_7dK~~9e$)b6Nc&?EvN8Dt^aBZ;%_?<5ES;Wf;QM+09Vi~TZ*mDBL_)qj&L+; z`tc0#(K*y}jS#9HdqYf`o+KvdRxZe(evD$28T(@rM7aS_^|}C6uVAj|PWRr8rS@Il z!_-7fIq<;P0F;=hP&oM4Lb-t)7=YnNM`K~AU!Kan{wq^j>>0qDHg#xTZuRsM+{*#K zes1(f0?zLzfnw){**uGgc5VF6&vJ6zb={pqp?go1(A4**=84Q*sGaj`z{%I7%6 zj8>E*{VS*mtyf{cx7`1%Bfp5jx7=d)CnyXn*H~W*Iwlp6RDY$-dMlF%>Muof&{Jg+ z>i!#*Eb(uQQ&|csa9FRoR=!>QwWmQ|KO|K6?lE&AVvq6p90db-AOPnzJa9J^JX6~R z7ibZka!!b#rv99NA3(E=a0HY8+hIZkxT!iq;C_H6KNOICsnkexWYKLF-wC}iE<8oa z(3UNrs~gK+WF{yjZ3u$Xsv-xCHQ1`{OLI!K*q;KlMod80B=pjEMi;T=e)n>o`S~KI z50qFCmDOg3mwbrSZQ@?%QYm%h&DvDSRTy z)pKL|uK|x^=uberagKAmbdxr40cOp}tYnH~Xf4rkQb*pkn&JsG+t8@CviH)_y`8sZ z5O8>{SA=sqea@mgm8z6n7Q?vr3#~u7yLiusdHxglf1Sm(lE_i%%b8j{9c0Cz}%#?G!Fp2&|gZHnDHg3{m3ad@d8+~U8z}9EqDBo2#w=n z)r;J`y-pI%(=Z=Q@Y}QGc|>{l3Qg`2q#Jp7h>reS{>&Xx1`cl&bW!%dIDv_hn~ltM z-Yf;gBI$>l5>0$F6NkGYS6jO1nb>1BX=wSL22`i!qMB}vt(<(lyt(xt>B+F;p~+57 zKRizj<6@3a0s7bcm0W@>w1@Qu^hG!CEcI8o6Nggx!STu@{Ur8}JxIXcL5%_94_@aP zzqV|D$Y<(?WbB-sasDzI7|d}*s3lsGI^<}E;2trV9_{E_@XS!W6J1!NL5u{H_$6o% zW}sqH_UOl(yg4WS$H3|#Xun%P!V3_Jioa^c}CLg%dG)Zz49 zdt4asocYWEi+8|c0(j13RfbX2&QXZ^H-8Ugz;g!BcX5tweA-4Cq`&e{D=J99H3B}5 zZLXgD%io=yMFF8duWECVomurGb7h=-Og$l~e69m2>3dIoY4P4}9fK9R7Rfb1978M$ zfehaprb5^+==#z&Zr}0JKJA{E&Mt2J3c&P&j&tg^KnS52?f%(V8rW^s%{l}S`n%(A z)>n_L6k?rWuDQ$g{*@_@TN&)|DRaPX`G?RU0+_Jf;z35WI_!G!kmqzX@m3g_G$No8 z7s49P(KL3R-0~ty=dNRY*wT>ss zR-n!#VLS{Xf85MTe(U0Aiep^hO0;WTUHT_&(WWz5-Zj`OS2+ zhR?yBsQ3^;1;4e!ffAt98~A=v(r!0^y-(*8D`m>wM&3V!6YdwFqNx>v2UQD85DPef zKP~v>8Kh&8y64t!a7#;`QqtZLi+4sWslA($75icRE!vX;+CmKW9ZfuTCHI<&7dbFu zn(3XrE@#F8&XCCwF%h`Xak<6fy?4bu;<@s2DMamNS(6Q8lCUcBkq>^2dxM3^hj(51 z{(I<@o)GWfCkVWEL_z4X)$RdQa9!5qPhEB!I6?kW(k_W}BTm!C4O~go==dUT){1*{ zb)u?=D_f!gsHl0&`&Xa669EZ2lY>;>g!Zv$dpc;hST@eOUL=)?p{pgxOm zeJ=eEPX-=5tYLyf4Y@{m5?4;v@sq=W1|XwnklFUQ=_r?8nnCf`F@#HTjG3(;4APNi;iK^$cAD(OYLvhcd?t$^;?W0)zaFn`O z*&Sr6es3TEL=1(ExxjlB>^FD#*L5wTz&C18z~j+iuR|U05gEBki5Ig?W=+|)11KHD z&x9}K(ZoW=M>CxoMK^dM3L2sk!!Nj>!bQDDniL38Zv`RhY0=WL6VF7>!bQEn|B8A_ zMz)zcat^VaQ&+oCMKqLfcy=!_n3w{D-zcq=4_W$+;sP) zd~jQUS-dSPWnr4Unr(5U;Q3a+n&`ry0^LQt#b?7(cD*ftXDv8I-~m1be25d={1PGO z?r43&4r{uU*D8m?(I?W(@63DEm5;lJpG+-h^!xnae+E!2V=mNca!dwuIQOBP(QH+juV zt&Boacjf~vs3^lPw*D1BeG-VGQD1ZTbCP=l-D=b}qdTkADeKnS(b{V&%y5VvGFONZ z_{%2*g^9sk9iR+b;09^{TH`0I6ROmXppRErX1OF_r|2OWqdCE3hA5aI){es6p|@s} zdBC`tV~MM6?7v`st$d}y-IX>T6(h^hK-N13n8C&Jcd7ua2N>d(rQUaX09fx6_@f%{ zxqQm#?yIGi+us295`bZrB94Yv!%*E4AuGS5`p3@*(iZlFua#Z%_3i)hGeW#S1p$7> z#B$+@UB?qiTvi1iuKS7`vWHgWPJ@{UT9e?T%$U|$+ONfLnv^LdA4n#Vkv$shzzUzt zSzG3>iz57gn-zBY4p<#g{%s8K9fU;#;0y2<2c2S;WXd904S?1E1B)x+u-Ns)_}}*! zNLY-XS(z(=n?2(~bCW2u86Vb1B!c=ey%KuMGw7D3hPop7nKZkT$$5)kQe+ZK*Uhvc zg*Ur(a_!E6)i2%}2gG8MTxG{xWjlIvHd};A#gyey zusA{$vU>8IUZOk0pQuAz-k*sCZ8RY&)`s#0PS;?|FAM|S9ryl?VRBl}o~a=)4Djp$iD6(!4Aa#kx`!WBAfJwS z#0C(Ee})Y-!y^MJp#6nmn5&2Dvv9zdLtqZFV`ta_{>$gr*ttj%%NjpzeIb&_Vmj7X zlE|8e@+mJeirt5#qNow9RNxmY{e@w^_)Xx9DLlsBDHIMw81>lT{9R=ka+~!Ov$q!l zMh5jq2i5vP58u_O|7Qaj3~*AGJU+Je=Y~nl*s0(W)|YyOSythLg1OI_5wnJY=i+z=(8wE)}F`~@Vi`sXo*3|s7O|MFcW;2rHRRTg;S z1AmM$6NW+?4e3Be_2&fq;7tqf>;K0DJm55~$8fm&nAyR$9GXZjt9Fi>aeq4*@pk&= z0OdJNUM3E1mq8`xO+6+BFH}c#KlA_7KrwtlP7v)2*M$BDR#_S`g8Cp&|E{Uq`2mQU z62H#(`-L5p$e$K?N+XkjR^G27q%vm6ucR7t?;54XV?(^h${p%0robEZ^r&li_IiBi z&CabL3!S0&xE`sIg}qn)o;DB6CyVcNFWuN{f8*PEc|97JP?dYx!mhi)?`j10>xp#!odXC+Xf`lWbATekJ1T%bSklrkK zRrtE+X8Y?iQC?D&dP9m7Y79NhA*E@(b1gzP?f$C4xpKJEtI}!8?d8+1J&rcG{fv<+ zG3D+<>;Sm>a)NNEv;B6c_Xyj@o5>#iovGa1awJv$@UM5b?&&35d?B)Cd#+{RwuwO# z&fLHpcXw#*xjk9D8(gPqe}Jo5PdmeU53tKApAXSkWywbvI8@ld9TXkCGRU14!Zr`+ zR7}Na%GglioFPie{GBWUr=yT0}fSyX}tnGIjKVG z>=uvP%UKa`X)f!&1jeDTZy3RR2uchmHg38p)SmI%*GXRSM-Mr_XV#5TrSbH9lVK;H z?O5_=gZ$vOqjff{W^)w!wc<-4irhB051iB3wBM&&@Rh!+#5nDeKtwce?!MQ}+fw^2 zW};2hPH&OoTxwf;68m$c{I7S(h?Wlg-yOtT(%e?y0D-XXv;sn-!RN2haG~*$ct4RN zEyl+GqwOt&@(99o?H8Be?(XjH!QI{6-Q5DgH4xn0-3d-`cZXoXAvl5a5_Y%hJG2#CS@b_V9f@sgn48Ta&ohSI zRA2&68UHqK|JNV|+{Pv~C$EEq%qO)f>CE<;3JMF=Vs%|P%sz9FVVg?o z8Fh_?8V|-CLaL&OUo)3}ojD}wa;j__OADSVHCsXC_6oY-pu^o}L+J;;7YEcK1>t*;>~!z7ps)bge*k@Er5I>>i?zM77!9L`_r>N?*pBfT&o3j5?Os&VI* zHSwqBS9Vjd9PHWpw%)4m_h-;7M`6?i*!M#W_<=rAB*Zt1zfWA-p`YNg8KYRIv zWi@j5Rf3b?7Whb?;TlbtGUos7mrhJ`E`H|Ia{w;#;?`j}sKoIEVP#`tVr5pEmYJd( znNXmfkfJv~k`ef2NZf9-%imDqBH;Lzeqo&5v4xaB&dfH@FG%&hS1HPazQXW2D@SnZ znD7!M;jO_=np{;XzM>$irb6Ey7VNo0Lxtp}#jo><@z3#HHLpE3lqxse1YDpHF?Agk zbzLMejbm(wW_Mi{a!ID<=Hf$sge|2??ogrGLE%uUMT+S_>cIh7N($OAp}w={+73V5 zZuiZ1TFy4PI{nnH8^z{J*EDDbGgtn{B621>ILIYGHJ!{@!cXYlw>C0Tnip=(q>zfY zX>Ae5W!_p4$8a7G0Z!*ls0hsuPwswx?X_j89ixoj^E;F0VY`B(LBky8tRq=hyX|Lm zlTJ$RM8}(buFCKg#ke26jt$vivjPlS&R%P;NTD=-KFNWiOkr_$2A5M$%1+71fm9f4 zUP*1YA2$!iJugZ*%w?E*rhX$wD=#N&Y89>F3fZyQi@{ajDtcwZYtfv2Dmfu}j*V6a zxNDXB=cD|;ss0e<7w$=X*&AQo$pDzf=Y%ycnuk5?&#YiXijvZF($ba2Utu%%7*ZT; zEe5D5Q(D-&ElJi!&`O#AA6J+ZJ;My`q~XJ==U&)?N0}h}Fnvnn>v8+fZqfpDKyR(H zStW>PCMz=l^owYi2>ijp9%{a8r2@Hw_pY*o){g5T9>BL2cKW{(kwH$bziw1!u5mZm z>%7pkdf$x>-S|)d06_(C4F8qNH0-BomlvkOOE%uElXZ>24~I32yp(%D(m5P{)jRLw z29Pvbwk2Js#!7kM{zZ5pyf#O;r?y;Z3iuKOGh1;3OlP*}#f7$t3w4P)8C zJ9Mg(WSgf*qPLV-)aqXSmquwQy(?~+dT+=)u;aAXA2tG8mE$z)D$QU(cL=k0Bp(H_wI zkUkKYj@nAjI4(J2h`p@Ozw7!up*rZl?+91-Uog1Qpzk?7)M*^Bj3OqTT0a_z0met0 zAUjya2jd4#3F8;OCE;`~|eT@p)~ zH}JDD|2v1Voc>9{PzG#KK4<^s<`eqi}%`ZcxmUC zpRB^*zM8fe+{u}F1rum4W3KHB(FR$Q2dOb1Jc)~Z`?X~xA1`sXEsF$AS4ui+HnoBJidiCM^ zKE(a}V3gPu`=Z#B)hqm4XUgU|FQu*=NMP}k2e>xtUFB1QI120xwUpMslL3aKgrNGp zq&{H>orEXi!06yUovqJhny|!^1m6SqAGlVnXO1-o4@=Z;RL5eOeZtxiQXLpqQID}2 zxg65Ednu_Y)olp102aU9rS7OKzW4YVv6T@9YB;&ktdj-U_Ot3H1)cen3ph04y9vRA zi@%zQB{Y$HkD8*?eE!hkM&o>^$W|XA{hD-VQcD6-r8=>)uuM^NVEuwk|1?c*)M$Jon4Bt=UY?tGYi~a7aG9A|4}w|N;s)8^U7az5?3C- zh@i-D%t^S+?Nl{ziFKIV$khC7b?M-^l(HU%DohgS_N!^A;X*ywUM|rA5d%iN(C#KH z5FJ_dcm8Ykr?HhPY+HF*8Kh)BAFG$}2FctvdfE`rANB@716(3oJ@!puDy`?wc2C$fu)BwOXK(55pDSW zK)fb9=Pk-;6b4F*(-W}7jBo@^V?IKlnyBcfa*^BruoL4N=++j>AGZAc9Oq<*-c3VP zrq~bT3mSH4uI@KG*VTE+<|4~+RmHt%O|DD}76jM2D}^?(3LJA>;VyLwO`OdxMu0y$c>|t5XWdeAv-E%+uIAgZN6KBE>%=A&b%ljSaX8C_2y-_YnLg*1VM%kZH3qY&hovb3Hn)zTJlp?1|J z(&tR;@6S|n0lTa$$g=q%SSHNz31 z?%-HxBxcKm|Mv?Di{EBM#0_67Qy<89RfLx=T(!#$^qG88Mauo>$Xt%fOT#iTrQPFQ zzvCfB)ScxdR<9Rlt&>`kz+oQE*@4mhZPy8vG7*0bG@2@f@t#QN$^EiA<3DF)DoQ`a z>5HFBY}L@IH4_5s6cGHKSqyV>#$jVjv6LB%vHR{79VY8& zw>NVYdu;3tBAT#zx$YPz3s5+eS`sZC3)BHdIOeUtpkpU{K}IvQFL7$S!q*NXA1;^Zdh9r4 zN`vfE`(yC&aeWO|L5jo@@t{gdIqfkMA{Q7yEOKgrRGe}b)vJa z%I@w#!5>-CNI6ce34L$E$|FM%bTiZg9lPJ8NT_#ZKA6n@dE20cmH^7*OTZzU(|3dU zGnupJoyD#Eu0z96FqH}yw-Lt4n^}(w8&|CHzXb;)!KlLhA?}}NuPnA27bjzF;Ek)3 zNfX!2=7ED=8|+*O3Qr0fomgWM1XGE=mHCf@-biRwLKw{9mrVDO0h%s8;yv$u@y^p`i^<)X5We{OF=SNz_9uGwa zX~n<3lA-@0$*nSc>F+XcT50T4imxu}cq+(g9WqS9`HI8*IfF{I8^?DnCKMf}5uC** z`UcxApek{5ixZw8;Txrjkr<@Z6xz@^!^8*!U!D3Y_TLnO(>WwWTG&>!cvR|hf@af3 zhqHJz3wnDdudiUVG)L>@#}#O_&;3e~_zD(>0(3v=8^h4y;r!hrJocAvs+39Ah`YH; zPE>z%!BxCft#ED%Oft^LUM8h;9^nqwc6Y)(dq6%yc$@g?`Sl7v$r_17^Z)i5M&8@3 zLzIz7$PB0<*gho;y!C)Ea6o`(Ufgdca;NT;Rj_zk)H^|#(hb2-`NWJx zyp8i@Bac3|z6M+fW7IO}P0+E07=(_ji^z>WW2(ZRB8TVq%f{G^_d=5tN1d#v{Mg#) zTXHU~huZSpS}J;~N75efZR~FuPKiKC)~-&*IdwPNoZFPrWP5)kXqX3cL$(0M^PIxK zlET#|eGVl3%^-C2Q+D5ylt0&>Wf%r4H4o6rJ!Dh5g3;l>OY>^0&_^vnGzt;M5=<$m z#&#pp@;o54h_8$w5}`q7L_)TwZ&;}mhZ_z8+_Q!%g4FPb7#`4^{B(ClKXvDUs>?Dh z&Uw-ODR%{s>SEQ&R-5t@1$+rcgQ4=^Qn0YZqynG$&VIvgvK94Y3=@qMNcdcL?!7Uz z(Xw|S@`|No8}Vs$Dij@=%^Kp?`f8Pa@`dUVyK6{UNg}qlpCUo%+;XRP-!+OI>XBA< zx6OFIFCPUVSQOIZI_`*IL$PbOoQppy!!|NDo%!qwI>aB3c8*x<+WNBmG1p9Zzl&lS z3K2~u5S@S|5dsaWzQs|bUQTRZJ%=e`;Hb1872eI8dBZHh5!dcjQ-C}bEBK#E3b zt5svfSU9FZ7gK)dW~AIw*{8eJ)D+@X7y&-hg@hM|EsptpKPe|xWqCxXH- zYcT7U{6y~!#lYpl>m8@$1+pr=V>^+x;lqw^p~x$*sC4uX$BXrQR78 zDR|8RsR3&i9{cTF9VQaVM-X*3rfWVS*Q5eWZWRJAu&sMCV^0AZSanxN^8%|o(j<{h zsGwk-*WJrhJ1l+$O=h7ujpV0OJchZA8J?xda*n8={^$Nk7!-^a#(~#r{ua$hSSj2A z82;!t9z<%`qXAR(3|<`9EjE*D^gc$eQ~p$i9%vA3)?*ardr`A8eZ%Hq^TiI`3$uqT zrLVc=eKAZi>9N=3OLN)}^hylk6^^7-WVNAEcKl4tslN7^-dAOs5{k8vtMIfer8|Z> zvN`lxH36&HIz+R~JdUQPT@6%1%)|y$*^*y7IykVvQQ{KZZAY-p~Qqt>JOp;p@}HM4AagRC75d-^m~SJ02ImCx30*@5u$|XeJ1oKe5`8s(4%tvDoY9 zE2{+&Lv0(0fmC;e4+jw3Rf(;NfPOAyiL_WHcyK6G?oB(YcSHT;7G_?(tUIrVyJ61^ zy%Z%4_HcJeul(&Q%^@nu{V%;;@-rT18wf4WcbjB?Fjgg+0B45Xmz-4 zki!1;@H7lEpY*mVR;X_i=5nZ&-wkh9BZs-XN|lwooJVba)Ax?&_yN>7&^nu`riI+! z&^MBa+EF<39EjpldZ9sL4^RbCAkM{8@X+JcHsO&v8;<}?(Dmg0LI+!@knnyTbl-K zb+m1CjlRre|IHDu4#A&@=yC_tI>tm|P_w@Chviy1+j;pBlr=Emzl$rp;)n(SUzL8+ zlgT#WS7AODUrpsr*036v5D$Rg=uAnOGV`l^!jFm-Hc|Tl-@NkjgP4qLaCfimswcgy zn|c&(X!D}(1_#SepaG1u>yl6k?vQf#QMdM?O8L0aw3#NJh3rs+k#EuNAo~pp9d`A# zFaYRNSkO>KHAjQGoC?h|EM=pTNr{v!T|q5K1muH85}_fFl^SA!kOhWltH6_DeHnEm zmX;4>Q7ttD2qWcPA$`S(il$I1$3hxIn$ZmNvwXgOeC55*^tpJ;mg(6mGp+CXK{?jH zggV9#y%{C9b)@7#Pd2$HPw zYoONL<^UaoxVkN!!BW@G)3^HPLQ!B>^=O}L8e5RkwQ^T@%=(tMv=Su62pprP#l_v< zP@Ra*6qPrqY79FGIQ? z5N9_4kMxb05I9?$G{X2P-)X2DB&0pRws|Cm-?R?AOC4=la}dj{***FRkeA3YCcAd3 z6#F7U*zoP3NEk-Em4dOq*=a*Gg#Y0ORTgEc^&itL_76%V4A-vJXSx?Op-Talyf$5@ zNy8@9u04$l{k258jfR;`@RK_DbS~y<*|1&R<4(#i&R?K6++uD3eU1cZ=Y=obIr3|C zT(q0{%JO=`wkelq2U>)(bcnMr3`mtn@_fw!qghEIrgcuE|G9&i(m zrOw2p4z!^d2%QOQ5lNk5XgBfjO~Cjs?59(#P;_)%u7;EK>;{8|{kgBRK2`9V>a*F5 zLttGqpoXmc^Apr!lTX@A`=-9R+!!hy}Tl>xP#uzh@0hCyWO0&Qr z>N&P(vEZtjqMZbXAoP-MU_OiiVr-)^;~$#!V0N*FHiYqxaXmOLC7>TH+&EHXPL zQ6lHFNd9KIT-GlPqn1#WN4fpH>ORJ>N&EZ8IVYALq#3cjkid3Nv{;%Xd&v`I*9nFe zTnR_E<$R+BQ#PFr8!c~kXKpjF9^9secwVWTz(nYUMr5Isml??Xj7~02+;>KsmgC|l z(CYy_V5r;~aUIK;ZZSty!fuy+usvc6vagIRn_5G=|wF8>!*2Ao`Q< zP~gv~B=ZFwA4#C0Q-x=i3{=Kv4gDmWBdkI9$58@E?ToM{rQe2RYP$sUj9!+NHj@cB zH7r-;!Sb0!lLJwBiixg%F}qESLZ4Uhwy*~OPOPr(k@{PO(B%T}VG8D2ig{loATfSb ztm8zE1Lo&O>mH)B({pIMq9kxCOF5;gvQc-+WmSJ;`e2*pK8psJ>Gaw;g5XBbhwRLG-d**1~hZTxt=zU8a!Pjl#TI{w+uBvkD zv!xgQ79j3y4ayOb1b&LWs;s1r^&}i#;8F8*JK&v6cPN;MGAPq~&1HC35(Kgor+(v$ zt(VB0b2CtzkaUIEHcr@E0r_7}U1(=F*DJS$F*zf}NWtN@5UcFCu}8)rcR^=f1qk53 zDxj5ARnto};+1-G4Ih1t6nr`_cRrVIT3iVDlnnWFekw2gKYQ^*JLq0Z!T&FCwFknF zxH)g-2eD2KZXPtPQD>}=ws3&FD%{ThD?LZ|*_rPVxE)LO+P*K6PCG_EN@vbSfW}?C z=l?6c|G7Ep z-!+DmpI^$RP(xG@DBOGf*q43hxziu6+BLQ5cPAfn?}-WUt-+(n2mRk#!t8$(`ve#g zfUh|L4eSJ_KQU{ckeQXM_Wp zE`Yoom!%#!P2_Ge|QXi<{M1WH=6y# zfgup29{%$$-AH*V{0jVVS64*L5z`>t4YP$g7d*D|0hGLT><^X$6v#XdiPJAL z7xqre7lL_$QA0N{Ecy-i<$ z&HOFJLKRIXHLL=)&I-p8oNR8|)G|5KHrY2KQO!9P6cjW#QFsJH4;nu|e`b2n6-s0RhFILw4Kh;b0|J^jLP$bEDOH)`n}eP!7@KLJ zpyMade2^YCB53%dd`v?S02BBh%J2Yi(jXzJULuyj2eezk^S7!fF8~4vh#?s`3?Ntk zI!N_?thMjFCE?fIkrQ@DTAgxFZye#(NN@Kie3a3nI~Vs{4|2>A5Q5l0t_K;e?yg^Z z^jc1Slwn(jn#cJUgGO!u)b9F};UZH~slMG6-4L?GIYu9ml*+u(M9s0}7e)`&E(>+o zV?{A7Q6xzqwk0$CK3^<06t z{b`jOeikLP8~%j5iR5#Sl*BI`dtoa^G2+8U{j_*R(M{i?d%ulrb`D#n-gq+dByEYq zcD%sz%%WdzqBYj0 z1l0)61Ymg>k-0{Z=5t))xQ}X#xiHk=?8q{Wa9rGNi#}1iXO52YX^NAk-X{AXBYqd@ zQZMyo>BPE+-1>^ghMtT`db1AV=8<5|$UgEbj9Rx&?Idn`z`3n)Vpyx<0%DbZba;>) zx=2GmV2Zmt8TjkqL7I8x;26_7-MX z!fY+b7>6LrU-H8!$u@L6u$(ik8Iy3clFTbX1wivD*r^n&mOa`}sT*Qi$T4ze=G#NV z+qb6;o0|EhAT6%9PEe+>f_uOp_vasHjN0&p`SC?E4Wl%Fip&mShy9eY@Z#lWx>4QT zj1j}8ia4C`=g#Mjks!G};feguU z4ZOtxE(Jk$B>~_dpNYv7u}-%@L^-U8G8=V`r4@Pn$eu>sPNp;TN5rTQ&MQzkJ3G|x z-kRw~*d24!xieF>xW|4Dn$UZv%;mr8PoCV_9N8j9vcE~>2W!cItRe*Gy|!S9j<_ez zk*oX&u*Sl&SudC$zYxJfvqN~o88QFFoyE_ohdBBbO~>GSyjnygd`O2CE+?5a7IZQV z%b3{Va)jBaK*bKRN9_pnBqwrC%zk5OBYHl{v#z!FZIEgFxB$wQV9Ko2iitrz*^-0V|t|9Aj6J!gE5_gM-=lyyNx`2PI>sm`w;|$7RykyHjf`TeE`b1bb z#v9I*(uMz4pc0PfJhGd_7v-A(9kxu4i?a_cYb`Sg_TjvT!bCQ|n(CQ9*(1YjyjJBg z7{2=HMo6p74A1GxIscAqqVqAV7bLGV385d@291QF>*t7}ZzA?gplQKM!q!gTpr-gW9(Q&#d5E23mE=0}Ub`JDnE zP5TG|lgCIiXZ#YAr+T;EoBsJi2|a11-m8)2Gdpr59=4tBG=1CIDTv<-mc7-PsKG<` zRR-M#D}H(Oohk}(NM~)Dw@KNO*>h!>wn*{L`0e=MgjTzQmoPu;!9&TP{7%u$oIrAj%PTL`ax!d|0Ja6!?rnNjk8g)giy~CY? zK@DlW40i(fNp4mj*SWFnlYbK9((0H&7QN`ONxW!yNW>Y^wZal82wSGH^wXkfs>fOQ zqCAlX!s|*(jnnz{b$t|d@b3?wd`eX3@5mk2W&)wZ^2)1=KCytec% zGc%-<#L_lo>ew>|j!bf_k>#DOn~k06gxGt2OW)iSoT~e(X*9JjK$fbYwHE6){@p65E3lcl@PxL^<)Pz+C<@yKhyJdOAf0kNWS)U6wej_jTIO&{8D^X zPqFE>SkVII)&jb*w~qXyfFK{!l5HYtb8lE2ZFh6X7KV|$JkIHJmST^kLwj>9r!Ix5 zcFwi>cCaJXZw@+A7s0j3g6P3}SI@(`DUdRrl@6FQP zw=&#FT2Jk6HPVg*yqre{6E{7f4&2g0(%TU?i|7nzDLAWN*@czMQtMtx?}K_<^WbDC zBz}+5P%L5alF4YZn_7I2ao2SG6FRQWBUj&=?f2cu8Pc>#K=bhzheeFs6m`k@rI&5x z%hwQhie_EwU5&o*7RexYwuVY%8=^iIO+mXWqgJhs&Jhg|S6w@ieRKDQ?@T|D8aL|T z2^7{$2s35MQg7tFDH74lQknzkckv^xSG>?*f=q%OmxtEFLO1LPf5%Ge;+1o)9fR!& zDCgFMZ!Ly4U_jo6T@68KdY>isknX3-qu7koMbeCvZ$IT%7TEc?I!9~d6_{GiB*RG;bwV-9er^So!8Fw|-iQx^iL7TGlE*t&x*B z-jM$C==c~a=uI$ELc+Xjgxf}X{zjHZ^4%mQ^wM77lG*U;+fI<;iTS-9aZ^NVf4@6N z_C7+G`4`nM@}^=QJBVR*by7Kor9Tq94$~Zeb|G=pFd6``7Ms5xHew6@ju0jz@k=?X zS@yvE8+jC^ko3to;}jRmBF#)A>4N?sL59wu!RSRDs+fwl`^~*@p`+RbEB4R4#4kBw z;?`G+UNFUr=j_ndA($3=rGXB-SQJW$2T4z~@;yasmRC^Em#H*_KqM=XFe?Bxmp$26 zQZan!ey4CM;RFC@?trii!ZeMriX$Gi^f~|mr+5+{(vdO&L<#_UIB!I(Ne}h;ZGxNS_v1Gdv87<-Cwx|u zuCHQ`om{vu!rQ28V_le=`&_*GAu$+>ytA2u_=sW3x7PWgSND9faKE8ma2?hOp#BaB z$eXK``#g`?=)bwmIxKe~9(a7I>4!aE(3MGO9FTN$*iF37c9TxUveKT%M&SOjh1w;i zFc}}Xvu{!GZF^!`uMl3CvgKU*o<^#hxA{ew-$dGg?DOkHC;T9u~xM4i_eSIs8B-TOpiPoXZW z|4BMn0(=7)v!)_4_6#*rREdgZ^|*|Q=|t7y%Uy%Vc^>!?t)!}z+v6ONol{9Y2sAw$ z;dhFKIGY_4@qR}FwR>7HXT^1Pkt{p3{rmIiWiXAB+)p`O(>N21=hcQa1*W?Gw%!lI zfpA9fCq&;5Aqf1V1RS&1%L=0kcFgLbU*H+w-~tP&*y9g*en`#}3>FMe>6(kOV=^@q zcZLMV0 zg5n=m7~AGTOU(*t-j z8EnsBKB8!{r4O-~W`FX7M*`1*woQ0|qizQFxMr(w5seXi}<_+W7~%2*`}taXJScphbD{8 zaXUQ8==sckiaAgqDMAucU_+Y7&LbrIb%<-lcC4ZbJr4Vam_8*f2-BpfOD8~1; zZWcj0=BYsyhvD1sfDJxjJb3*S?ujc`2b`@p)G_{~+TN9xoBXMv=sY&1;V4a~Ohq+1 zB`wDgF``m`01Bvvgw<{m^vpbqm3#jg-kv~C=KkW=TrB7uI9qCodbM^Fl>g6 z1PLN=l&5GDqj_iU?h*``Z%u9NmcIm1|!$%X_ z=WaBBv1j~LT4fZ1)WtDp(yOA|*;`F(>B2gP>6MyuiIj4h%(S_DFkJJqx&Dzq^DQ?3 zQ`Hv?4IbU_ztt1tzxBvU8q+G!m(X85Ztv?3c0mE9*)X3QAUreShkD@yiQpinc~yL` zV!^l~A~2_|BbHHUp2v>6ytI{5$i++~plDx;@rPE&U`YFlBGGi9K~@c)W{0)xlV6a; z?J$3|kD^z}V6_tlPvD+(0X;J=|KFC`sTYIL)g^v}EkiFGDIzaZ*VUzgg}{b@6)W=c z7D|u$1Yo5^p7{;B?B&E*@;}uTwEDkECIJc%ga3X zI4^<2T!mj=B4?mlEx&|Dp)T0*cxHBtcqTdZ$(ysHCPny|f{_1&fH?$F`WK(&Br-CP z2778wLGga6sI+Dj=c)jJwJ&tVDVXa=5~Y>to}Tdm(cb@90#9t|EcLU)qRmOG1UrB^ zE2CG@=P+bWbiXHp6@6KFG*bG8LVZ+ry$k?I+QewEkN~PowtjKSzs3N-k%HdYYyMXR z+P_QcFa@EI2jAKU1^Nd+QaV>92k+l};Xk|T3r8&T&gptS&BD33V)zUO_Fe{H2BvGo zi33n(&&;(x&ork#3@=pR0&gmy-^*xPhul+v!nvZZG-u&%bHC~nPtD{yGyuXIk!KIW z_&vjNADdB_g_E9+*-V^};;)OMgF^M4u|BHxDuU>ZwJC^=wL%ZyDa{BxE9qcHfudTakPo1)~u z0YyQz1vr4v@`9)SH;eK=iy-*-=0#wpZ*g91^YG6@Kg^lYim}I$SW~bapos@fyi9Xo z5{fEU%-GyV)$jbukWnK>Ss;cs(Bc1@bxVS^)b=Jr#Qw2WUqJG~VH8N5s}bY#jUqP_6(fEYP69 zydM~7ux8;-PCfo0f?kP9pFku?kR8E;G0CG3_IW4bgrL8|WDXgMii ?0P!U>0{9b z|Md$!(?AzWx@5HU0$63s+ox*c{8-D6kfPgFi%&B6AtRl)d^`QoI6 z1>4&L`u_7&#@nz1`qkENtR4vS=LP6!09%k)#${|826lkz@33->RvWtmA)7L09#-!Q zm4_<(S>X*etAj(>LnMj%R4P#(%;)Ba7EvV$wj3_AM>+S<4OXEiwvZGhUDFM9ZWkyH zh}o9zUXh>T0n>fWLem!f(sW6!?Q0nkQ3)-)sYUu(wavm3n9HBgs}81i!v6F7^Aab5>Z38^a>=+tFVWKKSD4kSc zjQ=b|lvslLS_fOf${f4JlKKq9*uS9ZRI;!zQRm<#NLOD_8aph-tpRKd;>3UxdgJjh zdUJu1L2bbWKa_JKM#le6Z$ipC>y?oLEQD~rvGge#ZaPF<1XTu;HBz#Kw+m{h=dKLWY(5p`#g;0)BG_}m zVZ+2EIIp}(Jsq_%7HmhSs0ileC1lI6S+zN!zgV)yXuVy%5oev5B#mNcIkEs z@Vp1y%^;m$2VMM>=2JorEZQ{8iR^vwsf-WfjC*@DG&Npcqdc&D6WwbS%ttt_MhbG1 zyDeOCoJF6N^>+mpE}j_0Q|vXdp&$%S$g#!tL@rbAJAzDlMr3U+NNx|E3%90m;r|Y- zR8HzfDm2g8MvivX|6&2HZq=-tQ%()_T_8w%!_*OVzg^qs`YC0i8@I!*!jqp@rHsCF z*uuT=z-cecR1{U6;qWzL$onbcjT+}m!)1H}pjs6&?&od@lC+Xx-?!VRgRbNF$(2>_ zs1pO69`|dF>-h1z1#P)^OTC9xG?`r3O~Ix|7B1hqSEtseVZv{9WfYf3|xk(=It-)NlBIsUExvpC<{yGnw`=B zE!UkE8Epd6?oS8MtW+^~=qRmt;fju`Qyako=GVm$5ucLNA&T8*Y!dNZ9Da%0h0@Vr zf1Mgn$IYO>(chA~h4VmlhjHRpO76nLR_ylrG|ReV?5C2Vq7u>D4qK)F@_yAHjoNH} zUd}>?nx{{Ffi3f1SZ9JoMdI+;)%>=R=Jsv6IK=jfc>IDcj#a2e@QLwGdUED?AN=rn z(ka2hld7Au7ZmY#82|Is0>hA&uPWUhT}Fpv#evMzU@cV+wIng^@%99+UB>-h8IjgT zFDU-Y{?CVvuhri@NDgo|W9^fxNr)(u?x8+G5{@NQl?i&Yk{^Qw=>zKFq<=p7IXx>%WSuA88A%ihLtweC}+)7)0N2ku% zT2%l|6+XXm{`66Do$hDLgmb0EWn}ow5Oap-7{^~dyW{I*ie)uaqY8^jeku;ERO96) zL<$65{jg0WT{`1Oq7!mD`AJ)P@m5QnKQ@0A@d&fru&cgI8=zzrYsaH%Vjv; zQoz}pIb6QE3p0zPj$ZkG(xny9{ep|X;i2m1no{(ts_*gFHb~G0C(~RH!Fd{&@bbo- z!FMmCI=N&}u-h6=i*fvjrLLAvwBV_!oRL8tW|;7$wF83|>r7n4YyLFl?@TEIyZO~^ z_!hgRn0L}xm-~g+#@J1RzOT}E#e?i5d}Hm7$FuDI;+~Al_{6N~s(Md(->loHHP3|R zP{>WU+miY&_TPjCXW*XmFRGSPM@ilBSy9g2^nMA{$qsHJtDkJ{>;Zmb)?$8g*Ft+G zlM3id3MUbfWm(yjqr2w=Te6kBkoGE5;?v^HP>k1*G7e zkZ(M+kzHz}Z~W8LDQ)Y;xE0!G-7aP9C6PS3$(#L;#DdWd+2+!#k@ZROCJaxQ%t>p zXJrLWby3Dq|JKg+(H4v^n^a5A`D!Q`D+UdIN668l!}iMGkJ4?bCgqc3s)^DK1PTtlNQY*tgoib| z1(eNdxk=9C?psNVl-Zn4<$fcp_({vkudo>+m5c_=Rw%wdG#7tLsRb;zJ_^7_tg~X- zz!s2gA~|iXpGp3jg2kfip`CC1w2>|;gB8HtSGID8zlMnbBhhF}E;So+v;j2(H%HQ~ zI%w`gkdH~rGOd(lMZ-lgpv5e~B*^Urga(x<33e1#;`krF#SZw7C(o5bwS4!tUs zEruV4ID1LCX`}j2`%Rq-qJFxdd!aUGxKTKNVh%_W3k8MeP&+BNlMY z?}}uxk?kl?*0U&1Q}dR;)Jhz+AmsIu_I^J3l%u9(xy0n7v*uP1Lwu+P7RRZ>V4WpA#F zPTr7y9!(4(3N!WG1KCTCl<%u>MaKx>@fn(Pu~H)D&xvG`+p@24G$iLWodN`)D!?u? zSyTQ-YyHF!Ex+8}`D@LWI*rw|1%3ynI!>~UPKz~#@iM`bhBmJ^gBGUp=P_`*t9?IG z%wqU2XL;4i6#RjZ7)jVV8P9l5CuaKn86v^)>x@;~_-T*}jE=@l`thtH}eKJ9nL}5#1uSKeJSg@~0M6fv3{|%iNN7 z&4af&i}fzwP_l1l%bI!%E^53nEu`f6%cIfM@2IhtLyvq`zP+5zb6w#w@)+kLItP&9 z2YHok5pV%3%nqd1t#nD&wWZTzIY(RpYer_W-yrfM5qB9SN!^&n^iepn(j2>&aHz4LYzbcjjCite_R?d|8^myhqv+ zvdr}P{xY2C3rlNVSTpKb8xwbAIraIwJJkb{>BkXzyEpj}D!s!12k#x(2P}18^O8=* zb`Phb3#>8WDsSE$=+6y!h3B`YQ3Xy- zS-}AS761#&`@$Rm0FVrHkFb_`2k}Aa~Kn-UqA>sGAS4dq>m(bqfIY?_6Lm4gkk{Bs_Bez2aFxa&G~?_1G%{$Fm|9q-Uo~w$j$$Nu>-jUAFw8%ZXv+_9nl2TEdn^a zbAd@Y0i5rVtU!DA2kgD)<$S>2J3#IS?7dC$K49Y&Hzw&|)*t=Na2aFZSE&6~p z0CkH24)2Htpl%7k@tq5_a{;*CBRPTg+z%Kh(4O}J;{M^#U;^3;{=piU zfZW1=um&a|x9A_NfeFYh{s(Jd0&+|K!5Wx=+|qxr1|}f4>>sS57N}beaC%470(C0@ z&hK1chzG#)9{GMR7kW!d>%Trg4-!46n7v5<(7wt80J{V{#r_rPJ@Q`O{XO!@H|gzi z1q{Gy1!->oO7m{<5cy}x{0|FL7bK4TtLVF>Me?5|&WrMGrvVDU1_wD||Em8_O~U+L z&F4QXoN7=2`&Z9@YI6T9bN`vA46@Pw4dtJj@IT9!&W|hjUv-^(P!mTS$1mO@n zf`lZHGx)$4B2`hLijjwcGQMyuS`&%#*n;iyS>fL?cT+^&n_mRe<1+Dr{b0wTGBE@ zYVq;6D~GNrg$LQb1#uRa=LW@n7fSw#ZiHSKZVieP?2ByEE4(RH71#z*-3Qr=VR3?| z2o*a4`XhM^@2P*)54PILz3iGjX(>*%3U>@xZc(TR=b3101c-)wB&Pns>jardU2g*g zbVg9qy(mrV`L3<`ZZ4(D1jpe73=;#=EOSJt2m?Ae7sJwpX_he}lskeEa0P~cjH=8i zFu&xU1yRno=*uvUq=$3Dnv=G&^9q^!Fwg_h+ZQNZ%L$uBsB8i!AwL3B?**JNU!>^k zt?M9<#uPFYv`IU?iI`H}0sA0N3?znavP=-E>tV0}@)1OL?k0<$NL`}oK3r9#x+l~i zI9;_>`F<~>SO+E-qJa{r-V$gKW=YiTFD3xa!}P*pgVw&IZEayAr91%Mz?B%@W>2%( zrk7q<*Hf-KPy%0srWBezDuPoiE*%C1rd_}hzR+XpwBoQ= z!BeE_PVgRfqtD@(J<=A&2#!*0DLUNU8lpQtMyqNdy2@mrGj`7;3gH_q-VJRb4mXIl zcNUXn>|RDxv>R!!e_JN2nAv!cTG?|8HVUSP!w`)#eqkL8;RG<0M#XV+W>HaIltLDpPni6mX-^Q3P z`R_Dq{Tth_Pdt)Cu^JG;q2&zorM^6<^^mCMJCMz-!F20(Xz~6W>j3;Y6VcrTX)lSo z?V`Imejt&^G6|-3u{-p2e+|d0iAJtT2$1mTc+kZ0Xe9ud%P?>Oc&uBrOwv74W$`7B`VLPmvSMP z7FL(GxLt3jHcSYjR6T&<(2qiA>&Mzky}d-$5cqOvJ*czQPpH&8evW-icX9k6j91rJ zvRScWcZ#+2A&zG;=5!Ap)}NWlH}4)sxir(uIo=oJin{WEjL*>v=`0Sfl^}ODMjaF`RPUFlB9`tTRYvvJpAIPe9D5tMLR@8(_0rUqN9}KNx^pNu z12_TkIqQsytee{MTBOSHKo5C$OrgHu)F#n_`~`4={4}D_;CFbNNR^X8NF165tqm2H z)Ol_7J;{Cznm)}BGT)ylzX>PU)vKw!{X^GVvXZyogTYzbjxOwssXAlj I|11gq2YVExs{jB1 literal 0 HcmV?d00001 diff --git a/tests/fixtures/verify-bundle.sh b/tests/fixtures/verify-bundle.sh new file mode 100755 index 00000000..f543e212 --- /dev/null +++ b/tests/fixtures/verify-bundle.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# +# Guards tests/fixtures/foobar.bundle against unexpected changes: any pull +# request touching it should be reviewed by hand (see CONTRIBUTING.md), but +# this catches the obvious cases automatically: +# - a corrupted or incomplete bundle +# - refs that were not in the original fixture +# - a file that has silently grown well past its expected size +# +# Run it locally with: tests/fixtures/verify-bundle.sh + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" > /dev/null + +BUNDLE="foobar.bundle" +MAX_SIZE_KB=200 + +ALLOWED_REFS=" +HEAD +refs/heads/diff-features +refs/heads/master +refs/heads/new-feature +refs/heads/pagination +refs/heads/path-resolving +refs/tags/0.1 +refs/tags/annotated +" + +echo "== Verifying $BUNDLE ==" + +VERIFY_OUTPUT="$(git bundle verify "$BUNDLE")" +echo "$VERIFY_OUTPUT" + +SIZE_KB=$(( $(stat -c%s "$BUNDLE") / 1024 )) +echo "Size: ${SIZE_KB}KB (limit: ${MAX_SIZE_KB}KB)" +if [ "$SIZE_KB" -gt "$MAX_SIZE_KB" ]; then + echo "ERROR: $BUNDLE is ${SIZE_KB}KB, which exceeds the ${MAX_SIZE_KB}KB limit." >&2 + echo "If this growth is expected, bump MAX_SIZE_KB in $0 as part of the same PR." >&2 + exit 1 +fi + +ACTUAL_REFS="$(grep -oE '(refs/[^ ]+|HEAD)$' <<< "$VERIFY_OUTPUT" | sort -u)" +UNEXPECTED_REFS="$(comm -23 <(echo "$ACTUAL_REFS") <(sort -u <<< "$ALLOWED_REFS"))" + +if [ -n "$UNEXPECTED_REFS" ]; then + echo "ERROR: $BUNDLE contains refs that are not in the allow-list:" >&2 + echo "$UNEXPECTED_REFS" >&2 + echo "If this is expected, update ALLOWED_REFS in $0 as part of the same PR." >&2 + exit 1 +fi + +echo "OK: bundle structure and size are within expected bounds." From 3f177c4d32bc23e3b3126876632831419c864b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 17:26:15 +0200 Subject: [PATCH 073/150] Add a Console-based tool to regenerate the fixture bundle (#242) * Add a Symfony Console script to regenerate the fixture bundle tests/fixtures/generate-bundle.php replaces the manual git recipe for adding a new fixture scenario: `extract` clones the current bundle to a working directory with every branch checked out locally, `build` rebuilds tests/fixtures/foobar.bundle from it. It only ever extends the existing history rather than starting from scratch, so existing commit SHAs (and the GPG-signed one, which we couldn't reproduce without the original private key) stay valid. The ref list it bundles is now shared with verify-bundle.sh via tests/fixtures/bundle-refs.txt, so the two can no longer drift apart. Requires symfony/console (^8.1, require-dev only), which the script's Application/Command classes use for argument parsing and output. * Document generate-bundle.php in CONTRIBUTING.md Replaces the raw git recipe for adding a fixture scenario with the extract/build commands, and points at bundle-refs.txt for adding a new branch or tag. --- .github/CONTRIBUTING.md | 27 +++--- composer.json | 3 +- tests/fixtures/bundle-refs.txt | 8 ++ tests/fixtures/generate-bundle.php | 133 +++++++++++++++++++++++++++++ tests/fixtures/verify-bundle.sh | 14 +-- 5 files changed, 159 insertions(+), 26 deletions(-) create mode 100644 tests/fixtures/bundle-refs.txt create mode 100755 tests/fixtures/generate-bundle.php diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7486bbdd..8fd7040c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -48,26 +48,27 @@ a local git bundle. Using a bundle instead of a network clone keeps the tests fa fully offline. If you need a new fixture scenario (a specific merge, encoding, or signed-commit shape, -for example), regenerate the bundle locally, entirely from the one already in the repo: +for example), regenerate the bundle with `tests/fixtures/generate-bundle.php`, entirely +from the one already in the repo: ```bash -$ git clone tests/fixtures/foobar.bundle /tmp/foobar-fixture && cd /tmp/foobar-fixture -$ for b in $(git branch -r | grep -v HEAD | sed 's#origin/##'); do -$ git branch --track "$b" "origin/$b" -$ done -# ... add your commits, branches or tags ... -$ git bundle create foobar.bundle \ - HEAD refs/heads/master refs/heads/new-feature refs/heads/diff-features \ - refs/heads/pagination refs/heads/path-resolving refs/tags/0.1 refs/tags/annotated -$ cp foobar.bundle /path/to/gitlib/tests/fixtures/foobar.bundle +$ php tests/fixtures/generate-bundle.php extract +# ... add your commits, branches or tags in the printed directory ... +$ php tests/fixtures/generate-bundle.php build /path/printed/above ``` +`extract` clones the current bundle to a working directory with every branch checked out +locally, ready to receive new commits. `build` rebuilds `tests/fixtures/foobar.bundle` +from that directory, restricted to the refs listed in `tests/fixtures/bundle-refs.txt` — +add your new branch or tag there first if you introduced one. + Then update the commit SHA constants in `AbstractTestCase` to match, and run `tests/fixtures/verify-bundle.sh`. It checks the bundle's integrity, its ref list against -an allow-list, and its size, since GitHub renders any change to this binary file as an +`bundle-refs.txt`, and its size, since GitHub renders any change to this binary file as an opaque diff. If your change intentionally adds a ref or grows the file, update -`ALLOWED_REFS` or `MAX_SIZE_KB` in that script as part of the same pull request, so the -reason for the change is explicit and reviewable rather than a silent binary diff. +`tests/fixtures/bundle-refs.txt` or `MAX_SIZE_KB` in that script as part of the same pull +request, so the reason for the change is explicit and reviewable rather than a silent +binary diff. ## Standard code diff --git a/composer.json b/composer.json index 1d0afbc7..7104d44b 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,8 @@ "require-dev": { "ext-fileinfo": "*", "phpunit/phpunit": "^12.0", - "psr/log": "^3.0" + "psr/log": "^3.0", + "symfony/console": "^8.1" }, "config": { "preferred-install": "dist", diff --git a/tests/fixtures/bundle-refs.txt b/tests/fixtures/bundle-refs.txt new file mode 100644 index 00000000..4fb23c2e --- /dev/null +++ b/tests/fixtures/bundle-refs.txt @@ -0,0 +1,8 @@ +HEAD +refs/heads/master +refs/heads/new-feature +refs/heads/diff-features +refs/heads/pagination +refs/heads/path-resolving +refs/tags/0.1 +refs/tags/annotated diff --git a/tests/fixtures/generate-bundle.php b/tests/fixtures/generate-bundle.php new file mode 100755 index 00000000..59040b5a --- /dev/null +++ b/tests/fixtures/generate-bundle.php @@ -0,0 +1,133 @@ +#!/usr/bin/env php + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +require dirname(__DIR__, 2).'/vendor/autoload.php'; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Process\Process; + +const BUNDLE_PATH = __DIR__.'/foobar.bundle'; +const REFS_PATH = __DIR__.'/bundle-refs.txt'; + +function git(array $args, ?string $cwd = null): Process +{ + $process = new Process(['git', ...$args], $cwd); + $process->mustRun(); + + return $process; +} + +function bundleRefs(): array +{ + return array_values(array_filter(explode("\n", trim(file_get_contents(REFS_PATH))))); +} + +#[AsCommand(name: 'extract', description: 'Clone the fixture bundle to a working directory, ready for new commits, branches or tags')] +final class ExtractCommand extends Command +{ + protected function configure(): void + { + $this->addArgument('path', InputArgument::OPTIONAL, 'Where to clone the fixture', sys_get_temp_dir().'/foobar-fixture-'.bin2hex(random_bytes(4))); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dest = $input->getArgument('path'); + + if (file_exists($dest)) { + $io->error(sprintf('Destination "%s" already exists.', $dest)); + + return Command::FAILURE; + } + + git(['clone', '--quiet', BUNDLE_PATH, $dest]); + + $localBranches = explode("\n", trim(git(['branch', '--format=%(refname:short)'], $dest)->getOutput())); + + // Full ref names, not `--format=%(refname:short)`: git shortens the symbolic + // refs/remotes/origin/HEAD pointer to a bare "origin" on some git versions, which + // would otherwise be mistaken for a real branch called "origin". + $refs = trim(git(['for-each-ref', '--format=%(refname)', 'refs/remotes/origin'], $dest)->getOutput()); + foreach (explode("\n", $refs) as $ref) { + if ('' === $ref) { + continue; + } + $local = preg_replace('#^refs/remotes/origin/#', '', $ref); + if ('HEAD' === $local || in_array($local, $localBranches, true)) { + // Symbolic HEAD pointer, or already checked out as the clone's default branch. + continue; + } + git(['branch', '--track', $local, "origin/{$local}"], $dest); + } + + $io->success('Fixture extracted.'); + $io->writeln([ + sprintf('Path: %s', $dest), + '', + 'Make your changes there (commits, branches, tags), then run:', + sprintf(' %s build %s', $_SERVER['argv'][0], $dest), + ]); + + return Command::SUCCESS; + } +} + +#[AsCommand(name: 'build', description: 'Rebuild tests/fixtures/foobar.bundle from a working directory produced by "extract"')] +final class BuildCommand extends Command +{ + protected function configure(): void + { + $this->addArgument('path', InputArgument::REQUIRED, 'The working directory produced by "extract"'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $src = $input->getArgument('path'); + + if (!is_dir($src)) { + $io->error(sprintf('Source "%s" does not exist. Run "extract" first.', $src)); + + return Command::FAILURE; + } + + $tmpBundle = tempnam(sys_get_temp_dir(), 'foobar_bundle_'); + git(['bundle', 'create', $tmpBundle, ...bundleRefs()], $src); + git(['bundle', 'verify', $tmpBundle]); + + copy($tmpBundle, BUNDLE_PATH); + unlink($tmpBundle); + + $io->success('Bundle rebuilt.'); + $io->writeln([ + sprintf('Path: %s', BUNDLE_PATH), + '', + sprintf('If you added a ref not in %s, update that file first and re-run build.', REFS_PATH), + 'Otherwise, update the commit constants in AbstractTestCase if needed, then run:', + ' tests/fixtures/verify-bundle.sh', + ]); + + return Command::SUCCESS; + } +} + +$app = new Application('gitlib fixture bundle tool'); +$app->addCommands([new ExtractCommand(), new BuildCommand()]); +exit($app->run()); diff --git a/tests/fixtures/verify-bundle.sh b/tests/fixtures/verify-bundle.sh index f543e212..aaa52d63 100755 --- a/tests/fixtures/verify-bundle.sh +++ b/tests/fixtures/verify-bundle.sh @@ -15,17 +15,7 @@ cd "$(dirname "${BASH_SOURCE[0]}")" > /dev/null BUNDLE="foobar.bundle" MAX_SIZE_KB=200 - -ALLOWED_REFS=" -HEAD -refs/heads/diff-features -refs/heads/master -refs/heads/new-feature -refs/heads/pagination -refs/heads/path-resolving -refs/tags/0.1 -refs/tags/annotated -" +ALLOWED_REFS="$(cat bundle-refs.txt)" echo "== Verifying $BUNDLE ==" @@ -46,7 +36,7 @@ UNEXPECTED_REFS="$(comm -23 <(echo "$ACTUAL_REFS") <(sort -u <<< "$ALLOWED_REFS" if [ -n "$UNEXPECTED_REFS" ]; then echo "ERROR: $BUNDLE contains refs that are not in the allow-list:" >&2 echo "$UNEXPECTED_REFS" >&2 - echo "If this is expected, update ALLOWED_REFS in $0 as part of the same PR." >&2 + echo "If this is expected, update tests/fixtures/bundle-refs.txt as part of the same PR." >&2 exit 1 fi From 1da6ab3ab4aac8617f5a5dded233013721b2fa15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 17:36:19 +0200 Subject: [PATCH 074/150] test: add regression test for blob reading next to a submodule (#243) Reading a blob's content in a tree that also contains a submodule used to fail (issue #12) because the tree parser did not recognize the "commit" entry type. This has since been fixed, but there was no test covering the scenario, so add one to prevent a regression. --- tests/Gitonomy/Git/Tests/BlobTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 9c6fa44d..5673e782 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -39,6 +39,21 @@ public function testGetContent(Repository $repository): void $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); } + #[DataProvider('provideFoobar')] + public function testGetContentSiblingToSubmodule(Repository $repository): void + { + // Regression test for https://github.com/gitonomy/gitlib/issues/12: + // a submodule entry in a tree used to break parsing of that tree, + // making sibling blobs unreadable. + $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $this->assertNotEmpty($tree->getCommitReferenceEntries(), 'Tree contains a submodule entry'); + + $blob = $tree->resolvePath('README.md'); + + $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); + } + #[DataProvider('provideFoobar')] public function testNotExisting(Repository $repository): void { From f17e2a220398702979847298feb8f264bccb04e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 17:54:04 +0200 Subject: [PATCH 075/150] Support "copy from"/"copy to" in diff parser (#244) DiffParser only handled "rename from"/"rename to" after a "similarity index" line, but git also emits "copy from"/"copy to" there when copy detection is enabled (e.g. diff.renames = copies), causing a parse error on otherwise valid diffs. File gains an isCopy() flag, and isRename() no longer reports true for copies, since a copy's source file still exists afterward. Fixes #14 --- src/Gitonomy/Git/Diff/File.php | 18 ++++++++-- src/Gitonomy/Git/Parser/DiffParser.php | 10 ++++-- tests/Gitonomy/Git/Tests/DiffTest.php | 49 ++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/Gitonomy/Git/Diff/File.php b/src/Gitonomy/Git/Diff/File.php index 4ef2e0a2..8a73748c 100644 --- a/src/Gitonomy/Git/Diff/File.php +++ b/src/Gitonomy/Git/Diff/File.php @@ -38,6 +38,7 @@ public function __construct( private readonly ?string $oldIndex, private readonly ?string $newIndex, private readonly bool $isBinary, + private readonly bool $isCopy = false, ) { } @@ -65,11 +66,21 @@ public function isModification(): bool /** * Indicates if it's a rename. * - * A rename can only occurs if it's a modification (not a creation or a deletion). + * A rename can only occurs if it's a modification (not a creation or a deletion), and is not a copy. */ public function isRename(): bool { - return $this->isModification() && $this->oldName !== $this->newName; + return $this->isModification() && $this->oldName !== $this->newName && !$this->isCopy; + } + + /** + * Indicates if it's a copy. + * + * Unlike a rename, the source file of a copy still exists after the change. + */ + public function isCopy(): bool + { + return $this->isCopy; } /** @@ -184,6 +195,7 @@ public function toArray(): array 'old_index' => $this->oldIndex, 'new_index' => $this->newIndex, 'is_binary' => $this->isBinary, + 'is_copy' => $this->isCopy, 'changes' => array_map(static function (FileChange $change) { return $change->toArray(); }, $this->changes), @@ -192,7 +204,7 @@ public function toArray(): array public static function fromArray(array $array): self { - $file = new self($array['old_name'], $array['new_name'], $array['old_mode'], $array['new_mode'], $array['old_index'], $array['new_index'], $array['is_binary']); + $file = new self($array['old_name'], $array['new_name'], $array['old_mode'], $array['new_mode'], $array['old_index'], $array['new_index'], $array['is_binary'], $array['is_copy'] ?? false); foreach ($array['changes'] as $change) { $file->addChange(FileChange::fromArray($change)); diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index 3e95d1a8..43e968b1 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -76,12 +76,16 @@ protected function doParse(): void $this->consumeNewLine(); } + $isCopy = false; if ($this->expects('similarity index ')) { $this->consumeRegexp('/\d{1,3}%\n/'); - $this->consume('rename from '); + $isCopy = $this->expects('copy from '); + if (!$isCopy) { + $this->consume('rename from '); + } $this->consumeTo("\n"); $this->consumeNewLine(); - $this->consume('rename to '); + $this->consume($isCopy ? 'copy to ' : 'rename to '); $this->consumeTo("\n"); $this->consumeNewLine(); } @@ -120,7 +124,7 @@ protected function doParse(): void $newIndex = null === $newIndex ? '' : $newIndex; $oldIndex = preg_match('/^0+$/', $oldIndex) ? null : $oldIndex; $newIndex = preg_match('/^0+$/', $newIndex) ? null : $newIndex; - $file = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary); + $file = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary, $isCopy); // 5. Diff while ($this->expects('@@ ')) { diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index a79fd4bb..acfe0813 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -199,6 +199,55 @@ public function testModeChangeFileWithRaw(): void $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getNewIndex()); } + public function testCopyFileWithoutRaw(): void + { + $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); + + $diff = Diff::parse(<<<'DIFF' + diff --git a/style.css b/common.css + similarity index 97% + copy from style.css + copy to common.css + index 1234567..89abcde 100644 + --- a/style.css + +++ b/common.css + @@ -1,3 +1,4 @@ + body { + + color: red; + } + + DIFF); + $firstFile = $diff->getFiles()[0]; + + $this->assertTrue($firstFile->isModification()); + $this->assertTrue($firstFile->isCopy()); + $this->assertFalse($firstFile->isRename()); + $this->assertFalse($firstFile->isDeletion()); + $this->assertFalse($firstFile->isCreation()); + $this->assertSame('style.css', $firstFile->getOldName()); + $this->assertSame('common.css', $firstFile->getNewName()); + $this->assertSame(1, $firstFile->getAdditions()); + } + + public function testPureCopyFileWithoutRaw(): void + { + $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); + + $diff = Diff::parse(<<<'DIFF' + diff --git a/style.css b/common.css + similarity index 100% + copy from style.css + copy to common.css + + DIFF); + $firstFile = $diff->getFiles()[0]; + + $this->assertTrue($firstFile->isCopy()); + $this->assertFalse($firstFile->isRename()); + $this->assertSame('style.css', $firstFile->getOldName()); + $this->assertSame('common.css', $firstFile->getNewName()); + } + public function testThrowErrorOnBlobGetWithoutIndex(): void { $repository = self::createEmptyRepository(); From 0cbbf5915776c51b3b94e73ba3854d446cbc4470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 18:09:20 +0200 Subject: [PATCH 076/150] Fix diff parsing for dirty submodules without an index line (#246) A submodule whose working tree changed but still points at the same commit is reported by git without an "index " line before "--- "/"+++ ". DiffParser only looked for those markers inside the "index " block, so it never consumed them in that case and choked on the next hunk. Fixes #165 --- src/Gitonomy/Git/Parser/DiffParser.php | 28 ++++++++++++++------------ tests/Gitonomy/Git/Tests/DiffTest.php | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index 43e968b1..854b09ba 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -101,20 +101,22 @@ protected function doParse(): void $newMode = $oldMode = $vars[0]; } $this->consumeNewLine(); + } - // verifying if the file was deleted or created - if ($this->expects('--- ')) { - $oldName = '/dev/null' === $this->consumeTo("\n") ? '/dev/null' : $oldName; - $this->consumeNewLine(); - $this->consume('+++ '); - $newName = '/dev/null' === $this->consumeTo("\n") ? '/dev/null' : $newName; - $this->consumeNewLine(); - } elseif ($this->expects('Binary files ')) { - $vars = $this->consumeRegexp('/"?(.*?)"? and "?(.*?)"? differ\n/'); - $isBinary = true; - $oldName = $vars[1]; - $newName = $vars[2]; - } + // verifying if the file was deleted or created + // Note: a dirty submodule diff has no "index " line, so this must + // not be nested inside the block above. + if ($this->expects('--- ')) { + $oldName = '/dev/null' === $this->consumeTo("\n") ? '/dev/null' : $oldName; + $this->consumeNewLine(); + $this->consume('+++ '); + $newName = '/dev/null' === $this->consumeTo("\n") ? '/dev/null' : $newName; + $this->consumeNewLine(); + } elseif ($this->expects('Binary files ')) { + $vars = $this->consumeRegexp('/"?(.*?)"? and "?(.*?)"? differ\n/'); + $isBinary = true; + $oldName = $vars[1]; + $newName = $vars[2]; } $oldName = '/dev/null' === $oldName ? null : substr($oldName, 2); diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index acfe0813..00eb3c41 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -299,6 +299,27 @@ public function testEmptyOldFile(): void $this->assertSame('test', $firstFile->getOldName()); } + public function testDirtySubmoduleWithoutRaw(): void + { + $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); + + $diff = Diff::parse(<<<'DIFF' + diff --git a/sub b/sub + --- a/sub + +++ b/sub + @@ -1 +1 @@ + -Subproject commit e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 + +Subproject commit e69de29bb2d1d6434b8b29ae775ad8c2e48c5391-dirty + + DIFF); + $firstFile = $diff->getFiles()[0]; + + $this->assertFalse($firstFile->isCreation()); + $this->assertFalse($firstFile->isDeletion()); + $this->assertSame('sub', $firstFile->getOldName()); + $this->assertSame('sub', $firstFile->getNewName()); + } + protected function verifyCreateCommitDiff(Diff $diff): void { $files = $diff->getFiles(); From 9bdf6bddc942edaf145baaadaef4627a4ae50886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 18:10:04 +0200 Subject: [PATCH 077/150] Support git's mnemonic diff prefixes in DiffParser (#245) diff.mnemonicPrefix replaces the default "a/"/"b/" prefixes with context-specific ones ("c/", "i/", "o/", "w/"), which DiffParser's regexp didn't account for, causing a RuntimeException for tools like GrumPHP that enable it. Fixes #114 --- src/Gitonomy/Git/Parser/DiffParser.php | 5 ++++- tests/Gitonomy/Git/Tests/DiffTest.php | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index 854b09ba..9a672e74 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -45,7 +45,10 @@ protected function doParse(): void $fileIndex = 0; while (!$this->isFinished()) { // 1. title - $vars = $this->consumeRegexp("/diff --git \"?(a\\/.*?)\"? \"?(b\\/.*?)\"?\n/"); + // The prefix is normally "a" and "b", but git's diff.mnemonicPrefix option + // (used by tools such as GrumPHP) can produce other single-letter prefixes + // ("c"ommit, "i"ndex, "o"bject, "w"ork tree), or "1"/"2" with --no-index. + $vars = $this->consumeRegexp("/diff --git \"?([^\\/\\s]\\/.*?)\"? \"?([^\\/\\s]\\/.*?)\"?\n/"); $oldName = $vars[1]; $newName = $vars[2]; // Get indexes from raw if it exists diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 00eb3c41..6d605d10 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -273,6 +273,26 @@ public function testThrowErrorOnBlobGetWithoutIndex(): void $this->assertSame('', $file->getNewIndex()); } + public function testMnemonicPrefix(): void + { + $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); + + // With `diff.mnemonicPrefix` enabled, git replaces the default "a/" and "b/" + // prefixes with context-specific ones, e.g. "c/" (commit) and "i/" (index) + // for `git diff --cached`. See https://github.com/gitonomy/gitlib/issues/114. + $diff = Diff::parse(<<<'DIFF' + diff --git c/composer.json i/composer.json + index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 + --- c/composer.json + +++ i/composer.json + + DIFF); + $firstFile = $diff->getFiles()[0]; + + $this->assertSame('composer.json', $firstFile->getOldName()); + $this->assertSame('composer.json', $firstFile->getNewName()); + } + public function testEmptyNewFile(): void { $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); From b5cffd75f185a4af61e1ef648bad10fabbab3313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 18:11:29 +0200 Subject: [PATCH 078/150] Add Repository::runProcess() to expose stderr on successful commands (#247) Repository::run() only ever returns stdout, even on success. Some git commands (push, in particular) write their meaningful output to stderr even when they succeed, so callers had no way to read it (#205). runProcess() returns the full, already-run Process instead, giving access to getErrorOutput(). run() now delegates to it and keeps its exact original behavior. --- phpstan-baseline.neon | 6 ++++ src/Gitonomy/Git/Repository.php | 33 +++++++++++++++++---- tests/Gitonomy/Git/Tests/RepositoryTest.php | 21 +++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8b1e7216..8a951503 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -246,6 +246,12 @@ parameters: count: 1 path: src/Gitonomy/Git/Repository.php + - + message: '#^Method Gitonomy\\Git\\Repository\:\:runProcess\(\) has parameter \$args with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Gitonomy/Git/Repository.php + - message: '#^Method Gitonomy\\Git\\Repository\:\:shell\(\) has parameter \$env with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 3e499881..ac294e65 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -470,6 +470,11 @@ public function setDescription(string $description): static * This command is a facility command. You can run any command * directly on git repository. * + * Note that this only returns the standard output of the process. Some + * git commands (`push`, for instance) write their meaningful output to + * stderr even when they succeed: use {@see runProcess()} instead if you + * need access to it. + * * @param string $command Git command to run (checkout, branch, tag) * @param array $args Arguments of git command * @@ -478,6 +483,26 @@ public function setDescription(string $description): static * @throws RuntimeException Error while executing git command (debug-mode only) */ public function run(string $command, array $args = []): ?string + { + $process = $this->runProcess($command, $args); + + return $process->isSuccessful() ? $process->getOutput() : null; + } + + /** + * Same as {@see run()}, but returns the full, already-run process + * instead of only its standard output on success. + * + * This is useful for commands like `push`, which write their + * meaningful output to stderr even when they succeed, so it can't be + * read through run(). + * + * @param string $command Git command to run (checkout, branch, tag) + * @param array $args Arguments of git command + * + * @throws RuntimeException Error while executing git command (debug-mode only) + */ + public function runProcess(string $command, array $args = []): Process { $process = $this->getProcess($command, $args); @@ -488,13 +513,11 @@ public function run(string $command, array $args = []): ?string $process->run(); - $output = $process->getOutput(); - if ($this->logger && $this->debug) { $duration = microtime(true) - $before; $this->logger->debug(\sprintf('last command (%s) duration: %sms', $command, \sprintf('%.2f', $duration * 1000))); $this->logger->debug(\sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); - $this->logger->debug(\sprintf('last command (%s) output: %s', $command, $output)); + $this->logger->debug(\sprintf('last command (%s) output: %s', $command, $process->getOutput())); } if (!$process->isSuccessful()) { @@ -507,11 +530,9 @@ public function run(string $command, array $args = []): ?string if ($this->debug) { throw new ProcessException($process); } - - return null; } - return $output; + return $process; } /** diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index e7bc480b..e92b2b0f 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -30,6 +30,27 @@ public function testRunReturnsNullInsteadOfThrowingWhenDebugIsFalse(): void $this->assertNull($repository->run('not-a-command')); } + public function testRunProcessGivesAccessToStderrOnSuccess(): void + { + $repository = self::createFoobarRepository(false); + + // `git checkout` reports the switched branch on stderr, even on success. + $process = $repository->runProcess('checkout', ['master']); + + $this->assertTrue($process->isSuccessful()); + $this->assertStringContainsString('master', $process->getErrorOutput()); + } + + public function testRunProcessReturnsFailedProcessInsteadOfThrowingWhenDebugIsFalse(): void + { + $repository = self::createFoobarRepository(true); + $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); + + $process = $repository->runProcess('not-a-command'); + + $this->assertFalse($process->isSuccessful()); + } + public function testGetShortHashThrowsCleanExceptionWhenDebugIsFalse(): void { $repository = self::createFoobarRepository(true); From d7b9315a154b6bb8f99590286d801b0e3515a731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 18:17:56 +0200 Subject: [PATCH 079/150] Add WorkingCopy::merge() to support git merge (#248) * Add WorkingCopy::merge() to support git merge (#118) Mirrors the existing checkout() method: accepts a Commit, Reference or string revision, plus optional extra CLI args (e.g. ['--no-ff']). * Fix testMerge on CI: configure git identity before merging CI runners have no global git user.name/email, so the merge commit creation failed with "Committer identity unknown". Set a local identity on the test repository first, matching the pattern already used in ReferenceTest and LogTest for commit-creating tests. --- doc/workingcopy.md | 18 ++++++++++++++ src/Gitonomy/Git/WorkingCopy.php | 23 +++++++++++++++++ tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 26 ++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/doc/workingcopy.md b/doc/workingcopy.md index 4ecea6c1..375694a2 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -26,6 +26,24 @@ $wc->checkout('origin/master', 'master'); You can also pass a *Reference* or a *Commit*. +Merge a revision +---------------- + +You can merge any revision into the current branch using the *merge* method. +A second argument can be used to pass extra options to the underlying +`git merge` command: + +```php +// git merge origin/develop +$wc->merge('origin/develop'); + +// git merge --no-ff origin/develop +$wc->merge('origin/develop', ['--no-ff']); +``` + +You can also pass a *Reference* or a *Commit*. A `RuntimeException` is thrown +if the merge fails (for instance because of a conflict). + Staged modifications -------------------- diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index aa96d524..3d3612dd 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -101,6 +101,29 @@ public function checkout($revision, ?string $branch = null): static return $this; } + /** + * Merges a revision into the current branch. + * + * @param Commit|Reference|string $revision + * @param string[] $args extra arguments for the merge command (e.g. ``['--no-ff']``) + */ + public function merge($revision, array $args = []): static + { + if ($revision instanceof Commit) { + $args[] = $revision->getHash(); + } elseif ($revision instanceof Reference) { + $args[] = $revision->getFullname(); + } elseif (\is_string($revision)) { + $args[] = $revision; + } else { + throw new InvalidArgumentException(\sprintf('Unknown type "%s"', \gettype($revision))); + } + + $this->run('merge', $args); + + return $this; + } + private function run(string $command, array $args = []): ?string { return $this->repository->run($command, $args); diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 3e88ebca..1e532e72 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -110,4 +110,30 @@ public function testGetUntracked(): void $this->assertContains('untracked.txt', $wc->getUntrackedFiles()); } + + public function testMerge(): void + { + $repository = self::createFoobarRepository(false); + $repository->run('config', ['--local', 'user.name', '"Unit Test"']); + $repository->run('config', ['--local', 'user.email', '"unit_test@unit-test.com"']); + $wc = $repository->getWorkingCopy(); + $wc->checkout('master'); + + $wc->merge('origin/diff-features'); + + $head = $repository->getHeadCommit(); + $this->assertCount(2, $head->getParents(), 'Merge produced a merge commit'); + $this->assertFileExists($repository->getWorkingDir().'/script_A.php'); + } + + public function testMergeConflict(): void + { + $this->expectException(RuntimeException::class); + + $repository = self::createFoobarRepository(false); + $wc = $repository->getWorkingCopy(); + $wc->checkout('master'); + + $wc->merge('origin/new-feature'); + } } From 02cda1b8cf3baa02f295a03ad93fbf8302d8571d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 18:18:47 +0200 Subject: [PATCH 080/150] Fix relative paths not resolving against the repository in Repository::run() (#249) The git process spawned by Repository::run() never had its working directory set, so Symfony Process defaulted it to the calling PHP script's cwd. Commands such as "apply" resolve the paths referenced inside their arguments (e.g. the files listed in a patch) against the process cwd rather than --work-tree, so running them from outside the repository directory failed with errors like "No such file or directory" even though --git-dir/--work-tree were correctly set. Fixes #67 --- src/Gitonomy/Git/Repository.php | 1 + tests/Gitonomy/Git/Tests/RepositoryTest.php | 34 +++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index ac294e65..d31a1885 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -615,6 +615,7 @@ private function getProcess(string $command, array $args = []): Process $base[] = $command; $process = new Process(array_merge($base, $args)); + $process->setWorkingDirectory($this->getPath()); if ($this->inheritEnvironmentVariables) { $process->setEnv(array_replace($_SERVER, $this->environmentVariables)); diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index e92b2b0f..e8fcc8e8 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -137,4 +137,38 @@ public function testLoggerNOk(Repository $repository): void $repository->run('not-work'); } + + /** + * @see https://github.com/gitonomy/gitlib/issues/67 + */ + public function testRunResolvesRelativePathsAgainstTheRepositoryRegardlessOfCwd(): void + { + $repository = self::createFoobarRepository(false); + + $file = $repository->getWorkingDir().'/README.md'; + $original = file_get_contents($file); + file_put_contents($file, $original."Applied line.\n"); + + $patch = $repository->run('diff', ['--', 'README.md']); + file_put_contents($file, $original); + + $patchFile = tempnam(sys_get_temp_dir(), 'gitlib_patch_'); + file_put_contents($patchFile, $patch); + + $previousCwd = getcwd(); + $this->assertIsString($previousCwd); + chdir(sys_get_temp_dir()); + + try { + // "README.md" is relative to the repository work-tree, not to the + // process cwd (which is an unrelated directory here). This only + // works if the git process is run with its cwd set to the repository. + $repository->run('apply', [$patchFile]); + } finally { + chdir($previousCwd); + unlink($patchFile); + } + + $this->assertSame($original."Applied line.\n", file_get_contents($file)); + } } From c7569676196f47ceaaef983b2d0529da85a443c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 18:23:19 +0200 Subject: [PATCH 081/150] Add CHANGELOG.md (#250) Tracks notable changes since the last published release (v1.6.0), grouped into Features, Bug fixes and Minor. --- CHANGELOG.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..12ff55d0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,78 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [Unreleased] + +Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). + +### Features + +- Add `WorkingCopy::merge()` to support `git merge`: accepts a `Commit`, `Reference` or + string revision, plus optional extra CLI args (e.g. `['--no-ff']`), mirroring the existing + `checkout()` method. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) +- Add `Repository::runProcess()` to expose stderr from successful commands: `run()` only + ever returned stdout, even on success, but some git commands (e.g. `push`) write + meaningful output to stderr even when they succeed. `run()` now delegates to it and keeps + its exact original behavior. + ([#247](https://github.com/gitonomy/gitlib/pull/247), fixes #205) +- Support git's mnemonic diff prefixes (`diff.mnemonicPrefix`) in the diff parser: enabling + this option replaces the default `a/`/`b/` prefixes with context-specific ones + (`c/`/`i/`/`o/`/`w/`), which used to raise a `RuntimeException` in tools like GrumPHP that + enable it. ([#245](https://github.com/gitonomy/gitlib/pull/245), fixes #114) +- Support `"copy from"`/`"copy to"` in the diff parser: diffs produced with copy detection + enabled (e.g. `diff.renames = copies`) used to fail to parse. `File` gains a new `isCopy()` + flag, and `isRename()` no longer reports `true` for copies, since a copy's source file + still exists afterward. + ([#244](https://github.com/gitonomy/gitlib/pull/244), fixes #14) + +### Bug fixes + +- Fix relative paths not resolving against the repository in `Repository::run()`: the git + process spawned by `run()` never had its working directory set, so commands such as + `apply` resolved paths against the calling script's cwd instead of `--work-tree`, failing + when run from outside the repository directory. + ([#249](https://github.com/gitonomy/gitlib/pull/249), fixes #67) +- Fix diff parsing for dirty submodules without an index line: a submodule whose working + tree changed but still points at the same commit is reported by git without an `index ` + line, which made the parser choke on the next hunk. + ([#246](https://github.com/gitonomy/gitlib/pull/246), fixes #165) +- Fix bugs and hardening issues found by a code audit: + ([#239](https://github.com/gitonomy/gitlib/pull/239)) + - `ReferenceBag::getFirstBranch()` could return a non-branch reference, or throw, when no + branch existed. + - `Tag::getBodyMessage()` dropped or truncated a line for annotated tags using the + standard `subject\n\nbody` convention. + - `Repository::run()` can return `null` on failure when `debug=false`; several call sites + (`Tag::isAnnotated()`, `PushReference::isForce()`, `Blob::getContent()`, ...) did not + handle that case and produced wrong data or crashed. All call sites now check for `null`. + - `Repository::shell()` interpolated the repository path into the shell command without + escaping it; it is now passed through `escapeshellarg()`. + - `Hooks::set()` created hook scripts world-writable (`0777`); they are now created + `0700`. + - Fixed the `isSuccessFul()` casing typo in `Admin`. + +### Minor + +- Modernize for PHP 8.4+, Symfony 6.4/7.4/8.1+ and PHPUnit 12: full property/parameter/return + type coverage, `readonly`/`final` where applicable, PHPUnit 12 attributes, and new + php-cs-fixer/PHPStan/Castor tooling. + **Breaking:** the minimum supported PHP version is now 8.4. + ([#238](https://github.com/gitonomy/gitlib/pull/238)) +- Add a regression test for reading a blob's content in a tree that also contains a + submodule. ([#243](https://github.com/gitonomy/gitlib/pull/243)) +- Replace the network-dependent test fixture (cloned from GitHub on every run) with a local, + audited git bundle, plus a Console tool to regenerate it. + ([#241](https://github.com/gitonomy/gitlib/pull/241), + [#242](https://github.com/gitonomy/gitlib/pull/242)) +- CI: bump `actions/checkout` to v7 to fix a Node 20 deprecation warning. + ([#240](https://github.com/gitonomy/gitlib/pull/240)) + +## [v1.6.0] - 2025-12-09 + +_Earlier versions are not documented here. See the +[GitHub releases](https://github.com/gitonomy/gitlib/releases) and +[commit history](https://github.com/gitonomy/gitlib/commits/v1.6.0) for prior changes._ + +[Unreleased]: https://github.com/gitonomy/gitlib/compare/v1.6.0...HEAD +[v1.6.0]: https://github.com/gitonomy/gitlib/releases/tag/v1.6.0 From 36114f48bde081705cbf5f54a401287296622d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 7 Sep 2026 21:36:36 +0200 Subject: [PATCH 082/150] Rewrite test-git-versions.sh in PHP and update the tested git versions (#251) Port the git-version build/test tool to PHP with Symfony Console and Process, matching the style of tests/fixtures/generate-bundle.php, and split it into a "build" and a "test" command. Update the curated version list to versions picked for how long and widely each shipped as the default git on a major distro: v1.7.1 (RHEL/CentOS 6), v1.8.3.1 (RHEL/CentOS 7), v2.17.1 (Ubuntu 18.04), v2.25.1 (20.04), v2.34.1 (22.04), v2.43.0 (24.04). --- .github/CONTRIBUTING.md | 2 +- .gitignore | 1 + test-git-versions.php | 210 ++++++++++++++++++++++++++++++++++++++++ test-git-versions.sh | 74 -------------- 4 files changed, 212 insertions(+), 75 deletions(-) create mode 100755 test-git-versions.php delete mode 100755 test-git-versions.sh diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 8fd7040c..ed81c27d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -37,7 +37,7 @@ Then run [PHPUnit](https://phpunit.de/): $ vendor/bin/phpunit ``` -* A script `test-git-versions.sh` is available in repository to test gitlib against many git versions. +* A script `test-git-versions.php` is available in repository to test gitlib against many git versions: `php test-git-versions.php build` compiles them, then `php test-git-versions.php test` runs the suite against each. * The tests will be automatically run by [GitHub Actions](https://github.com/features/actions) against pull requests. * Tests run fully offline: no network access is required. diff --git a/.gitignore b/.gitignore index 6c80d033..0b49effe 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /phpunit.xml /var/ /vendor +/git-builds/ diff --git a/test-git-versions.php b/test-git-versions.php new file mode 100755 index 00000000..11365762 --- /dev/null +++ b/test-git-versions.php @@ -0,0 +1,210 @@ +#!/usr/bin/env php + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +require __DIR__.'/vendor/autoload.php'; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Process\Process; + +const GIT_URL = 'https://github.com/git/git.git'; +const BUILDS_PATH = __DIR__.'/git-builds'; + +// One v1.x and several v2.y releases, picked for how long and how widely each +// shipped as the default git on a major distro, rather than for a spread across +// git's history: v1.7.1 (RHEL/CentOS 6), v1.8.3.1 (RHEL/CentOS 7), v2.17.1 (Ubuntu +// 18.04), v2.25.1 (Ubuntu 20.04), v2.34.1 (Ubuntu 22.04), v2.43.0 (Ubuntu 24.04). +const DEFAULT_VERSIONS = ['v1.7.1', 'v1.8.3.1', 'v2.17.1', 'v2.25.1', 'v2.34.1', 'v2.43.0']; + +function sh(array $args, ?string $cwd = null, ?string $logfile = null): Process +{ + $process = new Process($args, $cwd, timeout: null); + + if (null !== $logfile) { + file_put_contents($logfile, '$ '.implode(' ', $args)."\n", \FILE_APPEND); + $process->run(static function (string $type, string $data) use ($logfile): void { + file_put_contents($logfile, $data, \FILE_APPEND); + }); + if (!$process->isSuccessful()) { + throw new RuntimeException(sprintf('Command "%s" failed, see %s', implode(' ', $args), $logfile)); + } + } else { + $process->mustRun(); + } + + return $process; +} + +function cachePath(): string +{ + return BUILDS_PATH.'/cache'; +} + +function versionPath(string $version): string +{ + return BUILDS_PATH.'/'.$version; +} + +function gitBinary(string $version): string +{ + return versionPath($version).'/build/bin/git'; +} + +#[AsCommand(name: 'build', description: 'Build one or more git versions from source, ready to be used by the "test" command')] +final class BuildCommand extends Command +{ + protected function configure(): void + { + $this + ->addArgument('versions', InputArgument::IS_ARRAY, 'git tags to build (defaults to the curated list)', DEFAULT_VERSIONS) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $versions = $input->getArgument('versions'); + + if (!is_dir(BUILDS_PATH)) { + mkdir(BUILDS_PATH); + } + + if (is_dir(cachePath())) { + $io->section('Updating cache from GitHub'); + sh(['git', 'fetch', '--quiet'], cachePath()); + } else { + $io->section('Cloning git/git from GitHub'); + sh(['git', 'clone', '--quiet', GIT_URL, cachePath()]); + } + + foreach ($versions as $version) { + $io->section(sprintf('Building %s', $version)); + + $path = versionPath($version); + $lockfile = BUILDS_PATH.'/'.$version.'.lock'; + $logfile = BUILDS_PATH.'/'.$version.'.log'; + + if (is_file($lockfile)) { + $io->warning('A previous build was interrupted, rebuilding from scratch.'); + new Process(['rm', '-rf', $path])->mustRun(); + unlink($lockfile); + } + + if (is_dir($path)) { + $io->writeln('Already built, skipping.'); + + continue; + } + + touch($lockfile); + mkdir($path, recursive: true); + file_put_contents($logfile, ''); + + $io->writeln(sprintf('Log: %s', $logfile)); + + $source = $path.'/source'; + sh(['git', 'clone', '--shared', '--quiet', cachePath(), $source]); + sh(['git', 'checkout', '--quiet', $version], $source); + + $prefix = $path.'/build'; + mkdir($prefix); + + $io->writeln('Compiling (autoconf, configure, make, make install)...'); + sh(['autoconf'], $source, $logfile); + sh(['./configure', '--prefix='.$prefix], $source, $logfile); + // gitlib only ever shells out to plumbing/porcelain in the main "git" binary, + // so building the GUI/language extras is both unnecessary and, for a build + // this old, unreliable on a modern machine: + // - -std=gnu17: recent git releases declare an "unreachable" identifier that + // collides with the C23 unreachable() macro glibc/gcc now define by default. + // - NO_OPENSSL: older git-imap-send.c uses OpenSSL 1.0 APIs (HMAC_CTX as a + // stack struct) removed in OpenSSL 3; the test suite is fully offline and + // never needs imap-send. + // - NO_PYTHON, NO_TCLTK: skip the Python remote-helper bridge and git-gui/gitk, + // which need toolchains (Python 2, Tcl/Tk) this machine doesn't have. + // All flags are passed to both invocations: git's Makefile forces a full + // rebuild whenever they change between "make all" and "make install". + $makeFlags = ['CFLAGS=-std=gnu17', 'NO_OPENSSL=1', 'NO_PYTHON=1', 'NO_TCLTK=1']; + sh(['make', 'all', ...$makeFlags], $source, $logfile); + sh(['make', 'install', ...$makeFlags], $source, $logfile); + + unlink($lockfile); + $io->success(sprintf('%s built at %s', $version, gitBinary($version))); + } + + return Command::SUCCESS; + } +} + +#[AsCommand(name: 'test', description: 'Run the test suite against one or more already-built git versions')] +final class TestCommand extends Command +{ + protected function configure(): void + { + $this + ->addArgument('versions', InputArgument::IS_ARRAY, 'git tags to test against (defaults to the curated list)', DEFAULT_VERSIONS) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $versions = $input->getArgument('versions'); + + // Old git binaries choke on config values introduced after their release (e.g. + // "merge.conflictstyle = zdiff3", added in 2.35, is a fatal "unknown style" error + // on anything older) if they pick up the invoking user's own ~/.gitconfig. Give + // each git binary an empty, isolated HOME so only gitlib's own fixtures apply. + $isolatedHome = sys_get_temp_dir().'/gitlib-test-git-versions-home-'.bin2hex(random_bytes(4)); + mkdir($isolatedHome); + + $results = []; + + foreach ($versions as $version) { + $binary = gitBinary($version); + + if (!is_file($binary)) { + $io->error(sprintf('%s is not built yet. Run the "build" command first.', $version)); + $results[] = [$version, 'not built']; + + continue; + } + + $io->section(sprintf('Testing against %s', $version)); + $io->writeln(sprintf('Command: %s', $binary)); + + $env = ['GIT_COMMAND' => $binary, 'HOME' => $isolatedHome, 'XDG_CONFIG_HOME' => $isolatedHome]; + $process = new Process(['vendor/bin/phpunit'], __DIR__, $env, timeout: null); + $process->run(static function (string $type, string $data) use ($output): void { + $output->write($data); + }); + + $results[] = [$version, $process->isSuccessful() ? 'ok' : 'FAILED']; + } + + $io->table(['Version', 'Result'], $results); + + $failed = array_filter($results, static fn (array $result) => 'ok' !== $result[1]); + + return [] === $failed ? Command::SUCCESS : Command::FAILURE; + } +} + +$app = new Application('gitlib multi-version git test tool'); +$app->addCommands([new BuildCommand(), new TestCommand()]); +exit($app->run()); diff --git a/test-git-versions.sh b/test-git-versions.sh deleted file mode 100755 index 0990e697..00000000 --- a/test-git-versions.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash -set -e - -URL="git@github.com:git/git.git" -VERSIONS="v1.6.3 v1.6.6 v1.7.1 v1.7.5 v1.7.9 v1.8.1" -DIRNAME="git-builds" - -if [ ! -d "$DIRNAME" ]; then - mkdir - "$DIRNAME" -fi - -cd "$DIRNAME" - -# remote repository -if [ -d cache ]; then - echo "- update from github..." - cd cache - git fetch -q - cd .. -else - echo "- clone from github..." - git clone "$URL" cache -q -fi - -# build -for VERSION in $VERSIONS; do - - echo "- build $VERSION" - LOGFILE="build.$VERSION.log" - LOCKFILE="build.$VERSION.lock" - - if [ -f "$LOCKFILE" ]; then - echo " - lock present, destroy build..." - rm -rf "$VERSION" "build.$VERSION.log" - fi - - if [ -d "$VERSION" ]; then - echo " - already built, skipping..." - continue - fi - - touch "$LOCKFILE" - - echo " - log: $LOGFILE" - touch "$LOGFILE" - mkdir "$VERSION" - echo " - clone repository" - git clone -s cache "$VERSION/source" -q - echo " - checkout version" - cd "$VERSION/source" - git checkout "$VERSION" -q - - mkdir ../build - PREFIX="`readlink -f ../build`" - - echo " - build..." - autoconf >> "../../$LOGFILE" 2>&1 - ./configure --prefix="$PREFIX" >> "../../$LOGFILE" 2>&1 - make all >> "../../$LOGFILE" 2>&1 - make install >> "../../$LOGFILE" 2>&1 - echo " - build ok" - cd ../.. - rm "$LOCKFILE" -done -cd .. - -# test -echo "" -for VERSION in $VERSIONS; do - echo "- test $VERSION" - GIT_COMMAND="`readlink -f $DIRNAME/$VERSION/build/bin/git`" - echo " - command: $GIT_COMMAND" - GIT_COMMAND="$GIT_COMMAND" phpunit || true -done From 95a0604bbefbdb555b19d5431e3232f781a50e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 9 Sep 2026 13:18:00 +0200 Subject: [PATCH 083/150] Replace Gitonomy\Git\Util\StringHelper with symfony/string (#252) StringHelper was a small mb_*-based wrapper (strlen/substr/strpos/strrpos) with a configurable encoding, used to keep string offsets correct on multi-byte commit messages, paths and branch names. symfony/string's CodePointString covers the exact same UTF-8-aware, code-point-based operations, so drop the custom class in favor of it. Breaking: StringHelper and its getEncoding()/setEncoding() are removed; the library now consistently targets UTF-8 (Log::getCommits() hardcodes --encoding=UTF-8, matching the previous default). --- CHANGELOG.md | 5 +++ composer.json | 4 +- src/Gitonomy/Git/Commit.php | 18 ++++---- src/Gitonomy/Git/Log.php | 3 +- src/Gitonomy/Git/Reference/Branch.php | 4 +- src/Gitonomy/Git/Util/StringHelper.php | 57 -------------------------- 6 files changed, 20 insertions(+), 71 deletions(-) delete mode 100644 src/Gitonomy/Git/Util/StringHelper.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ff55d0..d9d7a417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,11 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). ### Minor +- Replace `Gitonomy\Git\Util\StringHelper` with `symfony/string`'s `CodePointString`, which + covers the same UTF-8-aware `strlen`/`substr`/`strpos`/`strrpos` operations. + **Breaking:** `StringHelper` is removed, along with its configurable encoding + (`getEncoding()`/`setEncoding()`); the library now consistently targets `UTF-8`. + ([#252](https://github.com/gitonomy/gitlib/pull/252)) - Modernize for PHP 8.4+, Symfony 6.4/7.4/8.1+ and PHPUnit 12: full property/parameter/return type coverage, `readonly`/`final` where applicable, PHPUnit 12 attributes, and new php-cs-fixer/PHPStan/Castor tooling. diff --git a/composer.json b/composer.json index 7104d44b..53b61963 100644 --- a/composer.json +++ b/composer.json @@ -37,8 +37,8 @@ "require": { "php": "^8.4", "ext-pcre": "*", - "symfony/polyfill-mbstring": "^1.7", - "symfony/process": "^6.4 || ^7.4 || ^8.1" + "symfony/process": "^6.4 || ^7.4 || ^8.1", + "symfony/string": "^7.4.6 || ^8.0.6" }, "require-dev": { "ext-fileinfo": "*", diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 2e0215f0..a82c5738 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -17,7 +17,7 @@ use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; -use Gitonomy\Git\Util\StringHelper; +use Symfony\Component\String\CodePointString; /** * Representation of a Git commit. @@ -139,7 +139,7 @@ public function getShortHash(): string */ public function getFixedShortHash(int $length = 6): string { - return StringHelper::substr($this->revision, 0, $length); + return new CodePointString($this->revision)->slice(0, $length)->toString(); } /** @@ -191,7 +191,7 @@ public function getTree(): Tree public function getLastModification(?string $path = null): self { if (null !== $path && str_starts_with($path, '/')) { - $path = StringHelper::substr($path, 1); + $path = new CodePointString($path)->slice(1)->toString(); } if ($getWorkingDir = $this->repository->getWorkingDir()) { @@ -216,12 +216,14 @@ public function getShortMessage(int $length = 50, bool $preserve = false, string { $message = $this->getSubjectMessage(); - if (StringHelper::strlen($message) > $length) { - if ($preserve && false !== ($breakpoint = StringHelper::strpos($message, ' ', $length))) { + $codePointMessage = new CodePointString($message); + + if ($codePointMessage->length() > $length) { + if ($preserve && null !== ($breakpoint = $codePointMessage->indexOf(' ', $length))) { $length = $breakpoint; } - return rtrim(StringHelper::substr($message, 0, $length)).$separator; + return rtrim($codePointMessage->slice(0, $length)->toString()).$separator; } return $message; @@ -269,7 +271,7 @@ public function getIncludingBranches(bool $local = true, bool $remote = true): a $branchesName = explode("\n", trim(str_replace('*', '', $result))); $branchesName = array_filter($branchesName, static function ($v) { - return false === StringHelper::strpos($v, '->'); + return null === new CodePointString($v)->indexOf('->'); }); $branchesName = array_map('trim', $branchesName); @@ -279,7 +281,7 @@ public function getIncludingBranches(bool $local = true, bool $remote = true): a foreach ($branchesName as $branchName) { if (false === $local) { $branches[] = $references->getRemoteBranch($branchName); - } elseif (0 === StringHelper::strrpos($branchName, 'remotes/')) { + } elseif (0 === new CodePointString($branchName)->indexOfLast('remotes/')) { $branches[] = $references->getRemoteBranch(str_replace('remotes/', '', $branchName)); } else { $branches[] = $references->getBranch($branchName); diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index bc4c43d7..7d5137f3 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -15,7 +15,6 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; -use Gitonomy\Git\Util\StringHelper; /** * @author Alexandre Salomé @@ -120,7 +119,7 @@ public function getSingleCommit(): Commit */ public function getCommits(): array { - $args = ['--encoding='.StringHelper::getEncoding(), '--format=raw']; + $args = ['--encoding=UTF-8', '--format=raw']; if (null !== $this->offset) { $args[] = '--skip='.$this->offset; diff --git a/src/Gitonomy/Git/Reference/Branch.php b/src/Gitonomy/Git/Reference/Branch.php index f4edbf51..fdf4e431 100644 --- a/src/Gitonomy/Git/Reference/Branch.php +++ b/src/Gitonomy/Git/Reference/Branch.php @@ -15,7 +15,7 @@ use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference; -use Gitonomy\Git\Util\StringHelper; +use Symfony\Component\String\CodePointString; /** * Representation of a branch reference. @@ -82,7 +82,7 @@ public function isMergedTo(string $destinationBranchName = 'master', bool $compa $output = explode("\n", trim(str_replace(['*', 'remotes/'], '', $result))); $filtered_output = array_filter($output, static function ($v) { - return false === StringHelper::strpos($v, '->'); + return null === new CodePointString($v)->indexOf('->'); }); $trimmed_output = array_map('trim', $filtered_output); diff --git a/src/Gitonomy/Git/Util/StringHelper.php b/src/Gitonomy/Git/Util/StringHelper.php deleted file mode 100644 index 9b9db38b..00000000 --- a/src/Gitonomy/Git/Util/StringHelper.php +++ /dev/null @@ -1,57 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Util; - -/** - * Helper class to support language particularity. - * - * @author Alexandre Salomé - */ -final class StringHelper -{ - private static string $encoding = 'utf-8'; - - public static function getEncoding(): string - { - return self::$encoding; - } - - public static function setEncoding(string $encoding): void - { - self::$encoding = $encoding; - } - - public static function strlen(string $string): int - { - return \function_exists('mb_strlen') ? mb_strlen($string, self::$encoding) : \strlen($string); - } - - public static function substr(string $string, int $start, ?int $length = null): string - { - if (null === $length) { - $length = self::strlen($string); - } - - return \function_exists('mb_substr') ? mb_substr($string, $start, $length, self::$encoding) : substr($string, $start, $length); - } - - public static function strpos(string $haystack, string $needle, int $offset = 0): int|false - { - return \function_exists('mb_strpos') ? mb_strpos($haystack, $needle, $offset, self::$encoding) : strpos($haystack, $needle, $offset); - } - - public static function strrpos(string $haystack, string $needle, int $offset = 0): int|false - { - return \function_exists('mb_strrpos') ? mb_strrpos($haystack, $needle, $offset, self::$encoding) : strrpos($haystack, $needle, $offset); - } -} From a1d97b721953712844c3f07f6d85e3fe545daa58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 12 Oct 2026 22:45:35 +0200 Subject: [PATCH 084/150] Parse the diffs of git before 2.16 Before 2.16, `git diff --raw` writes "..." after abbreviated hashes, which the diff parser rejected: Repository::getDiff(), WorkingCopy::getDiffPending() and getDiffStaged() failed with every git before 2.16. --no-abbrev writes full hashes on every version. --- CHANGELOG.md | 3 +++ src/Gitonomy/Git/Repository.php | 3 ++- src/Gitonomy/Git/WorkingCopy.php | 5 +++-- tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d7a417..9262b518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). ### Bug fixes +- Fix `Repository::getDiff()`, `WorkingCopy::getDiffPending()` and `WorkingCopy::getDiffStaged()` + failing to parse the output of git before 2.16, which writes "..." after abbreviated hashes. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix relative paths not resolving against the repository in `Repository::run()`: the git process spawned by `run()` never had its working directory set, so commands such as `apply` resolved paths against the calling script's cwd instead of `--work-tree`, failing diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index d31a1885..4c98c53c 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -359,7 +359,8 @@ public function getDiff(RevisionList|Revision|string|array|null $revisions): Dif $revisions = new RevisionList($this, $revisions); } - $args = array_merge(['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index'], $revisions->getAsTextArray()); + // --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". + $args = array_merge(['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index', '--no-abbrev'], $revisions->getAsTextArray()); $result = $this->run('diff', $args); if (null === $result) { diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index 3d3612dd..29365810 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -52,7 +52,8 @@ public function getUntrackedFiles(): array public function getDiffPending(): Diff { - $result = $this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index']); + // --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". + $result = $this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--no-abbrev']); if (null === $result) { throw new RuntimeException('Unable to compute pending diff.'); } @@ -65,7 +66,7 @@ public function getDiffPending(): Diff public function getDiffStaged(): Diff { - $result = $this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--staged']); + $result = $this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--no-abbrev', '--staged']); if (null === $result) { throw new RuntimeException('Unable to compute staged diff.'); } diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 1e532e72..5e44a592 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -16,6 +16,7 @@ use Gitonomy\Git\Exception\LogicException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; class WorkingCopyTest extends AbstractTestCase { @@ -71,6 +72,19 @@ public function testDiffPending(): void $this->assertCount(1, $diffPending->getFiles()); } + public function testDiffPendingWithAbbreviatedHashesLikeOldGit(): void + { + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/test.sh', 'test'); + // Before 2.16, git always wrote "..." after the abbreviated hashes of --raw. + $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['environment_variables' => ['GIT_PRINT_SHA1_ELLIPSIS' => 'yes']])); + + $files = $repository->getWorkingCopy()->getDiffPending()->getFiles(); + + $this->assertCount(1, $files); + $this->assertSame(40, \strlen($files[0]->getOldIndex())); + } + public function testCheckoutUnexisting(): void { $this->expectException(RuntimeException::class); From 3e7fb0bc636dfc93de615e082daeca78d6915a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 12 Oct 2026 23:06:55 +0200 Subject: [PATCH 085/150] Extract git access behind a driver, with a CLI driver Every git operation goes through a driver, which returns value objects (Driver\Data) instead of building processes and parsing their output across the domain classes. Repository and Admin take the driver as their last argument: Driver\Cli\CliDriver by default, which runs the git binary like before, and is configured through its constructor instead of the "command", "environment_variables", "inherit_environment_variables" and "process_timeout" options. A Driver\DriverInterface opens each repository as a Driver\RepositoryDriverInterface, and runs the operations that need no repository (init, clone...): a stub of both replaces git in tests. A failing git operation always throws: the "debug" option is removed, and nothing returns null or empty data instead anymore. Unknown options throw too. Dates are \DateTimeImmutable, file names in diffs and trees are returned in UTF-8, and git 1.8.4 is required. UPGRADE-2.0.md lists every change, and the CHANGELOG the bugs this fixes. --- .php-cs-fixer.php | 1 + CHANGELOG.md | 50 ++- README.md | 5 +- UPGRADE-2.0.md | 181 +++++++++++ composer.json | 1 + doc/admin.md | 12 + doc/commit.md | 4 +- doc/hooks.md | 5 +- doc/repository.md | 90 +++--- phpstan-baseline.neon | 47 --- src/Gitonomy/Git/Admin.php | 134 +++----- src/Gitonomy/Git/Blame.php | 24 +- src/Gitonomy/Git/Blob.php | 11 +- src/Gitonomy/Git/Commit.php | 247 +++----------- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 87 +++++ .../Git/Driver/Cli/CliRepositoryDriver.php | 300 ++++++++++++++++++ .../Git/Driver/Cli/GitProcessFactory.php | 56 ++++ .../{ => Driver/Cli}/Parser/BlameParser.php | 31 +- .../Git/{ => Driver/Cli}/Parser/LogParser.php | 37 +-- .../Cli}/Parser/ReferenceParser.php | 14 +- .../Git/Driver/Cli/Parser/TagParser.php | 49 +++ .../{ => Driver/Cli}/Parser/TreeParser.php | 21 +- .../Git/Driver/Data/BlameLineData.php | 32 ++ src/Gitonomy/Git/Driver/Data/CommitData.php | 38 +++ .../Git/Driver/Data/ReferenceData.php | 27 ++ src/Gitonomy/Git/Driver/Data/TagData.php | 47 +++ .../Git/Driver/Data/TreeEntryData.php | 29 ++ .../Git/Driver/Data/TreeEntryType.php | 23 ++ src/Gitonomy/Git/Driver/DriverInterface.php | 57 ++++ .../Git/Driver/RepositoryDriverInterface.php | 135 ++++++++ .../Git/Exception/DriverException.php | 22 ++ .../Git/Exception/ProcessException.php | 2 +- .../UnsupportedOperationException.php | 23 ++ src/Gitonomy/Git/Log.php | 45 +-- src/Gitonomy/Git/Parser/CommitParser.php | 90 ------ src/Gitonomy/Git/Parser/DiffParser.php | 27 +- src/Gitonomy/Git/Parser/ParserBase.php | 14 + src/Gitonomy/Git/Parser/TagParser.php | 94 ------ src/Gitonomy/Git/PushReference.php | 7 +- src/Gitonomy/Git/Reference.php | 14 +- src/Gitonomy/Git/Reference/Branch.php | 32 +- src/Gitonomy/Git/Reference/Tag.php | 111 ++----- src/Gitonomy/Git/ReferenceBag.php | 18 +- src/Gitonomy/Git/Repository.php | 215 ++++--------- src/Gitonomy/Git/Tree.php | 26 +- src/Gitonomy/Git/WorkingCopy.php | 72 ++--- test-git-versions.php | 10 +- tests/Gitonomy/Git/Tests/AbstractTestCase.php | 50 ++- tests/Gitonomy/Git/Tests/AdminTest.php | 46 ++- tests/Gitonomy/Git/Tests/BlameTest.php | 19 ++ tests/Gitonomy/Git/Tests/BlobTest.php | 8 + tests/Gitonomy/Git/Tests/CommitTest.php | 31 ++ tests/Gitonomy/Git/Tests/DiffTest.php | 33 +- tests/Gitonomy/Git/Tests/LogTest.php | 159 +++++++++- .../Gitonomy/Git/Tests/PushReferenceTest.php | 8 + tests/Gitonomy/Git/Tests/ReferenceBagTest.php | 8 +- tests/Gitonomy/Git/Tests/ReferenceTest.php | 52 ++- tests/Gitonomy/Git/Tests/RepositoryTest.php | 90 ++++-- tests/Gitonomy/Git/Tests/TreeTest.php | 22 ++ tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 84 ++++- 60 files changed, 2118 insertions(+), 1109 deletions(-) create mode 100644 UPGRADE-2.0.md create mode 100644 src/Gitonomy/Git/Driver/Cli/CliDriver.php create mode 100644 src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php create mode 100644 src/Gitonomy/Git/Driver/Cli/GitProcessFactory.php rename src/Gitonomy/Git/{ => Driver/Cli}/Parser/BlameParser.php (75%) rename src/Gitonomy/Git/{ => Driver/Cli}/Parser/LogParser.php (67%) rename src/Gitonomy/Git/{ => Driver/Cli}/Parser/ReferenceParser.php (65%) create mode 100644 src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php rename src/Gitonomy/Git/{ => Driver/Cli}/Parser/TreeParser.php (59%) create mode 100644 src/Gitonomy/Git/Driver/Data/BlameLineData.php create mode 100644 src/Gitonomy/Git/Driver/Data/CommitData.php create mode 100644 src/Gitonomy/Git/Driver/Data/ReferenceData.php create mode 100644 src/Gitonomy/Git/Driver/Data/TagData.php create mode 100644 src/Gitonomy/Git/Driver/Data/TreeEntryData.php create mode 100644 src/Gitonomy/Git/Driver/Data/TreeEntryType.php create mode 100644 src/Gitonomy/Git/Driver/DriverInterface.php create mode 100644 src/Gitonomy/Git/Driver/RepositoryDriverInterface.php create mode 100644 src/Gitonomy/Git/Exception/DriverException.php create mode 100644 src/Gitonomy/Git/Exception/UnsupportedOperationException.php delete mode 100644 src/Gitonomy/Git/Parser/CommitParser.php delete mode 100644 src/Gitonomy/Git/Parser/TagParser.php diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index e9dffd22..f2669280 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -26,6 +26,7 @@ __FILE__, 'castor.php', ]) + ->notPath('git-builds') ->notPath('var') ; diff --git a/CHANGELOG.md b/CHANGELOG.md index 9262b518..b468273b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,15 @@ All notable changes to this project are documented in this file. ## [Unreleased] -Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). +Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). To upgrade, see +[UPGRADE-2.0.md](UPGRADE-2.0.md). ### Features +- `Repository` and `Admin` take the driver running git as their last argument: a + `Driver\Cli\CliDriver` by default, configured through its constructor, or any + `Driver\DriverInterface`, to replace it in tests; see + ["Using your own driver"](doc/repository.md#using-your-own-driver). ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Add `WorkingCopy::merge()` to support `git merge`: accepts a `Commit`, `Reference` or string revision, plus optional extra CLI args (e.g. `['--no-ff']`), mirroring the existing `checkout()` method. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) @@ -28,9 +33,18 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). ### Bug fixes +- Fix `Commit::getDiff()` returning an empty diff for a root commit: it now lists the files the + commit adds. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix `Repository::getDiff()`, `WorkingCopy::getDiffPending()` and `WorkingCopy::getDiffStaged()` failing to parse the output of git before 2.16, which writes "..." after abbreviated hashes. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix blame lines starting with a TAB character (`Blame\Line::getContent()`): code removing it + must stop doing so. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix reading a commit whose `encoding` header isn't UTF-8, which threw: its message, author and + committer are now re-encoded to UTF-8, like `git log` does. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix file names holding non-ASCII or special characters (tab, quote, backslash) in trees + (`Tree::getEntries()`), and special characters in diffs, which kept git's C-style quoting. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix relative paths not resolving against the repository in `Repository::run()`: the git process spawned by `run()` never had its working directory set, so commands such as `apply` resolved paths against the calling script's cwd instead of `--work-tree`, failing @@ -57,15 +71,47 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). ### Minor +- Require git 1.8.4 or later, the oldest version the test suite passes with: it simplifies the + log of a range filtered by path like the current versions, and `Log::count()` needs git + 1.7.2. **Breaking:** git 1.7 and 1.8 before 1.8.4 are no longer supported. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Replace `Gitonomy\Git\Util\StringHelper` with `symfony/string`'s `CodePointString`, which covers the same UTF-8-aware `strlen`/`substr`/`strpos`/`strrpos` operations. **Breaking:** `StringHelper` is removed, along with its configurable encoding (`getEncoding()`/`setEncoding()`); the library now consistently targets `UTF-8`. ([#252](https://github.com/gitonomy/gitlib/pull/252)) +- Introduce a driver abstraction (`Gitonomy\Git\Driver`) between the domain objects and git: + every operation goes through a `Driver\DriverInterface` returning value objects + (`Driver\Data\CommitData`, `TreeEntryData`, `ReferenceData`, `TagData`, `BlameLineData`), + instead of building processes and parsing their output across the domain classes. + `CliDriver`, the default, keeps running the `git` binary. + **Breaking** (see [UPGRADE-2.0.md](UPGRADE-2.0.md) for the complete list): + - The `command`, `environment_variables`, `inherit_environment_variables` and + `process_timeout` options are replaced by the arguments of `CliDriver`, and `Repository` + throws on unknown options. + - The `debug` option is removed: a failing operation always throws, an + `Exception\DriverException` (`ProcessException` with `CliDriver`) or a more specific + exception, and never returns empty data. `Repository::run()` returns a `string`, and + `Repository::getHead()` and `Repository::getHeadCommit()` never return `null`. + `Commit::getIncludingBranches()`, `Tag::isAnnotated()` and `ReferenceBag` throw where they + returned `false` or an empty result. Failures are no longer logged at the `error` level. + - `Commit::getAuthorDate()`, `Commit::getCommitterDate()` and `Tag::getTaggerDate()` return a + `\DateTimeImmutable`. + - File names in diffs and trees are returned in UTF-8, instead of git's quoted form (which + depended on `core.quotePath` for non-ASCII names in diffs). + - `Commit::setData()` and `Commit`'s constructor third argument take a + `Driver\Data\CommitData`. The parsers of `Gitonomy\Git\Parser` other than `DiffParser` + and `ParserBase` moved to `Driver\Cli\Parser`, and `CommitParser` is merged into + `LogParser`. + - `PushReference::isForce()` returns `true` for histories without a common ancestor, where + the constructor threw. + + ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Modernize for PHP 8.4+, Symfony 6.4/7.4/8.1+ and PHPUnit 12: full property/parameter/return type coverage, `readonly`/`final` where applicable, PHPUnit 12 attributes, and new php-cs-fixer/PHPStan/Castor tooling. - **Breaking:** the minimum supported PHP version is now 8.4. + **Breaking:** the minimum supported PHP version is now 8.4, most classes are `final` (some + `readonly`), and every method has native types: see [UPGRADE-2.0.md](UPGRADE-2.0.md). ([#238](https://github.com/gitonomy/gitlib/pull/238)) - Add a regression test for reading a blob's content in a tree that also contains a submodule. ([#243](https://github.com/gitonomy/gitlib/pull/243)) diff --git a/README.md b/README.md index 24b1c76e..ffbcefb2 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,8 @@ Gitlib for Gitonomy This library provides methods to access Git repository from PHP 8.4+. -It makes shell calls, which makes it less performant than any solution. - -Anyway, it's convenient and don't need to build anything to use it. That's how we love it. +It runs the `git` binary (1.8.4 or later) for every operation: convenient, and it doesn't need +anything built to use. Quick Start ----------- diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md new file mode 100644 index 00000000..a9649a54 --- /dev/null +++ b/UPGRADE-2.0.md @@ -0,0 +1,181 @@ +UPGRADE FROM 1.x to 2.0 +======================= + +gitlib 2.0 runs every git operation through a driver: `CliDriver`, the +default, runs the `git` binary like 1.x did. Code reading repositories through `Repository`, `Commit`, `Log`... mostly keeps working: +check the points below, starting with the configuration of git, errors, +dates and classes you extend. + +Requirements +------------ + + * PHP 8.4 or later is required. + * git 1.8.4 or later is required by `CliDriver`, the default driver: before, + git simplifies the log of a range filtered by path differently, and it + can't count commits (`Log::count()`) before 1.7.2. + * `symfony/process` must be `^6.4`, `^7.4` or `^8.1`. + * `symfony/string` `^7.4.6` or `^8.0.6` is a new dependency. + +Configuring git +--------------- + + * The `command`, `environment_variables`, `inherit_environment_variables` + and `process_timeout` options of `Repository` and `Admin` are replaced by + the arguments of `Gitonomy\Git\Driver\Cli\CliDriver`, which + `Repository` and `Admin` take as their last argument. + + Before: + + ```php + $repository = new Repository($path, [ + 'command' => '/usr/bin/git', + 'process_timeout' => 60, + ]); + ``` + + After: + + ```php + use Gitonomy\Git\Driver\Cli\CliDriver; + + $repository = new Repository($path, driver: new CliDriver(command: '/usr/bin/git', processTimeout: 60)); + ``` + + * `Admin::isValidRepository()` and `Admin::isValidRepositoryAndBranch()` + take the driver instead of options as their last argument. + * `Repository` throws an `InvalidArgumentException` for an unknown option, + like the removed ones, instead of ignoring it. + +Errors +------ + + * The `debug` option of `Repository` is removed: a failing git operation + always throws, a `Gitonomy\Git\Exception\ProcessException` with + `CliDriver`. Nothing returns `null` or empty data instead anymore: + `Repository::run()` returns a `string`, and `Repository::getHead()` and + `Repository::getHeadCommit()` never return `null`. + + Before: + + ```php + $repository = new Repository($path, ['debug' => false]); + + if (null === $output = $repository->run('fetch', ['--all'])) { + // handle the failure + } + ``` + + After: + + ```php + $repository = new Repository($path); + + try { + $output = $repository->run('fetch', ['--all']); + } catch (ProcessException $e) { + // handle the failure + } + ``` + + * Failures are no longer logged at the `error` level: the exception reports + them, and the logger gets their error output at the `debug` level. Alerts + based on `error` logs must rely on exceptions instead. + * `ProcessException` now extends the new + `Gitonomy\Git\Exception\DriverException`, itself a `RuntimeException`: + catching `RuntimeException` or `GitExceptionInterface` still works. + * These methods throw instead of returning a fallback value: + * `Commit::getIncludingBranches()` threw nothing and returned `[]` when git + failed; + * `Reference\Tag::isAnnotated()`, and the getters of a tag, return `false` + for a lightweight tag but throw for a tag that doesn't exist; + * `ReferenceBag` (`Repository::getReferences()`) only returns an empty bag + for a repository without references, and throws for any other failure, + like a directory that isn't a repository. + * `Commit::getShortHash()` throws a `ReferenceNotFoundException` (an + `\InvalidArgumentException`) for an unknown commit, instead of a + `ProcessException`. + * The `Admin` methods throw a `ProcessException`, still a + `RuntimeException`, with git's error output as message. + +Dates +----- + + * `Commit::getAuthorDate()`, `Commit::getCommitterDate()` and + `Reference\Tag::getTaggerDate()` return a `\DateTimeImmutable` instead of + a `\DateTime`. Code that modifies the returned date must use the returned + value of `modify()`, `setTimezone()`... + +Extending gitlib +---------------- + + * These classes are `final`: `Admin`, `Blame`, `Blob`, `Commit`, + `Diff\File`, `Log`, `Parser\DiffParser`, `Reference\Branch`, + `Reference\Stash`, `Reference\Tag`, `ReferenceBag`, `Repository`, `Tree`, + and the exceptions `InvalidArgumentException`, `LogicException`, + `ProcessException`, `ReferenceNotFoundException` and + `UnexpectedValueException`. + * These classes are `final readonly`: `Blame\Line`, `CommitReference`, + `Diff\Diff`, `Diff\FileChange`, `Hooks`, `PushReference`, `RevisionList`, + `WorkingCopy`. + * Every method has native parameter and return types. A class extending + `Revision`, `Reference` or `Parser\ParserBase`, which remain extendable, + must declare the same types, and must not redeclare their properties. + * To change how gitlib runs git, for example in tests, implement + `Gitonomy\Git\Driver\DriverInterface` and pass it as the driver of + `Repository`, instead of extending `Repository`: see + ["Using your own driver"](doc/repository.md#using-your-own-driver). + * `Commit::__construct()` and `Commit::setData()` take a + `Gitonomy\Git\Driver\Data\CommitData` instead of an array, and + `setData()` replaces all the data instead of merging it. + * `Gitonomy\Git\Util\StringHelper` is removed, with its configurable + encoding: use `symfony/string`. Logs are always read in UTF-8. + * The parsers of the git output moved to `Gitonomy\Git\Driver\Cli\Parser` + and are `final`, except `Parser\DiffParser` and `Parser\ParserBase`. + `CommitParser` is merged into `LogParser`, and their results are objects + of `Gitonomy\Git\Driver\Data`: `LogParser::$commits` (instead of `$log`), + `TagParser::$data`, `ReferenceParser::$references`, `TreeParser::$entries` + and `BlameParser::$lines`. `TreeParser` parses `git ls-tree -z`, and + `BlameParser` no longer takes a `Repository`. + * Objects serialized with 1.x (a `Commit` or a `Diff` in a cache...) can't + be unserialized: clear such caches. `Diff::toArray()` and + `Diff::fromArray()` remain compatible. + +Data returned +------------- + + * File names in diffs (`Diff\File`) and tree entries (`Tree`) are returned + in UTF-8, instead of git's quoted form (`"caf\303\251.txt"`) for names + with non-ASCII or special characters. + * `Blame\Line::getContent()` no longer starts with a TAB. + * `Commit::getDiff()` lists the files of a root commit, instead of + returning an empty diff. + * A commit read through `Repository::getCommit()` has its message as + `git log` prints it, like a commit of a `Log` already had: without blank + lines around it, nor whitespace at the end of its lines. It also comes + re-encoded to UTF-8 when the commit has an `encoding` header, instead of + throwing. + * `Commit::getShortMessage()` throws a + `Symfony\Component\String\Exception\InvalidArgumentException` when the + subject isn't valid UTF-8. + * `Reference\Tag::isSigned()` returns `false` for a lightweight tag, + `Reference\Tag::getBodyMessage()` no longer starts with a line break, and + `Reference\Tag::getGPGSignature()` throws a + `Gitonomy\Git\Exception\InvalidArgumentException` for an unsigned tag. + * `ReferenceBag::getFirstBranch()` returns the first branch, instead of the + first reference of any kind. + * `PushReference::isForce()` returns `true` for histories without a common + ancestor, where the constructor threw. + * `Blob::getContent()` only reads blobs, where `git cat-file -p` also + printed trees and commits. + * `WorkingCopy::getUntrackedFiles()` and `RevisionList::getAll()` return + lists, without gaps in their keys. + * `Diff\File::toArray()` has a new `is_copy` key. + +Other changes +------------- + + * Relative paths, in the arguments of `Repository::run()` and in the paths + of a log, are relative to the repository instead of the current directory + of PHP. + * `Hooks::set()` makes hooks executable by their owner only (`0700`, + instead of `0777`): change their permissions if another user runs them. diff --git a/composer.json b/composer.json index 53b61963..7e3b3989 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ }, "require-dev": { "ext-fileinfo": "*", + "ext-zlib": "*", "phpunit/phpunit": "^12.0", "psr/log": "^3.0", "symfony/console": "^8.1" diff --git a/doc/admin.md b/doc/admin.md index d4776804..8b6a7be7 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -71,6 +71,18 @@ $mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitono $mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git', false); ``` +Options +------- + +Every method on this page also accepts the driver running git as its last +argument, and the methods returning a `Repository` the same `$options` array +as its constructor before it — see +[Repository options](repository.md#options-for-repository). + +```php +$repository = Gitonomy\Git\Admin::init('/path/to/repository', true, driver: new Gitonomy\Git\Driver\Cli\CliDriver(processTimeout: 300)); +``` + ### References - diff --git a/doc/commit.md b/doc/commit.md index 08e9b1e3..7a6d53fd 100644 --- a/doc/commit.md +++ b/doc/commit.md @@ -74,12 +74,12 @@ methods: // Author $commit->getAuthorName(); $commit->getAuthorEmail(); -$commit->getAuthorDate(); // returns a DateTime object +$commit->getAuthorDate(); // returns a DateTimeImmutable object // Committer $commit->getCommitterName(); $commit->getCommitterEmail(); -$commit->getCommitterDate(); // returns a DateTime object +$commit->getCommitterDate(); // returns a DateTimeImmutable object ``` Commit message and short message diff --git a/doc/hooks.md b/doc/hooks.md index eb5c8ab4..28227a33 100644 --- a/doc/hooks.md +++ b/doc/hooks.md @@ -4,8 +4,9 @@ Hooks It's possible to define custom hooks on any repository with git. Those hooks are located in the *.git/hooks* folder. -Those files need to be executable. For convenience, gitlib will set them -to *777*. +Those files need to be executable: gitlib makes them executable by their +owner only (*0700*). If another user runs them (a git daemon, a web +server...), change their permissions yourself. With *gitlib*, you can manage hooks over a repository using the *Hooks* object. diff --git a/doc/repository.md b/doc/repository.md index 05b4a4e7..6d1f463e 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -16,12 +16,8 @@ This parameter can be used used to tune behavior of library. Available options are: -- **debug** (default: true): Enables exception when edge cases are met -- **environment\_variables**: (default: none) An array of environment - variables to be set in sub-process - **logger**: (default: none) Logger to use for reporting of execution (a `Psr\Log\LoggerInterface`) -- **command**: (default: `git`) Specify command to execute to run git - **working\_dir**: If you are using multiple working directories, this option is for you @@ -29,11 +25,13 @@ An example: ```php $repository = new Repository('/path/to/repo', [ - 'debug' => true, 'logger' => new Monolog\Logger(), ]); ``` +Its last parameter, `$driver`, is the backend running git operations: see +"Configuring `CliDriver`" below. + Test if a repository is bare ---------------------------- @@ -81,10 +79,50 @@ if ($repository->isHeadDetached()) { Options for repository ---------------------- +### Configuring `CliDriver` + +By default, gitlib spawns a `git` process for every operation, with +`CliDriver`, which requires git 1.8.4 or later. A driver holds no state of a +repository: create it once, and pass it to every `Repository` and `Admin` +call, as a service of your container for instance. + +The arguments of `CliDriver` set the git binary to run (`command`), the +environment variables to pass to it (`environmentVariables`, where `false` +removes an inherited one), whether git inherits the environment variables of +PHP (`inheritEnvironmentVariables`), and the timeout of every git process, +in seconds (`processTimeout`, `null` for none): + +```php +use Gitonomy\Git\Driver\Cli\CliDriver; + +$repository = new Gitonomy\Git\Repository('/tmp/foo', driver: new CliDriver( + command: '/home/alice/bin/git', + environmentVariables: ['GIT_SSH_COMMAND' => 'ssh -i /home/alice/.ssh/deploy'], + processTimeout: 60, +)); +``` + +### Using your own driver + +`$driver` accepts any `Gitonomy\Git\Driver\DriverInterface` implementation: +its `open()` method returns the `Gitonomy\Git\Driver\RepositoryDriverInterface` +the repository runs its operations with. In tests, a stub of both lets you +exercise code built on gitlib without any git repository: + +```php +$repositoryDriver = $this->createStub(RepositoryDriverInterface::class); +$repositoryDriver->method('getReferences')->willReturn([new ReferenceData('refs/heads/main', $hash)]); + +$driver = $this->createStub(DriverInterface::class); +$driver->method('open')->willReturn($repositoryDriver); + +$repository = new Repository('/path/to/any/directory', driver: $driver); +``` + ### Logger If you are developing, you may appreciate to have a logger inside -repository, telling you every executed command. +repository, telling you every executed command (`CliDriver` only). You call method `setLogger` as an option on repository creation: @@ -111,37 +149,13 @@ debug last command (fetch) return code: 0 debug last command (fetch) output: Fetching origin ``` -### Disable debug-mode +A failing command also logs its error output, at the `debug` level: the +exception it throws reports the failure. -Gitlib throws an exception when something seems wrong. If a `git` command exits -with a non-zero code, then execution will be stopped, and a `RuntimeException` -will be thrown. If you want to prevent this, set the `debug` option to` false`. -This will make `Repository` log errors and return empty data instead of -throwing exceptions. +### Errors -```php -$repository = new Repository('/tmp/foo', ['debug' => false, 'logger' => $logger]); -``` - -> **note** -> -> If you plan to disable debug, you should rely on the logger to keep a trace -> of the failing cases. - -### Specify git command to use - -You can pass the option `command` to specify which command to use to run git -calls. If you have a git binary located somewhere else, use this option to -specify to gitlib path to your git binary: - -```php -$repository = new Gitonomy\Git\Repository('/tmp/foo', ['command' => '/home/alice/bin/git']); -``` - -### Environment variables - -It is possible to send environment variables to the `git` commands. - -```php -$repository = new Gitonomy\Git\Repository('/tmp/foo', ['environment_variables' => ['GIT_']]) -``` +Gitlib throws an exception when git reports a failure: a +`Gitonomy\Git\Exception\DriverException` (a `ProcessException` with +`CliDriver`), or a more specific one where it makes sense, like a +`ReferenceNotFoundException` for an unknown revision. Operations never return +empty data instead. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8a951503..c9c65f94 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -24,36 +24,12 @@ parameters: count: 1 path: src/Gitonomy/Git/Admin.php - - - message: '#^Method Gitonomy\\Git\\Admin\:\:getProcess\(\) has parameter \$args with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:getProcess\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - message: '#^Method Gitonomy\\Git\\Admin\:\:init\(\) has parameter \$options with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Gitonomy/Git/Admin.php - - - message: '#^Method Gitonomy\\Git\\Admin\:\:isValidRepository\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:isValidRepositoryAndBranch\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - message: '#^Method Gitonomy\\Git\\Admin\:\:mirrorTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -168,12 +144,6 @@ parameters: count: 1 path: src/Gitonomy/Git/Log.php - - - message: '#^Property Gitonomy\\Git\\Parser\\LogParser\:\:\$log type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Parser/LogParser.php - - message: '#^Method Gitonomy\\Git\\Parser\\ParserBase\:\:consumeRegexp\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -234,12 +204,6 @@ parameters: count: 1 path: src/Gitonomy/Git/Repository.php - - - message: '#^Method Gitonomy\\Git\\Repository\:\:getProcess\(\) has parameter \$args with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - message: '#^Method Gitonomy\\Git\\Repository\:\:run\(\) has parameter \$args with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -264,12 +228,6 @@ parameters: count: 1 path: src/Gitonomy/Git/Repository.php - - - message: '#^Property Gitonomy\\Git\\Repository\:\:\$environmentVariables type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - message: '#^Method Gitonomy\\Git\\Revision\:\:getLog\(\) has parameter \$paths with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -300,8 +258,3 @@ parameters: count: 1 path: src/Gitonomy/Git/Tree.php - - - message: '#^Method Gitonomy\\Git\\WorkingCopy\:\:run\(\) has parameter \$args with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/WorkingCopy.php diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 35d4875d..6744a488 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -12,8 +12,8 @@ namespace Gitonomy\Git; -use Gitonomy\Git\Exception\RuntimeException; -use Symfony\Component\Process\Process; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\DriverInterface; /** * Administration class for Git repositories. @@ -25,23 +25,22 @@ final class Admin /** * Initializes a repository and returns the instance. * - * @param string $path path to the repository - * @param bool $bare indicate to create a bare repository - * @param array $options options for Repository creation + * @param string $path path to the repository + * @param bool $bare indicate to create a bare repository + * @param array $options options for Repository creation + * @param DriverInterface $driver backend used to run operations * - * @throws RuntimeException Directory exists or not writable (only if debug=true) + * @throws Exception\DriverException Directory exists or not writable */ - public static function init(string $path, bool $bare = true, array $options = []): Repository + public static function init(string $path, bool $bare = true, array $options = [], DriverInterface $driver = new CliDriver()): Repository { - $process = self::getProcess('init', array_merge(['-q'], $bare ? ['--bare'] : [], [$path]), $options); - - $process->run(); - - if (!$process->isSuccessful()) { - throw new RuntimeException(\sprintf("Error on repository initialization, command wasn't successful (%s). Error output:\n%s", $process->getCommandLine(), $process->getErrorOutput())); + if ($bare) { + $driver->initBare($path); + } else { + $driver->init($path); } - return new Repository($path, $options); + return new Repository($path, $options, $driver); } /** @@ -51,18 +50,14 @@ public static function init(string $path, bool $bare = true, array $options = [] * Usually, this command returns 0 when successful, and 128 when the * repository is not found. * - * @param string $url url of repository to check - * @param array $options options for Repository creation + * @param string $url url of repository to check + * @param DriverInterface $driver backend used to run operations * * @return bool true if url is valid */ - public static function isValidRepository(string $url, array $options = []): bool + public static function isValidRepository(string $url, DriverInterface $driver = new CliDriver()): bool { - $process = self::getProcess('ls-remote', [$url], $options); - - $process->run(); - - return $process->isSuccessful(); + return $driver->isValidRepository($url); } /** @@ -73,106 +68,79 @@ public static function isValidRepository(string $url, array $options = []): bool * Usually, this command returns 0 when successful, and 128 when the * repository is not found. * - * @param string $url url of repository to check - * @param string $branchName name of branch to check - * @param array $options options for Repository creation + * @param string $url url of repository to check + * @param string $branchName name of branch to check + * @param DriverInterface $driver backend used to run operations * * @return bool true if url is valid and branch exists */ - public static function isValidRepositoryAndBranch(string $url, string $branchName, array $options = []): bool + public static function isValidRepositoryAndBranch(string $url, string $branchName, DriverInterface $driver = new CliDriver()): bool { - $process = self::getProcess('ls-remote', ['--heads', $url, $branchName], $options); - - $process->run(); - $processOutput = $process->getOutput(); - - return $process->isSuccessful() && str_contains($processOutput, $branchName); + return $driver->isValidRepositoryAndBranch($url, $branchName); } /** * Clone a repository to a local path. * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param bool $bare indicates if repository should be bare or have a working copy - * @param array $options options for Repository creation + * @param string $path indicates where to clone repository + * @param string $url url of repository to clone + * @param bool $bare indicates if repository should be bare or have a working copy + * @param array $options options for Repository creation + * @param DriverInterface $driver backend used to run operations */ - public static function cloneTo(string $path, string $url, bool $bare = true, array $options = []): Repository + public static function cloneTo(string $path, string $url, bool $bare = true, array $options = [], DriverInterface $driver = new CliDriver()): Repository { $args = $bare ? ['--bare'] : []; - return self::cloneRepository($path, $url, $args, $options); + return self::cloneRepository($path, $url, $args, $options, $driver); } /** * Clone a repository branch to a local path. * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param string $branch branch to clone - * @param bool $bare indicates if repository should be bare or have a working copy - * @param array $options options for Repository creation + * @param string $path indicates where to clone repository + * @param string $url url of repository to clone + * @param string $branch branch to clone + * @param bool $bare indicates if repository should be bare or have a working copy + * @param array $options options for Repository creation + * @param DriverInterface $driver backend used to run operations */ - public static function cloneBranchTo(string $path, string $url, string $branch, bool $bare = true, array $options = []): Repository + public static function cloneBranchTo(string $path, string $url, string $branch, bool $bare = true, array $options = [], DriverInterface $driver = new CliDriver()): Repository { $args = ['--branch', $branch]; if ($bare) { $args[] = '--bare'; } - return self::cloneRepository($path, $url, $args, $options); + return self::cloneRepository($path, $url, $args, $options, $driver); } /** * Mirrors a repository (fetch all revisions, not only branches). * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param array $options options for Repository creation + * @param string $path indicates where to clone repository + * @param string $url url of repository to clone + * @param array $options options for Repository creation + * @param DriverInterface $driver backend used to run operations */ - public static function mirrorTo(string $path, string $url, array $options = []): Repository + public static function mirrorTo(string $path, string $url, array $options = [], DriverInterface $driver = new CliDriver()): Repository { - return self::cloneRepository($path, $url, ['--mirror'], $options); + return self::cloneRepository($path, $url, ['--mirror'], $options, $driver); } /** * Internal method to launch effective ``git clone`` command. * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param array $args arguments to be added to the command-line - * @param array $options options for Repository creation + * @param string $path indicates where to clone repository + * @param string $url url of repository to clone + * @param array $args arguments to be added to the command-line + * @param array $options options for Repository creation + * @param DriverInterface $driver backend used to run operations */ - public static function cloneRepository(string $path, string $url, array $args = [], array $options = []): Repository + public static function cloneRepository(string $path, string $url, array $args = [], array $options = [], DriverInterface $driver = new CliDriver()): Repository { - $process = self::getProcess('clone', array_merge(['-q'], $args, [$url, $path]), $options); - - $process->run(); - - if (!$process->isSuccessful()) { - throw new RuntimeException(\sprintf('Error while initializing repository: %s', $process->getErrorOutput())); - } - - return new Repository($path, $options); - } - - /** - * This internal method is used to create a process object. - */ - private static function getProcess(string $command, array $args = [], array $options = []): Process - { - $is_windows = \defined('PHP_WINDOWS_VERSION_BUILD'); - $options = array_merge([ - 'environment_variables' => $is_windows ? ['PATH' => getenv('PATH')] : [], - 'command' => 'git', - 'process_timeout' => 3600, - ], $options); - - $process = new Process(array_merge([$options['command'], $command], $args)); - $process->setEnv($options['environment_variables']); - $process->setTimeout($options['process_timeout']); - $process->setIdleTimeout($options['process_timeout']); + $driver->cloneRepository($path, $url, $args); - return $process; + return new Repository($path, $options, $driver); } } diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index 35dc2e8f..2660818d 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -14,7 +14,6 @@ use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Parser\BlameParser; /** * @author Alexandre Salomé @@ -94,20 +93,19 @@ public function getLines(): array return $this->lines; } - $args = ['-p']; + $lineDataList = $this->repository->getRepositoryDriver()->getBlameLines($this->revision->getRevision(), $this->file, $this->lineRange); - if (null !== $this->lineRange) { - $args[] = '-L'; - $args[] = $this->lineRange; - } - - $args[] = $this->revision->getRevision(); - $args[] = '--'; - $args[] = $this->file; + $this->lines = []; + $commits = []; + $lineNumber = 1; + foreach ($lineDataList as $lineData) { + if (!isset($commits[$lineData->commitHash])) { + $commits[$lineData->commitHash] = $this->repository->getCommit($lineData->commitHash); + } - $parser = new BlameParser($this->repository); - $parser->parse($this->repository->run('blame', $args)); - $this->lines = $parser->lines; + $this->lines[$lineNumber] = new Line($commits[$lineData->commitHash], $lineData->sourceLine, $lineData->targetLine, $lineData->blockLine, $lineData->content); + ++$lineNumber; + } return $this->lines; } diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index 60555cb4..290845c4 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -12,8 +12,6 @@ namespace Gitonomy\Git; -use Gitonomy\Git\Exception\ReferenceNotFoundException; - /** * Representation of a Blob commit. * @@ -50,17 +48,12 @@ public function getHash(): string /** * @return string content of the blob * - * @throws Exception\ProcessException Error occurred while getting content of blob + * @throws Exception\DriverException Error occurred while getting content of blob */ public function getContent(): string { if (null === $this->content) { - $content = $this->repository->run('cat-file', ['-p', $this->hash]); - if (null === $content) { - throw new ReferenceNotFoundException($this->hash); - } - - $this->content = $content; + $this->content = $this->repository->getRepositoryDriver()->getBlobContent($this->hash); } return $this->content; diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index a82c5738..0adcbc46 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -13,8 +13,9 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Symfony\Component\String\CodePointString; @@ -27,25 +28,9 @@ final class Commit extends Revision { /** - * Whether the fields below have been populated (either lazily fetched - * from the repository, or provided upfront through setData()). + * Raw commit data, as returned by the Driver. Lazily loaded. */ - private bool $loaded = false; - - private ?string $treeHash = null; - - /** - * @var string[]|null - */ - private ?array $parentHashes = null; - - private ?string $authorName = null; - private ?string $authorEmail = null; - private ?\DateTime $authorDate = null; - private ?string $committerName = null; - private ?string $committerEmail = null; - private ?\DateTime $committerDate = null; - private ?string $message = null; + private ?CommitData $data; // Values derived from the fields above, computed and cached independently. private ?string $shortHash = null; @@ -54,11 +39,10 @@ final class Commit extends Revision private ?string $bodyMessage = null; /** - * @param Repository $repository Repository of the commit - * @param string $hash Hash of the commit - * @param array $data + * @param Repository $repository Repository of the commit + * @param string $hash Hash of the commit */ - public function __construct(Repository $repository, string $hash, array $data = []) + public function __construct(Repository $repository, string $hash, ?CommitData $data = null) { if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { throw new ReferenceNotFoundException($hash); @@ -66,44 +50,17 @@ public function __construct(Repository $repository, string $hash, array $data = parent::__construct($repository, $hash); - $this->setData($data); + $this->data = $data; } - /** - * @param array $data - */ - public function setData(array $data): void + public function setData(CommitData $data): void { - foreach ($data as $name => $value) { - match ($name) { - 'treeHash' => $this->treeHash = self::asString($name, $value), - 'parentHashes' => $this->parentHashes = self::asStringList($name, $value), - 'authorName' => $this->authorName = self::asString($name, $value), - 'authorEmail' => $this->authorEmail = self::asString($name, $value), - 'authorDate' => $this->authorDate = self::asDateTime($name, $value), - 'committerName' => $this->committerName = self::asString($name, $value), - 'committerEmail' => $this->committerEmail = self::asString($name, $value), - 'committerDate' => $this->committerDate = self::asDateTime($name, $value), - 'message' => $this->message = self::asString($name, $value), - default => throw new InvalidArgumentException(\sprintf('Unknown commit data "%s".', $name)), - }; - } - - if ([] !== $data) { - $this->loaded = true; - } + $this->data = $data; } public function getDiff(): Diff { - $args = ['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index', $this->revision]; - - $result = $this->repository->run('diff-tree', $args); - if (null === $result) { - throw new ReferenceNotFoundException($this->revision); - } - - $diff = Diff::parse($result); + $diff = $this->repository->getRepositoryDriver()->getCommitDiff($this->revision); $diff->setRepository($this->repository); return $diff; @@ -123,12 +80,11 @@ public function getHash(): string public function getShortHash(): string { if (null === $this->shortHash) { - $result = $this->repository->run('log', ['--abbrev-commit', '--format=%h', '-n', 1, $this->revision]); - if (null === $result) { - throw new ReferenceNotFoundException($this->revision); + try { + $this->shortHash = $this->repository->getRepositoryDriver()->getShortHash($this->revision); + } catch (DriverException $e) { + throw new ReferenceNotFoundException($this->revision, 0, $e); } - - $this->shortHash = trim($result); } return $this->shortHash; @@ -149,9 +105,7 @@ public function getFixedShortHash(int $length = 6): string */ public function getParentHashes(): array { - $this->ensureLoaded(); - - return $this->parentHashes ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->parentHashes; } /** @@ -162,7 +116,7 @@ public function getParentHashes(): array public function getParents(): array { $result = []; - foreach ($this->getParentHashes() as $parentHash) { + foreach ($this->data()->parentHashes as $parentHash) { $result[] = $this->repository->getCommit($parentHash); } @@ -174,37 +128,21 @@ public function getParents(): array */ public function getTreeHash(): string { - $this->ensureLoaded(); - - return $this->treeHash ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->treeHash; } public function getTree(): Tree { - if (null === $this->tree) { - $this->tree = $this->repository->getTree($this->getTreeHash()); - } - - return $this->tree; + return $this->tree ??= $this->repository->getTree($this->getTreeHash()); } public function getLastModification(?string $path = null): self { if (null !== $path && str_starts_with($path, '/')) { - $path = new CodePointString($path)->slice(1)->toString(); - } - - if ($getWorkingDir = $this->repository->getWorkingDir()) { - $path = $getWorkingDir.'/'.$path; - } - - $result = $this->repository->run('log', ['--format=%H', '-n', 1, $this->revision, '--', $path]); - - if (null === $result) { - throw new ReferenceNotFoundException($this->revision); + $path = substr($path, 1); } - return $this->repository->getCommit(trim($result)); + return $this->getLog($path, null, 1)->getSingleCommit(); } /** @@ -249,42 +187,20 @@ public function resolveReferences(): array */ public function getIncludingBranches(bool $local = true, bool $remote = true): array { - $arguments = ['--contains', $this->revision]; - - if ($local && $remote) { - $arguments[] = '-a'; - } elseif (!$local && $remote) { - $arguments[] = '-r'; - } elseif (!$local) { + if (!$local && !$remote) { throw new InvalidArgumentException('You should a least set one argument to true'); } - try { - $result = $this->repository->run('branch', $arguments); - } catch (ProcessException $e) { - return []; - } - - if (!$result) { - return []; - } - - $branchesName = explode("\n", trim(str_replace('*', '', $result))); - $branchesName = array_filter($branchesName, static function ($v) { - return null === new CodePointString($v)->indexOf('->'); - }); - $branchesName = array_map('trim', $branchesName); - $references = $this->repository->getReferences(); $branches = []; - foreach ($branchesName as $branchName) { - if (false === $local) { - $branches[] = $references->getRemoteBranch($branchName); - } elseif (0 === new CodePointString($branchName)->indexOfLast('remotes/')) { - $branches[] = $references->getRemoteBranch(str_replace('remotes/', '', $branchName)); - } else { - $branches[] = $references->getBranch($branchName); + foreach ($this->repository->getRepositoryDriver()->getIncludingBranches($this->revision) as $fullname) { + if (str_starts_with($fullname, 'refs/heads/')) { + if ($local) { + $branches[] = $references->getBranch(substr($fullname, \strlen('refs/heads/'))); + } + } elseif ($remote) { + $branches[] = $references->getRemoteBranch(substr($fullname, \strlen('refs/remotes/'))); } } @@ -296,9 +212,7 @@ public function getIncludingBranches(bool $local = true, bool $remote = true): a */ public function getAuthorName(): string { - $this->ensureLoaded(); - - return $this->authorName ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->authorName; } /** @@ -306,19 +220,15 @@ public function getAuthorName(): string */ public function getAuthorEmail(): string { - $this->ensureLoaded(); - - return $this->authorEmail ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->authorEmail; } /** * Returns the authoring date. */ - public function getAuthorDate(): \DateTime + public function getAuthorDate(): \DateTimeImmutable { - $this->ensureLoaded(); - - return $this->authorDate ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->authorDate; } /** @@ -326,9 +236,7 @@ public function getAuthorDate(): \DateTime */ public function getCommitterName(): string { - $this->ensureLoaded(); - - return $this->committerName ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->committerName; } /** @@ -336,19 +244,15 @@ public function getCommitterName(): string */ public function getCommitterEmail(): string { - $this->ensureLoaded(); - - return $this->committerEmail ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->committerEmail; } /** * Returns the authoring date. */ - public function getCommitterDate(): \DateTime + public function getCommitterDate(): \DateTimeImmutable { - $this->ensureLoaded(); - - return $this->committerDate ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->committerDate; } /** @@ -356,9 +260,7 @@ public function getCommitterDate(): \DateTime */ public function getMessage(): string { - $this->ensureLoaded(); - - return $this->message ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->message; } /** @@ -396,75 +298,16 @@ public function getCommit(): self return $this; } - private function ensureLoaded(): void - { - if ($this->loaded) { - return; - } - - $parser = new Parser\CommitParser(); - - try { - $result = $this->repository->run('cat-file', ['commit', $this->revision]); - } catch (ProcessException $e) { - throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision)); - } - - if (null === $result) { - throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision)); - } - - $parser->parse($result); - - $this->treeHash = $parser->tree; - $this->parentHashes = $parser->parents; - $this->authorName = $parser->authorName; - $this->authorEmail = $parser->authorEmail; - $this->authorDate = $parser->authorDate; - $this->committerName = $parser->committerName; - $this->committerEmail = $parser->committerEmail; - $this->committerDate = $parser->committerDate; - $this->message = $parser->message; - - $this->loaded = true; - } - - private static function asString(string $name, mixed $value): string - { - if (!\is_string($value)) { - throw new InvalidArgumentException(\sprintf('Commit data "%s" must be a string.', $name)); - } - - return $value; - } - - private static function asDateTime(string $name, mixed $value): \DateTime - { - if (!$value instanceof \DateTime) { - throw new InvalidArgumentException(\sprintf('Commit data "%s" must be a DateTime.', $name)); - } - - return $value; - } - - /** - * @return string[] - */ - private static function asStringList(string $name, mixed $value): array + private function data(): CommitData { - if (!\is_array($value)) { - throw new InvalidArgumentException(\sprintf('Commit data "%s" must be an array.', $name)); - } - - $result = []; - foreach ($value as $item) { - if (!\is_string($item)) { - throw new InvalidArgumentException(\sprintf('Commit data "%s" must be an array of strings.', $name)); + if (null === $this->data) { + try { + $this->data = $this->repository->getRepositoryDriver()->getCommitData($this->revision); + } catch (DriverException $e) { + throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision), 0, $e); } - - $result[] = $item; } - return $result; + return $this->data; } } diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php new file mode 100644 index 00000000..fd65eca6 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -0,0 +1,87 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli; + +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Exception\ProcessException; +use Psr\Log\LoggerInterface; + +/** + * Runs the git binary, through Symfony Process. + * + * @author Grégoire Pineau + */ +final readonly class CliDriver implements DriverInterface +{ + private GitProcessFactory $processFactory; + + /** + * @param string $command The git binary + * @param array $environmentVariables Set on every git process, false removing an inherited one + * @param bool $inheritEnvironmentVariables Also pass every entry of $_SERVER to git processes + * @param float|null $processTimeout In seconds, for the whole process and without output; null for none + */ + public function __construct( + string $command = 'git', + array $environmentVariables = [], + bool $inheritEnvironmentVariables = false, + ?float $processTimeout = 3600, + ) { + $this->processFactory = new GitProcessFactory($command, $environmentVariables, $inheritEnvironmentVariables, $processTimeout); + } + + public function open(string $gitDir, ?string $workingDir, ?LoggerInterface $logger): CliRepositoryDriver + { + return new CliRepositoryDriver($this->processFactory, $gitDir, $workingDir, $logger); + } + + public function init(string $path): void + { + $this->mustRun(['init', '-q', $path]); + } + + public function initBare(string $path): void + { + $this->mustRun(['init', '-q', '--bare', $path]); + } + + public function cloneRepository(string $path, string $url, array $args): void + { + $this->mustRun(['clone', '-q', ...$args, $url, $path]); + } + + public function isValidRepository(string $url): bool + { + return 0 === $this->processFactory->create(['ls-remote', $url])->run(); + } + + public function isValidRepositoryAndBranch(string $url, string $branchName): bool + { + $process = $this->processFactory->create(['ls-remote', '--heads', $url, $branchName]); + + // git only lists the branches matching $branchName. + return 0 === $process->run() && '' !== $process->getOutput(); + } + + /** + * @param string[] $args + */ + private function mustRun(array $args): void + { + $process = $this->processFactory->create($args); + + if (0 !== $process->run()) { + throw new ProcessException($process); + } + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php new file mode 100644 index 00000000..284a2a43 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -0,0 +1,300 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli; + +use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Driver\Cli\Parser\BlameParser; +use Gitonomy\Git\Driver\Cli\Parser\LogParser; +use Gitonomy\Git\Driver\Cli\Parser\ReferenceParser; +use Gitonomy\Git\Driver\Cli\Parser\TagParser; +use Gitonomy\Git\Driver\Cli\Parser\TreeParser; +use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Driver\Data\TagData; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\ProcessException; +use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Process; + +/** + * Runs a git process for every operation on a repository. + * + * @author Grégoire Pineau + * + * @internal + */ +final readonly class CliRepositoryDriver implements RepositoryDriverInterface +{ + // --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". + private const array DIFF_ARGS = ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--no-abbrev']; + + public function __construct( + private GitProcessFactory $processFactory, + private string $gitDir, + private ?string $workingDir, + private ?LoggerInterface $logger, + ) { + } + + public function resolveRevision(string $revision): string + { + return trim($this->run('rev-parse', ['--verify', $revision])); + } + + public function getObjectType(string $revision): string + { + return trim($this->run('cat-file', ['-t', $revision])); + } + + public function getShortHash(string $revision): string + { + return trim($this->run('log', ['--abbrev-commit', '--format=%h', '-n', '1', $revision])); + } + + public function getCommitData(string $hash): CommitData + { + return $this->getCommitLog([$hash], [], null, 1)[$hash] ?? throw new DriverException(\sprintf('Commit "%s" not found.', $hash)); + } + + public function getCommitLog(?array $revisions, array $paths, ?int $offset, ?int $limit): array + { + // Re-encodes the commits whose "encoding" header isn't UTF-8. + $args = ['--encoding=UTF-8', '--format=raw']; + + if (null !== $offset) { + $args[] = '--skip='.$offset; + } + + if (null !== $limit) { + $args[] = '-n'; + $args[] = (string) $limit; + } + + $parser = new LogParser(); + $parser->parse($this->run('log', [...$args, ...$revisions ?? ['--all'], '--', ...$paths])); + + return $parser->commits; + } + + public function countCommits(?array $revisions, array $paths): int + { + return (int) $this->run('rev-list', ['--count', ...$revisions ?: ['--all'], '--', ...$paths]); + } + + public function getMergeBase(string $revision1, string $revision2): ?string + { + $process = $this->execute('merge-base', [$revision1, $revision2]); + + // git exits with 1 and no output when there is no common ancestor; any + // other failure (unknown revision, corrupt repository...) is an error. + if (1 === $process->getExitCode() && '' === $process->getOutput()) { + return null; + } + + if (!$process->isSuccessful()) { + throw new ProcessException($process); + } + + return trim($process->getOutput()); + } + + public function getTreeEntries(string $hash): array + { + $parser = new TreeParser(); + $parser->parse($this->run('ls-tree', ['-z', $hash])); + + return $parser->entries; + } + + public function getBlobContent(string $hash): string + { + return $this->run('cat-file', ['blob', $hash]); + } + + public function getTagData(string $revision): TagData + { + $parser = new TagParser(); + $parser->parse($this->run('cat-file', ['tag', $revision])); + + return $parser->data; + } + + public function getReferences(): array + { + // Unlike for-each-ref in git 1.x, show-ref skips a dangling symbolic reference. + $process = $this->execute('show-ref', []); + + // git exits with 1 and no output when there is no reference. + if (1 === $process->getExitCode() && '' === $process->getOutput()) { + return []; + } + + if (!$process->isSuccessful()) { + throw new ProcessException($process); + } + + $parser = new ReferenceParser(); + $parser->parse($process->getOutput()); + + return $parser->references; + } + + public function updateReference(string $fullname, string $hash): void + { + $this->run('update-ref', [$fullname, $hash]); + } + + public function deleteReference(string $fullname): void + { + $this->run('update-ref', ['-d', $fullname]); + } + + public function getIncludingBranches(string $hash): array + { + // for-each-ref only accepts --contains since git 2.7. + $output = $this->run('branch', ['-a', '--no-color', '--contains', $hash]); + + $branches = []; + foreach (explode("\n", rtrim($output, "\n")) as $line) { + // "* " flags the current branch, "(HEAD detached at ...)" a detached + // HEAD, and "origin/HEAD -> origin/main" a symbolic reference. + $name = substr($line, 2); + if ('' === $name || str_starts_with($name, '(') || str_contains($name, ' -> ')) { + continue; + } + + $branches[] = str_starts_with($name, 'remotes/') ? 'refs/'.$name : 'refs/heads/'.$name; + } + + return $branches; + } + + public function getDiff(array $revisions): Diff + { + return Diff::parse($this->run('diff', [...self::DIFF_ARGS, '--no-commit-id', ...$revisions])); + } + + public function getCommitDiff(string $hash): Diff + { + return Diff::parse($this->run('diff-tree', [...self::DIFF_ARGS, '--no-commit-id', '--root', $hash])); + } + + public function getDiffPending(): Diff + { + return Diff::parse($this->run('diff', self::DIFF_ARGS)); + } + + public function getDiffStaged(): Diff + { + return Diff::parse($this->run('diff', [...self::DIFF_ARGS, '--staged'])); + } + + public function getBlameLines(string $revision, string $file, ?string $lineRange): array + { + $args = ['-p']; + if (null !== $lineRange) { + $args[] = '-L'; + $args[] = $lineRange; + } + + $parser = new BlameParser(); + $parser->parse($this->run('blame', [...$args, $revision, '--', $file])); + + return $parser->lines; + } + + public function getUntrackedFiles(): array + { + $files = []; + foreach (explode("\0", $this->run('status', ['--porcelain', '--untracked-files=all', '-z'])) as $entry) { + if (str_starts_with($entry, '?? ')) { + $files[] = substr($entry, 3); + } + } + + return $files; + } + + public function checkout(string $revision): void + { + $this->run('checkout', [$revision]); + } + + public function checkoutNewBranch(string $branch, string $revision): void + { + $this->run('checkout', ['-b', $branch, $revision]); + } + + public function merge(string $revision, array $args): void + { + $this->run('merge', [...$args, $revision]); + } + + /** + * Runs any git command, for what no driver method covers. + * + * @param array $args + * + * @throws ProcessException The command failed + */ + public function runProcess(string $command, array $args = []): Process + { + $process = $this->execute($command, array_map(strval(...), $args)); + + if (!$process->isSuccessful()) { + throw new ProcessException($process); + } + + return $process; + } + + /** + * @param string[] $args + * + * @throws ProcessException + */ + private function run(string $command, array $args): string + { + return $this->runProcess($command, $args)->getOutput(); + } + + /** + * @param string[] $args + */ + private function execute(string $command, array $args): Process + { + $base = ['--git-dir', $this->gitDir]; + if (null !== $this->workingDir) { + $base = [...$base, '--work-tree', $this->workingDir]; + } + + // Relative paths, in arguments as in pathspecs, resolve against the repository. + $process = $this->processFactory->create([...$base, $command, ...$args], $this->workingDir ?? $this->gitDir); + + $this->logger?->info(\sprintf('run command: %s "%s" ', $command, implode(' ', $args))); + $before = microtime(true); + + $process->run(); + + if (null !== $this->logger) { + $this->logger->debug(\sprintf('last command (%s) duration: %.2fms', $command, (microtime(true) - $before) * 1000)); + $this->logger->debug(\sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); + $this->logger->debug(\sprintf('last command (%s) output: %s', $command, $process->getOutput())); + if (!$process->isSuccessful()) { + $this->logger->debug(\sprintf('last command (%s) error output: %s', $command, $process->getErrorOutput())); + } + } + + return $process; + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/GitProcessFactory.php b/src/Gitonomy/Git/Driver/Cli/GitProcessFactory.php new file mode 100644 index 00000000..acafa5c7 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/GitProcessFactory.php @@ -0,0 +1,56 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli; + +use Symfony\Component\Process\Process; + +/** + * Creates the processes running git, as configured on CliDriver. + * + * @author Grégoire Pineau + * + * @internal + */ +final readonly class GitProcessFactory +{ + /** + * @param array $environmentVariables + */ + public function __construct( + private string $command, + private array $environmentVariables, + private bool $inheritEnvironmentVariables, + private ?float $timeout, + ) { + } + + /** + * @param string[] $args + */ + public function create(array $args, ?string $workingDirectory = null): Process + { + $process = new Process([$this->command, ...$args], $workingDirectory); + + if ($this->inheritEnvironmentVariables) { + // $_SERVER also holds entries that aren't environment variables (argv, REQUEST_TIME...) + $process->setEnv(array_replace(array_filter($_SERVER, \is_string(...)), $this->environmentVariables)); + } else { + $process->setEnv($this->environmentVariables); + } + + $process->setTimeout($this->timeout); + $process->setIdleTimeout($this->timeout); + + return $process; + } +} diff --git a/src/Gitonomy/Git/Parser/BlameParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php similarity index 75% rename from src/Gitonomy/Git/Parser/BlameParser.php rename to src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php index 1dd35d83..d4e46da5 100644 --- a/src/Gitonomy/Git/Parser/BlameParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php @@ -10,28 +10,30 @@ * with this source code in the file LICENSE. */ -namespace Gitonomy\Git\Parser; +namespace Gitonomy\Git\Driver\Cli\Parser; -use Gitonomy\Git\Blame\Line; -use Gitonomy\Git\Repository; +use Gitonomy\Git\Driver\Data\BlameLineData; +use Gitonomy\Git\Parser\ParserBase; +/** + * Parses the output of ``git blame -p``. + */ final class BlameParser extends ParserBase { /** - * @var Line[] + * @var BlameLineData[] */ public array $lines = []; - public function __construct( - protected readonly Repository $repository, - ) { - } + /** + * @var array + */ + private array $seenHashes = []; protected function doParse(): void { $this->lines = []; - - $memory = []; + $this->seenHashes = []; $line = 1; while (!$this->isFinished()) { @@ -44,7 +46,7 @@ protected function doParse(): void $this->consumeTo("\n"); $this->consumeNewLine(); - if (!isset($memory[$hash])) { + if (!isset($this->seenHashes[$hash])) { foreach (['author', 'author-mail', 'author-time', 'author-tz', 'committer', 'committer-mail', 'committer-time', 'committer-tz', 'summary', ] as $key) { @@ -64,12 +66,13 @@ protected function doParse(): void $this->consume('filename'); $this->consumeTo("\n"); // filename $this->consumeNewLine(); - $memory[$hash] = $this->repository->getCommit($hash); + $this->seenHashes[$hash] = true; } - $content = $this->consumeTo("\n"); // content of line + $this->consume("\t"); + $content = $this->consumeTo("\n"); $this->consumeNewLine(); - $this->lines[$line] = new Line($memory[$hash], $sourceLine, $targetLine, $blockLine, $content); + $this->lines[$line] = new BlameLineData($hash, $sourceLine, $targetLine, $blockLine, $content); ++$line; } } diff --git a/src/Gitonomy/Git/Parser/LogParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php similarity index 67% rename from src/Gitonomy/Git/Parser/LogParser.php rename to src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php index 693abfc4..a5752ae5 100644 --- a/src/Gitonomy/Git/Parser/LogParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php @@ -10,43 +10,46 @@ * with this source code in the file LICENSE. */ -namespace Gitonomy\Git\Parser; +namespace Gitonomy\Git\Driver\Cli\Parser; -final class LogParser extends CommitParser +use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Parser\ParserBase; + +/** + * Parses the output of ``git log --format=raw``. + */ +final class LogParser extends ParserBase { /** - * @var array[] + * @var array Indexed by hash */ - public array $log = []; + public array $commits = []; protected function doParse(): void { - $this->log = []; + $this->commits = []; while (!$this->isFinished()) { - $commit = []; $this->consume('commit '); - $commit['id'] = $this->consumeHash(); + $hash = $this->consumeHash(); $this->consumeNewLine(); $this->consume('tree '); - $commit['treeHash'] = $this->consumeHash(); + $treeHash = $this->consumeHash(); $this->consumeNewLine(); - $commit['parentHashes'] = []; + $parentHashes = []; while ($this->expects('parent ')) { - $commit['parentHashes'][] = $this->consumeHash(); + $parentHashes[] = $this->consumeHash(); $this->consumeNewLine(); } $this->consume('author '); - [$commit['authorName'], $commit['authorEmail'], $authorDate] = $this->consumeNameEmailDate(); - $commit['authorDate'] = $this->parseDate($authorDate); + [$authorName, $authorEmail, $authorDate] = $this->consumeNameEmailDate(); $this->consumeNewLine(); $this->consume('committer '); - [$commit['committerName'], $commit['committerEmail'], $committerDate] = $this->consumeNameEmailDate(); - $commit['committerDate'] = $this->parseDate($committerDate); + [$committerName, $committerEmail, $committerDate] = $this->consumeNameEmailDate(); $this->consumeMergeTag(); @@ -75,13 +78,11 @@ protected function doParse(): void $this->consumeNewLine(); } - $commit['message'] = $message; - - $this->log[] = $commit; + $this->commits[$hash] = new CommitData($treeHash, $parentHashes, $authorName, $authorEmail, $authorDate, $committerName, $committerEmail, $committerDate, $message); } } - protected function consumeUnsupportedLinesToNewLine(): void + private function consumeUnsupportedLinesToNewLine(): void { // Consume any unsupported lines that may appear in the log output. For // example, gitbutler headers or other custom metadata but this should diff --git a/src/Gitonomy/Git/Parser/ReferenceParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php similarity index 65% rename from src/Gitonomy/Git/Parser/ReferenceParser.php rename to src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php index aae23af7..a32bb965 100644 --- a/src/Gitonomy/Git/Parser/ReferenceParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php @@ -10,12 +10,18 @@ * with this source code in the file LICENSE. */ -namespace Gitonomy\Git\Parser; +namespace Gitonomy\Git\Driver\Cli\Parser; +use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Parser\ParserBase; + +/** + * Parses `` `` lines. + */ final class ReferenceParser extends ParserBase { /** - * @var array + * @var ReferenceData[] */ public array $references = []; @@ -26,9 +32,9 @@ protected function doParse(): void while (!$this->isFinished()) { $hash = $this->consumeHash(); $this->consume(' '); - $name = $this->consumeTo("\n"); + $fullname = $this->consumeTo("\n"); $this->consumeNewLine(); - $this->references[] = [$hash, $name]; + $this->references[] = new ReferenceData($fullname, $hash); } } } diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php new file mode 100644 index 00000000..18bb6431 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php @@ -0,0 +1,49 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli\Parser; + +use Gitonomy\Git\Driver\Data\TagData; +use Gitonomy\Git\Parser\ParserBase; + +/** + * Parses the output of ``git cat-file tag``. + */ +final class TagParser extends ParserBase +{ + public TagData $data; + + protected function doParse(): void + { + $this->consume('object '); + $object = $this->consumeHash(); + $this->consumeNewLine(); + + $this->consume('type '); + $type = $this->consumeTo("\n"); + $this->consumeNewLine(); + + $this->consume('tag '); + $tag = $this->consumeTo("\n"); + $this->consumeNewLine(); + + $this->consume('tagger '); + [$taggerName, $taggerEmail, $taggerDate] = $this->consumeNameEmailDate(); + + $this->consumeNewLine(); + $this->consumeNewLine(); + + [$message, $gpgSignature] = TagData::splitSignature($this->consumeAll()); + + $this->data = new TagData($object, $type, $tag, $taggerName, $taggerEmail, $taggerDate, $message, $gpgSignature); + } +} diff --git a/src/Gitonomy/Git/Parser/TreeParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php similarity index 59% rename from src/Gitonomy/Git/Parser/TreeParser.php rename to src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php index 0dbb7db6..e68216b4 100644 --- a/src/Gitonomy/Git/Parser/TreeParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php @@ -10,33 +10,42 @@ * with this source code in the file LICENSE. */ -namespace Gitonomy\Git\Parser; +namespace Gitonomy\Git\Driver\Cli\Parser; +use Gitonomy\Git\Driver\Data\TreeEntryData; +use Gitonomy\Git\Driver\Data\TreeEntryType; +use Gitonomy\Git\Parser\ParserBase; + +/** + * Parses the output of ``git ls-tree -z``, whose names are never quoted. + */ final class TreeParser extends ParserBase { /** - * @var array + * @var TreeEntryData[] */ public array $entries = []; protected function doParse(): void { + $this->entries = []; + while (!$this->isFinished()) { $vars = $this->consumeRegexp('/\d{6}/A'); $mode = $vars[0]; $this->consume(' '); $vars = $this->consumeRegexp('/(blob|tree|commit)/A'); - $type = $vars[0]; + $type = TreeEntryType::from($vars[0]); $this->consume(' '); $hash = $this->consumeHash(); $this->consume("\t"); - $name = $this->consumeTo("\n"); - $this->consumeNewLine(); + $name = $this->consumeTo("\0"); + $this->consume("\0"); - $this->entries[] = [$mode, $type, $hash, $name]; + $this->entries[] = new TreeEntryData($mode, $type, $hash, $name); } } } diff --git a/src/Gitonomy/Git/Driver/Data/BlameLineData.php b/src/Gitonomy/Git/Driver/Data/BlameLineData.php new file mode 100644 index 00000000..edf02ad8 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/BlameLineData.php @@ -0,0 +1,32 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * One line of a blame result, as read by a Driver. Keeps only the commit + * hash: resolving it to a Commit object is Blame's job, since Driver has no + * Repository reference (and thus no commit cache). + * + * @author Grégoire Pineau + */ +final readonly class BlameLineData +{ + public function __construct( + public string $commitHash, + public string $sourceLine, + public string $targetLine, + public ?string $blockLine, + public string $content, + ) { + } +} diff --git a/src/Gitonomy/Git/Driver/Data/CommitData.php b/src/Gitonomy/Git/Driver/Data/CommitData.php new file mode 100644 index 00000000..0a57678f --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/CommitData.php @@ -0,0 +1,38 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * Raw commit data, as read by a Driver. Repository-agnostic: does not resolve + * parents/tree into objects, that's Commit's job. + * + * @author Grégoire Pineau + */ +final readonly class CommitData +{ + /** + * @param string[] $parentHashes + */ + public function __construct( + public string $treeHash, + public array $parentHashes, + public string $authorName, + public string $authorEmail, + public \DateTimeImmutable $authorDate, + public string $committerName, + public string $committerEmail, + public \DateTimeImmutable $committerDate, + public string $message, + ) { + } +} diff --git a/src/Gitonomy/Git/Driver/Data/ReferenceData.php b/src/Gitonomy/Git/Driver/Data/ReferenceData.php new file mode 100644 index 00000000..35974d11 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/ReferenceData.php @@ -0,0 +1,27 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * One reference entry (branch, tag, stash...), as read by a Driver. + * + * @author Grégoire Pineau + */ +final readonly class ReferenceData +{ + public function __construct( + public string $fullname, + public string $commitHash, + ) { + } +} diff --git a/src/Gitonomy/Git/Driver/Data/TagData.php b/src/Gitonomy/Git/Driver/Data/TagData.php new file mode 100644 index 00000000..26963ce1 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/TagData.php @@ -0,0 +1,47 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * Raw data of an annotated tag object, as read by a Driver. + * + * @author Grégoire Pineau + */ +final readonly class TagData +{ + public function __construct( + public string $objectHash, + public string $objectType, + public string $tagName, + public string $taggerName, + public string $taggerEmail, + public \DateTimeImmutable $taggerDate, + public string $message, + public ?string $gpgSignature, + ) { + } + + /** + * Splits the message of a tag object from the PGP signature git appends to it. + * + * @return array{string, string|null} The message, and the signature between its armor lines + */ + public static function splitSignature(string $message): array + { + if (!preg_match('/^(.*?)-----BEGIN PGP SIGNATURE-----(.*?)-----END PGP SIGNATURE-----/s', $message, $vars)) { + return [$message, null]; + } + + return [$vars[1], $vars[2]]; + } +} diff --git a/src/Gitonomy/Git/Driver/Data/TreeEntryData.php b/src/Gitonomy/Git/Driver/Data/TreeEntryData.php new file mode 100644 index 00000000..9cf04e7b --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/TreeEntryData.php @@ -0,0 +1,29 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * One entry of a tree, as read by a Driver. + * + * @author Grégoire Pineau + */ +final readonly class TreeEntryData +{ + public function __construct( + public string $mode, + public TreeEntryType $type, + public string $hash, + public string $name, + ) { + } +} diff --git a/src/Gitonomy/Git/Driver/Data/TreeEntryType.php b/src/Gitonomy/Git/Driver/Data/TreeEntryType.php new file mode 100644 index 00000000..4c708896 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/TreeEntryType.php @@ -0,0 +1,23 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * @author Grégoire Pineau + */ +enum TreeEntryType: string +{ + case Blob = 'blob'; + case Tree = 'tree'; + case Commit = 'commit'; +} diff --git a/src/Gitonomy/Git/Driver/DriverInterface.php b/src/Gitonomy/Git/Driver/DriverInterface.php new file mode 100644 index 00000000..d88cde6d --- /dev/null +++ b/src/Gitonomy/Git/Driver/DriverInterface.php @@ -0,0 +1,57 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver; + +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Psr\Log\LoggerInterface; + +/** + * A configured git backend: opens repositories, and runs the operations that + * need no repository yet. + * + * Pass one to Repository and Admin, which use {@see CliDriver} by default, + * to configure the backend, or to replace it in tests. + * + * @author Grégoire Pineau + */ +interface DriverInterface +{ + /** + * @param LoggerInterface|null $logger Receives the git commands the driver runs, if it runs any + */ + public function open(string $gitDir, ?string $workingDir, ?LoggerInterface $logger): RepositoryDriverInterface; + + /** + * @throws DriverException + */ + public function init(string $path): void; + + /** + * @throws DriverException + */ + public function initBare(string $path): void; + + /** + * @param string[] $args Arguments of ``git clone``: ``--bare``, ``--mirror`` and ``--branch `` are supported by every driver + * + * @throws DriverException + * @throws UnsupportedOperationException An argument the driver doesn't support + */ + public function cloneRepository(string $path, string $url, array $args): void; + + public function isValidRepository(string $url): bool; + + public function isValidRepositoryAndBranch(string $url, string $branchName): bool; +} diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php new file mode 100644 index 00000000..39621dab --- /dev/null +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -0,0 +1,135 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver; + +use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Driver\Data\BlameLineData; +use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Driver\Data\TagData; +use Gitonomy\Git\Driver\Data\TreeEntryData; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\UnsupportedOperationException; + +/** + * Operations on one repository, opened by {@see DriverInterface::open()}. + * + * Implementations know nothing of Repository: they read and write git data, + * and the domain objects (Commit, Tree, Reference...) build on it. + * + * Every method throws a {@see DriverException} when git reports a failure (an + * unknown revision, a conflict...), and an {@see UnsupportedOperationException} + * when the driver can't perform the operation at all. + * + * @author Grégoire Pineau + */ +interface RepositoryDriverInterface +{ + /** + * Resolves a revision to the hash of the object it names (``rev-parse --verify``). + */ + public function resolveRevision(string $revision): string; + + /** + * @return string The type of the object $revision names: ``commit``, ``tree``, ``blob`` or ``tag`` + */ + public function getObjectType(string $revision): string; + + public function getShortHash(string $revision): string; + + public function getCommitData(string $hash): CommitData; + + /** + * @param string[]|null $revisions Revisions or ranges (``master``, ``a..b``, ``a...b``, ``^a``), or null for every reference + * @param string[] $paths Paths, relative to the root of the repository + * + * @return array Indexed by hash, newest first + */ + public function getCommitLog(?array $revisions, array $paths, ?int $offset, ?int $limit): array; + + /** + * @param string[]|null $revisions Same as {@see getCommitLog()} + * @param string[] $paths + */ + public function countCommits(?array $revisions, array $paths): int; + + /** + * @return string|null The best common ancestor, or null if the revisions have none + */ + public function getMergeBase(string $revision1, string $revision2): ?string; + + /** + * @return TreeEntryData[] + */ + public function getTreeEntries(string $hash): array; + + public function getBlobContent(string $hash): string; + + public function getTagData(string $revision): TagData; + + /** + * @return ReferenceData[] Every reference under ``refs/``, none for an empty repository + */ + public function getReferences(): array; + + public function updateReference(string $fullname, string $hash): void; + + public function deleteReference(string $fullname): void; + + /** + * @return string[] Full names of the local and remote branches whose history contains $hash, symbolic ones excluded + */ + public function getIncludingBranches(string $hash): array; + + /** + * @param string[] $revisions One revision (compared with the working tree), two revisions, or one range + */ + public function getDiff(array $revisions): Diff; + + /** + * Changes introduced by a commit: against each of its parents, or against + * nothing for a root commit. + */ + public function getCommitDiff(string $hash): Diff; + + /** + * Changes of the working tree not staged yet. + */ + public function getDiffPending(): Diff; + + /** + * Changes staged in the index. + */ + public function getDiffStaged(): Diff; + + /** + * @param string|null $lineRange ``,`` or ``,+``; CliDriver accepts anything ``git blame -L`` does + * + * @return BlameLineData[] + */ + public function getBlameLines(string $revision, string $file, ?string $lineRange): array; + + /** + * @return string[] Paths of the untracked files, relative to the root of the working tree + */ + public function getUntrackedFiles(): array; + + public function checkout(string $revision): void; + + public function checkoutNewBranch(string $branch, string $revision): void; + + /** + * @param string[] $args Extra arguments of ``git merge`` (e.g. ``--no-ff``) + */ + public function merge(string $revision, array $args): void; +} diff --git a/src/Gitonomy/Git/Exception/DriverException.php b/src/Gitonomy/Git/Exception/DriverException.php new file mode 100644 index 00000000..4b8a3081 --- /dev/null +++ b/src/Gitonomy/Git/Exception/DriverException.php @@ -0,0 +1,22 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Exception; + +/** + * Thrown when git reports a failure, whatever the driver running it. + * + * @author Grégoire Pineau + */ +class DriverException extends RuntimeException +{ +} diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index 9d76688f..0a008c92 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -14,7 +14,7 @@ use Symfony\Component\Process\Process; -final class ProcessException extends RuntimeException implements GitExceptionInterface +final class ProcessException extends DriverException implements GitExceptionInterface { public function __construct(private readonly Process $process) { diff --git a/src/Gitonomy/Git/Exception/UnsupportedOperationException.php b/src/Gitonomy/Git/Exception/UnsupportedOperationException.php new file mode 100644 index 00000000..036653ae --- /dev/null +++ b/src/Gitonomy/Git/Exception/UnsupportedOperationException.php @@ -0,0 +1,23 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Exception; + +/** + * Thrown when the driver can't perform an operation at all: unlike a + * DriverException, using another driver may succeed. + * + * @author Grégoire Pineau + */ +final class UnsupportedOperationException extends RuntimeException +{ +} diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index 7d5137f3..de63a4d9 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -13,7 +13,7 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; -use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\ReferenceNotFoundException; /** @@ -119,43 +119,18 @@ public function getSingleCommit(): Commit */ public function getCommits(): array { - $args = ['--encoding=UTF-8', '--format=raw']; - - if (null !== $this->offset) { - $args[] = '--skip='.$this->offset; - } - - if (null !== $this->limit) { - $args[] = '-n'; - $args[] = $this->limit; - } - - if (null !== $this->revisions) { - $args = array_merge($args, $this->revisions->getAsTextArray()); - } else { - $args[] = '--all'; - } - - $args[] = '--'; - - $args = array_merge($args, $this->paths); + $revisions = $this->revisions?->getAsTextArray(); try { - $output = $this->repository->run('log', $args); - } catch (ProcessException $e) { - $revisionsText = null !== $this->revisions ? implode(' ', $this->revisions->getAsTextArray()) : '--all'; + $commitLog = $this->repository->getRepositoryDriver()->getCommitLog($revisions, $this->paths, $this->offset, $this->limit); + } catch (DriverException $e) { + $revisionsText = null !== $revisions ? implode(' ', $revisions) : '--all'; throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $revisionsText), 0, $e); } - $parser = new Parser\LogParser(); - $parser->parse($output); - $result = []; - foreach ($parser->log as $commitData) { - $hash = $commitData['id']; - unset($commitData['id']); - + foreach ($commitLog as $hash => $commitData) { $commit = $this->repository->getCommit($hash); $commit->setData($commitData); @@ -186,12 +161,6 @@ public function getIterator(): \ArrayIterator */ public function countCommits(): int { - if (null !== $this->revisions && \count($this->revisions)) { - $output = $this->repository->run('rev-list', array_merge(['--count'], $this->revisions->getAsTextArray(), ['--'], $this->paths)); - } else { - $output = $this->repository->run('rev-list', array_merge(['--count', '--all', '--'], $this->paths)); - } - - return (int) $output; + return $this->repository->getRepositoryDriver()->countCommits($this->revisions?->getAsTextArray(), $this->paths); } } diff --git a/src/Gitonomy/Git/Parser/CommitParser.php b/src/Gitonomy/Git/Parser/CommitParser.php deleted file mode 100644 index c9300e1b..00000000 --- a/src/Gitonomy/Git/Parser/CommitParser.php +++ /dev/null @@ -1,90 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Parser; - -use Gitonomy\Git\Exception\RuntimeException; - -class CommitParser extends ParserBase -{ - public string $tree; - - /** - * @var string[] - */ - public array $parents; - - public string $authorName; - public string $authorEmail; - public \DateTime $authorDate; - public string $committerName; - public string $committerEmail; - public \DateTime $committerDate; - public string $message; - - protected function doParse(): void - { - $this->consume('tree '); - $this->tree = $this->consumeHash(); - $this->consumeNewLine(); - - $this->parents = []; - while ($this->expects('parent ')) { - $this->parents[] = $this->consumeHash(); - $this->consumeNewLine(); - } - - $this->consume('author '); - [$this->authorName, $this->authorEmail, $authorDate] = $this->consumeNameEmailDate(); - $this->authorDate = $this->parseDate($authorDate); - $this->consumeNewLine(); - - $this->consume('committer '); - [$this->committerName, $this->committerEmail, $committerDate] = $this->consumeNameEmailDate(); - $this->committerDate = $this->parseDate($committerDate); - - $this->consumeMergeTag(); - - // will consume an GPG signed commit if there is one - $this->consumeGPGSignature(); - - $this->consumeNewLine(); - - $this->consumeNewLine(); - $this->message = $this->consumeAll(); - } - - /** - * @return string[] A tuple of [name, email, date] - */ - protected function consumeNameEmailDate(): array - { - if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+ [+-]\d{4}))/A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('Unable to parse name, email and date'); - } - - $this->cursor += \strlen($vars[1]); - - return [$vars[2], $vars[3], $vars[4]]; - } - - protected function parseDate(string $text): \DateTime - { - $date = \DateTime::createFromFormat('U e O', $text.' UTC'); - - if (!$date instanceof \DateTime) { - throw new RuntimeException(\sprintf('Unable to convert "%s" to datetime', $text)); - } - - return $date; - } -} diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index 9a672e74..4003691d 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -17,6 +17,12 @@ final class DiffParser extends ParserBase { + /** + * git quotes the names holding special or non-ASCII characters (unless + * core.quotePath is false, for the latter) like C strings. + */ + private const string QUOTED_NAME = '"(?:[^"\\\]|\\\.)*"'; + /** * @var File[] */ @@ -48,9 +54,9 @@ protected function doParse(): void // The prefix is normally "a" and "b", but git's diff.mnemonicPrefix option // (used by tools such as GrumPHP) can produce other single-letter prefixes // ("c"ommit, "i"ndex, "o"bject, "w"ork tree), or "1"/"2" with --no-index. - $vars = $this->consumeRegexp("/diff --git \"?([^\\/\\s]\\/.*?)\"? \"?([^\\/\\s]\\/.*?)\"?\n/"); - $oldName = $vars[1]; - $newName = $vars[2]; + $vars = $this->consumeRegexp('/diff --git ('.self::QUOTED_NAME.'|[^\/\s"]\/.*?) ('.self::QUOTED_NAME.'|[^\/\s"]\/.*?)\n/'); + $oldName = self::unquote($vars[1]); + $newName = self::unquote($vars[2]); // Get indexes from raw if it exists $oldIndex = isset($indexes[$fileIndex]) ? $indexes[$fileIndex][0] : null; $newIndex = isset($indexes[$fileIndex]) ? $indexes[$fileIndex][1] : null; @@ -116,10 +122,10 @@ protected function doParse(): void $newName = '/dev/null' === $this->consumeTo("\n") ? '/dev/null' : $newName; $this->consumeNewLine(); } elseif ($this->expects('Binary files ')) { - $vars = $this->consumeRegexp('/"?(.*?)"? and "?(.*?)"? differ\n/'); + $vars = $this->consumeRegexp('/('.self::QUOTED_NAME.'|.*?) and ('.self::QUOTED_NAME.'|.*?) differ\n/'); $isBinary = true; - $oldName = $vars[1]; - $newName = $vars[2]; + $oldName = self::unquote($vars[1]); + $newName = self::unquote($vars[2]); } $oldName = '/dev/null' === $oldName ? null : substr($oldName, 2); @@ -168,4 +174,13 @@ protected function doParse(): void $this->files[] = $file; } } + + private static function unquote(string $name): string + { + if (!str_starts_with($name, '"')) { + return $name; + } + + return stripcslashes(substr($name, 1, -1)); + } } diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index 70a7a81e..ac59d69b 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -121,6 +121,20 @@ protected function consumeNewLine(): string return $this->consume("\n"); } + /** + * @return array{string, string, \DateTimeImmutable} The name, email and date of an author, committer or tagger + */ + protected function consumeNameEmailDate(): array + { + if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+) [+-]\d{4})/A', $this->content, $vars, 0, $this->cursor)) { + throw new RuntimeException('Unable to parse name, email and date.'); + } + + $this->cursor += \strlen($vars[1]); + + return [$vars[2], $vars[3], new \DateTimeImmutable('@'.$vars[4])->setTimezone(new \DateTimeZone('UTC'))]; + } + protected function consumeGPGSignature(): string { $expected = "\ngpgsig "; diff --git a/src/Gitonomy/Git/Parser/TagParser.php b/src/Gitonomy/Git/Parser/TagParser.php deleted file mode 100644 index b517f4e0..00000000 --- a/src/Gitonomy/Git/Parser/TagParser.php +++ /dev/null @@ -1,94 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Parser; - -use Gitonomy\Git\Exception\RuntimeException; - -final class TagParser extends ParserBase -{ - public string $object; - public string $type; - public string $tag; - public string $taggerName; - public string $taggerEmail; - public \DateTime $taggerDate; - public ?string $gpgSignature = null; - public string $message; - - protected function doParse(): void - { - $this->consume('object '); - $this->object = $this->consumeHash(); - $this->consumeNewLine(); - - $this->consume('type '); - $this->type = $this->consumeTo("\n"); - $this->consumeNewLine(); - - $this->consume('tag '); - $this->tag = $this->consumeTo("\n"); - $this->consumeNewLine(); - - $this->consume('tagger '); - [$this->taggerName, $this->taggerEmail, $taggerDate] = $this->consumeNameEmailDate(); - $this->taggerDate = $this->parseDate($taggerDate); - - $this->consumeNewLine(); - $this->consumeNewLine(); - - try { - $this->message = $this->consumeTo('-----BEGIN PGP SIGNATURE-----'); - $this->gpgSignature = $this->consumeGPGSignature(); - } catch (RuntimeException $e) { - $this->message = $this->consumeAll(); - } - } - - protected function consumeGPGSignature(): string - { - $expected = '-----BEGIN PGP SIGNATURE-----'; - $length = \strlen($expected); - $actual = substr($this->content, $this->cursor, $length); - if ($actual != $expected) { - return ''; - } - $this->cursor += $length; - - return $this->consumeTo('-----END PGP SIGNATURE-----'); - } - - /** - * @return string[] A tuple of [name, email, date] - */ - protected function consumeNameEmailDate(): array - { - if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+ [+-]\d{4}))/A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('Unable to parse name, email and date'); - } - - $this->cursor += \strlen($vars[1]); - - return [$vars[2], $vars[3], $vars[4]]; - } - - protected function parseDate(string $text): \DateTime - { - $date = \DateTime::createFromFormat('U e O', $text.' UTC'); - - if (!$date instanceof \DateTime) { - throw new RuntimeException(\sprintf('Unable to convert "%s" to datetime', $text)); - } - - return $date; - } -} diff --git a/src/Gitonomy/Git/PushReference.php b/src/Gitonomy/Git/PushReference.php index e7ca21ff..e477bc6e 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -112,16 +112,13 @@ private function getForce(): bool return false; } - $result = $this->repository->run('merge-base', [ - $this->before, - $this->after, - ]); + $result = $this->repository->getRepositoryDriver()->getMergeBase($this->before, $this->after); if (null === $result) { // No common ancestor could be established: treat it as a force update. return true; } - return $this->before !== trim($result); + return $this->before !== $result; } } diff --git a/src/Gitonomy/Git/Reference.php b/src/Gitonomy/Git/Reference.php index 869e5b97..df4a28f1 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -12,7 +12,7 @@ namespace Gitonomy\Git; -use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\ReferenceNotFoundException; /** @@ -49,16 +49,12 @@ public function getCommitHash(): string } try { - $result = $this->repository->run('rev-parse', ['--verify', $this->revision]); - } catch (ProcessException $e) { - throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision)); + $hash = $this->repository->getRepositoryDriver()->resolveRevision($this->revision); + } catch (DriverException $e) { + throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision), 0, $e); } - if (null === $result) { - throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision)); - } - - return $this->commitHash = trim($result); + return $this->commitHash = $hash; } /** diff --git a/src/Gitonomy/Git/Reference/Branch.php b/src/Gitonomy/Git/Reference/Branch.php index fdf4e431..fd43fc35 100644 --- a/src/Gitonomy/Git/Reference/Branch.php +++ b/src/Gitonomy/Git/Reference/Branch.php @@ -12,10 +12,9 @@ namespace Gitonomy\Git\Reference; -use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference; -use Symfony\Component\String\CodePointString; /** * Representation of a branch reference. @@ -61,32 +60,19 @@ public function isLocal(): bool */ public function isMergedTo(string $destinationBranchName = 'master', bool $compareOnlyWithRemote = false): bool { - $arguments = ['-a']; - - if ($compareOnlyWithRemote) { - $arguments = ['-r']; + if ($compareOnlyWithRemote && $this->isLocal()) { + return false; } - $arguments[] = '--merged'; - $arguments[] = $destinationBranchName; - try { - $result = $this->repository->run('branch', $arguments); - } catch (ProcessException $e) { - throw new RuntimeException(\sprintf('Cannot determine if merged to the branch "%s"', $destinationBranchName), $e->getCode(), $e); - } + // The commit hash this object holds may be outdated. + $tip = $this->repository->getRepositoryDriver()->resolveRevision($this->getFullname()); - if (!$result) { - return false; + // Merged: the destination contains the tip of this branch. + return $tip === $this->repository->getRepositoryDriver()->getMergeBase($tip, $destinationBranchName); + } catch (DriverException $e) { + throw new RuntimeException(\sprintf('Cannot determine if merged to the branch "%s"', $destinationBranchName), $e->getCode(), $e); } - - $output = explode("\n", trim(str_replace(['*', 'remotes/'], '', $result))); - $filtered_output = array_filter($output, static function ($v) { - return null === new CodePointString($v)->indexOf('->'); - }); - $trimmed_output = array_map('trim', $filtered_output); - - return \in_array($this->getName(), $trimmed_output, true); } private function detectBranchType(): void diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index b07076de..8d9ebd96 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -13,10 +13,11 @@ namespace Gitonomy\Git\Reference; use Gitonomy\Git\Commit; -use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Driver\Data\TagData; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; -use Gitonomy\Git\Parser\ReferenceParser; -use Gitonomy\Git\Parser\TagParser; use Gitonomy\Git\Reference; /** @@ -27,13 +28,12 @@ */ final class Tag extends Reference { - private bool $loaded = false; + /** + * Raw annotated-tag data, as returned by the Driver. Lazily loaded. + */ + private ?TagData $data = null; - private ?string $taggerName = null; - private ?string $taggerEmail = null; - private ?\DateTime $taggerDate = null; - private ?string $message = null; - private ?string $gpgSignature = null; + private ?bool $annotated = null; // Values derived from the fields above, computed and cached independently. private ?string $subjectMessage = null; @@ -53,14 +53,7 @@ public function getName(): string */ public function isAnnotated(): bool { - try { - $result = $this->repository->run('cat-file', ['tag', $this->revision]); - } catch (ProcessException $e) { - return false; // Is not an annotated tag - } - - // In non-debug mode, a failed command returns null instead of throwing. - return null !== $result; + return $this->annotated ??= 'tag' === $this->repository->getRepositoryDriver()->getObjectType($this->revision); } /** @@ -68,26 +61,13 @@ public function isAnnotated(): bool */ public function getCommit(): Commit { - if ($this->isAnnotated()) { - try { - $output = $this->repository->run('show-ref', ['-d', '--tag', $this->revision]); - $parser = new ReferenceParser(); - $parser->parse($output); - - $commitHash = null; - foreach ($parser->references as [$row]) { - $commitHash = $row; - } - - if (null !== $commitHash) { - return $this->repository->getCommit($commitHash); - } - } catch (ProcessException $e) { - // ignore the exception - } + try { + $hash = $this->repository->getRepositoryDriver()->resolveRevision($this->revision.'^{commit}'); + } catch (DriverException $e) { + throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision), 0, $e); } - return parent::getCommit(); + return $this->repository->getCommit($hash); } /** @@ -98,9 +78,8 @@ public function getTaggerName(): string|false if (!$this->isAnnotated()) { return false; } - $this->ensureLoaded(); - return $this->taggerName ?? throw new \InvalidArgumentException('No data named "taggerName" in Tag.'); + return $this->data()->taggerName; } /** @@ -111,22 +90,20 @@ public function getTaggerEmail(): string|false if (!$this->isAnnotated()) { return false; } - $this->ensureLoaded(); - return $this->taggerEmail ?? throw new \InvalidArgumentException('No data named "taggerEmail" in Tag.'); + return $this->data()->taggerEmail; } /** * Returns the authoring date. */ - public function getTaggerDate(): \DateTime|false + public function getTaggerDate(): \DateTimeImmutable|false { if (!$this->isAnnotated()) { return false; } - $this->ensureLoaded(); - return $this->taggerDate ?? throw new \InvalidArgumentException('No data named "taggerDate" in Tag.'); + return $this->data()->taggerDate; } /** @@ -137,9 +114,8 @@ public function getMessage(): string|false if (!$this->isAnnotated()) { return false; } - $this->ensureLoaded(); - return $this->message ?? throw new \InvalidArgumentException('No data named "message" in Tag.'); + return $this->data()->message; } /** @@ -152,7 +128,7 @@ public function getSubjectMessage(): string|false } if (null === $this->subjectMessage) { - $lines = explode("\n", $this->getMessageOrThrow()); + $lines = explode("\n", $this->data()->message); $this->subjectMessage = reset($lines); } @@ -169,7 +145,7 @@ public function getBodyMessage(): string|false } if (null === $this->bodyMessage) { - $lines = explode("\n", $this->getMessageOrThrow()); + $lines = explode("\n", $this->data()->message); // Drop the subject line, then the blank separator line if the // message follows the "subject\n\nbody" convention. @@ -189,15 +165,16 @@ public function getBodyMessage(): string|false /** * Return the GPG signature. + * + * @throws InvalidArgumentException The tag isn't signed */ public function getGPGSignature(): string|false { if (!$this->isAnnotated()) { return false; } - $this->ensureLoaded(); - return $this->gpgSignature ?? throw new \InvalidArgumentException('No data named "gpgSignature" in Tag.'); + return $this->data()->gpgSignature ?? throw new InvalidArgumentException(\sprintf('Tag "%s" is not signed.', $this->getName())); } /** @@ -205,43 +182,15 @@ public function getGPGSignature(): string|false */ public function isSigned(): bool { - try { - $this->getGPGSignature(); - - return true; - } catch (\InvalidArgumentException $e) { - return false; - } - } - - private function getMessageOrThrow(): string - { - $this->ensureLoaded(); - - return $this->message ?? throw new \InvalidArgumentException('No data named "message" in Tag.'); + return $this->isAnnotated() && null !== $this->data()->gpgSignature; } - private function ensureLoaded(): void + private function data(): TagData { - if ($this->loaded) { - return; + if (null === $this->data) { + $this->data = $this->repository->getRepositoryDriver()->getTagData($this->revision); } - $parser = new TagParser(); - $result = $this->repository->run('cat-file', ['tag', $this->revision]); - - if (null === $result) { - throw new \InvalidArgumentException('Unable to read tag data.'); - } - - $parser->parse($result); - - $this->taggerName = $parser->taggerName; - $this->taggerEmail = $parser->taggerEmail; - $this->taggerDate = $parser->taggerDate; - $this->message = $parser->message; - $this->gpgSignature = $parser->gpgSignature; - - $this->loaded = true; + return $this->data; } } diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index e5408ad6..5c75d68a 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -13,7 +13,6 @@ namespace Gitonomy\Git; use Gitonomy\Git\Exception\ReferenceNotFoundException; -use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Stash; use Gitonomy\Git\Reference\Tag; @@ -92,7 +91,7 @@ public function update(Reference $reference): Reference $fullname = $reference->getFullname(); $this->initialize(); - $this->repository->run('update-ref', [$fullname, $reference->getCommitHash()]); + $this->repository->getRepositoryDriver()->updateReference($fullname, $reference->getCommitHash()); $this->references[$fullname] = $reference; @@ -115,7 +114,7 @@ public function createTag(string $name, string $commitHash): Tag public function delete(string $fullname): void { - $this->repository->run('update-ref', ['-d', $fullname]); + $this->repository->getRepositoryDriver()->deleteReference($fullname); unset($this->references[$fullname]); } @@ -345,16 +344,9 @@ private function initialize(): void } $this->initialized = true; - try { - $parser = new Parser\ReferenceParser(); - $output = $this->repository->run('show-ref'); - } catch (RuntimeException $e) { - return; - } - $parser->parse($output); - - foreach ($parser->references as $row) { - [$commitHash, $fullname] = $row; + foreach ($this->repository->getRepositoryDriver()->getReferences() as $reference) { + $commitHash = $reference->commitHash; + $fullname = $reference->fullname; if (preg_match('#^refs/(heads|remotes)/(.*)$#', $fullname)) { if (preg_match('#.*HEAD$#', $fullname)) { diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 4c98c53c..bfe99bf2 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -13,9 +13,14 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\Cli\CliRepositoryDriver; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\RepositoryDriverInterface; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Exception\UnsupportedOperationException; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; @@ -72,26 +77,14 @@ final class Repository private ?LoggerInterface $logger; /** - * Path to git command. + * Backend used to run operations. */ - private readonly string $command; + private readonly DriverInterface $driver; /** - * Debug flag, indicating if errors should be thrown. + * Operations on this repository, opened by the driver. */ - private readonly bool $debug; - - /** - * Environment variables that should be set for every running process. - */ - private readonly array $environmentVariables; - - private readonly bool $inheritEnvironmentVariables; - - /** - * Timeout that should be set for every running process. - */ - private readonly int $processTimeout; + private RepositoryDriverInterface $repositoryDriver; /** * Constructs a new repository. @@ -100,27 +93,25 @@ final class Repository * * * working_dir : specify where working copy is located (option --work-tree) * - * * debug : (default: true) enable/disable minimize errors and reduce log level - * * * logger : a logger to use for logging actions (Psr\Log\LoggerInterface) * - * * environment_variables : define environment variables for every ran process - * - * @param string $dir path to git repository - * @param array $options array of options values + * @param string $dir path to git repository + * @param array $options array of options values + * @param DriverInterface $driver backend used to run operations * - * @throws InvalidArgumentException The folder does not exists + * @throws InvalidArgumentException The folder does not exists, or an option is unknown */ - public function __construct(string $dir, array $options = []) + public function __construct(string $dir, array $options = [], DriverInterface $driver = new CliDriver()) { + // The options of 1.x configuring git are now arguments of the driver. + $unknownOptions = array_diff(array_keys($options), ['logger', 'working_dir']); + if ([] !== $unknownOptions) { + throw new InvalidArgumentException(\sprintf('Unknown options "%s": expected "logger" or "working_dir". Configure git through the driver argument, a CliDriver by default.', implode('", "', $unknownOptions))); + } + $options = array_merge([ 'working_dir' => null, - 'debug' => true, 'logger' => null, - 'command' => 'git', - 'environment_variables' => [], - 'inherit_environment_variables' => false, - 'process_timeout' => 3600, ], $options); if (null !== $options['logger'] && !$options['logger'] instanceof LoggerInterface) { @@ -130,20 +121,20 @@ public function __construct(string $dir, array $options = []) $this->logger = $options['logger']; [$this->gitDir, $this->workingDir] = self::resolveDir($dir, $options['working_dir']); - $this->command = $options['command']; - $this->debug = (bool) $options['debug']; - $this->processTimeout = $options['process_timeout']; - - if (\defined('PHP_WINDOWS_VERSION_BUILD') && isset($_SERVER['PATH']) && !isset($options['environment_variables']['PATH'])) { - $options['environment_variables']['PATH'] = $_SERVER['PATH']; - } + $this->driver = $driver; + $this->repositoryDriver = $driver->open($this->gitDir, $this->workingDir, $this->logger); - $this->environmentVariables = $options['environment_variables']; - $this->inheritEnvironmentVariables = $options['inherit_environment_variables']; + $this->logger?->debug(\sprintf('Repository created (git dir: "%s", working dir: "%s")', $this->gitDir, $this->workingDir ?? 'none')); + } - if (true === $this->debug && null !== $this->logger) { - $this->logger->debug(\sprintf('Repository created (git dir: "%s", working dir: "%s")', $this->gitDir, $this->workingDir ?: 'none')); - } + /** + * Returns the operations on this repository, opened by the driver. + * + * @internal + */ + public function getRepositoryDriver(): RepositoryDriverInterface + { + return $this->repositoryDriver; } /** @@ -156,10 +147,8 @@ public function isBare(): bool /** * Returns the HEAD resolved as a commit. - * - * @return Commit|null returns a Commit or ``null`` if repository is empty */ - public function getHeadCommit(): ?Commit + public function getHeadCommit(): Commit { $head = $this->getHead(); @@ -171,31 +160,21 @@ public function getHeadCommit(): ?Commit } /** - * @return Reference|Commit|null current HEAD object or null if error occurs + * @return Reference|Commit current HEAD object * - * @throws RuntimeException Unable to find file HEAD (debug-mode only) + * @throws RuntimeException Unable to read the HEAD file */ - public function getHead(): Reference|Commit|null + public function getHead(): Reference|Commit { $file = $this->gitDir.'/HEAD'; if (!file_exists($file)) { - $message = \sprintf('Unable to find HEAD file ("%s")', $file); - - if (null !== $this->logger) { - $this->logger->error($message); - } - - if (true === $this->debug) { - throw new RuntimeException($message); - } + throw new RuntimeException(\sprintf('Unable to find HEAD file ("%s").', $file)); } $content = trim(file_get_contents($file)); - if (null !== $this->logger) { - $this->logger->debug('HEAD file read: '.$content); - } + $this->logger?->debug('HEAD file read: '.$content); if (preg_match('/^ref: (.+)$/', $content, $vars)) { return $this->getReferences()->get($vars[1]); @@ -204,17 +183,7 @@ public function getHead(): Reference|Commit|null return $this->getCommit($content); } - $message = \sprintf('Unexpected HEAD file content (file: %s). Content of file: %s', $file, $content); - - if (null !== $this->logger) { - $this->logger->error($message); - } - - if (true === $this->debug) { - throw new RuntimeException($message); - } - - return null; + throw new RuntimeException(\sprintf('Unexpected HEAD file content (file: %s). Content of file: %s.', $file, $content)); } public function isHeadDetached(): bool @@ -359,15 +328,7 @@ public function getDiff(RevisionList|Revision|string|array|null $revisions): Dif $revisions = new RevisionList($this, $revisions); } - // --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". - $args = array_merge(['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index', '--no-abbrev'], $revisions->getAsTextArray()); - - $result = $this->run('diff', $args); - if (null === $result) { - throw new RuntimeException('Unable to compute diff.'); - } - - $diff = Diff::parse($result); + $diff = $this->repositoryDriver->getDiff($revisions->getAsTextArray()); $diff->setRepository($this); return $diff; @@ -427,12 +388,10 @@ public function getDescription(): string $file = $this->gitDir.'/description'; $exists = is_file($file); - if (null !== $this->logger && true === $this->debug) { - if (false === $exists) { - $this->logger->debug(\sprintf('no description file in repository ("%s")', $file)); - } else { - $this->logger->debug(\sprintf('reading description file in repository ("%s")', $file)); - } + if (false === $exists) { + $this->logger?->debug(\sprintf('no description file in repository ("%s")', $file)); + } else { + $this->logger?->debug(\sprintf('reading description file in repository ("%s")', $file)); } if (false === $exists) { @@ -459,9 +418,7 @@ public function setDescription(string $description): static { $file = $this->gitDir.'/description'; - if (null !== $this->logger && true === $this->debug) { - $this->logger->debug(\sprintf('change description file content to "%s" (file: %s)', $description, $file)); - } + $this->logger?->debug(\sprintf('change description file content to "%s" (file: %s)', $description, $file)); file_put_contents($file, $description); return $this; @@ -479,15 +436,14 @@ public function setDescription(string $description): static * @param string $command Git command to run (checkout, branch, tag) * @param array $args Arguments of git command * - * @return string|null output of a successful process or null if execution failed and debug-mode is disabled + * @return string output of the process * - * @throws RuntimeException Error while executing git command (debug-mode only) + * @throws ProcessException Error while executing git command + * @throws UnsupportedOperationException The driver isn't a CliDriver */ - public function run(string $command, array $args = []): ?string + public function run(string $command, array $args = []): string { - $process = $this->runProcess($command, $args); - - return $process->isSuccessful() ? $process->getOutput() : null; + return $this->runProcess($command, $args)->getOutput(); } /** @@ -501,39 +457,16 @@ public function run(string $command, array $args = []): ?string * @param string $command Git command to run (checkout, branch, tag) * @param array $args Arguments of git command * - * @throws RuntimeException Error while executing git command (debug-mode only) + * @throws ProcessException Error while executing git command + * @throws UnsupportedOperationException The driver isn't a CliDriver */ public function runProcess(string $command, array $args = []): Process { - $process = $this->getProcess($command, $args); - - if ($this->logger) { - $this->logger->info(\sprintf('run command: %s "%s" ', $command, implode(' ', $args))); - $before = microtime(true); - } - - $process->run(); - - if ($this->logger && $this->debug) { - $duration = microtime(true) - $before; - $this->logger->debug(\sprintf('last command (%s) duration: %sms', $command, \sprintf('%.2f', $duration * 1000))); - $this->logger->debug(\sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); - $this->logger->debug(\sprintf('last command (%s) output: %s', $command, $process->getOutput())); - } - - if (!$process->isSuccessful()) { - $error = \sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput()); - - if ($this->logger) { - $this->logger->error($error); - } - - if ($this->debug) { - throw new ProcessException($process); - } + if (!$this->repositoryDriver instanceof CliRepositoryDriver) { + throw new UnsupportedOperationException('Only CliDriver can run git commands.'); } - return $process; + return $this->repositoryDriver->runProcess($command, $args); } /** @@ -546,6 +479,7 @@ public function runProcess(string $command, array $args = []): Process public function setLogger(LoggerInterface $logger): static { $this->logger = $logger; + $this->repositoryDriver = $this->driver->open($this->gitDir, $this->workingDir, $logger); return $this; } @@ -561,6 +495,8 @@ public function getLogger(): ?LoggerInterface /** * Clones the current repository to a new directory and return instance of new repository. * + * The clone uses the driver of the current repository. + * * @param string $path path to the new repository in which current repository will be cloned * @param bool $bare flag indicating if repository is bare or has a working-copy * @@ -568,7 +504,7 @@ public function getLogger(): ?LoggerInterface */ public function cloneTo(string $path, bool $bare = true, array $options = []): self { - return Admin::cloneTo($path, $this->gitDir, $bare, $options); + return Admin::cloneTo($path, $this->gitDir, $bare, $options, $this->driver); } /** @@ -596,37 +532,4 @@ private static function resolveDir(string $gitDir, ?string $workingDir = null): return [$realGitDir, $workingDir]; } - - /** - * This internal method is used to create a process object. - * - * Made private to be sure that process creation is handled through the run method. - * run method ensures logging and debug. - * - * @see self::run - */ - private function getProcess(string $command, array $args = []): Process - { - $base = [$this->command, '--git-dir', $this->gitDir]; - - if ($this->workingDir) { - $base = array_merge($base, ['--work-tree', $this->workingDir]); - } - - $base[] = $command; - - $process = new Process(array_merge($base, $args)); - $process->setWorkingDirectory($this->getPath()); - - if ($this->inheritEnvironmentVariables) { - $process->setEnv(array_replace($_SERVER, $this->environmentVariables)); - } else { - $process->setEnv($this->environmentVariables); - } - - $process->setTimeout($this->processTimeout); - $process->setIdleTimeout($this->processTimeout); - - return $process; - } } diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index 43f9f706..96c66071 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Driver\Data\TreeEntryType; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\UnexpectedValueException; @@ -123,9 +124,7 @@ private function initialize(): void return; } - $output = $this->repository->run('cat-file', ['-p', $this->hash]); - $parser = new Parser\TreeParser(); - $parser->parse($output); + $entries = $this->repository->getRepositoryDriver()->getTreeEntries($this->hash); $this->entries = []; $this->entriesByType = [ @@ -134,19 +133,18 @@ private function initialize(): void 'commit' => [], ]; - foreach ($parser->entries as $entry) { - [$mode, $type, $hash, $name] = $entry; - if ('blob' == $type) { - $treeEntry = [$mode, $this->repository->getBlob($hash)]; - $this->entriesByType['blob'][$name] = $treeEntry; - } elseif ('tree' == $type) { - $treeEntry = [$mode, $this->repository->getTree($hash)]; - $this->entriesByType['tree'][$name] = $treeEntry; + foreach ($entries as $entry) { + if (TreeEntryType::Blob === $entry->type) { + $treeEntry = [$entry->mode, $this->repository->getBlob($entry->hash)]; + $this->entriesByType['blob'][$entry->name] = $treeEntry; + } elseif (TreeEntryType::Tree === $entry->type) { + $treeEntry = [$entry->mode, $this->repository->getTree($entry->hash)]; + $this->entriesByType['tree'][$entry->name] = $treeEntry; } else { - $treeEntry = [$mode, new CommitReference($hash)]; - $this->entriesByType['commit'][$name] = $treeEntry; + $treeEntry = [$entry->mode, new CommitReference($entry->hash)]; + $this->entriesByType['commit'][$entry->name] = $treeEntry; } - $this->entries[$name] = $treeEntry; + $this->entries[$entry->name] = $treeEntry; } $this->isInitialized = true; diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index 29365810..61f87f18 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -15,7 +15,6 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\LogicException; -use Gitonomy\Git\Exception\RuntimeException; /** * @author Alexandre Salomé @@ -35,30 +34,12 @@ public function __construct( */ public function getUntrackedFiles(): array { - $output = $this->run('status', ['--porcelain', '--untracked-files=all', '-z']); - if (null === $output) { - return []; - } - - $lines = explode("\0", $output); - $lines = array_filter($lines, static function ($l) { - return '?? ' === substr($l, 0, 3); - }); - - return array_map(static function ($l) { - return substr($l, 3); - }, $lines); + return $this->repository->getRepositoryDriver()->getUntrackedFiles(); } public function getDiffPending(): Diff { - // --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". - $result = $this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--no-abbrev']); - if (null === $result) { - throw new RuntimeException('Unable to compute pending diff.'); - } - - $diff = Diff::parse($result); + $diff = $this->repository->getRepositoryDriver()->getDiffPending(); $diff->setRepository($this->repository); return $diff; @@ -66,12 +47,7 @@ public function getDiffPending(): Diff public function getDiffStaged(): Diff { - $result = $this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--no-abbrev', '--staged']); - if (null === $result) { - throw new RuntimeException('Unable to compute staged diff.'); - } - - $diff = Diff::parse($result); + $diff = $this->repository->getRepositoryDriver()->getDiffStaged(); $diff->setRepository($this->repository); return $diff; @@ -82,23 +58,12 @@ public function getDiffStaged(): Diff */ public function checkout($revision, ?string $branch = null): static { - $args = []; - if ($revision instanceof Commit) { - $args[] = $revision->getHash(); - } elseif ($revision instanceof Reference) { - $args[] = $revision->getFullname(); - } elseif (\is_string($revision)) { - $args[] = $revision; + if (null === $branch) { + $this->repository->getRepositoryDriver()->checkout($this->revisionToString($revision)); } else { - throw new InvalidArgumentException(\sprintf('Unknown type "%s"', \gettype($revision))); + $this->repository->getRepositoryDriver()->checkoutNewBranch($branch, $this->revisionToString($revision)); } - if (null !== $branch) { - $args = array_merge($args, ['-b', $branch]); - } - - $this->run('checkout', $args); - return $this; } @@ -109,24 +74,27 @@ public function checkout($revision, ?string $branch = null): static * @param string[] $args extra arguments for the merge command (e.g. ``['--no-ff']``) */ public function merge($revision, array $args = []): static + { + $this->repository->getRepositoryDriver()->merge($this->revisionToString($revision), $args); + + return $this; + } + + /** + * @param Commit|Reference|string $revision + */ + private function revisionToString($revision): string { if ($revision instanceof Commit) { - $args[] = $revision->getHash(); + $result = $revision->getHash(); } elseif ($revision instanceof Reference) { - $args[] = $revision->getFullname(); + $result = $revision->getFullname(); } elseif (\is_string($revision)) { - $args[] = $revision; + $result = $revision; } else { throw new InvalidArgumentException(\sprintf('Unknown type "%s"', \gettype($revision))); } - $this->run('merge', $args); - - return $this; - } - - private function run(string $command, array $args = []): ?string - { - return $this->repository->run($command, $args); + return $result; } } diff --git a/test-git-versions.php b/test-git-versions.php index 11365762..82cdd48b 100755 --- a/test-git-versions.php +++ b/test-git-versions.php @@ -25,11 +25,11 @@ const GIT_URL = 'https://github.com/git/git.git'; const BUILDS_PATH = __DIR__.'/git-builds'; -// One v1.x and several v2.y releases, picked for how long and how widely each -// shipped as the default git on a major distro, rather than for a spread across -// git's history: v1.7.1 (RHEL/CentOS 6), v1.8.3.1 (RHEL/CentOS 7), v2.17.1 (Ubuntu -// 18.04), v2.25.1 (Ubuntu 20.04), v2.34.1 (Ubuntu 22.04), v2.43.0 (Ubuntu 24.04). -const DEFAULT_VERSIONS = ['v1.7.1', 'v1.8.3.1', 'v2.17.1', 'v2.25.1', 'v2.34.1', 'v2.43.0']; +// v1.8.4, the oldest version gitlib supports, and v2.y releases picked for how +// long and how widely each shipped as the default git on a major distro, rather +// than for a spread across git's history: v2.17.1 (Ubuntu 18.04), v2.25.1 (Ubuntu +// 20.04), v2.34.1 (Ubuntu 22.04), v2.43.0 (Ubuntu 24.04). +const DEFAULT_VERSIONS = ['v1.8.4', 'v2.17.1', 'v2.25.1', 'v2.34.1', 'v2.43.0']; function sh(array $args, ?string $cwd = null, ?string $logfile = null): Process { diff --git a/tests/Gitonomy/Git/Tests/AbstractTestCase.php b/tests/Gitonomy/Git/Tests/AbstractTestCase.php index 80cd16e9..2a942a34 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTestCase.php +++ b/tests/Gitonomy/Git/Tests/AbstractTestCase.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Admin; +use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Repository; use PHPUnit\Framework\TestCase; @@ -44,7 +45,7 @@ abstract class AbstractTestCase extends TestCase public static function createEmptyRepository(bool $bare = true): Repository { $dir = self::createTempDir(); - $repository = Admin::init($dir, $bare, self::getOptions()); + $repository = Admin::init($dir, $bare, driver: self::getDriver()); self::registerDeletion($repository); return $repository; @@ -78,10 +79,10 @@ public static function provideEmpty(): array public static function createFoobarRepository(bool $bare = true): Repository { if (null === self::$localRepository) { - self::$localRepository = Admin::cloneTo(self::createTempDir(), self::REPOSITORY_URL, $bare, self::getOptions()); + self::$localRepository = Admin::cloneTo(self::createTempDir(), self::REPOSITORY_URL, $bare, driver: self::getDriver()); } - $repository = self::$localRepository->cloneTo(self::createTempDir(), $bare, self::getOptions()); + $repository = self::$localRepository->cloneTo(self::createTempDir(), $bare); self::registerDeletion($repository); return $repository; @@ -133,15 +134,44 @@ protected static function deleteDir(string $dir): void rmdir($dir); } - protected static function getOptions(): array + /** + * Runs a git command on $repository: for setup (committing, staging, + * tagging...) that no method of gitlib covers. + * + * @param array $args + */ + protected static function git(Repository $repository, string $command, array $args = []): string + { + return $repository->run($command, $args); + } + + /** + * Writes a loose object, without the checks recent git versions apply to + * it (e.g. to a tag without tagger), nor the --literally option old ones lack. + * + * @return string The hash of the object + */ + protected static function writeObject(Repository $repository, string $type, string $content): string + { + $object = $type.' '.\strlen($content)."\0".$content; + $hash = sha1($object); + + $dir = $repository->getGitDir().'/objects/'.substr($hash, 0, 2); + if (!is_dir($dir)) { + mkdir($dir); + } + file_put_contents($dir.'/'.substr($hash, 2), gzcompress($object)); + + return $hash; + } + + /** + * @param array $environmentVariables Set on git processes, in addition to the GIT_ENVS environment variable + */ + protected static function getDriver(array $environmentVariables = [], ?float $processTimeout = 60): CliDriver { - $command = $_SERVER['GIT_COMMAND'] ?? 'git'; $envs = isset($_SERVER['GIT_ENVS']) ? (array) $_SERVER['GIT_ENVS'] : []; - return [ - 'command' => $command, - 'environment_variables' => $envs, - 'process_timeout' => 60, - ]; + return new CliDriver($_SERVER['GIT_COMMAND'] ?? 'git', [...$envs, ...$environmentVariables], processTimeout: $processTimeout); } } diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index f47702d8..0e23ebc9 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Admin; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; @@ -38,7 +39,7 @@ public function tearDownTmpDir(): void public function testBare(): void { - $repository = Admin::init($this->tmpDir, true, self::getOptions()); + $repository = Admin::init($this->tmpDir, true, driver: self::getDriver()); $objectDir = $this->tmpDir.'/objects'; @@ -51,7 +52,7 @@ public function testBare(): void public function testNotBare(): void { - $repository = Admin::init($this->tmpDir, false, self::getOptions()); + $repository = Admin::init($this->tmpDir, false, driver: self::getDriver()); $objectDir = $this->tmpDir.'/.git/objects'; @@ -66,7 +67,7 @@ public function testNotBare(): void public function testClone(Repository $repository): void { $newDir = self::createTempDir(); - $new = $repository->cloneTo($newDir, $repository->isBare(), self::getOptions()); + $new = $repository->cloneTo($newDir, $repository->isBare()); self::registerDeletion($new); $newRefs = array_keys($new->getReferences()->getAll()); @@ -77,6 +78,8 @@ public function testClone(Repository $repository): void if ($repository->isBare()) { $this->assertEquals($newDir, $new->getGitDir()); $this->assertContains('refs/heads/new-feature', $newRefs); + // Like `git clone --bare`: the remote has no fetch refspec. + $this->assertStringNotContainsString('fetch', file_get_contents($newDir.'/config')); } else { $this->assertEquals($newDir.'/.git', $new->getGitDir()); $this->assertEquals($newDir, $new->getWorkingDir()); @@ -90,7 +93,7 @@ public function testCloneBranchBare(): void // so we test it directly against the remote repository $newDir = self::createTempDir(); - $new = Admin::cloneBranchTo($newDir, self::REPOSITORY_URL, 'new-feature'); + $new = Admin::cloneBranchTo($newDir, self::REPOSITORY_URL, 'new-feature', true, driver: self::getDriver()); self::registerDeletion($new); $head = $new->getHead(); @@ -105,7 +108,7 @@ public function testCloneBranchNotBare(): void // so we test it directly against remote repository $newDir = self::createTempDir(); - $new = Admin::cloneBranchTo($newDir, self::REPOSITORY_URL, 'new-feature', false); + $new = Admin::cloneBranchTo($newDir, self::REPOSITORY_URL, 'new-feature', false, driver: self::getDriver()); self::registerDeletion($new); $head = $new->getHead(); @@ -117,7 +120,7 @@ public function testCloneBranchNotBare(): void public function testMirror(Repository $repository): void { $newDir = self::createTempDir(); - $new = Admin::mirrorTo($newDir, $repository->getGitDir(), self::getOptions()); + $new = Admin::mirrorTo($newDir, $repository->getGitDir(), driver: self::getDriver()); self::registerDeletion($new); $newRefs = array_keys($new->getReferences()->getAll()); @@ -131,13 +134,24 @@ public function testMirror(Repository $repository): void } else { $this->assertContains('refs/remotes/origin/new-feature', $newRefs); } + + $this->assertSame('true', trim(self::git($new, 'config', ['remote.origin.mirror']))); + $this->assertSame('+refs/*:refs/*', trim(self::git($new, 'config', ['remote.origin.fetch']))); + $this->assertSame('refs/heads/master', trim(self::git($new, 'symbolic-ref', ['HEAD']))); + } + + public function testCloneUnknownBranchFails(): void + { + $this->expectException(DriverException::class); + + Admin::cloneBranchTo(self::createTempDir().'/clone', self::REPOSITORY_URL, 'unknown', true, driver: self::getDriver()); } #[DataProvider('provideFoobar')] public function testCheckValidRepository(Repository $repository): void { $url = $repository->getGitDir(); - $this->assertTrue(Admin::isValidRepository($url)); + $this->assertTrue(Admin::isValidRepository($url, driver: self::getDriver())); } public function testCheckInvalidRepository(): void @@ -145,21 +159,21 @@ public function testCheckInvalidRepository(): void $url = $this->tmpDir.'/invalid.git'; mkdir($url); - $this->assertFalse(Admin::isValidRepository($url)); + $this->assertFalse(Admin::isValidRepository($url, driver: self::getDriver())); } #[DataProvider('provideFoobar')] public function testCheckValidRepositoryAndBranch(Repository $repository): void { $url = $repository->getGitDir(); - $this->assertTrue(Admin::isValidRepositoryAndBranch($url, 'master')); + $this->assertTrue(Admin::isValidRepositoryAndBranch($url, 'master', driver: self::getDriver())); } #[DataProvider('provideFoobar')] public function testCheckInvalidRepositoryAndBranch(Repository $repository): void { $url = $repository->getGitDir(); - $this->assertFalse(Admin::isValidRepositoryAndBranch($url, 'invalid-branch-name')); + $this->assertFalse(Admin::isValidRepositoryAndBranch($url, 'invalid-branch-name', driver: self::getDriver())); } public function testExistingFile(): void @@ -169,7 +183,7 @@ public function testExistingFile(): void $file = $this->tmpDir.'/test'; touch($file); - Admin::init($file, true, self::getOptions()); + Admin::init($file, true, driver: self::getDriver()); } public function testCloneRepository(): void @@ -177,7 +191,7 @@ public function testCloneRepository(): void $newDir = self::createTempDir(); $args = []; - $new = Admin::cloneRepository($newDir, self::REPOSITORY_URL, $args, self::getOptions()); + $new = Admin::cloneRepository($newDir, self::REPOSITORY_URL, $args, driver: self::getDriver()); self::registerDeletion($new); $newRefs = array_keys($new->getReferences()->getAll()); @@ -188,4 +202,12 @@ public function testCloneRepository(): void $this->assertEquals($newDir.'/.git', $new->getGitDir()); $this->assertEquals($newDir, $new->getWorkingDir()); } + + #[DataProvider('provideFoobar')] + public function testCheckRepositoryAndBranchOnlyMatchesBranches(Repository $repository): void + { + foreach (['0.1', 'HEAD', 'feature'] as $name) { + $this->assertFalse(Admin::isValidRepositoryAndBranch($repository->getGitDir(), $name, driver: self::getDriver()), $name); + } + } } diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index 3fe66c3f..f95b00ea 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -24,6 +24,8 @@ public function testBlame(Repository $repository): void $this->assertCount(7, $blame); + $this->assertSame('Foo Bar project', $blame->getLine(1)->getContent()); + $this->assertEquals('alice', $blame->getLine(1)->getCommit()->getAuthorName()); $this->assertEquals(self::INITIAL_COMMIT, $blame->getLine(1)->getCommit()->getHash()); @@ -40,4 +42,21 @@ public function testGroupedBlame(Repository $repository): void $this->assertEquals(self::INITIAL_COMMIT, $blame[0][0]->getHash()); } + + #[DataProvider('provideFoobar')] + public function testBlameLineRange(Repository $repository): void + { + $full = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md'); + + foreach (['3,+3', '3,5'] as $lineRange) { + $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', $lineRange); + + $this->assertCount(3, $blame, $lineRange); + foreach ([1 => 3, 2 => 4, 3 => 5] as $index => $lineNumber) { + $this->assertSame((string) $lineNumber, $blame->getLine($index)->getLine(), $lineRange); + $this->assertSame($full->getLine($lineNumber)->getCommit()->getHash(), $blame->getLine($index)->getCommit()->getHash(), $lineRange); + $this->assertSame($full->getLine($lineNumber)->getContent(), $blame->getLine($index)->getContent(), $lineRange); + } + } + } } diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 5673e782..3a2a6ebd 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -88,4 +88,12 @@ public function testIsBinary(Repository $repository): void $imageBlob = $this->getImageBlob($repository); $this->assertTrue($imageBlob->isBinary()); } + + #[DataProvider('provideFoobar')] + public function testAbbreviatedHash(Repository $repository): void + { + $hash = '412a0dad051163dcf9f9ce2fc04a141ffcb69479'; + + $this->assertSame($repository->getBlob($hash)->getContent(), $repository->getBlob(substr($hash, 0, 8))->getContent()); + } } diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 4fdf02dc..8a88a6d3 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -269,4 +269,35 @@ public function testEncoding(Repository $repository): void $this->assertEquals('contribute to AUTHORS file', $commit->getSubjectMessage()); } + + public function testReencodesCommitsToUtf8(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['i18n.commitEncoding', 'ISO-8859-1']); + self::git($repository, 'config', ['user.name', "Andr\xe9"]); + self::git($repository, 'config', ['user.email', 'andre@example.com']); + file_put_contents($repository->getWorkingDir().'/latin1.txt', 'latin1'); + self::git($repository, 'add', ['latin1.txt']); + self::git($repository, 'commit', ['-m', "Caf\xe9"]); + $hash = trim(self::git($repository, 'rev-parse', ['HEAD'])); + + $commit = new Repository($repository->getPath(), driver: self::getDriver())->getCommit($hash); + $this->assertSame("Caf\u{e9}\n", $commit->getMessage()); + $this->assertSame("Andr\u{e9}", $commit->getAuthorName()); + + $commit = new Repository($repository->getPath(), driver: self::getDriver())->getLog($hash, null, null, 1)->getSingleCommit(); + $this->assertSame("Caf\u{e9}\n", $commit->getMessage()); + $this->assertSame("Andr\u{e9}", $commit->getAuthorName()); + } + + public function testMessageIsReadLikeGitLogPrintsIt(): void + { + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + // Not cleaned up, unlike what `git commit` writes. + $hash = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\n\n\n \nSubject \t\n\nBody\r\n indented\n\n\n"); + + $this->assertSame("Subject\n\nBody\n indented\n", $repository->getCommit($hash)->getMessage()); + $this->assertSame("Subject\n\nBody\n indented\n", new Repository($repository->getPath(), driver: self::getDriver())->getLog([$hash])->getSingleCommit()->getMessage()); + } } diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 6d605d10..783d131b 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -114,11 +114,42 @@ public function testDiffRangeParse(Repository $repository): void $this->assertSame(1, $changes[0]->getRangeNewCount()); } + #[DataProvider('provideFoobar')] + public function testRootCommitDiffListsTheFilesItAdds(Repository $repository): void + { + $files = $repository->getCommit(self::INITIAL_COMMIT)->getDiff()->getFiles(); + + $this->assertCount(1, $files); + $this->assertTrue($files[0]->isCreation()); + $this->assertSame('README', $files[0]->getNewName()); + } + #[DataProvider('provideFoobar')] public function testWorksWithUmlauts(Repository $repository): void { $files = $repository->getCommit(self::FILE_WITH_UMLAUTS_COMMIT)->getDiff()->getFiles(); - $this->assertSame('file_with_umlauts_\303\244\303\266\303\274', $files[0]->getNewName()); + $this->assertSame('file_with_umlauts_äöü', $files[0]->getNewName()); + } + + public function testUnquotesNames(): void + { + $diff = Diff::parse(<<<'DIFF' + :000000 100644 0000000000000000000000000000000000000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 A "tab\there" + :100644 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 d00491fd7e5bb6fa28c517a0bb32b8b506539d4d M "caf\303\251 \"au lait\"" + + diff --git "a/tab\there" "b/tab\there" + new file mode 100644 + index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 + diff --git "a/caf\303\251 \"au lait\"" "b/caf\303\251 \"au lait\"" + index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d00491fd7e5bb6fa28c517a0bb32b8b506539d4d 100644 + Binary files "a/caf\303\251 \"au lait\"" and "b/caf\303\251 \"au lait\"" differ + + DIFF); + $files = $diff->getFiles(); + + $this->assertSame("tab\there", $files[0]->getNewName()); + $this->assertSame('café "au lait"', $files[1]->getOldName()); + $this->assertSame('café "au lait"', $files[1]->getNewName()); } public function testDeleteFileWithoutRaw(): void diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 74b94eaf..4fab5bfd 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -12,9 +12,12 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Parser\LogParser; +use Gitonomy\Git\Commit; +use Gitonomy\Git\Driver\Cli\Parser\LogParser; +use Gitonomy\Git\Log; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; class LogTest extends AbstractTestCase { @@ -73,17 +76,15 @@ public function testIterable(Repository $repository): void public function testFirstMessageEmpty(): void { - $repository = self::createEmptyRepository(false); - $repository->run('config', ['--local', 'user.name', '"Unit Test"']); - $repository->run('config', ['--local', 'user.email', '"unit_test@unit-test.com"']); - + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); // Edge case: first commit lacks a message. - file_put_contents($repository->getWorkingDir().'/file', 'foo'); - $repository->run('add', ['.']); - $repository->run('commit', ['--allow-empty-message', '--no-edit']); + $commit = self::writeObject($repository, 'commit', "tree {$tree}\nauthor Unit Test 1000000000 +0000\ncommitter Unit Test 1000000000 +0000\n\n"); + self::git($repository, 'update-ref', ['refs/heads/master', $commit]); $commits = $repository->getLog()->getCommits(); $this->assertCount(1, $commits); + $this->assertSame('', $commits[0]->getMessage()); } public function testParsesCommitsWithAndWithoutGitButlerHeaders(): void @@ -118,11 +119,145 @@ public function testParsesCommitsWithAndWithoutGitButlerHeaders(): void $parser = new LogParser(); $parser->parse($logContent); - $log = $parser->log; + $log = array_values($parser->commits); $this->assertCount(3, $log); - $this->assertEquals("First commit message\n", $log[0]['message']); - $this->assertEquals("Commit with GitButler headers\n", $log[1]['message']); - $this->assertEquals("Another commit without GitButler headers\n", $log[2]['message']); + $this->assertEquals("First commit message\n", $log[0]->message); + $this->assertEquals("Commit with GitButler headers\n", $log[1]->message); + $this->assertEquals("Another commit without GitButler headers\n", $log[2]->message); + } + + #[DataProvider('provideFoobar')] + public function testLogOnPathSkipsMergesThatDoNotChangeIt(Repository $repository): void + { + $log = $repository->getLog(null, 'README.md'); + + $expected = ['1917a09c23eb0129d38d4e6d6a492c7830f8d423', 'd8930cd3f7e343195b85d56408a701feb98b95a8']; + $this->assertSame($expected, array_values(array_map(static fn (Commit $commit) => $commit->getHash(), $log->getCommits()))); + $this->assertSame(2, $log->count()); + } + + public function testLogOfRanges(): void + { + $repository = self::createFoobarRepository(false); + + $this->assertSame(21, $repository->getLog('master...origin/new-feature')->count()); + $this->assertCount(21, $repository->getLog('master...origin/new-feature')->getCommits()); + $this->assertCount(19, $repository->getLog('origin/new-feature..master')->getCommits()); + } + + public function testLogOnPathSkipsTheHistoryAMergeDiscards(): void + { + $repository = self::createRepositoryToCommitTo(); + $initial = self::commit($repository, 'file', 'initial'); + self::git($repository, 'checkout', ['-b', 'side']); + self::commit($repository, 'file', 'side'); + self::git($repository, 'checkout', ['main']); + self::commit($repository, 'other', 'main'); + // Keeps the file of main: git doesn't walk the side branch for it. + self::git($repository, 'merge', ['-s', 'ours', '-m', 'Merge side', 'side']); + + $this->assertSame([$initial], self::hashes($repository->getLog('main', 'file'))); + $this->assertSame($initial, $repository->getHeadCommit()->getLastModification('file')->getHash()); + } + + public function testLogOfRangeOnPathOnlySimplifiesMergesToTheBottom(): void + { + $repository = self::createRepositoryToCommitTo(); + self::commit($repository, 'file', 'initial'); + self::git($repository, 'checkout', ['-b', 'topic']); + self::commit($repository, 'other', 'topic'); + $topic = self::commit($repository, 'file', 'topic'); + self::git($repository, 'checkout', ['main']); + self::commit($repository, 'file', 'main'); + self::git($repository, 'checkout', ['topic']); + self::git($repository, 'merge', ['-X', 'theirs', '-m', 'Merge main', 'main']); + $merge = $repository->getHeadCommit()->getHash(); + + // The merge has the file of main, the bottom of the range: git follows main only. + $this->assertSame([], self::hashes($repository->getLog('main..topic', 'file'))); + + self::git($repository, 'checkout', ['main']); + self::commit($repository, 'another', 'main'); + + // The merged commit is hidden, but no longer the bottom: git follows every parent. The + // first commit of topic is like its hidden parent on the file: git skips it. + $this->assertSame([$merge, $topic], self::hashes($repository->getLog('main..topic', 'file'))); + } + + /** + * @param list $paths + */ + #[TestWith([[]])] + #[TestWith([['file']])] + public function testLogOfSeveralRevisionsStartsFromTheNewest(array $paths): void + { + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', "100644 file\0".hex2bin(self::writeObject($repository, 'blob', 'content'))); + $commit = static fn (string $message, int $time): string => self::writeObject($repository, 'commit', "tree {$tree}\nauthor Unit Test {$time} +0000\ncommitter Unit Test {$time} +0000\n\n{$message}\n"); + $old = $commit('old', 1000000000); + $new = $commit('new', 1000000001); + $sameAsOld = $commit('same as old', 1000000000); + + $this->assertSame([$new, $old], self::hashes($repository->getLog([$old, $new], $paths))); + $this->assertSame([$new, $old], self::hashes($repository->getLog([$new, $old], $paths))); + // At equal dates, from the first given one. + $this->assertSame([$sameAsOld, $old], self::hashes($repository->getLog([$sameAsOld, $old], $paths))); + $this->assertSame([$old, $sameAsOld], self::hashes($repository->getLog([$old, $sameAsOld], $paths))); + } + + public function testLogOnWildcardPath(): void + { + $repository = self::createRepositoryToCommitTo(); + mkdir($repository->getWorkingDir().'/dir'); + self::commit($repository, 'dir/file.md', 'initial'); + $text = self::commit($repository, 'dir/file.txt', 'text'); + self::commit($repository, 'dir/file.md', 'markdown'); + + $this->assertSame([$text], self::hashes($repository->getLog('main', 'dir/*.txt'))); + } + + public function testLogOfAllReferencesIgnoresTheOnesToOtherObjects(): void + { + $repository = self::createRepositoryToCommitTo(); + $commit = self::commit($repository, 'file', 'initial'); + self::git($repository, 'update-ref', ['refs/tags/tree', $repository->getCommit($commit)->getTreeHash()]); + self::git($repository, 'symbolic-ref', ['refs/heads/dangling', 'refs/heads/missing']); + + $this->assertSame([$commit], self::hashes($repository->getLog())); + $this->assertSame(1, $repository->getLog()->countCommits()); + } + + private static function createRepositoryToCommitTo(): Repository + { + $repository = self::createEmptyRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + self::git($repository, 'symbolic-ref', ['HEAD', 'refs/heads/main']); + + return $repository; + } + + /** + * Commits $file, with $content as content and message. + * + * @return string The hash of the commit + */ + private static function commit(Repository $repository, string $file, string $content): string + { + // Ends with a line break: git 1.8 adds one when it merges a file without. + file_put_contents($repository->getWorkingDir().'/'.$file, $content."\n"); + self::git($repository, 'add', [$file]); + self::git($repository, 'commit', ['-m', $content]); + + return trim(self::git($repository, 'rev-parse', ['HEAD'])); + } + + /** + * @return list + */ + private static function hashes(Log $log): array + { + return array_values(array_map(static fn (Commit $commit): string => $commit->getHash(), $log->getCommits())); } } diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index dd115d0e..d965df07 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\PushReference; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; @@ -44,6 +45,13 @@ public static function provideIsers(): array ]; } + public function testUnknownBeforeIsAnErrorNotAForcePush(): void + { + $this->expectException(DriverException::class); + + new PushReference(self::createFoobarRepository(), 'foo', str_repeat('1', 40), self::LONGFILE_COMMIT); + } + #[DataProvider('provideFoobar')] public function testLog(Repository $repository): void { diff --git a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php index 7278b19b..cee0abf5 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php @@ -46,10 +46,10 @@ public function testUnknownReference(Repository $repository): void { $hash = $repository->getLog()->getSingleCommit()->getHash(); - $repository->run('update-ref', ['refs/pipelines/1', $hash]); - $repository->run('update-ref', ['refs/merge-request/1/head', $hash]); - $repository->run('update-ref', ['refs/pull/1/head', $hash]); - $repository->run('update-ref', ['refs/notes/gtm-data', $hash]); + self::git($repository, 'update-ref', ['refs/pipelines/1', $hash]); + self::git($repository, 'update-ref', ['refs/merge-request/1/head', $hash]); + self::git($repository, 'update-ref', ['refs/pull/1/head', $hash]); + self::git($repository, 'update-ref', ['refs/notes/gtm-data', $hash]); $refs = $repository->getReferences()->getAll(); $this->assertIsArray($refs); diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 55da2a5d..d4d77e70 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -70,16 +70,6 @@ public function testGetTag(Repository $repository): void $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommit()->getHash(), 'Commit hash is correct'); } - #[DataProvider('provideFoobar')] - public function testIsAnnotatedIsFalseForLightweightTagWhenDebugIsFalse(Repository $repository): void - { - $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); - - $tag = $repository->getReferences()->getTag('0.1'); - - $this->assertFalse($tag->isAnnotated(), 'Lightweight tag must not be reported as annotated'); - } - #[DataProvider('provideFoobar')] public function testAnnotatedTag(Repository $repository): void { @@ -107,11 +97,11 @@ public function testAnnotatedTag(Repository $repository): void #[DataProvider('provideFoobar')] public function testAnnotatedTagBodyMessageWithBlankLineSeparator(Repository $repository): void { - $repository->run('config', ['user.email', 'test@example.com']); - $repository->run('config', ['user.name', 'Test']); + self::git($repository, 'config', ['user.email', 'test@example.com']); + self::git($repository, 'config', ['user.name', 'Test']); $hash = $repository->getLog()->getSingleCommit()->getHash(); - $repository->run('tag', ['-a', 'blank-sep', '-m', 'Subject line', '-m', "Body line 1\nBody line 2", $hash]); + self::git($repository, 'tag', ['-a', 'blank-sep', '-m', 'Subject line', '-m', "Body line 1\nBody line 2", $hash]); $tag = $repository->getReferences()->getTag('blank-sep'); @@ -211,8 +201,8 @@ public function testIsBranchMergedToMaster(): void { $repository = self::createFoobarRepository(false); - $repository->run('config', ['--local', 'user.name', '"Unit Test"']); - $repository->run('config', ['--local', 'user.email', '"unit_test@unit-test.com"']); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); $master = $repository->getReferences()->getBranch('master'); $references = $repository->getReferences(); @@ -226,9 +216,37 @@ public function testIsBranchMergedToMaster(): void $file = $repository->getWorkingDir().'/foobar-test.txt'; file_put_contents($file, 'test'); - $repository->run('add', [$file]); - $repository->run('commit', ['-m', 'foobar-test.txt updated']); + self::git($repository, 'add', [$file]); + self::git($repository, 'commit', ['-m', 'foobar-test.txt updated']); $this->assertFalse($branch->isMergedTo('master')); } + + /** + * Old tags (e.g. in git.git or linux) have no "tagger" line. + */ + public function testAnnotatedTagWithoutTagger(): void + { + $repository = self::createFoobarRepository(false); + $hash = self::writeObject($repository, 'tag', \sprintf("object %s\ntype commit\ntag notagger\n\nNo tagger.\n", self::LONGFILE_COMMIT)); + self::git($repository, 'update-ref', ['refs/tags/notagger', $hash]); + + $tag = $repository->getReferences()->getTag('notagger'); + + $this->assertTrue($tag->isAnnotated()); + $this->assertSame(self::LONGFILE_COMMIT, $tag->getCommit()->getHash()); + } + + public function testSignedTag(): void + { + $repository = self::createFoobarRepository(false); + $hash = self::writeObject($repository, 'tag', \sprintf("object %s\ntype commit\ntag signed\ntagger Alice 1471428000 +0000\n\nSigned.\n-----BEGIN PGP SIGNATURE-----\n\nsignature\n-----END PGP SIGNATURE-----\n", self::LONGFILE_COMMIT)); + self::git($repository, 'update-ref', ['refs/tags/signed', $hash]); + + $tag = $repository->getReferences()->getTag('signed'); + + $this->assertTrue($tag->isSigned()); + $this->assertSame("\n\nsignature\n", $tag->getGPGSignature()); + $this->assertSame("Signed.\n", $tag->getMessage()); + } } diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index e8fcc8e8..d7ad2029 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -14,20 +14,37 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\Commit; +use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; use Psr\Log\LoggerInterface; class RepositoryTest extends AbstractTestCase { - public function testRunReturnsNullInsteadOfThrowingWhenDebugIsFalse(): void + public function testNullProcessTimeoutDisablesTheTimeout(): void { $repository = self::createFoobarRepository(true); - $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); + $repository = new Repository($repository->getPath(), driver: self::getDriver(processTimeout: null)); - $this->assertNull($repository->run('not-a-command')); + $process = $repository->runProcess('rev-parse', ['HEAD']); + $this->assertNull($process->getTimeout()); + $this->assertNull($process->getIdleTimeout()); + } + + public function testRunThrowsWhenTheCommandFails(): void + { + $repository = self::createFoobarRepository(true); + + $this->expectException(ProcessException::class); + + $repository->run('not-a-command'); } public function testRunProcessGivesAccessToStderrOnSuccess(): void @@ -41,20 +58,9 @@ public function testRunProcessGivesAccessToStderrOnSuccess(): void $this->assertStringContainsString('master', $process->getErrorOutput()); } - public function testRunProcessReturnsFailedProcessInsteadOfThrowingWhenDebugIsFalse(): void - { - $repository = self::createFoobarRepository(true); - $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); - - $process = $repository->runProcess('not-a-command'); - - $this->assertFalse($process->isSuccessful()); - } - - public function testGetShortHashThrowsCleanExceptionWhenDebugIsFalse(): void + public function testGetShortHashOfAnUnknownCommitThrowsReferenceNotFound(): void { $repository = self::createFoobarRepository(true); - $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); $commit = new Commit($repository, str_repeat('a', 40)); @@ -124,13 +130,13 @@ public function testLoggerNOk(Repository $repository): void ->method('info') ->with($this->isString()) ; - $logger->expects($this->exactly(3)) // duration, return code and output + $logger->expects($this->exactly(4)) // duration, return code, output and error output ->method('debug') ->with($this->isString()) ; - $logger->expects($this->once()) + // The exception reports the failure: the caller may well expect it. + $logger->expects($this->never()) ->method('error') - ->with($this->isString()) ; $repository->setLogger($logger); @@ -171,4 +177,52 @@ public function testRunResolvesRelativePathsAgainstTheRepositoryRegardlessOfCwd( $this->assertSame($original."Applied line.\n", file_get_contents($file)); } + + public function testRunIsOnlySupportedByCliDriver(): void + { + $repository = new Repository(self::createTempDir(), driver: $this->createStub(DriverInterface::class)); + + $this->expectException(UnsupportedOperationException::class); + + $repository->run('status'); + } + + public function testDriverOpensTheRepository(): void + { + $dir = realpath(self::createTempDir()); + $logger = $this->createStub(LoggerInterface::class); + $repositoryDriver = $this->createStub(RepositoryDriverInterface::class); + $repositoryDriver->method('getReferences')->willReturn([new ReferenceData('refs/heads/main', self::INITIAL_COMMIT)]); + + $driver = $this->createMock(DriverInterface::class); + $driver->expects($this->once())->method('open')->with($dir, null, $logger)->willReturn($repositoryDriver); + + $repository = new Repository($dir, ['logger' => $logger], $driver); + + $this->assertSame(self::INITIAL_COMMIT, $repository->getReferences()->getBranch('main')->getCommitHash()); + } + + public function testSetLoggerReopensTheRepositoryWithIt(): void + { + $loggers = []; + $driver = $this->createStub(DriverInterface::class); + $driver->method('open')->willReturnCallback(function (string $gitDir, ?string $workingDir, ?LoggerInterface $logger) use (&$loggers): RepositoryDriverInterface { + $loggers[] = $logger; + + return $this->createStub(RepositoryDriverInterface::class); + }); + $logger = $this->createStub(LoggerInterface::class); + + new Repository(self::createTempDir(), driver: $driver)->setLogger($logger); + + $this->assertSame([null, $logger], $loggers); + } + + public function testUnknownOptionsAreRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown options "command", "process_timeout": expected "logger" or "working_dir". Configure git through the driver argument, a CliDriver by default.'); + + new Repository(self::createTempDir(), ['command' => 'git', 'process_timeout' => 60]); + } } diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index fb8a4d6c..fdc4b1d6 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -35,6 +35,28 @@ public function testGetEntries(Repository $repository): void $this->assertTrue($entries['README.md'][1] instanceof Blob, 'README.md is a Blob'); } + #[DataProvider('provideFoobar')] + public function testGetEntriesWithNonAsciiNames(Repository $repository): void + { + $entries = $repository->getCommit(DiffTest::FILE_WITH_UMLAUTS_COMMIT)->getTree()->getEntries(); + + $this->assertArrayHasKey('file_with_umlauts_äöü', $entries); + } + + public function testGetEntriesWithSpecialCharactersInNames(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + file_put_contents($repository->getWorkingDir().'/tab\t"quote"', 'special'); + self::git($repository, 'add', ['--all']); + self::git($repository, 'commit', ['-m', 'Add a file with special characters in its name']); + + $entries = $repository->getHeadCommit()->getTree()->getEntries(); + + $this->assertArrayHasKey('tab\t"quote"', $entries); + } + #[DataProvider('provideFoobar')] public function testGetCommitReferenceEntries(Repository $repository): void { diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 5e44a592..dc92a30e 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Admin; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\LogicException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; @@ -25,7 +26,7 @@ public function testNoWorkingCopyInBare(): void $this->expectException(LogicException::class); $path = self::createTempDir(); - $repo = Admin::init($path, true, self::getOptions()); + $repo = Admin::init($path, true, driver: self::getDriver()); $repo->getWorkingCopy(); } @@ -51,7 +52,7 @@ public function testDiffStaged(): void $file = $repository->getWorkingDir().'/foobar-test'; file_put_contents($file, 'test'); - $repository->run('add', [$file]); + self::git($repository, 'add', [$file]); $diffStaged = $wc->getDiffStaged(); $this->assertCount(1, $diffStaged->getFiles()); @@ -77,7 +78,7 @@ public function testDiffPendingWithAbbreviatedHashesLikeOldGit(): void $repository = self::createFoobarRepository(false); file_put_contents($repository->getWorkingDir().'/test.sh', 'test'); // Before 2.16, git always wrote "..." after the abbreviated hashes of --raw. - $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['environment_variables' => ['GIT_PRINT_SHA1_ELLIPSIS' => 'yes']])); + $repository = new Repository($repository->getPath(), driver: self::getDriver(['GIT_PRINT_SHA1_ELLIPSIS' => 'yes'])); $files = $repository->getWorkingCopy()->getDiffPending()->getFiles(); @@ -128,8 +129,8 @@ public function testGetUntracked(): void public function testMerge(): void { $repository = self::createFoobarRepository(false); - $repository->run('config', ['--local', 'user.name', '"Unit Test"']); - $repository->run('config', ['--local', 'user.email', '"unit_test@unit-test.com"']); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); $wc = $repository->getWorkingCopy(); $wc->checkout('master'); @@ -137,6 +138,7 @@ public function testMerge(): void $head = $repository->getHeadCommit(); $this->assertCount(2, $head->getParents(), 'Merge produced a merge commit'); + $this->assertSame("Merge remote-tracking branch 'origin/diff-features'", $head->getSubjectMessage()); $this->assertFileExists($repository->getWorkingDir().'/script_A.php'); } @@ -150,4 +152,76 @@ public function testMergeConflict(): void $wc->merge('origin/new-feature'); } + + public function testMergeFastForwardsTheCurrentBranch(): void + { + $repository = self::createFoobarRepository(false); + $master = $repository->getHeadCommit(); + $repository->getReferences()->createBranch('behind', $master->getParentHashes()[0]); + $wc = $repository->getWorkingCopy(); + $wc->checkout('behind'); + + $wc->merge('master'); + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame('refs/heads/behind', $repository->getHead()->getFullname(), 'HEAD stays on the branch'); + $this->assertSame($master->getHash(), $repository->getHeadCommit()->getHash()); + $this->assertCount(0, $repository->getWorkingCopy()->getDiffPending()->getFiles()); + $this->assertCount(0, $repository->getWorkingCopy()->getDiffStaged()->getFiles()); + } + + public function testMergeOfAnAncestorDoesNothing(): void + { + $repository = self::createFoobarRepository(false); + $head = $repository->getHeadCommit(); + $wc = $repository->getWorkingCopy(); + + $wc->merge($head->getParentHashes()[0]); + $wc->merge($head->getHash(), ['--no-ff']); + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame($head->getHash(), $repository->getHeadCommit()->getHash()); + } + + public function testCheckoutKeepsLocalChangesItDoesNotConflictWith(): void + { + $repository = self::createFoobarRepository(false); + $repository->getReferences()->createBranch('twin', $repository->getHeadCommit()->getHash()); + $file = $repository->getWorkingDir().'/README.md'; + file_put_contents($file, 'local change'); + + $repository->getWorkingCopy()->checkout('twin'); + + $this->assertSame('local change', file_get_contents($file)); + } + + public function testCheckoutRefusesToOverwriteLocalChanges(): void + { + $repository = self::createFoobarRepository(false); + $file = $repository->getWorkingDir().'/AUTHORS'; + file_put_contents($file, 'local change'); + + try { + $repository->getWorkingCopy()->checkout('origin/pagination', 'pagination'); + $this->fail('The checkout should have been refused.'); + } catch (DriverException) { + } + + $this->assertSame('local change', file_get_contents($file)); + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertFalse($repository->getReferences()->hasBranch('pagination'), 'No branch is created when the checkout fails'); + } + + public function testUntrackedFilesInNewDirectories(): void + { + $repository = self::createFoobarRepository(false); + mkdir($repository->getWorkingDir().'/newdir/sub', 0o777, true); + file_put_contents($repository->getWorkingDir().'/newdir/a.txt', 'a'); + file_put_contents($repository->getWorkingDir().'/newdir/sub/b.txt', 'b'); + + $files = $repository->getWorkingCopy()->getUntrackedFiles(); + sort($files); + + $this->assertSame(['newdir/a.txt', 'newdir/sub/b.txt'], $files); + } } From 893eabce8850c22bf937d5161f0525f2e86bb3b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 12 Oct 2026 23:06:55 +0200 Subject: [PATCH 086/150] Add an experimental LibGit2Driver, running libgit2 in-process Driver\Ffi\LibGit2Driver calls libgit2 1.9 through PHP's FFI extension, instead of starting a git process for every operation: it reads objects up to 28 times faster, but walks long histories slower, as libgit2 doesn't use git's commit-graph, and its blame is very slow (libgit2#3027). It reproduces git's behavior, notably how git log simplifies the history of a path, and is checked by running the whole suite with it in CI (GITLIB_DRIVER=ffi). It is experimental: it isn't covered by the backward compatibility promise. With php-fpm, LibGit2Driver::preload() keeps libgit2 loaded from opcache.preload. benchmark.php compares both drivers on a repository. --- .github/workflows/tests.yml | 47 + CHANGELOG.md | 7 + README.md | 8 +- UPGRADE-2.0.md | 5 +- benchmark.php | 158 ++ composer.json | 3 + doc/admin.md | 5 +- doc/repository.md | 137 +- doc/workingcopy.md | 7 + phpstan.neon | 11 +- src/Gitonomy/Git/Driver/Ffi/LibGit2.php | 224 +++ src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 285 ++++ .../Driver/Ffi/LibGit2RepositoryDriver.php | 1488 +++++++++++++++++ src/Gitonomy/Git/Driver/Ffi/PathFilter.php | 137 ++ src/Gitonomy/Git/Driver/Ffi/libgit2.h | 486 ++++++ tests/Gitonomy/Git/Tests/AbstractTestCase.php | 67 +- tests/Gitonomy/Git/Tests/AdminTest.php | 8 +- .../Gitonomy/Git/Tests/LibGit2DriverTest.php | 70 + .../Gitonomy/Git/Tests/LibGit2HeaderTest.php | 38 + tests/Gitonomy/Git/Tests/RepositoryTest.php | 14 +- tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 4 +- 21 files changed, 3187 insertions(+), 22 deletions(-) create mode 100644 benchmark.php create mode 100644 src/Gitonomy/Git/Driver/Ffi/LibGit2.php create mode 100644 src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php create mode 100644 src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php create mode 100644 src/Gitonomy/Git/Driver/Ffi/PathFilter.php create mode 100644 src/Gitonomy/Git/Driver/Ffi/libgit2.h create mode 100644 tests/Gitonomy/Git/Tests/LibGit2DriverTest.php create mode 100644 tests/Gitonomy/Git/Tests/LibGit2HeaderTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 73634aab..59c7d55f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -90,3 +90,50 @@ jobs: - name: Execute PHPUnit run: vendor/bin/phpunit + + tests-ffi: + name: Test PHP ${{ matrix.php }} (LibGit2Driver) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + env: + # LibGit2Driver only supports libgit2 1.9, which Ubuntu 24.04 doesn't ship. + LIBGIT2_VERSION: '1.9.1' + + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Cache libgit2 + id: cache-libgit2 + uses: actions/cache@v4 + with: + path: ~/libgit2 + key: libgit2-${{ env.LIBGIT2_VERSION }}-ubuntu-24.04 + + - name: Build libgit2 + if: steps.cache-libgit2.outputs.cache-hit != 'true' + run: | + curl -fsSL "https://github.com/libgit2/libgit2/archive/refs/tags/v${LIBGIT2_VERSION}.tar.gz" | tar xz + cmake -S "libgit2-${LIBGIT2_VERSION}" -B libgit2-build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$HOME/libgit2" -DCMAKE_INSTALL_LIBDIR=lib -DBUILD_TESTS=OFF -DBUILD_CLI=OFF -DUSE_HTTPS=OFF -DUSE_SSH=OFF + cmake --build libgit2-build --parallel + cmake --install libgit2-build + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: ffi + + - name: Setup Problem Matchers + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install Composer dependencies + run: composer update --prefer-dist --no-interaction + + - name: Execute PHPUnit + run: LD_LIBRARY_PATH="$HOME/libgit2/lib" vendor/bin/phpunit + env: + GITLIB_DRIVER: ffi diff --git a/CHANGELOG.md b/CHANGELOG.md index b468273b..0718da78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). ### Features +- Add an experimental `Driver\Ffi\LibGit2Driver`, running libgit2 1.9 in-process through PHP's + FFI extension, as an alternative to the default `Driver\Cli\CliDriver`: pass it as the driver of + `Repository`/`Admin`. It avoids spawning a `git` process for every operation; see + ["Choosing a driver"](doc/repository.md#choosing-a-driver) for its requirements, its + differences with `CliDriver`, and when it is faster. `benchmark.php` compares both drivers + on a repository. With php-fpm, `LibGit2Driver::preload()` keeps libgit2 loaded from + `opcache.preload`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - `Repository` and `Admin` take the driver running git as their last argument: a `Driver\Cli\CliDriver` by default, configured through its constructor, or any `Driver\DriverInterface`, to replace it in tests; see diff --git a/README.md b/README.md index ffbcefb2..4a2e7a30 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,12 @@ Gitlib for Gitonomy This library provides methods to access Git repository from PHP 8.4+. -It runs the `git` binary (1.8.4 or later) for every operation: convenient, and it doesn't need -anything built to use. +By default, it runs the `git` binary (1.8.4 or later) for every operation: convenient, and it +doesn't need anything built to use. It can instead run [libgit2](https://libgit2.org/) in-process +via PHP's `FFI` extension, an experimental driver which is faster for small reads but slower on +long histories: see +["Choosing a driver"](doc/repository.md#choosing-a-driver) and +["Performance"](doc/repository.md#performance). Quick Start ----------- diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index a9649a54..0a9dc002 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -2,7 +2,10 @@ UPGRADE FROM 1.x to 2.0 ======================= gitlib 2.0 runs every git operation through a driver: `CliDriver`, the -default, runs the `git` binary like 1.x did. Code reading repositories through `Repository`, `Commit`, `Log`... mostly keeps working: +default, runs the `git` binary like 1.x did, and the experimental +`LibGit2Driver` runs libgit2 in-process (see +["Choosing a driver"](doc/repository.md#choosing-a-driver)). Code reading +repositories through `Repository`, `Commit`, `Log`... mostly keeps working: check the points below, starting with the configuration of git, errors, dates and classes you extend. diff --git a/benchmark.php b/benchmark.php new file mode 100644 index 00000000..eb610af9 --- /dev/null +++ b/benchmark.php @@ -0,0 +1,158 @@ +#!/usr/bin/env php + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +require __DIR__.'/vendor/autoload.php'; + +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Repository; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\SingleCommandApplication; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Process\Exception\ProcessTimedOutException; +use Symfony\Component\Process\Process; + +/** + * @return array The operations to compare + */ +function operations(string $head, string $commit, string $file): array +{ + return [ + 'Resolve HEAD' => static fn (Repository $repository) => $repository->getHeadCommit()->getHash(), + 'Read a commit' => static fn (Repository $repository) => $repository->getCommit($commit)->getMessage(), + 'Log 100 commits' => static fn (Repository $repository) => $repository->getLog($head, null, 0, 100)->getCommits(), + 'Log 100 commits of a directory' => static fn (Repository $repository) => $repository->getLog($head, dirname($file), 0, 100)->getCommits(), + 'Last commit of a file' => static fn (Repository $repository) => $repository->getCommit($head)->getLastModification($file)->getHash(), + 'Count the commits of HEAD' => static fn (Repository $repository) => $repository->getLog($head)->countCommits(), + 'List the branches' => static fn (Repository $repository) => $repository->getReferences()->getBranches(), + 'Read a file' => static fn (Repository $repository) => $repository->getCommit($head)->getTree()->resolvePath($file)->getContent(), + 'Diff a commit' => static fn (Repository $repository) => $repository->getCommit($commit)->getDiff()->getFiles(), + 'List the branches including a commit' => static fn (Repository $repository) => $repository->getCommit($commit)->getIncludingBranches(), + 'Blame a file' => static fn (Repository $repository) => $repository->getBlame($head, $file)->getLines(), + ]; +} + +/** + * @param list $values + */ +function median(array $values): float +{ + sort($values); + $middle = intdiv(count($values), 2); + + return count($values) % 2 ? $values[$middle] : ($values[$middle - 1] + $values[$middle]) / 2; +} + +// One measure, in the process measure() starts. +if (false !== $measure = getenv('GITLIB_BENCHMARK_MEASURE')) { + /** @var class-string $driver */ + [$path, $driver, $operation, $head, $commit, $file, $iterations] = json_decode($measure, true, flags: \JSON_THROW_ON_ERROR); + $run = operations($head, $commit, $file)[$operation]; + + // Warms up, and fails before timing anything when the operation throws. + $run(new Repository($path, driver: new $driver())); + + $durations = []; + for ($i = 0; $i < $iterations; ++$i) { + // A new Repository every time, like a web request would open it. + $start = hrtime(true); + $run(new Repository($path, driver: new $driver())); + $durations[] = hrtime(true) - $start; + } + + echo median($durations) / 1e6; + + exit(0); +} + +/** + * Measures an operation in a process of its own: libgit2's caches don't + * carry over from one operation to the next, and an operation that takes too + * long can be stopped. + * + * @param class-string $driver + * + * @return float|null The median duration, in milliseconds, or null when the measure took more than $timeout seconds + */ +function measure(string $path, string $driver, string $operation, string $head, string $commit, string $file, int $iterations, int $timeout): ?float +{ + $process = new Process([\PHP_BINARY, __FILE__], env: ['GITLIB_BENCHMARK_MEASURE' => json_encode([$path, $driver, $operation, $head, $commit, $file, $iterations])], timeout: $timeout); + + try { + return (float) $process->mustRun()->getOutput(); + } catch (ProcessTimedOutException) { + return null; + } +} + +/** + * Times the loading of libgit2, in new PHP processes: a process pays it once. + * + * @return float The median duration, in milliseconds + */ +function measureLibraryLoading(int $iterations): float +{ + $code = <<<'PHP' + require $argv[1]; + $start = hrtime(true); + new Gitonomy\Git\Driver\Ffi\LibGit2Driver(); + echo hrtime(true) - $start; + PHP; + + $durations = []; + for ($i = 0; $i < $iterations; ++$i) { + $durations[] = (int) new Process([\PHP_BINARY, '-r', $code, __DIR__.'/vendor/autoload.php'])->mustRun()->getOutput(); + } + + return median($durations) / 1e6; +} + +new SingleCommandApplication() + ->setName('Compare CliDriver and LibGit2Driver on read operations') + ->addArgument('repository', InputArgument::REQUIRED, 'The repository to read') + ->addArgument('file', InputArgument::REQUIRED, 'A file of HEAD, to log, read and blame') + ->addOption('iterations', null, InputOption::VALUE_REQUIRED, 'The number of timed runs of each operation, of which the median is kept', '10') + ->addOption('timeout', null, InputOption::VALUE_REQUIRED, 'The time after which the measure of an operation is stopped, in seconds', '60') + ->setCode(static function (InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + $path = $input->getArgument('repository'); + $file = $input->getArgument('file'); + $iterations = (int) $input->getOption('iterations'); + $timeout = (int) $input->getOption('timeout'); + + $repository = new Repository($path); + $head = $repository->getHeadCommit()->getHash(); + // The last commit that changed $file: its diff isn't empty. + $commit = $repository->getHeadCommit()->getLastModification($file)->getHash(); + + $io->title(sprintf('%s (HEAD: %s, %d commits, %d references)', $path, $head, $repository->getLog($head)->countCommits(), count($repository->getReferences()))); + $io->writeln(sprintf('PHP %s, %s, median of %d runs.', \PHP_VERSION, trim(new Process(['git', '--version'])->mustRun()->getOutput()), $iterations)); + + $format = static fn (?float $duration): string => null === $duration ? sprintf('> %d s', $timeout) : sprintf('%.1f ms', $duration); + $rows = [['Load libgit2 (once per process)', '-', $format(measureLibraryLoading($iterations)), '-']]; + foreach (array_keys(operations($head, $commit, $file)) as $operation) { + $cli = measure($path, CliDriver::class, $operation, $head, $commit, $file, $iterations, $timeout); + $libGit2 = measure($path, LibGit2Driver::class, $operation, $head, $commit, $file, $iterations, $timeout); + $rows[] = [$operation, $format($cli), $format($libGit2), null === $cli || null === $libGit2 ? '-' : sprintf('%.1f', $cli / $libGit2)]; + } + + $io->table(['Operation', 'CliDriver', 'LibGit2Driver', 'Ratio'], $rows); + + return 0; + }) + ->run() +; diff --git a/composer.json b/composer.json index 7e3b3989..1b628125 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,9 @@ "psr/log": "^3.0", "symfony/console": "^8.1" }, + "suggest": { + "ext-ffi": "To use the experimental LibGit2Driver (in-process libgit2) instead of the default CliDriver — also requires libgit2 1.9 to be installed on the system." + }, "config": { "preferred-install": "dist", "sort-packages": true diff --git a/doc/admin.md b/doc/admin.md index 8b6a7be7..dbf1c599 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -77,10 +77,11 @@ Options Every method on this page also accepts the driver running git as its last argument, and the methods returning a `Repository` the same `$options` array as its constructor before it — see -[Repository options](repository.md#options-for-repository). +[Repository options](repository.md#options-for-repository), notably +["Choosing a driver"](repository.md#choosing-a-driver). ```php -$repository = Gitonomy\Git\Admin::init('/path/to/repository', true, driver: new Gitonomy\Git\Driver\Cli\CliDriver(processTimeout: 300)); +$repository = Gitonomy\Git\Admin::init('/path/to/repository', true, driver: new Gitonomy\Git\Driver\Ffi\LibGit2Driver()); ``` ### References diff --git a/doc/repository.md b/doc/repository.md index 6d1f463e..0eae032a 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -30,7 +30,7 @@ $repository = new Repository('/path/to/repo', [ ``` Its last parameter, `$driver`, is the backend running git operations: see -"Configuring `CliDriver`" below. +"Choosing a driver" below. Test if a repository is bare ---------------------------- @@ -79,12 +79,139 @@ if ($repository->isHeadDetached()) { Options for repository ---------------------- -### Configuring `CliDriver` +### Choosing a driver By default, gitlib spawns a `git` process for every operation, with -`CliDriver`, which requires git 1.8.4 or later. A driver holds no state of a -repository: create it once, and pass it to every `Repository` and `Admin` -call, as a service of your container for instance. +`CliDriver`, which requires git 1.8.4 or later. You can instead run +[libgit2](https://libgit2.org/) in-process, through PHP's `FFI` extension, +by passing a `LibGit2Driver`: + +```php +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; + +$repository = new Repository('/path/to/repo', driver: new LibGit2Driver()); +``` + +> **warning** +> +> `LibGit2Driver` is experimental: it isn't covered by the backward +> compatibility promise, and can change in a minor version. It reproduces +> git's behavior with libgit2, and may still differ from git in cases its +> tests don't cover. + +A driver holds no state of a repository: create it once, and pass it to +every `Repository` and `Admin` call, as a service of your container for +instance. + +`LibGit2Driver` avoids spawning a process for every operation. It requires +the `ext-ffi` PHP extension and libgit2 1.9: libgit2 changes its binary +interface with every minor version, so any other version is refused. If +either is missing, constructing the `LibGit2Driver` throws an +`UnsupportedOperationException` right away — there is no silent fallback to +`CliDriver`. You can check availability upfront: + +```php +use Gitonomy\Git\Driver\Cli\CliDriver; + +$driver = LibGit2Driver::isSupported() ? new LibGit2Driver() : new CliDriver(); +``` + +If libgit2 isn't discoverable under one of the usual shared-library names +(`libgit2.so.1.9`, `libgit2.so`, `libgit2.dylib`, `git2.dll`...), pass its +path to the driver: + +```php +$driver = new LibGit2Driver('/opt/libgit2/lib/libgit2.so.1.9'); +``` + +Differences with `CliDriver`: + +- `Repository::run()` and `Repository::runProcess()` throw an + `UnsupportedOperationException`: there is no `git` process to run. +- Nothing is logged, and there is no timeout on network operations. +- `WorkingCopy::merge()` only supports fast-forward and conflict-free + merges, with `--no-ff` as the only extra argument: a conflict throws + instead of leaving conflict markers in the working copy. +- `WorkingCopy::checkout()` doesn't create a tracking branch for a name + that only exists on a remote, like `git checkout` does: it throws. +- `Admin::cloneRepository()` only supports the `--bare`, `--mirror` and + `--branch` arguments. +- Rename detection in diffs may pair files differently. +- Blame line ranges are limited to `,` and `,+`. + +#### With php-fpm + +Call `LibGit2Driver::preload()` from the +[`opcache.preload`](https://www.php.net/manual/en/opcache.preloading.php) +script, with the library path you give to `LibGit2Driver`, if any: + +```php +// preload.php +require __DIR__.'/vendor/autoload.php'; + +Gitonomy\Git\Driver\Ffi\LibGit2Driver::preload(); +``` + +libgit2 then stays loaded and initialized for the lifetime of php-fpm, +instead of every request loading it again (about 10 ms, see below), and the +driver works with the default `ffi.enable` setting, `preload`: outside the +CLI, only preloaded code can use FFI. Without preloading, `LibGit2Driver` +throws an `UnsupportedOperationException` unless `ffi.enable` is `true`, +which lets any code of the application call C functions. + +### Performance + +`CliDriver` starts a `git` process for every operation, which costs a few +milliseconds each time. `LibGit2Driver` calls libgit2 directly, once +loaded. Which one is faster depends on the operation and on the size of the +history: + +| Operation (ms) | gitlib `CliDriver` | gitlib `LibGit2Driver` | symfony `CliDriver` | symfony `LibGit2Driver` | +|--------------------------------------|-------------------:|-----------------------:|--------------------:|------------------------:| +| Load libgit2, once per process | - | 10.5 | - | 10.5 | +| Resolve HEAD | 3.6 | 0.6 | 9.3 | 4.5 | +| Read a commit | 3.1 | 0.3 | 4.0 | 0.3 | +| Read a file | 18.5 | 0.7 | 24.4 | 1.0 | +| List the branches | 3.0 | 0.6 | 10.0 | 4.4 | +| Log 100 commits | 9.9 | 4.0 | 11.3 | 3.8 | +| Last commit of a file | 3.7 | 0.9 | 7.9 | 5.1 | +| Log 100 commits of a directory | 13.8 | 11.0 | 95 | 262 | +| Count the commits of HEAD | 7.3 | 5.1 | 43 | 1,332 | +| List the branches including a commit | 8.4 | 3.3 | 16 | 912 | +| Diff a commit | 23.3 | 17.4 | 5.1 | 18.4 | +| Blame a file | 23 | 78 | 613 | about 580,000 | + +These are medians of 10 runs, each on a new `Repository`, as a web request +would open it. gitlib is this repository (350 commits), with +`src/Gitonomy/Git/Repository.php` as the file. symfony/symfony has 80,000 +commits, 910 references and a commit-graph (which `git gc` writes by +default), with `src/Symfony/Component/Process/Process.php` (1,700 lines, 750 +commits) as the file. Measured with PHP 8.5, git 2.53 and libgit2 1.9.1 on +an AMD Ryzen AI 9 HX 370. + +- `LibGit2Driver` reads objects (commits, files, references) 2 to 28 + times faster: no process to start. +- Loading libgit2 costs about 10 ms, mostly libgit2 setting up OpenSSL, + which reads the system's CA certificates. A command or a worker pays it + once. PHP unloads libgit2 at the end of every request: with php-fpm, each + request pays it again, unless + [libgit2 is preloaded](#with-php-fpm), which brings it down to 0.1 ms. +- libgit2 doesn't use git's commit-graph, and caches objects per + repository only: walking a long history (counting commits, finding the + branches including a commit, logging a path) is up to 60 times slower. +- libgit2's blame is much slower than git's on a long history: about 10 + minutes instead of 0.6 s for symfony's `Process.php`. It diffs trees for + every commit it goes through, where git compares the hash of the file + ([libgit2#3027](https://github.com/libgit2/libgit2/issues/3027)). + +Keep the default `CliDriver`, unless your application runs many small reads +in a long-running process, and check with your own repository: + +```bash +php benchmark.php /path/to/repository path/to/a/file +``` + +### Configuring `CliDriver` The arguments of `CliDriver` set the git binary to run (`command`), the environment variables to pass to it (`environmentVariables`, where `false` diff --git a/doc/workingcopy.md b/doc/workingcopy.md index 375694a2..7b3c92df 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -44,6 +44,13 @@ $wc->merge('origin/develop', ['--no-ff']); You can also pass a *Reference* or a *Commit*. A `RuntimeException` is thrown if the merge fails (for instance because of a conflict). +> **note** +> +> With `LibGit2Driver` (see [Choosing a driver](repository.md#choosing-a-driver)), +> `merge()` only supports fast-forward and conflict-free merges, with `--no-ff` +> as the only extra argument: a conflict, or any other extra argument, throws +> instead of leaving conflict markers in the working copy. + Staged modifications -------------------- diff --git a/phpstan.neon b/phpstan.neon index 6d1c7337..6b86729c 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,4 +8,13 @@ parameters: tmpDir: 'var/phpstan/tmp' inferPrivatePropertyTypeFromConstructor: true excludePaths: - analyse: [] + analyse: + # PHP FFI is dynamic by design: `$ffi->some_c_function()` and + # `$cdata->some_field` are resolved at runtime against the cdef + # loaded into that specific FFI/CData instance, not against any + # PHP class PHPStan can see. There is no stub format for this, so + # every call/property access here reads as "undefined" to static + # analysis even though it's correct — verified by actually + # exercising this driver against a live libgit2 and the full test + # suite (`GITLIB_DRIVER=ffi vendor/bin/phpunit`), not by type-checking it. + - src/Gitonomy/Git/Driver/Ffi/* diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php new file mode 100644 index 00000000..9c10874a --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php @@ -0,0 +1,224 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Ffi; + +use FFI\CData; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\UnsupportedOperationException; + +/** + * Loads libgit2, and helps calling it. + * + * @author Grégoire Pineau + * + * @internal + */ +final class LibGit2 +{ + /** + * The only libgit2 version libgit2.h is written for: libgit2 changes its + * struct layouts on every minor release, so loading any other version + * would silently corrupt memory. + */ + private const array SUPPORTED_VERSION = [1, 9]; + + /** + * The names to look libgit2 up with, when no path is given. + */ + private const array LIBRARY_NAMES = [ + 'libgit2.so.1.9', + 'libgit2.1.9.dylib', + 'libgit2.so', + 'libgit2.dylib', + 'git2.dll', + ]; + + /** + * Loads libgit2 once per request (the whole process with the CLI) and per + * library path, or takes the one preload() loaded. + * + * @throws UnsupportedOperationException The FFI extension or libgit2 1.9 is missing, or "ffi.enable" restricts FFI + */ + public static function load(?string $libraryPath): \FFI + { + static $ffiByLibraryPath = []; + + return $ffiByLibraryPath[$libraryPath ?? ''] ??= self::doLoad($libraryPath); + } + + /** + * Loads and initializes libgit2 for the whole process, from the + * opcache.preload script: PHP unloads a library at the end of every + * request otherwise, and libgit2 initializes itself again (about 10 ms). + * + * @throws UnsupportedOperationException The FFI extension or libgit2 1.9 is missing + */ + public static function preload(?string $libraryPath): void + { + self::assertFfiIsAvailable(); + $path = $libraryPath ?? self::locate(); + self::assertSupportedVersion($path); + + // FFI::load() reads the library to load from the header. + $header = tempnam(sys_get_temp_dir(), 'gitlib-libgit2-'); + + try { + file_put_contents($header, \sprintf("#define FFI_SCOPE \"%s\"\n#define FFI_LIB \"%s\"\n", self::scope($libraryPath), $path).file_get_contents(__DIR__.'/libgit2.h')); + $ffi = \FFI::load($header); + } catch (\FFI\Exception $e) { + throw new UnsupportedOperationException(\sprintf('Unable to preload libgit2 from "%s".', $path), 0, $e); + } finally { + unlink($header); + } + + // FFI::load() only warns while preloading. + if (null === $ffi) { + throw new UnsupportedOperationException(\sprintf('Unable to preload libgit2 from "%s".', $path)); + } + + // Once for the forked processes (php-fpm workers...) too. + self::initialize($ffi); + } + + /** + * @throws DriverException When $rc is a libgit2 error code + */ + public static function check(\FFI $ffi, int $rc, string $context): void + { + if ($rc < 0) { + throw new DriverException(\sprintf('%s: %s', $context, self::lastErrorMessage($ffi))); + } + } + + /** + * A PHP string can't be stored in a `char *` field: copy it into a C + * buffer, to keep alive as long as the field is in use. + */ + public static function cString(\FFI $ffi, string $value): CData + { + $buffer = $ffi->new('char['.(\strlen($value) + 1).']'); + \FFI::memcpy($buffer, $value, \strlen($value)); + + return $buffer; + } + + public static function oidToHex(\FFI $ffi, CData $oid): string + { + $buffer = $ffi->new('char[41]'); + $ffi->git_oid_tostr($buffer, 41, $oid); + + return \FFI::string($buffer); + } + + private static function doLoad(?string $libraryPath): \FFI + { + self::assertFfiIsAvailable(); + + $ffi = self::preloaded($libraryPath) ?? self::define($libraryPath ?? self::locate()); + // Only counts one more user once libgit2 is initialized. + self::initialize($ffi); + + return $ffi; + } + + /** + * @return \FFI|null What preload() loaded for $libraryPath, if it was called + */ + private static function preloaded(?string $libraryPath): ?\FFI + { + try { + return \FFI::scope(self::scope($libraryPath)); + } catch (\FFI\Exception) { + return null; + } + } + + private static function define(string $libraryPath): \FFI + { + self::assertSupportedVersion($libraryPath); + + try { + return \FFI::cdef(file_get_contents(__DIR__.'/libgit2.h'), $libraryPath); + } catch (\FFI\Exception $e) { + throw new UnsupportedOperationException(\sprintf('Unable to load libgit2 from "%s"; use CliDriver.', $libraryPath), 0, $e); + } + } + + private static function scope(?string $libraryPath): string + { + return 'gitonomy/gitlib:'.($libraryPath ?? ''); + } + + private static function assertFfiIsAvailable(): void + { + if (!\extension_loaded('ffi')) { + throw new UnsupportedOperationException('LibGit2Driver requires the FFI extension; use CliDriver.'); + } + + try { + \FFI::cdef(); + } catch (\FFI\Exception $e) { + // ffi.enable defaults to "preload": outside the CLI, only preloaded code can use FFI. + throw new UnsupportedOperationException('The "ffi.enable" setting restricts the FFI API: call LibGit2Driver::preload() from the opcache.preload script, set "ffi.enable" to "true", or use CliDriver.', 0, $e); + } + } + + private static function assertSupportedVersion(string $libraryPath): void + { + try { + $ffi = \FFI::cdef('int git_libgit2_version(int *major, int *minor, int *rev);', $libraryPath); + } catch (\FFI\Exception $e) { + throw new UnsupportedOperationException(\sprintf('Unable to load libgit2 from "%s"; use CliDriver.', $libraryPath), 0, $e); + } + + $major = $ffi->new('int'); + $minor = $ffi->new('int'); + $rev = $ffi->new('int'); + $ffi->git_libgit2_version(\FFI::addr($major), \FFI::addr($minor), \FFI::addr($rev)); + if (self::SUPPORTED_VERSION !== [$major->cdata, $minor->cdata]) { + throw new UnsupportedOperationException(\sprintf('LibGit2Driver requires libgit2 %s, but "%s" is libgit2 %d.%d.%d; use CliDriver.', implode('.', self::SUPPORTED_VERSION), $libraryPath, $major->cdata, $minor->cdata, $rev->cdata)); + } + } + + private static function initialize(\FFI $ffi): void + { + if ($ffi->git_libgit2_init() < 0) { + throw new UnsupportedOperationException(\sprintf('Unable to initialize libgit2: %s', self::lastErrorMessage($ffi))); + } + } + + private static function locate(): string + { + foreach (self::LIBRARY_NAMES as $name) { + try { + \FFI::cdef('', $name); + + return $name; + } catch (\FFI\Exception) { + continue; + } + } + + throw new UnsupportedOperationException('LibGit2Driver requires libgit2 1.9, but none could be found: pass its path to LibGit2Driver, or use CliDriver.'); + } + + private static function lastErrorMessage(\FFI $ffi): string + { + $error = $ffi->git_error_last(); + if (\FFI::isNull($error)) { + return 'unknown error.'; + } + + return rtrim(\FFI::string($error->message), '.').'.'; + } +} diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php new file mode 100644 index 00000000..b6bb88a7 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -0,0 +1,285 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Ffi; + +use FFI\CData; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Psr\Log\LoggerInterface; + +/** + * Runs libgit2 1.9 in-process, through PHP's FFI extension. + * + * @author Grégoire Pineau + * + * @experimental + */ +final readonly class LibGit2Driver implements DriverInterface +{ + private const int GIT_ENOTFOUND = -3; + private const int GIT_REMOTE_DOWNLOAD_TAGS_ALL = 3; + + private \FFI $ffi; + + /** + * @param string|null $libraryPath The libgit2 shared library, looked up under its usual names when null + * + * @throws UnsupportedOperationException The FFI extension or libgit2 1.9 is missing, or "ffi.enable" restricts FFI + */ + public function __construct(?string $libraryPath = null) + { + $this->ffi = LibGit2::load($libraryPath); + } + + /** + * To call from the opcache.preload script, with php-fpm for instance: + * libgit2 then stays loaded and initialized for the whole process, + * instead of every request loading it again (about 10 ms), and the driver + * may call FFI whatever "ffi.enable" is. + * + * @param string|null $libraryPath The libgit2 shared library, as given to the constructor + * + * @throws UnsupportedOperationException The FFI extension or libgit2 1.9 is missing + */ + public static function preload(?string $libraryPath = null): void + { + LibGit2::preload($libraryPath); + + // Classes declared while preloading may call FFI, even when + // "ffi.enable" is "preload", its default. + class_exists(LibGit2RepositoryDriver::class); + class_exists(PathFilter::class); + } + + public static function isSupported(): bool + { + try { + LibGit2::load(null); + + return true; + } catch (UnsupportedOperationException) { + return false; + } + } + + /** + * LibGit2Driver runs no command, so it logs nothing. + */ + public function open(string $gitDir, ?string $workingDir, ?LoggerInterface $logger): LibGit2RepositoryDriver + { + return new LibGit2RepositoryDriver($this->ffi, $gitDir, $workingDir); + } + + public function init(string $path): void + { + $this->ffi->git_repository_free($this->initRepository($path, false)); + } + + public function initBare(string $path): void + { + $this->ffi->git_repository_free($this->initRepository($path, true)); + } + + public function cloneRepository(string $path, string $url, array $args): void + { + $refspec = null; + $branch = null; + for ($i = 0, $count = \count($args); $i < $count; ++$i) { + match ($args[$i]) { + // Like git: --mirror fetches every reference, --bare only the branches (and the tags). + '--bare' => $refspec ??= '+refs/heads/*:refs/heads/*', + '--mirror' => $refspec = '+refs/*:refs/*', + '--branch' => $branch = $args[++$i] ?? throw new UnsupportedOperationException('The "--branch" clone argument expects a branch name.'), + default => throw new UnsupportedOperationException(\sprintf('LibGit2Driver does not support the "%s" clone argument; use CliDriver.', $args[$i])), + }; + } + + if (null === $refspec) { + $this->cloneWithWorkingTree($path, $url, $branch); + + return; + } + + $repository = $this->initRepository($path, true); + + try { + $this->fetch($repository, $url, $refspec, $branch); + + $config = ['remote.origin.url' => $url]; + if ('+refs/*:refs/*' === $refspec) { + $config['remote.origin.fetch'] = $refspec; + $config['remote.origin.mirror'] = 'true'; + } + $this->setConfig($repository, $config); + } finally { + $this->ffi->git_repository_free($repository); + } + } + + public function isValidRepository(string $url): bool + { + return null !== $this->listRemoteReferences($url); + } + + public function isValidRepositoryAndBranch(string $url, string $branchName): bool + { + foreach ($this->listRemoteReferences($url) ?? [] as $fullname) { + // Same matching as `git ls-remote --heads `: + // only branches, matched on whole trailing path components. + if (str_starts_with($fullname, 'refs/heads/') && ($fullname === 'refs/heads/'.$branchName || str_ends_with($fullname, '/'.$branchName))) { + return true; + } + } + + return false; + } + + private function initRepository(string $path, bool $bare): CData + { + $repository = $this->ffi->new('git_repository*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_init(\FFI::addr($repository), $path, (int) $bare), \sprintf('Unable to initialize a repository in "%s"', $path)); + + return $repository; + } + + private function cloneWithWorkingTree(string $path, string $url, ?string $branch): void + { + $options = $this->ffi->new('git_clone_options'); + LibGit2::check($this->ffi, $this->ffi->git_clone_options_init(\FFI::addr($options), 1), 'Unable to initialize clone options'); + + // The buffer must stay alive until git_clone() returns. + $branchBuffer = null; + if (null !== $branch) { + $branchBuffer = LibGit2::cString($this->ffi, $branch); + $options->checkout_branch = $this->ffi->cast('const char *', $branchBuffer); + } + + $repository = $this->ffi->new('git_repository*'); + LibGit2::check($this->ffi, $this->ffi->git_clone(\FFI::addr($repository), $url, $path, \FFI::addr($options)), \sprintf('Unable to clone "%s"', $url)); + $this->ffi->git_repository_free($repository); + } + + /** + * Fetches $refspec from $url, and points HEAD to $branch, or to the + * default branch of $url. + */ + private function fetch(CData $repository, string $url, string $refspec, ?string $branch): void + { + $remote = $this->ffi->new('git_remote*'); + LibGit2::check($this->ffi, $this->ffi->git_remote_create_anonymous(\FFI::addr($remote), $repository, $url), \sprintf('Unable to create a remote for "%s"', $url)); + + try { + $options = $this->ffi->new('git_fetch_options'); + LibGit2::check($this->ffi, $this->ffi->git_fetch_options_init(\FFI::addr($options), 1), 'Unable to initialize fetch options'); + $options->download_tags = self::GIT_REMOTE_DOWNLOAD_TAGS_ALL; + + $refspecBuffer = LibGit2::cString($this->ffi, $refspec); + $refspecs = $this->ffi->new('char*[1]'); + $refspecs[0] = $this->ffi->cast('char *', $refspecBuffer); + $refspecArray = $this->ffi->new('git_strarray'); + $refspecArray->strings = $this->ffi->cast('char **', $refspecs); + $refspecArray->count = 1; + + LibGit2::check($this->ffi, $this->ffi->git_remote_fetch($remote, \FFI::addr($refspecArray), \FFI::addr($options), null), \sprintf('Unable to fetch "%s"', $url)); + + $head = null !== $branch ? 'refs/heads/'.$branch : $this->defaultBranch($remote); + } finally { + $this->ffi->git_remote_free($remote); + } + + if (null === $head) { + return; + } + + $reference = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($reference), $repository, $head); + if (self::GIT_ENOTFOUND === $rc) { + throw new DriverException(\sprintf('Remote branch "%s" not found in "%s".', $branch ?? $head, $url)); + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up "%s"', $head)); + $this->ffi->git_reference_free($reference); + + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($repository, $head), 'Unable to update HEAD'); + } + + /** + * @return string|null The full name of the branch HEAD points to on the remote, or null for an empty repository + */ + private function defaultBranch(CData $remote): ?string + { + $buffer = $this->ffi->new('git_buf'); + $rc = $this->ffi->git_remote_default_branch(\FFI::addr($buffer), $remote); + if (self::GIT_ENOTFOUND === $rc) { + return null; + } + LibGit2::check($this->ffi, $rc, 'Unable to find the default branch of the remote'); + + try { + return \FFI::string($buffer->ptr, $buffer->size); + } finally { + $this->ffi->git_buf_dispose(\FFI::addr($buffer)); + } + } + + /** + * @param array $config + */ + private function setConfig(CData $repository, array $config): void + { + $handle = $this->ffi->new('git_config*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_config(\FFI::addr($handle), $repository), 'Unable to open the repository configuration'); + + try { + foreach ($config as $name => $value) { + LibGit2::check($this->ffi, $this->ffi->git_config_set_string($handle, $name, $value), \sprintf('Unable to set "%s"', $name)); + } + } finally { + $this->ffi->git_config_free($handle); + } + } + + /** + * @return string[]|null The full names of the references of $url, or null when it can't be reached + */ + private function listRemoteReferences(string $url): ?array + { + $remote = $this->ffi->new('git_remote*'); + if ($this->ffi->git_remote_create_detached(\FFI::addr($remote), $url) < 0) { + return null; + } + + try { + if ($this->ffi->git_remote_connect($remote, 0, null, null, null) < 0) { // GIT_DIRECTION_FETCH + return null; + } + + try { + $heads = $this->ffi->new('git_remote_head**'); + $count = $this->ffi->new('size_t'); + LibGit2::check($this->ffi, $this->ffi->git_remote_ls(\FFI::addr($heads), \FFI::addr($count), $remote), \sprintf('Unable to list the references of "%s"', $url)); + + $references = []; + for ($i = 0; $i < $count->cdata; ++$i) { + $references[] = \FFI::string($heads[$i]->name); + } + + return $references; + } finally { + $this->ffi->git_remote_disconnect($remote); + } + } finally { + $this->ffi->git_remote_free($remote); + } + } +} diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php new file mode 100644 index 00000000..2141ff7e --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -0,0 +1,1488 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Ffi; + +use FFI\CData; +use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Driver\Data\BlameLineData; +use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Driver\Data\TagData; +use Gitonomy\Git\Driver\Data\TreeEntryData; +use Gitonomy\Git\Driver\Data\TreeEntryType; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Symfony\Component\String\ByteString; +use Symfony\Component\String\Exception\InvalidArgumentException; + +/** + * Runs libgit2 in-process on a repository, through PHP's FFI extension. + * + * Every libgit2 object it allocates is freed in a `finally` block, so that a + * failure halfway through an operation doesn't leak native memory. + * + * @author Grégoire Pineau + * + * @internal + */ +final class LibGit2RepositoryDriver implements RepositoryDriverInterface +{ + private const int GIT_ENOTFOUND = -3; + private const int GIT_EUNBORNBRANCH = -9; + private const int GIT_EINVALIDSPEC = -12; + private const int GIT_EPEEL = -19; + private const int GIT_ITEROVER = -31; + + private const int GIT_SORT_NONE = 0; + private const int GIT_SORT_TOPOLOGICAL = 1 << 0; + private const int GIT_DIFF_FIND_RENAMES = 1 << 0; + private const int GIT_STATUS_OPT_INCLUDE_UNTRACKED = 1 << 0; + private const int GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS = 1 << 4; + private const int GIT_STATUS_WT_NEW = 1 << 7; + + private readonly CData $repo; + + public function __construct( + private readonly \FFI $ffi, + string $gitDir, + private readonly ?string $workingDir, + ) { + $out = $this->ffi->new('git_repository*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_open(\FFI::addr($out), $gitDir), 'Unable to open repository'); + + if (null !== $workingDir) { + try { + LibGit2::check($this->ffi, $this->ffi->git_repository_set_workdir($out, $workingDir, 0), 'Unable to set working directory'); + } catch (DriverException $e) { + // __destruct() isn't called when the constructor throws. + $this->ffi->git_repository_free($out); + + throw $e; + } + } + + $this->repo = $out; + } + + public function __destruct() + { + $this->ffi->git_repository_free($this->repo); + } + + /** + * Two drivers sharing one git_repository would free it twice. + */ + private function __clone() + { + } + + public function resolveRevision(string $revision): string + { + $object = $this->revparse($revision); + + try { + return $this->oidToHex($this->ffi->git_object_id($object)); + } finally { + $this->ffi->git_object_free($object); + } + } + + public function getObjectType(string $revision): string + { + $object = $this->revparse($revision); + + try { + return $this->objectTypeName($this->ffi->git_object_type($object)); + } finally { + $this->ffi->git_object_free($object); + } + } + + public function getCommitData(string $hash): CommitData + { + $commit = $this->lookupCommit($hash); + + try { + return $this->commitToData($commit); + } finally { + $this->ffi->git_commit_free($commit); + } + } + + public function getCommitLog(?array $revisions, array $paths, ?int $offset, ?int $limit): array + { + $result = []; + if (0 === $limit) { + return $result; + } + + $skipped = 0; + foreach ($this->walk($revisions, $paths) as $hash) { + if (null !== $offset && $skipped < $offset) { + ++$skipped; + + continue; + } + + $result[$hash] = $this->getCommitData($hash); + + // Stops before walking to the next commit: it can be far away in the history. + if (null !== $limit && \count($result) >= $limit) { + break; + } + } + + return $result; + } + + public function countCommits(?array $revisions, array $paths): int + { + // Like CliDriver's `rev-list --count`, which counts everything when given no revision. + return iterator_count($this->walk([] === $revisions ? null : $revisions, $paths)); + } + + public function getShortHash(string $revision): string + { + $object = $this->revparse($revision); + + try { + $buf = $this->ffi->new('git_buf'); + LibGit2::check($this->ffi, $this->ffi->git_object_short_id(\FFI::addr($buf), $object), 'Unable to compute short hash'); + + try { + return \FFI::string($buf->ptr, $buf->size); + } finally { + $this->ffi->git_buf_dispose(\FFI::addr($buf)); + } + } finally { + $this->ffi->git_object_free($object); + } + } + + public function getTreeEntries(string $hash): array + { + $tree = $this->lookupTree($hash); + + try { + $count = $this->ffi->git_tree_entrycount($tree); + $result = []; + for ($i = 0; $i < $count; ++$i) { + $entry = $this->ffi->git_tree_entry_byindex($tree, $i); + $result[] = new TreeEntryData( + \sprintf('%06o', $this->ffi->git_tree_entry_filemode($entry)), + $this->treeEntryType($this->ffi->git_tree_entry_type($entry)), + $this->oidToHex($this->ffi->git_tree_entry_id($entry)), + $this->ffi->git_tree_entry_name($entry), + ); + } + + return $result; + } finally { + $this->ffi->git_tree_free($tree); + } + } + + public function getBlobContent(string $hash): string + { + $oid = $this->hexToOid($hash); + $blob = $this->ffi->new('git_blob*'); + LibGit2::check($this->ffi, $this->ffi->git_blob_lookup_prefix(\FFI::addr($blob), $this->repo, \FFI::addr($oid), \strlen($hash)), \sprintf('Unable to find blob "%s"', $hash)); + + try { + return \FFI::string($this->ffi->git_blob_rawcontent($blob), $this->ffi->git_blob_rawsize($blob)); + } finally { + $this->ffi->git_blob_free($blob); + } + } + + public function getTagData(string $revision): TagData + { + $object = $this->revparse($revision); + + try { + if ($this->ffi->git_object_type($object) !== $this->ffi->GIT_OBJECT_TAG) { + throw new DriverException(\sprintf('"%s" is not an annotated tag.', $revision)); + } + + $tag = $this->ffi->new('git_tag*'); + LibGit2::check($this->ffi, $this->ffi->git_tag_lookup(\FFI::addr($tag), $this->repo, $this->ffi->git_object_id($object)), 'Unable to look up tag'); + } finally { + $this->ffi->git_object_free($object); + } + + try { + $tagger = $this->ffi->git_tag_tagger($tag); + if (null === $tagger) { + throw new DriverException(\sprintf('Tag "%s" has no tagger.', $revision)); + } + + [$message, $gpgSignature] = TagData::splitSignature($this->ffi->git_tag_message($tag) ?? ''); + + return new TagData( + $this->oidToHex($this->ffi->git_tag_target_id($tag)), + $this->objectTypeName($this->ffi->git_tag_target_type($tag)), + $this->ffi->git_tag_name($tag), + \FFI::string($tagger->name), + \FFI::string($tagger->email), + $this->signatureDate($tagger), + $message, + $gpgSignature, + ); + } finally { + $this->ffi->git_tag_free($tag); + } + } + + public function getReferences(): array + { + $result = []; + foreach ($this->iterateReferences() as $ref) { + $hash = $this->referenceTargetHex($ref); + if (null !== $hash) { + $result[] = new ReferenceData($this->ffi->git_reference_name($ref), $hash); + } + } + + return $result; + } + + public function updateReference(string $fullname, string $hash): void + { + $oid = $this->hexToOid($hash); + $ref = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_create(\FFI::addr($ref), $this->repo, $fullname, \FFI::addr($oid), 1, null), \sprintf('Unable to update reference "%s"', $fullname)); + $this->ffi->git_reference_free($ref); + } + + public function deleteReference(string $fullname): void + { + $ref = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname), \sprintf('Unable to find reference "%s"', $fullname)); + + try { + LibGit2::check($this->ffi, $this->ffi->git_reference_delete($ref), \sprintf('Unable to delete reference "%s"', $fullname)); + } finally { + $this->ffi->git_reference_free($ref); + } + } + + public function getIncludingBranches(string $hash): array + { + $tips = []; + foreach ($this->iterateBranches() as [$fullname, $ref]) { + $tips[$fullname] = $this->oidToHex($this->ffi->git_reference_target($ref)); + } + + // One walk from every branch down to $hash, instead of a merge base + // per branch: parents first, a commit reaches $hash when one of its + // parents does. + $reaching = [$hash => true]; + $commits = iterator_to_array($this->revwalk(array_values($tips), $this->parentHashes($hash), self::GIT_SORT_TOPOLOGICAL), false); + foreach (array_reverse($commits) as $commit) { + foreach ($this->parentHashes($commit) as $parent) { + if (isset($reaching[$parent])) { + $reaching[$commit] = true; + + break; + } + } + } + + return array_keys(array_filter($tips, static fn (string $tip): bool => isset($reaching[$tip]))); + } + + public function getDiff(array $revisions): Diff + { + if (2 === \count($revisions) && !str_contains($revisions[0], '..')) { + [$old, $new] = $revisions; + } elseif (1 === \count($revisions) && str_contains($revisions[0], '...')) { + [$a, $new] = $this->rangeEnds($revisions[0], '...'); + $old = $this->getMergeBase($a, $new) ?? throw new DriverException(\sprintf('"%s" and "%s" have no common ancestor.', $a, $new)); + } elseif (1 === \count($revisions) && str_contains($revisions[0], '..')) { + [$old, $new] = $this->rangeEnds($revisions[0], '..'); + } elseif (1 === \count($revisions)) { + return $this->diffToWorkdir($revisions[0]); + } else { + throw new UnsupportedOperationException(\sprintf('LibGit2Driver cannot diff "%s".', implode(' ', $revisions))); + } + + $oldTree = $this->treeForRevision($old); + + try { + $newTree = $this->treeForRevision($new); + + try { + return $this->diffTrees($oldTree, $newTree); + } finally { + $this->ffi->git_tree_free($newTree); + } + } finally { + $this->ffi->git_tree_free($oldTree); + } + } + + public function getCommitDiff(string $hash): Diff + { + $commit = $this->lookupCommit($hash); + + try { + $tree = $this->commitTree($commit); + + try { + $parentCount = $this->ffi->git_commit_parentcount($commit); + if (0 === $parentCount) { + return $this->diffTrees(null, $tree); + } + + // Like `git diff-tree -m`: one diff per parent of a merge commit. + $files = []; + for ($i = 0; $i < $parentCount; ++$i) { + $parentTree = $this->parentTree($commit, $i); + + try { + $files = [...$files, ...$this->diffTrees($parentTree, $tree)->getFiles()]; + } finally { + $this->ffi->git_tree_free($parentTree); + } + } + + return new Diff($files, ''); + } finally { + $this->ffi->git_tree_free($tree); + } + } finally { + $this->ffi->git_commit_free($commit); + } + } + + public function getDiffPending(): Diff + { + $diff = $this->ffi->new('git_diff*'); + $opts = $this->diffOptions(); + LibGit2::check($this->ffi, $this->ffi->git_diff_index_to_workdir(\FFI::addr($diff), $this->repo, null, \FFI::addr($opts)), 'Unable to compute working copy diff'); + + return $this->buildDiff($diff); + } + + public function getDiffStaged(): Diff + { + $tree = $this->headTree(); + + try { + $diff = $this->ffi->new('git_diff*'); + $opts = $this->diffOptions(); + LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_index(\FFI::addr($diff), $this->repo, $tree, null, \FFI::addr($opts)), 'Unable to compute staged diff'); + } finally { + if (null !== $tree) { + $this->ffi->git_tree_free($tree); + } + } + + return $this->buildDiff($diff); + } + + public function getMergeBase(string $revision1, string $revision2): ?string + { + $one = $this->hexToOid($this->resolveCommit($revision1)); + $two = $this->hexToOid($this->resolveCommit($revision2)); + $out = $this->ffi->new('git_oid'); + + $rc = $this->ffi->git_merge_base(\FFI::addr($out), $this->repo, \FFI::addr($one), \FFI::addr($two)); + if (self::GIT_ENOTFOUND === $rc) { + return null; + } + LibGit2::check($this->ffi, $rc, 'Unable to compute merge base'); + + return $this->oidToHex(\FFI::addr($out)); + } + + public function getBlameLines(string $revision, string $file, ?string $lineRange): array + { + [$firstLine, $lastLine] = $this->parseLineRange($lineRange); + + $options = $this->ffi->new('git_blame_options'); + LibGit2::check($this->ffi, $this->ffi->git_blame_options_init(\FFI::addr($options), 1), 'Unable to initialize blame options'); + $options->newest_commit = $this->hexToOid($this->resolveCommit($revision)); + $options->min_line = $firstLine; + $options->max_line = $lastLine ?? 0; // 0: up to the end of the file + + $blame = $this->ffi->new('git_blame*'); + LibGit2::check($this->ffi, $this->ffi->git_blame_file(\FFI::addr($blame), $this->repo, $file, \FFI::addr($options)), \sprintf('Unable to blame "%s"', $file)); + + try { + $count = $this->ffi->git_blame_linecount($blame); + $result = []; + for ($i = $firstLine; $i <= min($lastLine ?? $count, $count); ++$i) { + $hunk = $this->ffi->git_blame_hunk_byline($blame, $i); + $line = $this->ffi->git_blame_line_byindex($blame, $i); + + if (null === $hunk || null === $line) { + // git_blame_linecount() can overcount by one for files not + // ending in a newline; stop rather than dereference NULL. + // (A NULL `const git_blame_line *` return is auto-converted + // to PHP null by FFI, unlike a NULL struct-field pointer.) + break; + } + + $content = rtrim(\FFI::string($line->ptr, $line->len), "\n"); + $sourceLine = (string) ($hunk->orig_start_line_number + ($i - $hunk->final_start_line_number)); + $blockLine = ($i === $hunk->final_start_line_number || $i === $firstLine) ? (string) ($hunk->lines_in_hunk - ($i - $hunk->final_start_line_number)) : null; + + $result[] = new BlameLineData($this->oidToHex(\FFI::addr($hunk->final_commit_id)), $sourceLine, (string) $i, $blockLine, $content); + } + + return $result; + } finally { + $this->ffi->git_blame_free($blame); + } + } + + public function getUntrackedFiles(): array + { + $opts = $this->ffi->new('git_status_options'); + LibGit2::check($this->ffi, $this->ffi->git_status_options_init(\FFI::addr($opts), 1), 'Unable to initialize status options'); + $opts->flags = self::GIT_STATUS_OPT_INCLUDE_UNTRACKED | self::GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS; + + $list = $this->ffi->new('git_status_list*'); + LibGit2::check($this->ffi, $this->ffi->git_status_list_new(\FFI::addr($list), $this->repo, \FFI::addr($opts)), 'Unable to compute status'); + + try { + $count = $this->ffi->git_status_list_entrycount($list); + $result = []; + for ($i = 0; $i < $count; ++$i) { + $entry = $this->ffi->git_status_byindex($list, $i); + if (0 !== ($entry->status & self::GIT_STATUS_WT_NEW)) { + $result[] = $entry->index_to_workdir->new_file->path; + } + } + + return $result; + } finally { + $this->ffi->git_status_list_free($list); + } + } + + public function checkout(string $revision): void + { + $this->checkoutRevision($revision); + + // `git checkout ` attaches HEAD when names a + // local branch, and detaches it for anything else (a tag, a hash, a + // remote branch, a full reference name...). + if ($this->referenceExists('refs/heads/'.$revision)) { + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, 'refs/heads/'.$revision), 'Unable to update HEAD'); + } else { + $oid = $this->hexToOid($this->resolveCommit($revision)); + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($this->repo, \FFI::addr($oid)), 'Unable to detach HEAD'); + } + } + + public function checkoutNewBranch(string $branch, string $revision): void + { + if ($this->referenceExists('refs/heads/'.$branch)) { + throw new DriverException(\sprintf('A branch named "%s" already exists.', $branch)); + } + + $this->checkoutRevision($revision); + + $commit = $this->lookupCommit($this->resolveCommit($revision)); + + try { + $reference = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_branch_create(\FFI::addr($reference), $this->repo, $branch, $commit, 0), \sprintf('Unable to create branch "%s"', $branch)); + $this->ffi->git_reference_free($reference); + } finally { + $this->ffi->git_commit_free($commit); + } + + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, 'refs/heads/'.$branch), 'Unable to update HEAD'); + } + + public function merge(string $revision, array $args): void + { + $noFastForward = false; + foreach ($args as $arg) { + if ('--no-ff' !== $arg) { + throw new UnsupportedOperationException(\sprintf('LibGit2Driver does not support the "%s" merge argument; use CliDriver.', $arg)); + } + $noFastForward = true; + } + + $branch = $this->currentBranch(); + $headHash = $this->resolveCommit('HEAD'); + $theirHash = $this->resolveCommit($revision); + $head = $this->hexToOid($headHash); + $their = $this->hexToOid($theirHash); + + // Already up to date, even with --no-ff. + if ($headHash === $theirHash || $this->isDescendantOf($head, $their)) { + return; + } + + if (!$noFastForward && $this->isDescendantOf($their, $head)) { + $target = $this->revparse($theirHash); + + try { + $this->checkoutObject($target, 'Unable to fast-forward'); + } finally { + $this->ffi->git_object_free($target); + } + $this->moveHead($branch, $theirHash); + + return; + } + + $tree = $this->mergeTrees($headHash, $theirHash, $revision); + + try { + $mergeHash = $this->createMergeCommit($tree, $headHash, $theirHash, $this->mergeMessage($revision, $branch)); + $this->checkoutObject($this->ffi->cast('git_object *', $tree), 'Unable to checkout merge result'); + } finally { + $this->ffi->git_tree_free($tree); + } + $this->moveHead($branch, $mergeHash); + } + + // -- history ----------------------------------------------------------- + + /** + * Walks the history in `git log`'s default order. With $paths, only + * yields the commits that change them, and simplifies the history like + * git does. + * + * @param string[]|null $revisions null for every reference + * @param string[] $paths + * + * @return \Generator Commit hashes + */ + private function walk(?array $revisions, array $paths): \Generator + { + $tips = []; + $bottoms = []; + foreach ([] === $revisions ? ['HEAD'] : $revisions ?? ['--all'] as $spec) { + [$specTips, $specBottoms] = $this->revspecEnds($spec); + array_push($tips, ...$specTips); + array_push($bottoms, ...$specBottoms); + } + + $pathFilter = $this->pathFilter($paths); + + if (null === $pathFilter) { + yield from $this->revwalk($tips, $bottoms); + + return; + } + + // With hidden commits, libgit2 walks the whole range before yielding the first one anyway. + $interesting = [] === $bottoms ? null : array_fill_keys(iterator_to_array($this->revwalk($tips, $bottoms), false), true); + + yield from $this->simplifiedHistory($tips, $bottoms, $interesting, $pathFilter); + } + + /** + * Walks the history with libgit2, whose default sorting is git's. + * + * @param string[] $tips The commits to walk from + * @param string[] $bottoms The commits to hide, along with their ancestors + * + * @return \Generator + */ + private function revwalk(array $tips, array $bottoms, int $sorting = self::GIT_SORT_NONE): \Generator + { + $walker = $this->ffi->new('git_revwalk*'); + LibGit2::check($this->ffi, $this->ffi->git_revwalk_new(\FFI::addr($walker), $this->repo), 'Unable to create revwalk'); + + try { + LibGit2::check($this->ffi, $this->ffi->git_revwalk_sorting($walker, $sorting), 'Unable to sort the walk'); + + // git starts from the newest tip (the first given one, at equal + // dates), libgit2 from the last pushed one. + $times = array_map($this->commitTime(...), $tips); + uksort($tips, static fn (int $a, int $b): int => $times[$a] <=> $times[$b] ?: $b <=> $a); + foreach ($tips as $hash) { + $oid = $this->hexToOid($hash); + LibGit2::check($this->ffi, $this->ffi->git_revwalk_push($walker, \FFI::addr($oid)), \sprintf('Unable to push "%s"', $hash)); + } + foreach ($bottoms as $hash) { + $oid = $this->hexToOid($hash); + LibGit2::check($this->ffi, $this->ffi->git_revwalk_hide($walker, \FFI::addr($oid)), \sprintf('Unable to hide "%s"', $hash)); + } + + $oid = $this->ffi->new('git_oid'); + while (0 === ($rc = $this->ffi->git_revwalk_next(\FFI::addr($oid), $walker))) { + yield $this->oidToHex(\FFI::addr($oid)); + } + $this->checkIterationEnd($rc, 'Unable to walk the history'); + } finally { + $this->ffi->git_revwalk_free($walker); + } + } + + /** + * @return array{list, list} The commits a revision walks from, and the ones it hides along with their ancestors + */ + private function revspecEnds(string $spec): array + { + if (str_contains($spec, '...')) { + // Commits reachable from either end but not from both. + [$a, $b] = $this->rangeEnds($spec, '...'); + $one = $this->resolveCommit($a); + $two = $this->resolveCommit($b); + + return [[$one, $two], $this->mergeBases($one, $two)]; + } + + if (str_contains($spec, '..')) { + [$a, $b] = $this->rangeEnds($spec, '..'); + + return [[$this->resolveCommit($b)], [$this->resolveCommit($a)]]; + } + + if (str_starts_with($spec, '^')) { + return [[], [$this->resolveCommit(substr($spec, 1))]]; + } + + if ('--all' === $spec) { + return [$this->referencedCommits(), []]; + } + + return [[$this->resolveCommit($spec)], []]; + } + + /** + * @return array{string, string} Both ends of an "a..b" or "a...b" range, an omitted end meaning HEAD + */ + private function rangeEnds(string $range, string $separator): array + { + [$a, $b] = explode($separator, $range, 2); + + return ['' === $a ? 'HEAD' : $a, '' === $b ? 'HEAD' : $b]; + } + + /** + * @return list No merge base when the commits have no common ancestor + */ + private function mergeBases(string $one, string $two): array + { + $oneOid = $this->hexToOid($one); + $twoOid = $this->hexToOid($two); + $bases = $this->ffi->new('git_oidarray'); + $rc = $this->ffi->git_merge_bases(\FFI::addr($bases), $this->repo, \FFI::addr($oneOid), \FFI::addr($twoOid)); + if (self::GIT_ENOTFOUND === $rc) { + return []; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to compute the merge bases of "%s" and "%s"', $one, $two)); + + try { + $hashes = []; + for ($i = 0; $i < $bases->count; ++$i) { + $hashes[] = $this->oidToHex(\FFI::addr($bases->ids[$i])); + } + + return $hashes; + } finally { + $this->ffi->git_oidarray_dispose(\FFI::addr($bases)); + } + } + + /** + * @return list The commits the references point to; like `git log --all`, ignores the references to other objects and the dangling ones + */ + private function referencedCommits(): array + { + $hashes = []; + foreach ($this->iterateReferences() as $ref) { + $commit = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_reference_peel(\FFI::addr($commit), $ref, $this->ffi->GIT_OBJECT_COMMIT); + if (\in_array($rc, [self::GIT_ENOTFOUND, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { + continue; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to peel "%s"', $this->ffi->git_reference_name($ref))); + + try { + $hashes[] = $this->oidToHex($this->ffi->git_object_id($commit)); + } finally { + $this->ffi->git_object_free($commit); + } + } + + return $hashes; + } + + /** + * @param string[] $paths + */ + private function pathFilter(array $paths): ?PathFilter + { + $paths = array_values(array_filter( + array_map(fn (string $path): string => trim($this->toRepoRelativePath($path), '/'), $paths), + static fn (string $path): bool => '' !== $path, + )); + + return [] === $paths ? null : new PathFilter($this->ffi, $this->repo, $paths, $this->diffOptions()); + } + + /** + * Walks the history like git does with paths: from a merge that has the + * same paths as one of its parents, it only follows that parent, and it + * only yields the commits that change the paths. + * + * @param string[] $tips The commits to walk from + * @param string[] $bottoms The commits hidden along with their ancestors + * @param array|null $interesting The commits the bottoms don't hide, null without bottoms + * + * @return \Generator + */ + private function simplifiedHistory(array $tips, array $bottoms, ?array $interesting, PathFilter $pathFilter): \Generator + { + // git doesn't simplify a merge to a hidden parent, unless it is a bottom. + $relevant = null === $interesting ? null : $interesting + array_fill_keys($bottoms, true); + + // Newest commit first, then first queued first, like git. + $queue = new \SplPriorityQueue(); + $queued = []; + $enqueue = function (string $hash) use ($queue, &$queued, $interesting): void { + if (isset($queued[$hash]) || (null !== $interesting && !isset($interesting[$hash]))) { + return; + } + + $queued[$hash] = true; + $queue->insert($hash, [$this->commitTime($hash), -\count($queued)]); + }; + + foreach ($tips as $hash) { + $enqueue($hash); + } + + while (!$queue->isEmpty()) { + $hash = $queue->extract(); + + [$parents, $changed] = $this->simplifyCommit($hash, $pathFilter, $relevant); + foreach ($parents as $parent) { + $enqueue($parent); + } + + if ($changed) { + yield $hash; + } + } + } + + /** + * Compares a commit with its parents, on the paths of $pathFilter, like + * git's history simplification. + * + * @param array|null $relevant The parents a merge can be simplified to, null for any + * + * @return array{list, bool} The parents to follow, and whether the commit changes the paths + */ + private function simplifyCommit(string $hash, PathFilter $pathFilter, ?array $relevant): array + { + $commit = $this->lookupCommit($hash); + + try { + $tree = $this->commitTree($commit); + + try { + $count = $this->ffi->git_commit_parentcount($commit); + if (0 === $count) { + return [[], $pathFilter->differ(null, $tree)]; + } + + $parents = []; + $changed = false; + for ($i = 0; $i < $count; ++$i) { + $parents[] = $parent = $this->oidToHex($this->ffi->git_commit_parent_id($commit, $i)); + $parentTree = $this->parentTree($commit, $i); + + try { + $same = !$pathFilter->differ($parentTree, $tree); + } finally { + $this->ffi->git_tree_free($parentTree); + } + + if ($same && (null === $relevant || isset($relevant[$parent]))) { + return [[$parent], false]; + } + + $changed = $changed || !$same; + } + + return [$parents, $changed]; + } finally { + $this->ffi->git_tree_free($tree); + } + } finally { + $this->ffi->git_commit_free($commit); + } + } + + /** + * git also accepts the absolute paths of files in the working tree, where + * libgit2 only deals with paths relative to the repository. + */ + private function toRepoRelativePath(string $path): string + { + if (null !== $this->workingDir && str_starts_with($path, $this->workingDir.'/')) { + return substr($path, \strlen($this->workingDir) + 1); + } + + return $path; + } + + // -- diff ---------------------------------------------------------------- + + private function diffToWorkdir(string $revision): Diff + { + $tree = $this->treeForRevision($revision); + + try { + $diff = $this->ffi->new('git_diff*'); + $opts = $this->diffOptions(); + LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_workdir_with_index(\FFI::addr($diff), $this->repo, $tree, \FFI::addr($opts)), 'Unable to compute working copy diff'); + } finally { + $this->ffi->git_tree_free($tree); + } + + return $this->buildDiff($diff); + } + + private function diffTrees(?CData $oldTree, CData $newTree): Diff + { + $diff = $this->ffi->new('git_diff*'); + $opts = $this->diffOptions(); + LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_tree(\FFI::addr($diff), $this->repo, $oldTree, $newTree, \FFI::addr($opts)), 'Unable to compute diff'); + + return $this->buildDiff($diff); + } + + private function diffOptions(): CData + { + $opts = $this->ffi->new('git_diff_options'); + LibGit2::check($this->ffi, $this->ffi->git_diff_options_init(\FFI::addr($opts), 1), 'Unable to initialize diff options'); + $opts->context_lines = 3; + + return $opts; + } + + /** + * Converts a git_diff to a Diff, and frees it. + */ + private function buildDiff(CData $diff): Diff + { + try { + // Like CliDriver's `-M`. + $findOptions = $this->ffi->new('git_diff_find_options'); + LibGit2::check($this->ffi, $this->ffi->git_diff_find_options_init(\FFI::addr($findOptions), 1), 'Unable to initialize rename detection options'); + $findOptions->flags = self::GIT_DIFF_FIND_RENAMES; + LibGit2::check($this->ffi, $this->ffi->git_diff_find_similar($diff, \FFI::addr($findOptions)), 'Unable to detect renames'); + + $files = []; + $count = $this->ffi->git_diff_num_deltas($diff); + for ($i = 0; $i < $count; ++$i) { + $files[] = $this->patchToFile($diff, $i); + } + + return new Diff($files, ''); + } finally { + $this->ffi->git_diff_free($diff); + } + } + + private function patchToFile(CData $diff, int $index): File + { + $patch = $this->ffi->new('git_patch*'); + LibGit2::check($this->ffi, $this->ffi->git_patch_from_diff(\FFI::addr($patch), $diff, $index), 'Unable to compute patch'); + + if (\FFI::isNull($patch)) { + // No patch for a binary or unchanged file: computing it flagged + // the delta accordingly. + return $this->deltaToFile($this->ffi->git_diff_get_delta($diff, $index)); + } + + try { + $file = $this->deltaToFile($this->ffi->git_patch_get_delta($patch)); + + $hunkCount = $this->ffi->git_patch_num_hunks($patch); + for ($hunkIndex = 0; $hunkIndex < $hunkCount; ++$hunkIndex) { + $hunk = $this->ffi->new('git_diff_hunk*'); + $lineCount = $this->ffi->new('size_t'); + LibGit2::check($this->ffi, $this->ffi->git_patch_get_hunk(\FFI::addr($hunk), \FFI::addr($lineCount), $patch, $hunkIndex), 'Unable to read diff hunk'); + + $lines = []; + for ($lineIndex = 0; $lineIndex < $lineCount->cdata; ++$lineIndex) { + $line = $this->ffi->new('git_diff_line*'); + LibGit2::check($this->ffi, $this->ffi->git_patch_get_line_in_hunk(\FFI::addr($line), $patch, $hunkIndex, $lineIndex), 'Unable to read diff line'); + + // ->origin is a scalar `char`, which PHP FFI represents + // as a single-character PHP string. + $type = match ($line->origin) { + '+' => FileChange::LINE_ADD, + '-' => FileChange::LINE_REMOVE, + ' ' => FileChange::LINE_CONTEXT, + default => null, + }; + + if (null !== $type) { + $lines[] = [$type, rtrim(\FFI::string($line->content, $line->content_len), "\n")]; + } + } + + $file->addChange(new FileChange($hunk->old_start, $hunk->old_lines, $hunk->new_start, $hunk->new_lines, $lines)); + } + + return $file; + } finally { + $this->ffi->git_patch_free($patch); + } + } + + private function deltaToFile(CData $delta): File + { + $status = $delta->status; + $isCopy = 5 === $status; // GIT_DELTA_COPIED + $isAdded = 1 === $status; // GIT_DELTA_ADDED + $isDeleted = 2 === $status; // GIT_DELTA_DELETED + $isBinary = 0 !== ($delta->flags & (1 << 0)); // GIT_DIFF_FLAG_BINARY + + $oldName = $isAdded ? null : $delta->old_file->path; + $newName = $isDeleted ? null : $delta->new_file->path; + + $oldMode = $isAdded ? null : \sprintf('%06o', $delta->old_file->mode); + $newMode = $isDeleted ? null : \sprintf('%06o', $delta->new_file->mode); + + $oldIndex = $this->ffi->git_oid_is_zero(\FFI::addr($delta->old_file->id)) ? null : $this->oidToHex(\FFI::addr($delta->old_file->id)); + $newIndex = $this->ffi->git_oid_is_zero(\FFI::addr($delta->new_file->id)) ? null : $this->oidToHex(\FFI::addr($delta->new_file->id)); + + return new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary, $isCopy); + } + + // -- working copy -------------------------------------------------------- + + private function checkoutRevision(string $revision): void + { + $target = $this->revparse($revision); + + try { + $this->checkoutObject($target, \sprintf('Unable to checkout "%s"', $revision)); + } finally { + $this->ffi->git_object_free($target); + } + } + + /** + * Safe checkout (libgit2's default strategy): like `git checkout`, it + * refuses to overwrite local changes, and keeps the ones it doesn't + * conflict with. + */ + private function checkoutObject(CData $treeish, string $context): void + { + $opts = $this->ffi->new('git_checkout_options'); + LibGit2::check($this->ffi, $this->ffi->git_checkout_options_init(\FFI::addr($opts), 1), 'Unable to initialize checkout options'); + LibGit2::check($this->ffi, $this->ffi->git_checkout_tree($this->repo, $treeish, \FFI::addr($opts)), $context); + } + + /** + * Points the checked out branch, or a detached HEAD, to $hash. + */ + private function moveHead(?string $branch, string $hash): void + { + if (null !== $branch) { + $this->updateReference($branch, $hash); + + return; + } + + $oid = $this->hexToOid($hash); + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($this->repo, \FFI::addr($oid)), 'Unable to update HEAD'); + } + + /** + * Merges two commits in memory and writes the resulting tree. + */ + private function mergeTrees(string $oursHash, string $theirsHash, string $revision): CData + { + $ours = $this->lookupCommit($oursHash); + + try { + $theirs = $this->lookupCommit($theirsHash); + + try { + $mergeOpts = $this->ffi->new('git_merge_options'); + LibGit2::check($this->ffi, $this->ffi->git_merge_options_init(\FFI::addr($mergeOpts), 1), 'Unable to initialize merge options'); + + $index = $this->ffi->new('git_index*'); + LibGit2::check($this->ffi, $this->ffi->git_merge_commits(\FFI::addr($index), $this->repo, $ours, $theirs, \FFI::addr($mergeOpts)), \sprintf('Unable to merge "%s"', $revision)); + + try { + if (0 !== $this->ffi->git_index_has_conflicts($index)) { + throw new DriverException(\sprintf('Merging "%s" produces conflicts, which LibGit2Driver does not resolve.', $revision)); + } + + $treeOid = $this->ffi->new('git_oid'); + LibGit2::check($this->ffi, $this->ffi->git_index_write_tree_to(\FFI::addr($treeOid), $index, $this->repo), 'Unable to write merge tree'); + + return $this->lookupTree($this->oidToHex(\FFI::addr($treeOid))); + } finally { + $this->ffi->git_index_free($index); + } + } finally { + $this->ffi->git_commit_free($theirs); + } + } finally { + $this->ffi->git_commit_free($ours); + } + } + + private function createMergeCommit(CData $tree, string $oursHash, string $theirsHash, string $message): string + { + $signature = $this->ffi->new('git_signature*'); + LibGit2::check($this->ffi, $this->ffi->git_signature_default(\FFI::addr($signature), $this->repo), 'Unable to determine the merge commit author (configure user.name and user.email)'); + + try { + $ours = $this->lookupCommit($oursHash); + + try { + $theirs = $this->lookupCommit($theirsHash); + + try { + $parents = $this->ffi->new('git_commit*[2]'); + $parents[0] = $ours; + $parents[1] = $theirs; + + $oid = $this->ffi->new('git_oid'); + LibGit2::check($this->ffi, $this->ffi->git_commit_create(\FFI::addr($oid), $this->repo, null, $signature, $signature, null, $message, $tree, 2, $parents), 'Unable to create merge commit'); + + return $this->oidToHex(\FFI::addr($oid)); + } finally { + $this->ffi->git_commit_free($theirs); + } + } finally { + $this->ffi->git_commit_free($ours); + } + } finally { + $this->ffi->git_signature_free($signature); + } + } + + /** + * The message `git merge` writes (see git's fmt-merge-msg). + */ + private function mergeMessage(string $revision, ?string $branch): string + { + $message = null; + // Same lookup order as git's revision parsing. + foreach ([ + 'refs/tags/' => "Merge tag '%s'", + 'refs/heads/' => "Merge branch '%s'", + 'refs/remotes/' => "Merge remote-tracking branch '%s'", + ] as $prefix => $format) { + $name = str_starts_with($revision, $prefix) ? substr($revision, \strlen($prefix)) : $revision; + if ($this->referenceExists($prefix.$name)) { + $message = \sprintf($format, $name); + + break; + } + } + $message ??= \sprintf("Merge commit '%s'", $revision); + + // git leaves out the destination when merging into main or master. + if (null !== $branch && !\in_array($branch, ['refs/heads/main', 'refs/heads/master'], true)) { + $message .= ' into '.substr($branch, \strlen('refs/heads/')); + } + + return $message."\n"; + } + + // -- references ---------------------------------------------------------- + + /** + * @return string|null The full name of the checked out branch, or null when HEAD is detached + */ + private function currentBranch(): ?string + { + $detached = $this->ffi->git_repository_head_detached($this->repo); + LibGit2::check($this->ffi, $detached, 'Unable to resolve HEAD'); + if (1 === $detached) { + return null; + } + + $head = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_head(\FFI::addr($head), $this->repo), 'Unable to resolve HEAD'); + + try { + return $this->ffi->git_reference_name($head); + } finally { + $this->ffi->git_reference_free($head); + } + } + + private function referenceExists(string $fullname): bool + { + $ref = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname); + if (self::GIT_ENOTFOUND === $rc || self::GIT_EINVALIDSPEC === $rc) { + return false; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up reference "%s"', $fullname)); + $this->ffi->git_reference_free($ref); + + return true; + } + + /** + * @return \Generator The git_reference* of every reference under refs/, freed once the consumer moves on + */ + private function iterateReferences(): \Generator + { + $iter = $this->ffi->new('git_reference_iterator*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_iterator_glob_new(\FFI::addr($iter), $this->repo, 'refs/*'), 'Unable to create reference iterator'); + + try { + $ref = $this->ffi->new('git_reference*'); + while (0 === ($rc = $this->ffi->git_reference_next(\FFI::addr($ref), $iter))) { + try { + yield $ref; + } finally { + $this->ffi->git_reference_free($ref); + } + } + $this->checkIterationEnd($rc, 'Unable to list references'); + } finally { + $this->ffi->git_reference_iterator_free($iter); + } + } + + /** + * @return \Generator The full name and the git_reference* of every local and remote branch, symbolic ones excluded; the reference is freed once the consumer moves on + */ + private function iterateBranches(): \Generator + { + $iter = $this->ffi->new('git_branch_iterator*'); + LibGit2::check($this->ffi, $this->ffi->git_branch_iterator_new(\FFI::addr($iter), $this->repo, $this->ffi->GIT_BRANCH_ALL), 'Unable to create branch iterator'); + + try { + $ref = $this->ffi->new('git_reference*'); + $type = $this->ffi->new('git_branch_t'); + while (0 === ($rc = $this->ffi->git_branch_next(\FFI::addr($ref), \FFI::addr($type), $iter))) { + try { + if ($this->ffi->git_reference_type($ref) === $this->ffi->GIT_REFERENCE_DIRECT) { + yield [$this->ffi->git_reference_name($ref), $ref]; + } + } finally { + $this->ffi->git_reference_free($ref); + } + } + $this->checkIterationEnd($rc, 'Unable to list branches'); + } finally { + $this->ffi->git_branch_iterator_free($iter); + } + } + + private function referenceTargetHex(CData $ref): ?string + { + if ($this->ffi->git_reference_type($ref) === $this->ffi->GIT_REFERENCE_SYMBOLIC) { + $resolved = $this->ffi->new('git_reference*'); + if (0 !== $this->ffi->git_reference_resolve(\FFI::addr($resolved), $ref)) { + // Dangling symbolic reference. + return null; + } + + try { + return $this->oidToHex($this->ffi->git_reference_target($resolved)); + } finally { + $this->ffi->git_reference_free($resolved); + } + } + + $target = $this->ffi->git_reference_target($ref); + if (null === $target || \FFI::isNull($target)) { + return null; + } + + return $this->oidToHex($target); + } + + private function isDescendantOf(CData $commit, CData $ancestor): bool + { + $rc = $this->ffi->git_graph_descendant_of($this->repo, \FFI::addr($commit), \FFI::addr($ancestor)); + LibGit2::check($this->ffi, $rc, 'Unable to compare commits'); + + return 1 === $rc; + } + + // -- objects ------------------------------------------------------------- + + private function revparse(string $spec): CData + { + $out = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_revparse_single(\FFI::addr($out), $this->repo, $spec), \sprintf('Unable to resolve "%s"', $spec)); + + return $out; + } + + /** + * Resolves a revision to a commit hash, peeling annotated tags. + */ + private function resolveCommit(string $revision): string + { + return $this->resolveRevision($revision.'^{commit}'); + } + + private function lookupCommit(string $hash): CData + { + $oid = $this->hexToOid($hash); + $out = $this->ffi->new('git_commit*'); + LibGit2::check($this->ffi, $this->ffi->git_commit_lookup_prefix(\FFI::addr($out), $this->repo, \FFI::addr($oid), \strlen($hash)), \sprintf('Unable to find commit "%s"', $hash)); + + return $out; + } + + /** + * @return list + */ + private function parentHashes(string $hash): array + { + $commit = $this->lookupCommit($hash); + + try { + $parents = []; + for ($i = 0, $count = $this->ffi->git_commit_parentcount($commit); $i < $count; ++$i) { + $parents[] = $this->oidToHex($this->ffi->git_commit_parent_id($commit, $i)); + } + + return $parents; + } finally { + $this->ffi->git_commit_free($commit); + } + } + + private function commitTime(string $hash): int + { + $commit = $this->lookupCommit($hash); + + try { + return $this->ffi->git_commit_committer($commit)->when->time; + } finally { + $this->ffi->git_commit_free($commit); + } + } + + private function lookupTree(string $hash): CData + { + $oid = $this->hexToOid($hash); + $out = $this->ffi->new('git_tree*'); + LibGit2::check($this->ffi, $this->ffi->git_tree_lookup_prefix(\FFI::addr($out), $this->repo, \FFI::addr($oid), \strlen($hash)), \sprintf('Unable to find tree "%s"', $hash)); + + return $out; + } + + private function commitTree(CData $commit): CData + { + $out = $this->ffi->new('git_tree*'); + LibGit2::check($this->ffi, $this->ffi->git_commit_tree(\FFI::addr($out), $commit), 'Unable to get commit tree'); + + return $out; + } + + private function parentTree(CData $commit, int $n): CData + { + $parent = $this->ffi->new('git_commit*'); + LibGit2::check($this->ffi, $this->ffi->git_commit_parent(\FFI::addr($parent), $commit, $n), 'Unable to get parent commit'); + + try { + return $this->commitTree($parent); + } finally { + $this->ffi->git_commit_free($parent); + } + } + + private function treeForRevision(string $spec): CData + { + $object = $this->revparse($spec); + + try { + $peeled = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_object_peel(\FFI::addr($peeled), $object, $this->ffi->GIT_OBJECT_TREE), \sprintf('Unable to resolve "%s" to a tree', $spec)); + + try { + // git_object_peel() hands back a generic git_object*; look it + // up again as the git_tree* the diff functions expect. + return $this->lookupTree($this->oidToHex($this->ffi->git_object_id($peeled))); + } finally { + $this->ffi->git_object_free($peeled); + } + } finally { + $this->ffi->git_object_free($object); + } + } + + /** + * @return CData|null The tree of HEAD, or null on an unborn branch + */ + private function headTree(): ?CData + { + $head = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_repository_head(\FFI::addr($head), $this->repo); + if (self::GIT_EUNBORNBRANCH === $rc || self::GIT_ENOTFOUND === $rc) { + return null; + } + LibGit2::check($this->ffi, $rc, 'Unable to resolve HEAD'); + + try { + $peeled = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_peel(\FFI::addr($peeled), $head, $this->ffi->GIT_OBJECT_TREE), 'Unable to peel HEAD to a tree'); + + try { + return $this->lookupTree($this->oidToHex($this->ffi->git_object_id($peeled))); + } finally { + $this->ffi->git_object_free($peeled); + } + } finally { + $this->ffi->git_reference_free($head); + } + } + + private function commitToData(CData $commit): CommitData + { + $parentCount = $this->ffi->git_commit_parentcount($commit); + $parents = []; + for ($i = 0; $i < $parentCount; ++$i) { + $parents[] = $this->oidToHex($this->ffi->git_commit_parent_id($commit, $i)); + } + + $author = $this->ffi->git_commit_author($commit); + $committer = $this->ffi->git_commit_committer($commit); + $encoding = $this->ffi->git_commit_message_encoding($commit); + + return new CommitData( + $this->oidToHex($this->ffi->git_commit_tree_id($commit)), + $parents, + $this->toUtf8(\FFI::string($author->name), $encoding), + $this->toUtf8(\FFI::string($author->email), $encoding), + $this->signatureDate($author), + $this->toUtf8(\FFI::string($committer->name), $encoding), + $this->toUtf8(\FFI::string($committer->email), $encoding), + $this->signatureDate($committer), + self::cleanMessage($this->toUtf8($this->ffi->git_commit_message($commit), $encoding)), + ); + } + + /** + * Like `git log`, which CliDriver reads commits with: without + * blank lines around it, nor whitespace at the end of its lines. + */ + private static function cleanMessage(string $message): string + { + $message = trim(preg_replace('/[ \t\r]+$/m', '', $message), "\n"); + + return '' === $message ? '' : $message."\n"; + } + + /** + * Re-encodes a text of a commit from its "encoding" header, like `git log --encoding=UTF-8`. + */ + private function toUtf8(string $text, ?string $encoding): string + { + if (null === $encoding) { + return $text; + } + + try { + return new ByteString($text)->toCodePointString($encoding)->toString(); + } catch (\ValueError|InvalidArgumentException) { + // Like git, keep the text as is when the encoding is unknown or wrong. + return $text; + } + } + + private function signatureDate(CData $signature): \DateTimeImmutable + { + // Same timezone as CliDriver's parsers. + return new \DateTimeImmutable('@'.$signature->when->time)->setTimezone(new \DateTimeZone('UTC')); + } + + /** + * @return array{int, int|null} The first line, and the last one (null for the end of the file) + */ + private function parseLineRange(?string $lineRange): array + { + if (null === $lineRange) { + return [1, null]; + } + + if (!preg_match('/^(\d+),(\+?)(\d*)$/', $lineRange, $vars)) { + throw new UnsupportedOperationException(\sprintf('LibGit2Driver only supports "," and ",+" line ranges, got "%s"; use CliDriver.', $lineRange)); + } + + $first = (int) $vars[1]; + if ('' === $vars[3]) { + return [$first, null]; + } + + return [$first, '+' === $vars[2] ? $first + (int) $vars[3] - 1 : (int) $vars[3]]; + } + + private function treeEntryType(int $type): TreeEntryType + { + return match ($type) { + 2 => TreeEntryType::Tree, + 3 => TreeEntryType::Blob, + default => TreeEntryType::Commit, + }; + } + + private function objectTypeName(int $type): string + { + return match ($type) { + 1 => 'commit', + 2 => 'tree', + 3 => 'blob', + 4 => 'tag', + default => throw new DriverException(\sprintf('Unknown git object type %d.', $type)), + }; + } + + /** + * @param CData $oidPtr a `git_oid*` — callers holding a `git_oid` value must pass `FFI::addr($value)` + */ + private function oidToHex(CData $oidPtr): string + { + return LibGit2::oidToHex($this->ffi, $oidPtr); + } + + /** + * Also accepts an abbreviated hash: lookups then go through the *_lookup_prefix() functions. + */ + private function hexToOid(string $hash): CData + { + $oid = $this->ffi->new('git_oid'); + LibGit2::check($this->ffi, $this->ffi->git_oid_fromstrn(\FFI::addr($oid), $hash, \strlen($hash)), \sprintf('Invalid hash "%s"', $hash)); + + return $oid; + } + + /** + * libgit2 iterators return GIT_ITEROVER once exhausted, and another + * negative code when they stop on an error. + */ + private function checkIterationEnd(int $rc, string $context): void + { + if (self::GIT_ITEROVER !== $rc) { + LibGit2::check($this->ffi, $rc, $context); + } + } +} diff --git a/src/Gitonomy/Git/Driver/Ffi/PathFilter.php b/src/Gitonomy/Git/Driver/Ffi/PathFilter.php new file mode 100644 index 00000000..40220126 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/PathFilter.php @@ -0,0 +1,137 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Ffi; + +use FFI\CData; + +/** + * Tells whether two trees differ on some paths, matched like git's pathspecs. + * + * @author Grégoire Pineau + * + * @internal + */ +final readonly class PathFilter +{ + private const int GIT_ENOTFOUND = -3; + + /** + * The part of each path before its first wildcard. + * + * @var list + */ + private array $prefixes; + + private CData $diffOptions; + + /** + * The C strings the diff options point to, to keep alive as long as them. + * + * @var list + */ + private array $buffers; // @phpstan-ignore property.onlyWritten + + /** + * @param list $paths Relative to the repository, without leading or trailing slash + */ + public function __construct( + private \FFI $ffi, + private CData $repo, + private array $paths, + CData $diffOptions, + ) { + $prefixes = []; + foreach ($paths as $path) { + $wildcard = strcspn($path, '*?[\\'); + // A wildcard can match more than a name: keep the directories before it. + $prefixes[] = \strlen($path) === $wildcard ? $path : substr($path, 0, (int) strrpos(substr($path, 0, $wildcard), '/')); + } + $this->prefixes = $prefixes; + + $buffers = []; + $strings = $ffi->new('char*['.\count($paths).']'); + foreach ($paths as $i => $path) { + $buffers[] = $buffer = LibGit2::cString($ffi, $path); + $strings[$i] = $ffi->cast('char *', $buffer); + } + $buffers[] = $strings; + $this->buffers = $buffers; + + $diffOptions->pathspec->strings = $ffi->cast('char **', $strings); + $diffOptions->pathspec->count = \count($paths); + $this->diffOptions = $diffOptions; + } + + /** + * @param CData|null $oldTree null for the empty tree + */ + public function differ(?CData $oldTree, CData $newTree): bool + { + // Comparing the entries at the paths is much cheaper than a diff, and + // enough for a path without wildcards. + $needsDiff = false; + foreach ($this->paths as $i => $path) { + $prefix = $this->prefixes[$i]; + if ($this->entryId($oldTree, $prefix) === $this->entryId($newTree, $prefix)) { + continue; + } + + if ($prefix === $path) { + return true; + } + + $needsDiff = true; + } + + return $needsDiff && $this->diffHasDeltas($oldTree, $newTree); + } + + /** + * @return string|null The hash and the mode of the entry at $path ('' for the tree itself), or null when there is none + */ + private function entryId(?CData $tree, string $path): ?string + { + if (null === $tree) { + return null; + } + + if ('' === $path) { + return LibGit2::oidToHex($this->ffi, $this->ffi->git_tree_id($tree)); + } + + $entry = $this->ffi->new('git_tree_entry*'); + $rc = $this->ffi->git_tree_entry_bypath(\FFI::addr($entry), $tree, $path); + if (self::GIT_ENOTFOUND === $rc) { + return null; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up "%s"', $path)); + + try { + return LibGit2::oidToHex($this->ffi, $this->ffi->git_tree_entry_id($entry)).' '.$this->ffi->git_tree_entry_filemode($entry); + } finally { + $this->ffi->git_tree_entry_free($entry); + } + } + + private function diffHasDeltas(?CData $oldTree, CData $newTree): bool + { + $diff = $this->ffi->new('git_diff*'); + LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_tree(\FFI::addr($diff), $this->repo, $oldTree, $newTree, \FFI::addr($this->diffOptions)), 'Unable to compute diff'); + + try { + return $this->ffi->git_diff_num_deltas($diff) > 0; + } finally { + $this->ffi->git_diff_free($diff); + } + } +} diff --git a/src/Gitonomy/Git/Driver/Ffi/libgit2.h b/src/Gitonomy/Git/Driver/Ffi/libgit2.h new file mode 100644 index 00000000..da14ee85 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/libgit2.h @@ -0,0 +1,486 @@ +/* + * This file is part of Gitonomy. + * + * (c) Alexandre Salomé + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + * + * Trimmed C declarations for libgit2 1.9, hand-maintained for use with + * PHP's FFI::cdef(). Struct layouts are transcribed from the real libgit2 + * 1.9 headers (git2/*.h) with GIT_EXPERIMENTAL_SHA256 unset (the default, + * plain SHA1 git_oid). libgit2 breaks its ABI on every minor release (struct + * layouts change while the *_OPTIONS_VERSION constants stay at 1), so + * LibGit2Driver refuses to load any other minor version. Function-pointer fields that this driver never + * populates (left zeroed) are declared as plain `void *` to avoid pulling + * in every callback typedef: this is safe for layout purposes since a + * function pointer and a data pointer have the same size/representation + * on every platform libgit2 supports. + */ + +typedef struct git_oid { unsigned char id[20]; } git_oid; + +typedef struct { char *ptr; size_t reserved; size_t size; } git_buf; + +typedef struct git_strarray { char **strings; size_t count; } git_strarray; + +typedef struct git_oidarray { git_oid *ids; size_t count; } git_oidarray; + +typedef struct git_time { int64_t time; int offset; char sign; } git_time; + +typedef struct git_signature { + char *name; + char *email; + git_time when; +} git_signature; + +typedef struct git_error { char *message; int klass; } git_error; + +typedef enum { + GIT_OBJECT_ANY = -2, GIT_OBJECT_INVALID = -1, + GIT_OBJECT_COMMIT = 1, GIT_OBJECT_TREE = 2, GIT_OBJECT_BLOB = 3, GIT_OBJECT_TAG = 4, + GIT_OBJECT_OFS_DELTA = 6, GIT_OBJECT_REF_DELTA = 7 +} git_object_t; + +typedef enum { + GIT_REFERENCE_INVALID = 0, GIT_REFERENCE_DIRECT = 1, GIT_REFERENCE_SYMBOLIC = 2, GIT_REFERENCE_ALL = 3 +} git_reference_t; + +typedef enum { + GIT_BRANCH_LOCAL = 1, GIT_BRANCH_REMOTE = 2, GIT_BRANCH_ALL = 3 +} git_branch_t; + +typedef enum { + GIT_FILEMODE_UNREADABLE = 0000000, GIT_FILEMODE_TREE = 0040000, + GIT_FILEMODE_BLOB = 0100644, GIT_FILEMODE_BLOB_EXECUTABLE = 0100755, + GIT_FILEMODE_LINK = 0120000, GIT_FILEMODE_COMMIT = 0160000 +} git_filemode_t; + +typedef struct git_repository git_repository; +typedef struct git_object git_object; +typedef struct git_commit git_commit; +typedef struct git_tree git_tree; +typedef struct git_tree_entry git_tree_entry; +typedef struct git_blob git_blob; +typedef struct git_tag git_tag; +typedef struct git_reference git_reference; +typedef struct git_reference_iterator git_reference_iterator; +typedef struct git_branch_iterator git_branch_iterator; +typedef struct git_revwalk git_revwalk; +typedef struct git_index git_index; +typedef struct git_diff git_diff; +typedef struct git_blame git_blame; +typedef struct git_status_list git_status_list; +typedef struct git_patch git_patch; +typedef struct git_remote git_remote; +typedef struct git_config git_config; + +/* -- global -- */ + +int git_libgit2_init(void); +int git_libgit2_version(int *major, int *minor, int *rev); + +/* -- buffers / signatures / arrays -- */ + +void git_buf_dispose(git_buf *buffer); +int git_signature_default(git_signature **out, git_repository *repo); +void git_signature_free(git_signature *sig); +void git_oidarray_dispose(git_oidarray *array); + +/* -- errors -- */ + +const git_error *git_error_last(void); + +/* -- oid -- */ + +int git_oid_fromstrn(git_oid *out, const char *str, size_t length); +char *git_oid_tostr(char *out, size_t n, const git_oid *id); +int git_oid_is_zero(const git_oid *id); + +/* -- repository -- */ + +int git_repository_open(git_repository **out, const char *path); +int git_repository_set_workdir(git_repository *repo, const char *workdir, int update_gitlink); +void git_repository_free(git_repository *repo); +int git_repository_init(git_repository **out, const char *path, unsigned is_bare); +int git_repository_head(git_reference **out, git_repository *repo); +int git_repository_head_detached(git_repository *repo); +int git_repository_set_head(git_repository *repo, const char *refname); +int git_repository_set_head_detached(git_repository *repo, const git_oid *committish); +int git_repository_config(git_config **out, git_repository *repo); + +/* -- config -- */ + +int git_config_set_string(git_config *cfg, const char *name, const char *value); +void git_config_free(git_config *cfg); + +/* -- object / commit / tree / blob / tag -- */ + +const git_oid *git_object_id(const git_object *obj); +git_object_t git_object_type(const git_object *obj); +int git_object_short_id(git_buf *out, const git_object *obj); +int git_object_peel(git_object **peeled, const git_object *object, git_object_t target_type); +void git_object_free(git_object *object); + +int git_commit_lookup_prefix(git_commit **commit, git_repository *repo, const git_oid *id, size_t len); +void git_commit_free(git_commit *commit); +const char *git_commit_message(const git_commit *commit); +const char *git_commit_message_encoding(const git_commit *commit); +const git_signature *git_commit_committer(const git_commit *commit); +const git_signature *git_commit_author(const git_commit *commit); +int git_commit_tree(git_tree **tree_out, const git_commit *commit); +const git_oid *git_commit_tree_id(const git_commit *commit); +unsigned int git_commit_parentcount(const git_commit *commit); +int git_commit_parent(git_commit **out, const git_commit *commit, unsigned int n); +const git_oid *git_commit_parent_id(const git_commit *commit, unsigned int n); +int git_commit_create( + git_oid *id, git_repository *repo, const char *update_ref, + const git_signature *author, const git_signature *committer, + const char *message_encoding, const char *message, + const git_tree *tree, size_t parent_count, const git_commit *parents[]); + +int git_tree_lookup_prefix(git_tree **out, git_repository *repo, const git_oid *id, size_t len); +void git_tree_free(git_tree *tree); +const git_oid *git_tree_id(const git_tree *tree); +int git_tree_entry_bypath(git_tree_entry **out, const git_tree *root, const char *path); +void git_tree_entry_free(git_tree_entry *entry); +size_t git_tree_entrycount(const git_tree *tree); +const git_tree_entry *git_tree_entry_byindex(const git_tree *tree, size_t idx); +const char *git_tree_entry_name(const git_tree_entry *entry); +const git_oid *git_tree_entry_id(const git_tree_entry *entry); +git_object_t git_tree_entry_type(const git_tree_entry *entry); +git_filemode_t git_tree_entry_filemode(const git_tree_entry *entry); + +int git_blob_lookup_prefix(git_blob **blob, git_repository *repo, const git_oid *id, size_t len); +void git_blob_free(git_blob *blob); +const void *git_blob_rawcontent(const git_blob *blob); +uint64_t git_blob_rawsize(const git_blob *blob); + +int git_tag_lookup(git_tag **out, git_repository *repo, const git_oid *id); +void git_tag_free(git_tag *tag); +const git_oid *git_tag_target_id(const git_tag *tag); +git_object_t git_tag_target_type(const git_tag *tag); +const char *git_tag_name(const git_tag *tag); +const git_signature *git_tag_tagger(const git_tag *tag); +const char *git_tag_message(const git_tag *tag); + +/* -- revparse / revwalk -- */ + +int git_revparse_single(git_object **out, git_repository *repo, const char *spec); + +int git_revwalk_new(git_revwalk **out, git_repository *repo); +int git_revwalk_push(git_revwalk *walk, const git_oid *id); +int git_revwalk_hide(git_revwalk *walk, const git_oid *commit_id); +int git_revwalk_next(git_oid *out, git_revwalk *walk); +int git_revwalk_sorting(git_revwalk *walk, unsigned int sort_mode); +void git_revwalk_free(git_revwalk *walk); + +/* -- refs / branch -- */ + +int git_reference_lookup(git_reference **out, git_repository *repo, const char *name); +int git_reference_resolve(git_reference **out, const git_reference *ref); +const git_oid *git_reference_target(const git_reference *ref); +git_reference_t git_reference_type(const git_reference *ref); +const char *git_reference_name(const git_reference *ref); +int git_reference_create(git_reference **out, git_repository *repo, const char *name, const git_oid *id, int force, const char *log_message); +int git_reference_delete(git_reference *ref); +void git_reference_free(git_reference *ref); +int git_reference_iterator_glob_new(git_reference_iterator **out, git_repository *repo, const char *glob); +int git_reference_next(git_reference **out, git_reference_iterator *iter); +void git_reference_iterator_free(git_reference_iterator *iter); +int git_reference_peel(git_object **out, const git_reference *ref, git_object_t type); + +int git_branch_create(git_reference **out, git_repository *repo, const char *branch_name, const git_commit *target, int force); +int git_branch_iterator_new(git_branch_iterator **out, git_repository *repo, git_branch_t list_flags); +int git_branch_next(git_reference **out, git_branch_t *out_type, git_branch_iterator *iter); +void git_branch_iterator_free(git_branch_iterator *iter); + +/* -- graph -- */ + +int git_graph_descendant_of(git_repository *repo, const git_oid *commit, const git_oid *ancestor); + +/* -- merge_base -- */ + +int git_merge_base(git_oid *out, git_repository *repo, const git_oid *one, const git_oid *two); +int git_merge_bases(git_oidarray *out, git_repository *repo, const git_oid *one, const git_oid *two); + +/* -- diff -- */ + +typedef struct { + git_oid id; + const char *path; + uint64_t size; + uint32_t flags; + uint16_t mode; + uint16_t id_abbrev; +} git_diff_file; + +typedef struct { + int status; + uint32_t flags; + uint16_t similarity; + uint16_t nfiles; + git_diff_file old_file; + git_diff_file new_file; +} git_diff_delta; + +typedef struct { + unsigned int version; + uint32_t flags; + int ignore_submodules; + git_strarray pathspec; + void *notify_cb; + void *progress_cb; + void *payload; + uint32_t context_lines; + uint32_t interhunk_lines; + int oid_type; + uint16_t id_abbrev; + int64_t max_size; + const char *old_prefix; + const char *new_prefix; +} git_diff_options; + +typedef struct { + int old_start; + int old_lines; + int new_start; + int new_lines; + size_t header_len; + char header[128]; +} git_diff_hunk; + +typedef struct { + char origin; + int old_lineno; + int new_lineno; + int num_lines; + size_t content_len; + int64_t content_offset; + /* Declared non-const (unlike the real libgit2 header) so PHP FFI does not + * auto-convert it to a NUL-terminated PHP string: this span is documented + * as *not* NUL-terminated, so it must be read with an explicit length via + * FFI::string($line->content, $line->content_len). */ + char *content; +} git_diff_line; + +typedef struct { + unsigned int version; + uint32_t flags; + uint16_t rename_threshold; + uint16_t rename_from_rewrite_threshold; + uint16_t copy_threshold; + uint16_t break_rewrite_threshold; + size_t rename_limit; + void *metric; +} git_diff_find_options; + +int git_diff_options_init(git_diff_options *opts, unsigned int version); +void git_diff_free(git_diff *diff); +int git_diff_tree_to_tree(git_diff **diff, git_repository *repo, git_tree *old_tree, git_tree *new_tree, const git_diff_options *opts); +int git_diff_tree_to_index(git_diff **diff, git_repository *repo, git_tree *old_tree, git_index *index, const git_diff_options *opts); +int git_diff_tree_to_workdir_with_index(git_diff **diff, git_repository *repo, git_tree *old_tree, const git_diff_options *opts); +int git_diff_index_to_workdir(git_diff **diff, git_repository *repo, git_index *index, const git_diff_options *opts); +int git_diff_find_options_init(git_diff_find_options *opts, unsigned int version); +int git_diff_find_similar(git_diff *diff, const git_diff_find_options *options); +size_t git_diff_num_deltas(const git_diff *diff); +const git_diff_delta *git_diff_get_delta(const git_diff *diff, size_t idx); + +/* -- patch -- */ + +int git_patch_from_diff(git_patch **out, git_diff *diff, size_t idx); +const git_diff_delta *git_patch_get_delta(const git_patch *patch); +size_t git_patch_num_hunks(const git_patch *patch); +int git_patch_get_hunk(const git_diff_hunk **out, size_t *lines_in_hunk, git_patch *patch, size_t hunk_idx); +int git_patch_get_line_in_hunk(const git_diff_line **out, git_patch *patch, size_t hunk_idx, size_t line_of_hunk); +void git_patch_free(git_patch *patch); + +/* -- blame -- */ + +typedef struct git_blame_options { + unsigned int version; + unsigned int flags; + uint16_t min_match_characters; + git_oid newest_commit; + git_oid oldest_commit; + size_t min_line; + size_t max_line; +} git_blame_options; + +typedef struct git_blame_hunk { + size_t lines_in_hunk; + git_oid final_commit_id; + size_t final_start_line_number; + git_signature *final_signature; + git_signature *final_committer; + git_oid orig_commit_id; + const char *orig_path; + size_t orig_start_line_number; + git_signature *orig_signature; + git_signature *orig_committer; + const char *summary; + char boundary; +} git_blame_hunk; + +/* ->ptr declared non-const so PHP FFI does not auto-convert it to a + * NUL-terminated string; read with FFI::string($line->ptr, $line->len). */ +typedef struct git_blame_line { char *ptr; size_t len; } git_blame_line; + +int git_blame_options_init(git_blame_options *opts, unsigned int version); +int git_blame_file(git_blame **out, git_repository *repo, const char *path, git_blame_options *options); +size_t git_blame_linecount(git_blame *blame); +const git_blame_hunk *git_blame_hunk_byline(git_blame *blame, size_t lineno); +const git_blame_line *git_blame_line_byindex(git_blame *blame, size_t idx); +void git_blame_free(git_blame *blame); + +/* -- status -- */ + +typedef struct { + unsigned int version; + int show; + unsigned int flags; + git_strarray pathspec; + git_tree *baseline; + uint16_t rename_threshold; +} git_status_options; + +typedef struct { + int status; + git_diff_delta *head_to_index; + git_diff_delta *index_to_workdir; +} git_status_entry; + +int git_status_options_init(git_status_options *opts, unsigned int version); +int git_status_list_new(git_status_list **out, git_repository *repo, const git_status_options *opts); +size_t git_status_list_entrycount(git_status_list *statuslist); +const git_status_entry *git_status_byindex(git_status_list *statuslist, size_t idx); +void git_status_list_free(git_status_list *statuslist); + +/* -- index -- */ + +void git_index_free(git_index *index); +int git_index_write_tree_to(git_oid *out, git_index *index, git_repository *repo); +int git_index_has_conflicts(const git_index *index); + +/* -- checkout -- */ + +typedef struct git_checkout_options { + unsigned int version; + unsigned int checkout_strategy; + int disable_filters; + unsigned int dir_mode; + unsigned int file_mode; + int file_open_flags; + unsigned int notify_flags; + void *notify_cb; + void *notify_payload; + void *progress_cb; + void *progress_payload; + git_strarray paths; + git_tree *baseline; + git_index *baseline_index; + const char *target_directory; + const char *ancestor_label; + const char *our_label; + const char *their_label; + void *perfdata_cb; + void *perfdata_payload; +} git_checkout_options; + +int git_checkout_options_init(git_checkout_options *opts, unsigned int version); +int git_checkout_tree(git_repository *repo, const git_object *treeish, const git_checkout_options *opts); + +/* -- merge -- */ + +typedef struct { + unsigned int version; + uint32_t flags; + unsigned int rename_threshold; + unsigned int target_limit; + void *metric; + unsigned int recursion_limit; + const char *default_driver; + int file_favor; + uint32_t file_flags; +} git_merge_options; + +int git_merge_options_init(git_merge_options *opts, unsigned int version); +int git_merge_commits(git_index **out, git_repository *repo, const git_commit *our_commit, const git_commit *their_commit, const git_merge_options *opts); + +/* -- clone / remote (network operations) -- */ + +typedef struct git_proxy_options { + unsigned int version; + int type; + const char *url; + void *credentials; + void *certificate_check; + void *payload; +} git_proxy_options; + +typedef struct { + unsigned int version; + void *sideband_progress; + void *completion; + void *credentials; + void *certificate_check; + void *transfer_progress; + void *update_tips; + void *pack_progress; + void *push_transfer_progress; + void *push_update_reference; + void *push_negotiation; + void *transport; + void *remote_ready; + void *payload; + void *resolve_url; + void *update_refs; +} git_remote_callbacks; + +typedef struct { + int version; + git_remote_callbacks callbacks; + int prune; + unsigned int update_fetchhead; + int download_tags; + git_proxy_options proxy_opts; + int depth; + int follow_redirects; + git_strarray custom_headers; +} git_fetch_options; + +typedef struct { + unsigned int version; + git_checkout_options checkout_opts; + git_fetch_options fetch_opts; + int bare; + int local; + const char *checkout_branch; + void *repository_cb; + void *repository_cb_payload; + void *remote_cb; + void *remote_cb_payload; +} git_clone_options; + +int git_clone_options_init(git_clone_options *options, unsigned int version); +int git_clone(git_repository **out, const char *url, const char *local_path, const git_clone_options *options); + +typedef struct git_remote_head { + int local; + git_oid oid; + git_oid loid; + char *name; + char *symref_target; +} git_remote_head; + +int git_fetch_options_init(git_fetch_options *opts, unsigned int version); + +int git_remote_create_anonymous(git_remote **out, git_repository *repo, const char *url); +int git_remote_create_detached(git_remote **out, const char *url); +int git_remote_default_branch(git_buf *out, git_remote *remote); +int git_remote_fetch(git_remote *remote, const git_strarray *refspecs, const git_fetch_options *opts, const char *reflog_message); +int git_remote_connect(git_remote *remote, int direction, const git_remote_callbacks *callbacks, const git_proxy_options *proxy_opts, const git_strarray *custom_headers); +int git_remote_ls(const git_remote_head ***out, size_t *size, git_remote *remote); +int git_remote_disconnect(git_remote *remote); +void git_remote_free(git_remote *remote); diff --git a/tests/Gitonomy/Git/Tests/AbstractTestCase.php b/tests/Gitonomy/Git/Tests/AbstractTestCase.php index 2a942a34..691ed5cd 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTestCase.php +++ b/tests/Gitonomy/Git/Tests/AbstractTestCase.php @@ -14,8 +14,11 @@ use Gitonomy\Git\Admin; use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use Gitonomy\Git\Repository; use PHPUnit\Framework\TestCase; +use Symfony\Component\Process\Process; abstract class AbstractTestCase extends TestCase { @@ -39,6 +42,14 @@ abstract class AbstractTestCase extends TestCase */ private static ?Repository $localRepository = null; + /** + * Materialized (unbundled) copy of REPOSITORY_URL, used in place of the + * raw .bundle file for drivers whose clone implementation doesn't + * understand the git bundle format (unlike the `git` CLI, which + * special-cases it) — currently LibGit2Driver. + */ + private static ?string $materializedRepositoryUrl = null; + /** * Creates an empty git repository and returns instance. */ @@ -79,7 +90,7 @@ public static function provideEmpty(): array public static function createFoobarRepository(bool $bare = true): Repository { if (null === self::$localRepository) { - self::$localRepository = Admin::cloneTo(self::createTempDir(), self::REPOSITORY_URL, $bare, driver: self::getDriver()); + self::$localRepository = Admin::cloneTo(self::createTempDir(), self::repositoryUrl(), $bare, driver: self::getDriver()); } $repository = self::$localRepository->cloneTo(self::createTempDir(), $bare); @@ -112,6 +123,28 @@ public static function createTempDir(): string return $tmpDir; } + /** + * Returns the source to clone the fixture from: the raw .bundle file for + * drivers that understand it (the `git` CLI does natively), or a + * one-time unbundled bare repository otherwise. + */ + protected static function repositoryUrl(): string + { + if (self::getDriver() instanceof CliDriver) { + return self::REPOSITORY_URL; + } + + if (null === self::$materializedRepositoryUrl) { + $dir = self::createTempDir(); + $command = $_SERVER['GIT_COMMAND'] ?? 'git'; + $process = new Process([$command, 'clone', '-q', '--bare', self::REPOSITORY_URL, $dir]); + $process->mustRun(); + self::$materializedRepositoryUrl = $dir; + } + + return self::$materializedRepositoryUrl; + } + /** * Deletes a directory recursively. */ @@ -135,14 +168,29 @@ protected static function deleteDir(string $dir): void } /** - * Runs a git command on $repository: for setup (committing, staging, - * tagging...) that no method of gitlib covers. + * Skips the current test under LibGit2Driver: use at the top of tests + * whose subject is the CLI-only Repository::run()/runProcess() escape + * hatch, or the configuration of CliDriver. For mere setup, use git(). + */ + protected function skipUnlessCliDriver(): void + { + if (!self::getDriver() instanceof CliDriver) { + self::markTestSkipped('This test is specific to CliDriver.'); + } + } + + /** + * Runs a git command on $repository through CliDriver, whatever the + * driver under test: for setup (committing, staging, tagging...) that no + * driver method covers. * * @param array $args */ protected static function git(Repository $repository, string $command, array $args = []): string { - return $repository->run($command, $args); + $cli = new Repository($repository->getPath(), driver: self::getCliDriver()); + + return $cli->run($command, $args); } /** @@ -165,10 +213,19 @@ protected static function writeObject(Repository $repository, string $type, stri return $hash; } + /** + * Returns the driver under test: LibGit2Driver when the GITLIB_DRIVER + * environment variable is "ffi", CliDriver otherwise. + */ + protected static function getDriver(): DriverInterface + { + return 'ffi' === ($_SERVER['GITLIB_DRIVER'] ?? 'cli') ? new LibGit2Driver() : self::getCliDriver(); + } + /** * @param array $environmentVariables Set on git processes, in addition to the GIT_ENVS environment variable */ - protected static function getDriver(array $environmentVariables = [], ?float $processTimeout = 60): CliDriver + protected static function getCliDriver(array $environmentVariables = [], ?float $processTimeout = 60): CliDriver { $envs = isset($_SERVER['GIT_ENVS']) ? (array) $_SERVER['GIT_ENVS'] : []; diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index 0e23ebc9..505b1570 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -93,7 +93,7 @@ public function testCloneBranchBare(): void // so we test it directly against the remote repository $newDir = self::createTempDir(); - $new = Admin::cloneBranchTo($newDir, self::REPOSITORY_URL, 'new-feature', true, driver: self::getDriver()); + $new = Admin::cloneBranchTo($newDir, self::repositoryUrl(), 'new-feature', true, driver: self::getDriver()); self::registerDeletion($new); $head = $new->getHead(); @@ -108,7 +108,7 @@ public function testCloneBranchNotBare(): void // so we test it directly against remote repository $newDir = self::createTempDir(); - $new = Admin::cloneBranchTo($newDir, self::REPOSITORY_URL, 'new-feature', false, driver: self::getDriver()); + $new = Admin::cloneBranchTo($newDir, self::repositoryUrl(), 'new-feature', false, driver: self::getDriver()); self::registerDeletion($new); $head = $new->getHead(); @@ -144,7 +144,7 @@ public function testCloneUnknownBranchFails(): void { $this->expectException(DriverException::class); - Admin::cloneBranchTo(self::createTempDir().'/clone', self::REPOSITORY_URL, 'unknown', true, driver: self::getDriver()); + Admin::cloneBranchTo(self::createTempDir().'/clone', self::repositoryUrl(), 'unknown', true, driver: self::getDriver()); } #[DataProvider('provideFoobar')] @@ -191,7 +191,7 @@ public function testCloneRepository(): void $newDir = self::createTempDir(); $args = []; - $new = Admin::cloneRepository($newDir, self::REPOSITORY_URL, $args, driver: self::getDriver()); + $new = Admin::cloneRepository($newDir, self::repositoryUrl(), $args, driver: self::getDriver()); self::registerDeletion($new); $newRefs = array_keys($new->getReferences()->getAll()); diff --git a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php new file mode 100644 index 00000000..f6c5fbe4 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php @@ -0,0 +1,70 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Process\Process; + +/** + * @author Grégoire Pineau + */ +class LibGit2DriverTest extends TestCase +{ + private const string AUTOLOAD = __DIR__.'/../../../../vendor/autoload.php'; + + #[RequiresPhpExtension('ffi')] + public function testRestrictedFfiApiIsReported(): void + { + // The setting only applies at startup: it needs a new PHP process. + $code = <<<'PHP' + require $argv[1]; + try { + new Gitonomy\Git\Driver\Ffi\LibGit2Driver(); + } catch (Gitonomy\Git\Exception\UnsupportedOperationException $e) { + echo $e->getMessage(); + } + PHP; + + $process = new Process([\PHP_BINARY, '-d', 'ffi.enable=0', '-r', $code, self::AUTOLOAD]); + $process->mustRun(); + + $this->assertSame('The "ffi.enable" setting restricts the FFI API: call LibGit2Driver::preload() from the opcache.preload script, set "ffi.enable" to "true", or use CliDriver.', $process->getOutput()); + } + + #[RequiresPhpExtension('ffi')] + #[RequiresPhpExtension('Zend OPcache')] + public function testPreloadKeepsLibgit2LoadedForTheProcess(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + + $preload = AbstractTestCase::createTempDir().'/preload.php'; + file_put_contents($preload, \sprintf('getHeadCommit()->getHash(); + PHP; + + $repository = AbstractTestCase::createFoobarRepository(); + $process = new Process([\PHP_BINARY, '-d', 'opcache.enable_cli=1', '-d', 'opcache.preload='.$preload, '-r', $code, self::AUTOLOAD, $repository->getGitDir()]); + $process->mustRun(); + + $this->assertSame($repository->getHeadCommit()->getHash(), $process->getOutput()); + } +} diff --git a/tests/Gitonomy/Git/Tests/LibGit2HeaderTest.php b/tests/Gitonomy/Git/Tests/LibGit2HeaderTest.php new file mode 100644 index 00000000..2f4fff94 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/LibGit2HeaderTest.php @@ -0,0 +1,38 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use PHPUnit\Framework\TestCase; + +/** + * PHPStan can't analyse FFI calls: this catches a libgit2 function called + * without being declared in libgit2.h (which only fails at runtime), and + * declarations left unused (each one must exist in the loaded libgit2). + */ +class LibGit2HeaderTest extends TestCase +{ + private const string FFI_DIR = __DIR__.'/../../../../src/Gitonomy/Git/Driver/Ffi'; + + public function testCalledFunctionsAreTheDeclaredOnes(): void + { + preg_match_all('/->(git_\w+)\(/', implode('', array_map(file_get_contents(...), glob(self::FFI_DIR.'/*.php'))), $called); + preg_match_all('/^[\w ]+?\**\b(git_\w+)\(/m', file_get_contents(self::FFI_DIR.'/libgit2.h'), $declared); + + $called = array_values(array_unique($called[1])); + $declared = array_values(array_unique($declared[1])); + sort($called); + sort($declared); + + $this->assertSame($declared, $called); + } +} diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index d7ad2029..4f72c18c 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -30,8 +30,10 @@ class RepositoryTest extends AbstractTestCase { public function testNullProcessTimeoutDisablesTheTimeout(): void { + $this->skipUnlessCliDriver(); + $repository = self::createFoobarRepository(true); - $repository = new Repository($repository->getPath(), driver: self::getDriver(processTimeout: null)); + $repository = new Repository($repository->getPath(), driver: self::getCliDriver(processTimeout: null)); $process = $repository->runProcess('rev-parse', ['HEAD']); $this->assertNull($process->getTimeout()); @@ -40,6 +42,8 @@ public function testNullProcessTimeoutDisablesTheTimeout(): void public function testRunThrowsWhenTheCommandFails(): void { + $this->skipUnlessCliDriver(); + $repository = self::createFoobarRepository(true); $this->expectException(ProcessException::class); @@ -49,6 +53,8 @@ public function testRunThrowsWhenTheCommandFails(): void public function testRunProcessGivesAccessToStderrOnSuccess(): void { + $this->skipUnlessCliDriver(); + $repository = self::createFoobarRepository(false); // `git checkout` reports the switched branch on stderr, even on success. @@ -105,6 +111,8 @@ public function testGetDescription(Repository $repository): void #[DataProvider('provideFoobar')] public function testLoggerOk(Repository $repository): void { + $this->skipUnlessCliDriver(); + $logger = $this->createMock(LoggerInterface::class); $logger->expects($this->once()) ->method('info') @@ -123,6 +131,8 @@ public function testLoggerOk(Repository $repository): void #[DataProvider('provideFoobar')] public function testLoggerNOk(Repository $repository): void { + $this->skipUnlessCliDriver(); + $this->expectException(RuntimeException::class); $logger = $this->createMock(LoggerInterface::class); @@ -149,6 +159,8 @@ public function testLoggerNOk(Repository $repository): void */ public function testRunResolvesRelativePathsAgainstTheRepositoryRegardlessOfCwd(): void { + $this->skipUnlessCliDriver(); + $repository = self::createFoobarRepository(false); $file = $repository->getWorkingDir().'/README.md'; diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index dc92a30e..5926f05d 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -75,10 +75,12 @@ public function testDiffPending(): void public function testDiffPendingWithAbbreviatedHashesLikeOldGit(): void { + $this->skipUnlessCliDriver(); + $repository = self::createFoobarRepository(false); file_put_contents($repository->getWorkingDir().'/test.sh', 'test'); // Before 2.16, git always wrote "..." after the abbreviated hashes of --raw. - $repository = new Repository($repository->getPath(), driver: self::getDriver(['GIT_PRINT_SHA1_ELLIPSIS' => 'yes'])); + $repository = new Repository($repository->getPath(), driver: self::getCliDriver(['GIT_PRINT_SHA1_ELLIPSIS' => 'yes'])); $files = $repository->getWorkingCopy()->getDiffPending()->getFiles(); From 49720b2c63203adcbd3335df42d7f33c5e2e08a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 09:17:29 +0200 Subject: [PATCH 087/150] Deprecate Admin and the git options of Repository Admin only forwarded to the driver: Repository gets static methods instead, one for each kind of repository rather than a $bare argument, taking the URL before the path: init(), initBare(), cloneFrom(), cloneBareFrom(), mirrorFrom(), isValidRepository() and isValidRepositoryAndBranch(). Repository::cloneTo() is deprecated too. DriverInterface replaces cloneRepository() and its arguments of git clone with cloneFrom(), cloneBareFrom() and mirrorFrom(). Like Symfony does, the API of 1.x is deprecated instead of removed, until 3.0: Admin keeps its signatures of 1.x, and the "command", "environment_variables", "inherit_environment_variables" and "process_timeout" options of Repository and Admin configure a CliDriver again, through the internal Driver\Cli\LegacyOptions. The "debug" option is deprecated, and refused when false. Each deprecation names the exact replacement, through symfony/deprecation-contracts. A test calling deprecated code fails, unless it is #[IgnoreDeprecations]: the legacy tests check every deprecation message, and that Admin behaves like in 1.x. --- CHANGELOG.md | 29 ++- README.md | 2 +- UPGRADE-2.0.md | 67 ++++- composer.json | 1 + doc/admin.md | 93 ++++--- doc/repository.md | 9 +- phpstan-baseline.neon | 48 ---- phpunit.xml.dist | 5 +- src/Gitonomy/Git/Admin.php | 145 +++++++---- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 26 +- src/Gitonomy/Git/Driver/Cli/LegacyOptions.php | 71 +++++ src/Gitonomy/Git/Driver/DriverInterface.php | 28 +- src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 77 +++--- src/Gitonomy/Git/Repository.php | 141 +++++++++- tests/Gitonomy/Git/Tests/AbstractTestCase.php | 17 +- tests/Gitonomy/Git/Tests/AdminTest.php | 246 +++++++++--------- tests/Gitonomy/Git/Tests/DiffTest.php | 18 ++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 228 +++++++++++++++- tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 3 +- 19 files changed, 903 insertions(+), 351 deletions(-) create mode 100644 src/Gitonomy/Git/Driver/Cli/LegacyOptions.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0718da78..4a62e6a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,15 +11,19 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). - Add an experimental `Driver\Ffi\LibGit2Driver`, running libgit2 1.9 in-process through PHP's FFI extension, as an alternative to the default `Driver\Cli\CliDriver`: pass it as the driver of - `Repository`/`Admin`. It avoids spawning a `git` process for every operation; see + `Repository`. It avoids spawning a `git` process for every operation; see ["Choosing a driver"](doc/repository.md#choosing-a-driver) for its requirements, its differences with `CliDriver`, and when it is faster. `benchmark.php` compares both drivers on a repository. With php-fpm, `LibGit2Driver::preload()` keeps libgit2 loaded from `opcache.preload`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- `Repository` and `Admin` take the driver running git as their last argument: a +- `Repository` takes the driver running git as its last argument: a `Driver\Cli\CliDriver` by default, configured through its constructor, or any `Driver\DriverInterface`, to replace it in tests; see ["Using your own driver"](doc/repository.md#using-your-own-driver). ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Add static methods to `Repository` creating repositories, `init()`, `initBare()`, + `cloneFrom()`, `cloneBareFrom()` and `mirrorFrom()`, and checking a repository URL, + `isValidRepository()` and `isValidRepositoryAndBranch()`; see + ["Create and access git repositories"](doc/admin.md). - Add `WorkingCopy::merge()` to support `git merge`: accepts a `Commit`, `Reference` or string revision, plus optional extra CLI args (e.g. `['--no-ff']`), mirroring the existing `checkout()` method. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) @@ -38,6 +42,17 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). still exists afterward. ([#244](https://github.com/gitonomy/gitlib/pull/244), fixes #14) +### Deprecations + +- Deprecate `Admin`, use the static methods of `Repository` instead, and + `Repository::cloneTo()`, use `Repository::cloneFrom()` or `Repository::cloneBareFrom()` + instead; see [UPGRADE-2.0.md](UPGRADE-2.0.md#creating-repositories). +- Deprecate the `command`, `environment_variables`, `inherit_environment_variables` and + `process_timeout` options of `Repository` and `Admin`, pass a `Driver\Cli\CliDriver` + configured through its arguments as the driver of `Repository` instead, and the `debug` + option, as a failing operation always throws; see + [UPGRADE-2.0.md](UPGRADE-2.0.md#configuring-git). + ### Bug fixes - Fix `Commit::getDiff()` returning an empty diff for a root commit: it now lists the files the @@ -93,12 +108,10 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). instead of building processes and parsing their output across the domain classes. `CliDriver`, the default, keeps running the `git` binary. **Breaking** (see [UPGRADE-2.0.md](UPGRADE-2.0.md) for the complete list): - - The `command`, `environment_variables`, `inherit_environment_variables` and - `process_timeout` options are replaced by the arguments of `CliDriver`, and `Repository` - throws on unknown options. - - The `debug` option is removed: a failing operation always throws, an - `Exception\DriverException` (`ProcessException` with `CliDriver`) or a more specific - exception, and never returns empty data. `Repository::run()` returns a `string`, and + - `Repository` throws on unknown options. + - The `debug` option set to `false` is not supported anymore: a failing operation always + throws, an `Exception\DriverException` (`ProcessException` with `CliDriver`) or a more + specific exception, and never returns empty data. `Repository::run()` returns a `string`, and `Repository::getHead()` and `Repository::getHeadCommit()` never return `null`. `Commit::getIncludingBranches()`, `Tag::isAnnotated()` and `ReferenceBag` throw where they returned `false` or an empty result. Failures are no longer logged at the `error` level. diff --git a/README.md b/README.md index 4a2e7a30..790ce5f8 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,11 @@ $repository->run('fetch', ['--all']); API Documentation ----------------- -+ [Admin](doc/admin.md) + [Blame](doc/blame.md) + [Blob](doc/blob.md) + [Branch](doc/branch.md) + [Commit](doc/commit.md) ++ [Creating repositories](doc/admin.md) + [Diff](doc/diff.md) + [Hooks](doc/hooks.md) + [Log](doc/log.md) diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 0a9dc002..4377133e 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -17,15 +17,17 @@ Requirements git simplifies the log of a range filtered by path differently, and it can't count commits (`Log::count()`) before 1.7.2. * `symfony/process` must be `^6.4`, `^7.4` or `^8.1`. - * `symfony/string` `^7.4.6` or `^8.0.6` is a new dependency. + * `symfony/string` `^7.4.6` or `^8.0.6`, and `symfony/deprecation-contracts` + `^2.5` or `^3`, are new dependencies. Configuring git --------------- * The `command`, `environment_variables`, `inherit_environment_variables` - and `process_timeout` options of `Repository` and `Admin` are replaced by - the arguments of `Gitonomy\Git\Driver\Cli\CliDriver`, which - `Repository` and `Admin` take as their last argument. + and `process_timeout` options of `Repository` and `Admin` are deprecated, + and will be removed in 3.0: pass a `Gitonomy\Git\Driver\Cli\CliDriver` + configured through its arguments as the last argument of `Repository` + instead. They can't be combined with this argument. Before: @@ -44,15 +46,64 @@ Configuring git $repository = new Repository($path, driver: new CliDriver(command: '/usr/bin/git', processTimeout: 60)); ``` - * `Admin::isValidRepository()` and `Admin::isValidRepositoryAndBranch()` - take the driver instead of options as their last argument. * `Repository` throws an `InvalidArgumentException` for an unknown option, - like the removed ones, instead of ignoring it. + instead of ignoring it. + +Creating repositories +--------------------- + + * `Admin` is deprecated, and will be removed in 3.0: use the static methods + of `Repository`. They take the URL before the path, and have a method for + each kind of repository instead of the `$bare` argument, whose default + was a bare repository: + + | 1.x | 2.0 | + |------------------------------------------------------|--------------------------------------------------------------| + | `Admin::init($path)` | `Repository::initBare($path)` | + | `Admin::init($path, false)` | `Repository::init($path)` | + | `Admin::cloneTo($path, $url)` | `Repository::cloneBareFrom($url, $path)` | + | `Admin::cloneTo($path, $url, false)` | `Repository::cloneFrom($url, $path)` | + | `Admin::cloneBranchTo($path, $url, $branch)` | `Repository::cloneBareFrom($url, $path, $branch)` | + | `Admin::cloneBranchTo($path, $url, $branch, false)` | `Repository::cloneFrom($url, $path, $branch)` | + | `Admin::mirrorTo($path, $url)` | `Repository::mirrorFrom($url, $path)` | + | `Admin::isValidRepository($url)` | `Repository::isValidRepository($url)` | + | `Admin::isValidRepositoryAndBranch($url, $branch)` | `Repository::isValidRepositoryAndBranch($url, $branch)` | + | `$repository->cloneTo($path)` | `Repository::cloneBareFrom($repository->getGitDir(), $path)` | + | `$repository->cloneTo($path, false)` | `Repository::cloneFrom($repository->getGitDir(), $path)` | + + Until then, `Admin` keeps working like in 1.x, with `CliDriver`. + * The new methods take the logger of the repository instead of options, + and the driver as their last argument: + + Before: + + ```php + $repository = Admin::cloneTo($path, $url, false, [ + 'logger' => $logger, + 'process_timeout' => 60, + ]); + ``` + + After: + + ```php + use Gitonomy\Git\Driver\Cli\CliDriver; + + $repository = Repository::cloneFrom($url, $path, logger: $logger, driver: new CliDriver(processTimeout: 60)); + ``` + + * `Repository::cloneTo()` used the driver of the repository: pass it to + `Repository::cloneFrom()` or `Repository::cloneBareFrom()` as well. + * `Admin::cloneRepository()` is deprecated, with no replacement for the + arguments of `git clone` other than `--bare`, `--branch` and `--mirror`: + run `git clone` yourself, then open the repository with + `new Repository($path)`. Errors ------ - * The `debug` option of `Repository` is removed: a failing git operation + * The `debug` option of `Repository` is deprecated, and setting it to + `false` throws an `InvalidArgumentException`: a failing git operation always throws, a `Gitonomy\Git\Exception\ProcessException` with `CliDriver`. Nothing returns `null` or empty data instead anymore: `Repository::run()` returns a `string`, and `Repository::getHead()` and diff --git a/composer.json b/composer.json index 1b628125..6f8fd78f 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "require": { "php": "^8.4", "ext-pcre": "*", + "symfony/deprecation-contracts": "^2.5 || ^3", "symfony/process": "^6.4 || ^7.4 || ^8.1", "symfony/string": "^7.4.6 || ^8.0.6" }, diff --git a/doc/admin.md b/doc/admin.md index dbf1c599..be38124f 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -1,24 +1,22 @@ Create and access git repositories ================================== -gitlib provides methods to initialize new repositories. +The static methods of `Repository` create new repositories, and return them. +They replace `Admin`, deprecated since 2.0: see +[UPGRADE-2.0.md](../UPGRADE-2.0.md#creating-repositories). Create a repository ------------------- -To initialize a new repository, use method `Admin::init`. - ```php -// Initialize a bare repository -$repository = Gitonomy\Git\Admin::init('/path/to/repository'); +use Gitonomy\Git\Repository; -// Initialize a non-bare repository -$repository = Gitonomy\Git\Admin::init('/path/to/repository', false); -``` +// Initialize a repository with a working directory +$repository = Repository::init('/path/to/repository'); -Default behavior is to create a bare repository. If you want to -initialize a repository with a working copy,pass `false` as third -argument of Repository constructor. +// Initialize a bare repository +$repository = Repository::initBare('/path/to/repository'); +``` Cloning repositories -------------------- @@ -26,62 +24,71 @@ Cloning repositories You can clone a repository from an URL by doing: ```php -// Clone to a bare repository -$repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git'); +// Clone with a working directory +$repository = Repository::cloneFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib'); -// Clone to a non-bare repository -$repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', false); +// Clone to a bare repository +$repository = Repository::cloneBareFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib'); ``` -Default behavior is to clone in a bare repository. - -You can also clone a repository and point it to a specific branch. In a -non-bare repository, this branch will be checked out: +You can also clone a repository and point it to a specific branch. With a +working directory, this branch will be checked out: ```php -// Clone to a bare repository -$repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch'); - -// Clone to a non-bare repository -$repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch', false); +$repository = Repository::cloneFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib', 'a-branch'); ``` -Clone a Repository object -------------------------- +To clone a `Repository` object, use its git directory as URL: -If you already have a Repository instance and want to clone it, you can -use this shortcut: +```php +$new = Repository::cloneBareFrom($repository->getGitDir(), '/tmp/clone'); +``` + +These methods support no other argument of `git clone`. With `CliDriver`, +run `git clone` yourself, then open the repository: ```php -$new = $repository->cloneTo('/tmp/clone'); +use Symfony\Component\Process\Process; + +new Process(['git', 'clone', '--depth', '1', $url, '/tmp/gitlib'])->mustRun(); + +$repository = new Repository('/tmp/gitlib'); ``` Mirror a repository ------------------- If you want to mirror fully a repository and all references, use the -`mirrorTo` method. This method takes only two arguments, where to mirror -and what to mirror: +`mirrorFrom` method. It clones into a bare repository, set up to mirror the +remote when fetching, like `git clone --mirror`: ```php -// Mirror to a bare repository -$mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git'); +$mirror = Repository::mirrorFrom('https://github.com/gitonomy/gitlib.git', '/tmp/mirror'); +``` + +Check a repository URL +---------------------- + +You can check that a repository exists, and that it has a branch, without +cloning it: -// Mirror to a non-bare repository -$mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git', false); +```php +Repository::isValidRepository('https://github.com/gitonomy/gitlib.git'); // true +Repository::isValidRepositoryAndBranch('https://github.com/gitonomy/gitlib.git', 'main'); // true ``` -Options -------- +They return `false` as well for a repository that can't be reached, or that +refuses access. + +Logger and driver +----------------- -Every method on this page also accepts the driver running git as its last -argument, and the methods returning a `Repository` the same `$options` array -as its constructor before it — see -[Repository options](repository.md#options-for-repository), notably -["Choosing a driver"](repository.md#choosing-a-driver). +The methods returning a `Repository` take its [logger](repository.md#logger), +and all of them the driver running git as their last argument — see +["Choosing a driver"](repository.md#choosing-a-driver): ```php -$repository = Gitonomy\Git\Admin::init('/path/to/repository', true, driver: new Gitonomy\Git\Driver\Ffi\LibGit2Driver()); +$repository = Repository::initBare('/path/to/repository', driver: new Gitonomy\Git\Driver\Ffi\LibGit2Driver()); ``` ### References diff --git a/doc/repository.md b/doc/repository.md index 0eae032a..78788173 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -30,7 +30,10 @@ $repository = new Repository('/path/to/repo', [ ``` Its last parameter, `$driver`, is the backend running git operations: see -"Choosing a driver" below. +"Choosing a driver" below. The `command`, `environment_variables`, +`inherit_environment_variables` and `process_timeout` options of 1.x are +deprecated in favor of the arguments of `CliDriver`: see +["Configuring `CliDriver`"](#configuring-clidriver). Test if a repository is bare ---------------------------- @@ -100,7 +103,7 @@ $repository = new Repository('/path/to/repo', driver: new LibGit2Driver()); > tests don't cover. A driver holds no state of a repository: create it once, and pass it to -every `Repository` and `Admin` call, as a service of your container for +every `Repository` you open or create, as a service of your container for instance. `LibGit2Driver` avoids spawning a process for every operation. It requires @@ -134,8 +137,6 @@ Differences with `CliDriver`: instead of leaving conflict markers in the working copy. - `WorkingCopy::checkout()` doesn't create a tracking branch for a name that only exists on a remote, like `git checkout` does: it throws. -- `Admin::cloneRepository()` only supports the `--bare`, `--mirror` and - `--branch` arguments. - Rename detection in diffs may pair files differently. - Blame line ranges are limited to `,` and `,+`. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c9c65f94..00a93fc2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,41 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Method Gitonomy\\Git\\Admin\:\:cloneBranchTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:cloneRepository\(\) has parameter \$args with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:cloneRepository\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:cloneTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:init\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:mirrorTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - message: '#^Method Gitonomy\\Git\\Blame\:\:getGroupedLines\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -168,18 +132,6 @@ parameters: count: 1 path: src/Gitonomy/Git/ReferenceBag.php - - - message: '#^Method Gitonomy\\Git\\Repository\:\:__construct\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:cloneTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - message: '#^Method Gitonomy\\Git\\Repository\:\:getDescription\(\) should return string but returns string\|false\.$#' identifier: return.type diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b6df36eb..a08183b3 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -3,6 +3,8 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="tests/bootstrap.php" colors="true" + displayDetailsOnTestsThatTriggerDeprecations="true" + failOnDeprecation="true" failOnRisky="true" failOnWarning="true" beStrictAboutOutputDuringTests="true" @@ -14,7 +16,8 @@ ./tests/Gitonomy/Git/Tests - + + ./src/Gitonomy/Git diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 6744a488..309eef21 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -13,34 +13,46 @@ namespace Gitonomy\Git; use Gitonomy\Git\Driver\Cli\CliDriver; -use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\Cli\LegacyOptions; /** * Administration class for Git repositories. * + * Its methods behave like in 1.x: the git options of 1.x (command, + * environment_variables, inherit_environment_variables and process_timeout) + * configure the CliDriver they run, and the repositories they return. + * * @author Alexandre Salomé + * + * @deprecated since gitonomy/gitlib 2.0, use the static methods of Repository instead + * + * @phpstan-import-type Options from Repository */ final class Admin { /** * Initializes a repository and returns the instance. * - * @param string $path path to the repository - * @param bool $bare indicate to create a bare repository - * @param array $options options for Repository creation - * @param DriverInterface $driver backend used to run operations + * @param string $path path to the repository + * @param bool $bare indicate to create a bare repository + * @param Options $options options for Repository creation * * @throws Exception\DriverException Directory exists or not writable + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::initBare() or Repository::init() instead */ - public static function init(string $path, bool $bare = true, array $options = [], DriverInterface $driver = new CliDriver()): Repository + public static function init(string $path, bool $bare = true, array $options = []): Repository { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" instead.', __METHOD__, Repository::class, $bare ? 'initBare' : 'init'); + + $driver = LegacyOptions::createDriver($options); if ($bare) { $driver->initBare($path); } else { $driver->init($path); } - return new Repository($path, $options, $driver); + return self::createRepository($path, $options, $driver); } /** @@ -50,14 +62,18 @@ public static function init(string $path, bool $bare = true, array $options = [] * Usually, this command returns 0 when successful, and 128 when the * repository is not found. * - * @param string $url url of repository to check - * @param DriverInterface $driver backend used to run operations + * @param string $url url of repository to check + * @param Options $options options for Repository creation * * @return bool true if url is valid + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::isValidRepository() instead */ - public static function isValidRepository(string $url, DriverInterface $driver = new CliDriver()): bool + public static function isValidRepository(string $url, array $options = []): bool { - return $driver->isValidRepository($url); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class); + + return LegacyOptions::createDriver($options)->isValidRepository($url); } /** @@ -68,79 +84,114 @@ public static function isValidRepository(string $url, DriverInterface $driver = * Usually, this command returns 0 when successful, and 128 when the * repository is not found. * - * @param string $url url of repository to check - * @param string $branchName name of branch to check - * @param DriverInterface $driver backend used to run operations + * @param string $url url of repository to check + * @param string $branchName name of branch to check + * @param Options $options options for Repository creation * * @return bool true if url is valid and branch exists + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::isValidRepositoryAndBranch() instead */ - public static function isValidRepositoryAndBranch(string $url, string $branchName, DriverInterface $driver = new CliDriver()): bool + public static function isValidRepositoryAndBranch(string $url, string $branchName, array $options = []): bool { - return $driver->isValidRepositoryAndBranch($url, $branchName); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class); + + return LegacyOptions::createDriver($options)->isValidRepositoryAndBranch($url, $branchName); } /** * Clone a repository to a local path. * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param bool $bare indicates if repository should be bare or have a working copy - * @param array $options options for Repository creation - * @param DriverInterface $driver backend used to run operations + * @param string $path indicates where to clone repository + * @param string $url url of repository to clone + * @param bool $bare indicates if repository should be bare or have a working copy + * @param Options $options options for Repository creation + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() instead */ - public static function cloneTo(string $path, string $url, bool $bare = true, array $options = [], DriverInterface $driver = new CliDriver()): Repository + public static function cloneTo(string $path, string $url, bool $bare = true, array $options = []): Repository { - $args = $bare ? ['--bare'] : []; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + + $driver = LegacyOptions::createDriver($options); + if ($bare) { + $driver->cloneBareFrom($url, $path, null); + } else { + $driver->cloneFrom($url, $path, null); + } - return self::cloneRepository($path, $url, $args, $options, $driver); + return self::createRepository($path, $options, $driver); } /** * Clone a repository branch to a local path. * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param string $branch branch to clone - * @param bool $bare indicates if repository should be bare or have a working copy - * @param array $options options for Repository creation - * @param DriverInterface $driver backend used to run operations + * @param string $path indicates where to clone repository + * @param string $url url of repository to clone + * @param string $branch branch to clone + * @param bool $bare indicates if repository should be bare or have a working copy + * @param Options $options options for Repository creation + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() with their "branch" argument instead */ - public static function cloneBranchTo(string $path, string $url, string $branch, bool $bare = true, array $options = [], DriverInterface $driver = new CliDriver()): Repository + public static function cloneBranchTo(string $path, string $url, string $branch, bool $bare = true, array $options = []): Repository { - $args = ['--branch', $branch]; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + + $driver = LegacyOptions::createDriver($options); if ($bare) { - $args[] = '--bare'; + $driver->cloneBareFrom($url, $path, $branch); + } else { + $driver->cloneFrom($url, $path, $branch); } - return self::cloneRepository($path, $url, $args, $options, $driver); + return self::createRepository($path, $options, $driver); } /** * Mirrors a repository (fetch all revisions, not only branches). * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param array $options options for Repository creation - * @param DriverInterface $driver backend used to run operations + * @param string $path indicates where to clone repository + * @param string $url url of repository to clone + * @param Options $options options for Repository creation + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::mirrorFrom() instead */ - public static function mirrorTo(string $path, string $url, array $options = [], DriverInterface $driver = new CliDriver()): Repository + public static function mirrorTo(string $path, string $url, array $options = []): Repository { - return self::cloneRepository($path, $url, ['--mirror'], $options, $driver); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class); + + $driver = LegacyOptions::createDriver($options); + $driver->mirrorFrom($url, $path); + + return self::createRepository($path, $options, $driver); } /** * Internal method to launch effective ``git clone`` command. * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param array $args arguments to be added to the command-line - * @param array $options options for Repository creation - * @param DriverInterface $driver backend used to run operations + * @param string $path indicates where to clone repository + * @param string $url url of repository to clone + * @param string[] $args arguments to be added to the command-line + * @param Options $options options for Repository creation + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneFrom(), Repository::cloneBareFrom() or Repository::mirrorFrom() instead */ - public static function cloneRepository(string $path, string $url, array $args = [], array $options = [], DriverInterface $driver = new CliDriver()): Repository + public static function cloneRepository(string $path, string $url, array $args = [], array $options = []): Repository { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class); + + $driver = LegacyOptions::createDriver($options); $driver->cloneRepository($path, $url, $args); - return new Repository($path, $options, $driver); + return self::createRepository($path, $options, $driver); + } + + /** + * @param Options $options + */ + private static function createRepository(string $path, array $options, CliDriver $driver): Repository + { + return new Repository($path, LegacyOptions::remove($options), $driver); } } diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index fd65eca6..a6f44844 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -55,9 +55,19 @@ public function initBare(string $path): void $this->mustRun(['init', '-q', '--bare', $path]); } - public function cloneRepository(string $path, string $url, array $args): void + public function cloneFrom(string $url, string $path, ?string $branch): void { - $this->mustRun(['clone', '-q', ...$args, $url, $path]); + $this->cloneRepository($path, $url, null === $branch ? [] : ['--branch', $branch]); + } + + public function cloneBareFrom(string $url, string $path, ?string $branch): void + { + $this->cloneRepository($path, $url, null === $branch ? ['--bare'] : ['--branch', $branch, '--bare']); + } + + public function mirrorFrom(string $url, string $path): void + { + $this->cloneRepository($path, $url, ['--mirror']); } public function isValidRepository(string $url): bool @@ -73,6 +83,18 @@ public function isValidRepositoryAndBranch(string $url, string $branchName): boo return 0 === $process->run() && '' !== $process->getOutput(); } + /** + * @internal For the deprecated Admin::cloneRepository(), to be removed in 3.0 + * + * @param string[] $args Arguments of ``git clone`` + * + * @throws ProcessException + */ + public function cloneRepository(string $path, string $url, array $args): void + { + $this->mustRun(['clone', '-q', ...$args, $url, $path]); + } + /** * @param string[] $args */ diff --git a/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php b/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php new file mode 100644 index 00000000..746cb610 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php @@ -0,0 +1,71 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli; + +use Gitonomy\Git\Repository; + +/** + * The options of Repository and Admin configuring git in 1.x, deprecated in + * favor of the arguments of CliDriver. + * + * @author Grégoire Pineau + * + * @internal To be removed in 3.0, with these options + * + * @phpstan-import-type Options from Repository + */ +final class LegacyOptions +{ + private const array NAMES = ['command', 'environment_variables', 'inherit_environment_variables', 'process_timeout']; + + /** + * @param Options $options + * + * @return list The names of the options configuring git in $options + */ + public static function find(array $options): array + { + return array_values(array_intersect(array_keys($options), self::NAMES)); + } + + /** + * @param Options $options + * + * @return Options $options without the options configuring git + */ + public static function remove(array $options): array + { + foreach (self::NAMES as $name) { + unset($options[$name]); + } + + return $options; + } + + /** + * Creates the CliDriver the options of $options configure, with the + * defaults of 1.x. + * + * @param Options $options + */ + public static function createDriver(array $options): CliDriver + { + return new CliDriver( + $options['command'] ?? 'git', + $options['environment_variables'] ?? [], + $options['inherit_environment_variables'] ?? false, + // null disables the timeout. + \array_key_exists('process_timeout', $options) ? $options['process_timeout'] : 3600, + ); + } +} diff --git a/src/Gitonomy/Git/Driver/DriverInterface.php b/src/Gitonomy/Git/Driver/DriverInterface.php index d88cde6d..d786384d 100644 --- a/src/Gitonomy/Git/Driver/DriverInterface.php +++ b/src/Gitonomy/Git/Driver/DriverInterface.php @@ -14,15 +14,14 @@ use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\DriverException; -use Gitonomy\Git\Exception\UnsupportedOperationException; use Psr\Log\LoggerInterface; /** * A configured git backend: opens repositories, and runs the operations that * need no repository yet. * - * Pass one to Repository and Admin, which use {@see CliDriver} by default, - * to configure the backend, or to replace it in tests. + * Pass one to Repository, which uses {@see CliDriver} by default, to + * configure the backend, or to replace it in tests. * * @author Grégoire Pineau */ @@ -44,12 +43,29 @@ public function init(string $path): void; public function initBare(string $path): void; /** - * @param string[] $args Arguments of ``git clone``: ``--bare``, ``--mirror`` and ``--branch `` are supported by every driver + * Clones $url with a working directory in $path, checking out $branch, + * or the default branch of $url when null. * * @throws DriverException - * @throws UnsupportedOperationException An argument the driver doesn't support */ - public function cloneRepository(string $path, string $url, array $args): void; + public function cloneFrom(string $url, string $path, ?string $branch): void; + + /** + * Clones the branches and the tags of $url into a bare repository in + * $path, with HEAD pointing to $branch, or to the default branch of $url + * when null. + * + * @throws DriverException + */ + public function cloneBareFrom(string $url, string $path, ?string $branch): void; + + /** + * Clones every reference of $url into a bare repository in $path, set up + * to mirror $url when fetching. + * + * @throws DriverException + */ + public function mirrorFrom(string $url, string $path): void; public function isValidRepository(string $url): bool; diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index b6bb88a7..56f4b91c 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -91,40 +91,36 @@ public function initBare(string $path): void $this->ffi->git_repository_free($this->initRepository($path, true)); } - public function cloneRepository(string $path, string $url, array $args): void + public function cloneFrom(string $url, string $path, ?string $branch): void { - $refspec = null; - $branch = null; - for ($i = 0, $count = \count($args); $i < $count; ++$i) { - match ($args[$i]) { - // Like git: --mirror fetches every reference, --bare only the branches (and the tags). - '--bare' => $refspec ??= '+refs/heads/*:refs/heads/*', - '--mirror' => $refspec = '+refs/*:refs/*', - '--branch' => $branch = $args[++$i] ?? throw new UnsupportedOperationException('The "--branch" clone argument expects a branch name.'), - default => throw new UnsupportedOperationException(\sprintf('LibGit2Driver does not support the "%s" clone argument; use CliDriver.', $args[$i])), - }; - } - - if (null === $refspec) { - $this->cloneWithWorkingTree($path, $url, $branch); + $options = $this->ffi->new('git_clone_options'); + LibGit2::check($this->ffi, $this->ffi->git_clone_options_init(\FFI::addr($options), 1), 'Unable to initialize clone options'); - return; + // The buffer must stay alive until git_clone() returns. + $branchBuffer = null; + if (null !== $branch) { + $branchBuffer = LibGit2::cString($this->ffi, $branch); + $options->checkout_branch = $this->ffi->cast('const char *', $branchBuffer); } - $repository = $this->initRepository($path, true); + $repository = $this->ffi->new('git_repository*'); + LibGit2::check($this->ffi, $this->ffi->git_clone(\FFI::addr($repository), $url, $path, \FFI::addr($options)), \sprintf('Unable to clone "%s"', $url)); + $this->ffi->git_repository_free($repository); + } - try { - $this->fetch($repository, $url, $refspec, $branch); + public function cloneBareFrom(string $url, string $path, ?string $branch): void + { + // Like git, only the branches (and the tags). + $this->fetchInto($path, $url, '+refs/heads/*:refs/heads/*', $branch, ['remote.origin.url' => $url]); + } - $config = ['remote.origin.url' => $url]; - if ('+refs/*:refs/*' === $refspec) { - $config['remote.origin.fetch'] = $refspec; - $config['remote.origin.mirror'] = 'true'; - } - $this->setConfig($repository, $config); - } finally { - $this->ffi->git_repository_free($repository); - } + public function mirrorFrom(string $url, string $path): void + { + $this->fetchInto($path, $url, '+refs/*:refs/*', null, [ + 'remote.origin.url' => $url, + 'remote.origin.fetch' => '+refs/*:refs/*', + 'remote.origin.mirror' => 'true', + ]); } public function isValidRepository(string $url): bool @@ -153,21 +149,22 @@ private function initRepository(string $path, bool $bare): CData return $repository; } - private function cloneWithWorkingTree(string $path, string $url, ?string $branch): void + /** + * Initializes a bare repository in $path, and fetches $refspec from $url + * into it. + * + * @param array $config + */ + private function fetchInto(string $path, string $url, string $refspec, ?string $branch, array $config): void { - $options = $this->ffi->new('git_clone_options'); - LibGit2::check($this->ffi, $this->ffi->git_clone_options_init(\FFI::addr($options), 1), 'Unable to initialize clone options'); + $repository = $this->initRepository($path, true); - // The buffer must stay alive until git_clone() returns. - $branchBuffer = null; - if (null !== $branch) { - $branchBuffer = LibGit2::cString($this->ffi, $branch); - $options->checkout_branch = $this->ffi->cast('const char *', $branchBuffer); + try { + $this->fetch($repository, $url, $refspec, $branch); + $this->setConfig($repository, $config); + } finally { + $this->ffi->git_repository_free($repository); } - - $repository = $this->ffi->new('git_repository*'); - LibGit2::check($this->ffi, $this->ffi->git_clone(\FFI::addr($repository), $url, $path, \FFI::addr($options)), \sprintf('Unable to clone "%s"', $url)); - $this->ffi->git_repository_free($repository); } /** diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index bfe99bf2..8cbe1e63 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -15,8 +15,10 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Driver\Cli\CliRepositoryDriver; +use Gitonomy\Git\Driver\Cli\LegacyOptions; use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\RuntimeException; @@ -30,6 +32,8 @@ * Main entry point for browsing a Git repository. * * @author Alexandre Salomé + * + * @phpstan-type Options array{command?: string, debug?: bool, environment_variables?: array, inherit_environment_variables?: bool, logger?: LoggerInterface|null, process_timeout?: float|null, working_dir?: string|null} */ final class Repository { @@ -95,18 +99,42 @@ final class Repository * * * logger : a logger to use for logging actions (Psr\Log\LoggerInterface) * + * The command, environment_variables, inherit_environment_variables and + * process_timeout options of 1.x are deprecated: they configure a + * CliDriver, as the arguments of its constructor. + * * @param string $dir path to git repository - * @param array $options array of options values + * @param Options $options array of options values * @param DriverInterface $driver backend used to run operations * * @throws InvalidArgumentException The folder does not exists, or an option is unknown */ public function __construct(string $dir, array $options = [], DriverInterface $driver = new CliDriver()) { - // The options of 1.x configuring git are now arguments of the driver. + if (\array_key_exists('debug', $options)) { + if (!$options['debug']) { + throw new InvalidArgumentException('The "debug" option set to false is not supported anymore: a failing git operation always throws, catch its exception instead.'); + } + + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing "debug" in the options of "%s()" is deprecated, failing git operations always throw.', __METHOD__); + unset($options['debug']); + } + + $gitOptions = LegacyOptions::find($options); + if ([] !== $gitOptions) { + // The driver is the third argument. + if (\func_num_args() > 2) { + throw new InvalidArgumentException(\sprintf('The driver argument can\'t be combined with "%s" in the options: configure the driver instead.', implode('", "', $gitOptions))); + } + + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing "%s" in the options of "%s()" is deprecated, pass a "%s" as driver argument instead.', implode('", "', $gitOptions), __METHOD__, CliDriver::class); + $driver = LegacyOptions::createDriver($options); + $options = LegacyOptions::remove($options); + } + $unknownOptions = array_diff(array_keys($options), ['logger', 'working_dir']); if ([] !== $unknownOptions) { - throw new InvalidArgumentException(\sprintf('Unknown options "%s": expected "logger" or "working_dir". Configure git through the driver argument, a CliDriver by default.', implode('", "', $unknownOptions))); + throw new InvalidArgumentException(\sprintf('Unknown options "%s": expected "logger" or "working_dir".', implode('", "', $unknownOptions))); } $options = array_merge([ @@ -114,8 +142,9 @@ public function __construct(string $dir, array $options = [], DriverInterface $d 'logger' => null, ], $options); - if (null !== $options['logger'] && !$options['logger'] instanceof LoggerInterface) { - throw new InvalidArgumentException(\sprintf('Argument "logger" passed to Repository should be a Psr\Log\LoggerInterface. A %s was provided', \is_object($options['logger']) ? \get_class($options['logger']) : \gettype($options['logger']))); + // PHP doesn't check the types of the options, unlike PHPStan. + if (null !== $options['logger'] && !$options['logger'] instanceof LoggerInterface) { // @phpstan-ignore instanceof.alwaysTrue, booleanAnd.alwaysFalse + throw new InvalidArgumentException(\sprintf('Argument "logger" passed to Repository should be a Psr\Log\LoggerInterface. A %s was provided', get_debug_type($options['logger']))); } $this->logger = $options['logger']; @@ -127,6 +156,89 @@ public function __construct(string $dir, array $options = [], DriverInterface $d $this->logger?->debug(\sprintf('Repository created (git dir: "%s", working dir: "%s")', $this->gitDir, $this->workingDir ?? 'none')); } + /** + * Initializes a repository with a working directory in $path. + * + * @throws DriverException The directory can't be written, or is already a file + */ + public static function init(string $path, ?LoggerInterface $logger = null, DriverInterface $driver = new CliDriver()): self + { + $driver->init($path); + + return new self($path, ['logger' => $logger], $driver); + } + + /** + * Initializes a bare repository in $path. + * + * @throws DriverException The directory can't be written, or is already a file + */ + public static function initBare(string $path, ?LoggerInterface $logger = null, DriverInterface $driver = new CliDriver()): self + { + $driver->initBare($path); + + return new self($path, ['logger' => $logger], $driver); + } + + /** + * Clones $url with a working directory in $path. + * + * @param string|null $branch The branch to check out, the default branch of $url when null + * + * @throws DriverException + */ + public static function cloneFrom(string $url, string $path, ?string $branch = null, ?LoggerInterface $logger = null, DriverInterface $driver = new CliDriver()): self + { + $driver->cloneFrom($url, $path, $branch); + + return new self($path, ['logger' => $logger], $driver); + } + + /** + * Clones the branches and the tags of $url into a bare repository in $path. + * + * @param string|null $branch The branch HEAD points to, the default branch of $url when null + * + * @throws DriverException + */ + public static function cloneBareFrom(string $url, string $path, ?string $branch = null, ?LoggerInterface $logger = null, DriverInterface $driver = new CliDriver()): self + { + $driver->cloneBareFrom($url, $path, $branch); + + return new self($path, ['logger' => $logger], $driver); + } + + /** + * Clones every reference of $url into a bare repository in $path, set up + * to mirror $url when fetching. + * + * @throws DriverException + */ + public static function mirrorFrom(string $url, string $path, ?LoggerInterface $logger = null, DriverInterface $driver = new CliDriver()): self + { + $driver->mirrorFrom($url, $path); + + return new self($path, ['logger' => $logger], $driver); + } + + /** + * Checks, without cloning it, that $url is a repository the driver can + * read: false when it doesn't exist, can't be reached, or refuses access. + */ + public static function isValidRepository(string $url, DriverInterface $driver = new CliDriver()): bool + { + return $driver->isValidRepository($url); + } + + /** + * Checks, without cloning it, that $url is a repository the driver can + * read, and that it has the $branchName branch. + */ + public static function isValidRepositoryAndBranch(string $url, string $branchName, DriverInterface $driver = new CliDriver()): bool + { + return $driver->isValidRepositoryAndBranch($url, $branchName); + } + /** * Returns the operations on this repository, opened by the driver. * @@ -497,14 +609,27 @@ public function getLogger(): ?LoggerInterface * * The clone uses the driver of the current repository. * - * @param string $path path to the new repository in which current repository will be cloned - * @param bool $bare flag indicating if repository is bare or has a working-copy + * @param string $path path to the new repository in which current repository will be cloned + * @param bool $bare flag indicating if repository is bare or has a working-copy + * @param Options $options options of the new repository * * @return Repository the newly created repository + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() with getGitDir() as URL instead */ public function cloneTo(string $path, bool $bare = true, array $options = []): self { - return Admin::cloneTo($path, $this->gitDir, $bare, $options, $this->driver); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" with "getGitDir()" as URL instead.', __METHOD__, self::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + + // Like in 1.x, the options configuring git apply to the clone. + $driver = [] === LegacyOptions::find($options) ? $this->driver : LegacyOptions::createDriver($options); + if ($bare) { + $driver->cloneBareFrom($this->gitDir, $path, null); + } else { + $driver->cloneFrom($this->gitDir, $path, null); + } + + return new self($path, LegacyOptions::remove($options), $driver); } /** diff --git a/tests/Gitonomy/Git/Tests/AbstractTestCase.php b/tests/Gitonomy/Git/Tests/AbstractTestCase.php index 691ed5cd..ed10c77f 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTestCase.php +++ b/tests/Gitonomy/Git/Tests/AbstractTestCase.php @@ -12,7 +12,6 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Admin; use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\Ffi\LibGit2Driver; @@ -56,7 +55,7 @@ abstract class AbstractTestCase extends TestCase public static function createEmptyRepository(bool $bare = true): Repository { $dir = self::createTempDir(); - $repository = Admin::init($dir, $bare, driver: self::getDriver()); + $repository = $bare ? Repository::initBare($dir, driver: self::getDriver()) : Repository::init($dir, driver: self::getDriver()); self::registerDeletion($repository); return $repository; @@ -90,10 +89,10 @@ public static function provideEmpty(): array public static function createFoobarRepository(bool $bare = true): Repository { if (null === self::$localRepository) { - self::$localRepository = Admin::cloneTo(self::createTempDir(), self::repositoryUrl(), $bare, driver: self::getDriver()); + self::$localRepository = self::cloneIntoTempDir(self::repositoryUrl(), $bare); } - $repository = self::$localRepository->cloneTo(self::createTempDir(), $bare); + $repository = self::cloneIntoTempDir(self::$localRepository->getGitDir(), $bare); self::registerDeletion($repository); return $repository; @@ -231,4 +230,14 @@ protected static function getCliDriver(array $environmentVariables = [], ?float return new CliDriver($_SERVER['GIT_COMMAND'] ?? 'git', [...$envs, ...$environmentVariables], processTimeout: $processTimeout); } + + /** + * Clones $url into a new temporary directory, with the driver under test. + */ + private static function cloneIntoTempDir(string $url, bool $bare): Repository + { + $dir = self::createTempDir(); + + return $bare ? Repository::cloneBareFrom($url, $dir, driver: self::getDriver()) : Repository::cloneFrom($url, $dir, driver: self::getDriver()); + } } diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index 505b1570..abd3aefc 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -13,201 +13,193 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Admin; -use Gitonomy\Git\Exception\DriverException; -use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Reference\Branch; -use Gitonomy\Git\Repository; -use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use Psr\Log\LoggerInterface; +/** + * Admin is deprecated: its methods must keep behaving like in 1.x, always + * with CliDriver, whatever the driver under test. + */ +#[Group('legacy')] +#[IgnoreDeprecations] class AdminTest extends AbstractTestCase { - private string $tmpDir; - - #[Before] - public function setUpTmpDir(): void + public function testInit(): void { - $this->tmpDir = self::createTempDir(); - } + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::initBare()" instead.'); - #[After] - public function tearDownTmpDir(): void - { - self::deleteDir(self::createTempDir()); + $repository = Admin::init(self::createTempDir(), options: self::getOptions()); + self::registerDeletion($repository); + + $this->assertTrue($repository->isBare()); } - public function testBare(): void + public function testInitNotBare(): void { - $repository = Admin::init($this->tmpDir, true, driver: self::getDriver()); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::init()" instead.'); - $objectDir = $this->tmpDir.'/objects'; + $repository = Admin::init(self::createTempDir(), false, self::getOptions()); + self::registerDeletion($repository); - $this->assertTrue($repository->isBare(), 'Repository is bare'); - $this->assertDirectoryExists($objectDir, 'objects/ folder is present'); - $this->assertInstanceOf(Repository::class, $repository, 'Admin::init returns a repository'); - $this->assertEquals($this->tmpDir, $repository->getGitDir(), 'The folder passed as argument is git dir'); - $this->assertNull($repository->getWorkingDir(), 'No working dir in bare repository'); + $this->assertFalse($repository->isBare()); } - public function testNotBare(): void + public function testIsValidRepository(): void { - $repository = Admin::init($this->tmpDir, false, driver: self::getDriver()); - - $objectDir = $this->tmpDir.'/.git/objects'; + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::isValidRepository()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepository()" instead.'); - $this->assertFalse($repository->isBare(), 'Repository is not bare'); - $this->assertDirectoryExists($objectDir, 'objects/ folder is present'); - $this->assertInstanceOf(Repository::class, $repository, 'Admin::init returns a repository'); - $this->assertEquals($this->tmpDir.'/.git', $repository->getGitDir(), 'git dir as subfolder of argument'); - $this->assertEquals($this->tmpDir, $repository->getWorkingDir(), 'working dir present in bare repository'); + $this->assertTrue(Admin::isValidRepository(self::repositoryUrl(), self::getOptions())); } - #[DataProvider('provideFoobar')] - public function testClone(Repository $repository): void + public function testIsValidRepositoryAndBranch(): void { - $newDir = self::createTempDir(); - $new = $repository->cloneTo($newDir, $repository->isBare()); - self::registerDeletion($new); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::isValidRepositoryAndBranch()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepositoryAndBranch()" instead.'); - $newRefs = array_keys($new->getReferences()->getAll()); - - $this->assertContains('refs/heads/master', $newRefs); - $this->assertContains('refs/tags/0.1', $newRefs); - - if ($repository->isBare()) { - $this->assertEquals($newDir, $new->getGitDir()); - $this->assertContains('refs/heads/new-feature', $newRefs); - // Like `git clone --bare`: the remote has no fetch refspec. - $this->assertStringNotContainsString('fetch', file_get_contents($newDir.'/config')); - } else { - $this->assertEquals($newDir.'/.git', $new->getGitDir()); - $this->assertEquals($newDir, $new->getWorkingDir()); - } + $this->assertTrue(Admin::isValidRepositoryAndBranch(self::repositoryUrl(), 'new-feature', self::getOptions())); + $this->assertFalse(Admin::isValidRepositoryAndBranch(self::repositoryUrl(), 'unknown', self::getOptions())); } - public function testCloneBranchBare(): void + public function testCloneTo(): void { - // we can't use AbstractTestCase::createFoobarRepository() - // because it does not clone other branches than "master" - // so we test it directly against the remote repository + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" instead, which takes the URL before the path.'); - $newDir = self::createTempDir(); - $new = Admin::cloneBranchTo($newDir, self::repositoryUrl(), 'new-feature', true, driver: self::getDriver()); - self::registerDeletion($new); + $repository = Admin::cloneTo(self::createTempDir(), self::repositoryUrl(), options: self::getOptions()); + self::registerDeletion($repository); - $head = $new->getHead(); - $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); - $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + $this->assertTrue($repository->isBare()); + $this->assertContains('refs/heads/new-feature', array_keys($repository->getReferences()->getAll())); } - public function testCloneBranchNotBare(): void + public function testCloneToNotBare(): void { - // we can't use AbstractTestCase::createFoobarRepository() - // because it does not clone other branches than "master" - // so we test it directly against remote repository + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" instead, which takes the URL before the path.'); - $newDir = self::createTempDir(); - $new = Admin::cloneBranchTo($newDir, self::repositoryUrl(), 'new-feature', false, driver: self::getDriver()); - self::registerDeletion($new); + $repository = Admin::cloneTo(self::createTempDir(), self::repositoryUrl(), false, self::getOptions()); + self::registerDeletion($repository); - $head = $new->getHead(); - $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); - $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + $this->assertFalse($repository->isBare()); } - #[DataProvider('provideFoobar')] - public function testMirror(Repository $repository): void + public function testCloneBranchTo(): void { - $newDir = self::createTempDir(); - $new = Admin::mirrorTo($newDir, $repository->getGitDir(), driver: self::getDriver()); - self::registerDeletion($new); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with its "branch" argument instead, which takes the URL before the path.'); - $newRefs = array_keys($new->getReferences()->getAll()); + $repository = Admin::cloneBranchTo(self::createTempDir(), self::repositoryUrl(), 'new-feature', options: self::getOptions()); + self::registerDeletion($repository); - $this->assertContains('refs/heads/master', $newRefs); - $this->assertContains('refs/tags/0.1', $newRefs); - $this->assertEquals($newDir, $new->getGitDir()); + $this->assertTrue($repository->isBare()); + $head = $repository->getHead(); + $this->assertInstanceOf(Branch::class, $head); + $this->assertSame('new-feature', $head->getName()); + } - if ($repository->isBare()) { - $this->assertContains('refs/heads/new-feature', $newRefs); - } else { - $this->assertContains('refs/remotes/origin/new-feature', $newRefs); - } + public function testCloneBranchToNotBare(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with its "branch" argument instead, which takes the URL before the path.'); + + $repository = Admin::cloneBranchTo(self::createTempDir(), self::repositoryUrl(), 'new-feature', false, self::getOptions()); + self::registerDeletion($repository); - $this->assertSame('true', trim(self::git($new, 'config', ['remote.origin.mirror']))); - $this->assertSame('+refs/*:refs/*', trim(self::git($new, 'config', ['remote.origin.fetch']))); - $this->assertSame('refs/heads/master', trim(self::git($new, 'symbolic-ref', ['HEAD']))); + $this->assertFalse($repository->isBare()); + $head = $repository->getHead(); + $this->assertInstanceOf(Branch::class, $head); + $this->assertSame('new-feature', $head->getName()); } - public function testCloneUnknownBranchFails(): void + public function testMirrorTo(): void { - $this->expectException(DriverException::class); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::mirrorTo()" method is deprecated, use "Gitonomy\Git\Repository::mirrorFrom()" instead, which takes the URL before the path.'); + + $repository = Admin::mirrorTo(self::createTempDir(), self::repositoryUrl(), self::getOptions()); + self::registerDeletion($repository); - Admin::cloneBranchTo(self::createTempDir().'/clone', self::repositoryUrl(), 'unknown', true, driver: self::getDriver()); + $this->assertSame('true', trim($repository->run('config', ['remote.origin.mirror']))); } - #[DataProvider('provideFoobar')] - public function testCheckValidRepository(Repository $repository): void + public function testCloneRepositoryPassesItsArgumentsToGit(): void { - $url = $repository->getGitDir(); - $this->assertTrue(Admin::isValidRepository($url, driver: self::getDriver())); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneRepository()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()", "Gitonomy\Git\Repository::cloneBareFrom()" or "Gitonomy\Git\Repository::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".'); + + $repository = Admin::cloneRepository(self::createTempDir(), self::repositoryUrl(), ['--bare', '--origin', 'upstream'], self::getOptions()); + self::registerDeletion($repository); + + $this->assertTrue($repository->isBare()); + $this->assertSame('upstream', trim($repository->run('remote'))); } - public function testCheckInvalidRepository(): void + public function testOptionsConfigureGitAndTheRepository(): void { - $url = $this->tmpDir.'/invalid.git'; - mkdir($url); + $logger = $this->createStub(LoggerInterface::class); + + $repository = Admin::init(self::createTempDir(), options: [...self::getOptions(), 'logger' => $logger, 'process_timeout' => 42]); + self::registerDeletion($repository); - $this->assertFalse(Admin::isValidRepository($url, driver: self::getDriver())); + $this->assertSame($logger, $repository->getLogger()); + $this->assertSame(42.0, $repository->runProcess('config', ['core.bare'])->getTimeout()); } - #[DataProvider('provideFoobar')] - public function testCheckValidRepositoryAndBranch(Repository $repository): void + public function testCommandOptionRunsAnotherGitBinary(): void { - $url = $repository->getGitDir(); - $this->assertTrue(Admin::isValidRepositoryAndBranch($url, 'master', driver: self::getDriver())); + $this->expectException(ProcessException::class); + + Admin::init(self::createTempDir(), options: ['command' => 'not-git']); } - #[DataProvider('provideFoobar')] - public function testCheckInvalidRepositoryAndBranch(Repository $repository): void + public function testRemovedOptionsAreRefused(): void { - $url = $repository->getGitDir(); - $this->assertFalse(Admin::isValidRepositoryAndBranch($url, 'invalid-branch-name', driver: self::getDriver())); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "debug" option set to false is not supported anymore: a failing git operation always throws, catch its exception instead.'); + + Admin::init(self::createTempDir(), options: ['debug' => false]); } - public function testExistingFile(): void + public function testRepositoryCloneTo(): void { - $this->expectException(RuntimeException::class); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with "getGitDir()" as URL instead.'); - $file = $this->tmpDir.'/test'; - touch($file); + $repository = self::createFoobarRepository(); + $new = $repository->cloneTo(self::createTempDir()); + self::registerDeletion($new); - Admin::init($file, true, driver: self::getDriver()); + $this->assertTrue($new->isBare()); + $this->assertSame($repository->getHeadCommit()->getHash(), $new->getHeadCommit()->getHash()); } - public function testCloneRepository(): void + public function testRepositoryCloneToNotBare(): void { - $newDir = self::createTempDir(); - $args = []; + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with "getGitDir()" as URL instead.'); - $new = Admin::cloneRepository($newDir, self::repositoryUrl(), $args, driver: self::getDriver()); + $repository = self::createFoobarRepository(); + $new = $repository->cloneTo(self::createTempDir(), false); self::registerDeletion($new); - $newRefs = array_keys($new->getReferences()->getAll()); + $this->assertFalse($new->isBare()); + $this->assertSame($repository->getHeadCommit()->getHash(), $new->getHeadCommit()->getHash()); + } - $this->assertContains('refs/heads/master', $newRefs); - $this->assertContains('refs/tags/0.1', $newRefs); + public function testRepositoryCloneToWithGitOptions(): void + { + $repository = self::createFoobarRepository(); + $new = $repository->cloneTo(self::createTempDir(), options: [...self::getOptions(), 'process_timeout' => 42]); + self::registerDeletion($new); - $this->assertEquals($newDir.'/.git', $new->getGitDir()); - $this->assertEquals($newDir, $new->getWorkingDir()); + $this->assertSame(42.0, $new->runProcess('config', ['core.bare'])->getTimeout()); } - #[DataProvider('provideFoobar')] - public function testCheckRepositoryAndBranchOnlyMatchesBranches(Repository $repository): void + /** + * The options of 1.x running the git binary of the tests. + * + * @return array{command: string, environment_variables: array} + */ + private static function getOptions(): array { - foreach (['0.1', 'HEAD', 'feature'] as $name) { - $this->assertFalse(Admin::isValidRepositoryAndBranch($repository->getGitDir(), $name, driver: self::getDriver()), $name); - } + return [ + 'command' => $_SERVER['GIT_COMMAND'] ?? 'git', + 'environment_variables' => isset($_SERVER['GIT_ENVS']) ? (array) $_SERVER['GIT_ENVS'] : [], + ]; } } diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 783d131b..8dac404d 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -16,6 +16,8 @@ use Gitonomy\Git\Diff\File; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class DiffTest extends AbstractTestCase { @@ -152,6 +154,8 @@ public function testUnquotesNames(): void $this->assertSame('café "au lait"', $files[1]->getNewName()); } + #[Group('legacy')] + #[IgnoreDeprecations] public function testDeleteFileWithoutRaw(): void { $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); @@ -171,6 +175,8 @@ public function testDeleteFileWithoutRaw(): void $this->assertNull($firstFile->getNewIndex()); } + #[Group('legacy')] + #[IgnoreDeprecations] public function testModeChangeFileWithoutRaw(): void { $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); @@ -230,6 +236,8 @@ public function testModeChangeFileWithRaw(): void $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getNewIndex()); } + #[Group('legacy')] + #[IgnoreDeprecations] public function testCopyFileWithoutRaw(): void { $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); @@ -260,6 +268,8 @@ public function testCopyFileWithoutRaw(): void $this->assertSame(1, $firstFile->getAdditions()); } + #[Group('legacy')] + #[IgnoreDeprecations] public function testPureCopyFileWithoutRaw(): void { $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); @@ -304,6 +314,8 @@ public function testThrowErrorOnBlobGetWithoutIndex(): void $this->assertSame('', $file->getNewIndex()); } + #[Group('legacy')] + #[IgnoreDeprecations] public function testMnemonicPrefix(): void { $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); @@ -324,6 +336,8 @@ public function testMnemonicPrefix(): void $this->assertSame('composer.json', $firstFile->getNewName()); } + #[Group('legacy')] + #[IgnoreDeprecations] public function testEmptyNewFile(): void { $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); @@ -337,6 +351,8 @@ public function testEmptyNewFile(): void $this->assertNull($firstFile->getOldName()); } + #[Group('legacy')] + #[IgnoreDeprecations] public function testEmptyOldFile(): void { $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); @@ -350,6 +366,8 @@ public function testEmptyOldFile(): void $this->assertSame('test', $firstFile->getOldName()); } + #[Group('legacy')] + #[IgnoreDeprecations] public function testDirtySubmoduleWithoutRaw(): void { $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 4f72c18c..e86ca065 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -17,17 +17,197 @@ use Gitonomy\Git\Driver\Data\ReferenceData; use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use Psr\Log\LoggerInterface; class RepositoryTest extends AbstractTestCase { + public function testInitBare(): void + { + $dir = self::createTempDir(); + $repository = Repository::initBare($dir, driver: self::getDriver()); + self::registerDeletion($repository); + + $this->assertTrue($repository->isBare(), 'Repository is bare'); + $this->assertDirectoryExists($dir.'/objects', 'objects/ folder is present'); + $this->assertEquals($dir, $repository->getGitDir(), 'The folder passed as argument is git dir'); + $this->assertNull($repository->getWorkingDir(), 'No working dir in bare repository'); + } + + public function testInit(): void + { + $dir = self::createTempDir(); + $repository = Repository::init($dir, driver: self::getDriver()); + self::registerDeletion($repository); + + $this->assertFalse($repository->isBare(), 'Repository is not bare'); + $this->assertDirectoryExists($dir.'/.git/objects', 'objects/ folder is present'); + $this->assertEquals($dir.'/.git', $repository->getGitDir(), 'git dir as subfolder of argument'); + $this->assertEquals($dir, $repository->getWorkingDir(), 'working dir present in bare repository'); + } + + public function testInitOnAnExistingFileFails(): void + { + $this->expectException(RuntimeException::class); + + $file = self::createTempDir().'/test'; + touch($file); + + Repository::initBare($file, driver: self::getDriver()); + } + + public function testNamedConstructorsGiveTheLoggerToTheRepository(): void + { + $logger = $this->createStub(LoggerInterface::class); + + $repository = Repository::initBare(self::createTempDir(), $logger, self::getDriver()); + self::registerDeletion($repository); + + $this->assertSame($logger, $repository->getLogger()); + } + + public function testCloneBareFrom(): void + { + $repository = self::createFoobarRepository(true); + + $newDir = self::createTempDir(); + $new = Repository::cloneBareFrom($repository->getGitDir(), $newDir, driver: self::getDriver()); + self::registerDeletion($new); + + $newRefs = array_keys($new->getReferences()->getAll()); + + $this->assertContains('refs/heads/master', $newRefs); + $this->assertContains('refs/tags/0.1', $newRefs); + $this->assertEquals($newDir, $new->getGitDir()); + $this->assertContains('refs/heads/new-feature', $newRefs); + // Like `git clone --bare`: the remote has no fetch refspec. + $this->assertStringNotContainsString('fetch', file_get_contents($newDir.'/config')); + } + + public function testCloneFrom(): void + { + $repository = self::createFoobarRepository(false); + + $newDir = self::createTempDir(); + $new = Repository::cloneFrom($repository->getGitDir(), $newDir, driver: self::getDriver()); + self::registerDeletion($new); + + $newRefs = array_keys($new->getReferences()->getAll()); + + $this->assertContains('refs/heads/master', $newRefs); + $this->assertContains('refs/tags/0.1', $newRefs); + $this->assertEquals($newDir.'/.git', $new->getGitDir()); + $this->assertEquals($newDir, $new->getWorkingDir()); + } + + public function testCloneBareFromABranch(): void + { + // we can't use AbstractTestCase::createFoobarRepository() + // because it does not clone other branches than "master" + // so we test it directly against the remote repository + + $newDir = self::createTempDir(); + $new = Repository::cloneBareFrom(self::repositoryUrl(), $newDir, 'new-feature', driver: self::getDriver()); + self::registerDeletion($new); + + $head = $new->getHead(); + $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); + $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + } + + public function testCloneFromABranch(): void + { + // we can't use AbstractTestCase::createFoobarRepository() + // because it does not clone other branches than "master" + // so we test it directly against remote repository + + $newDir = self::createTempDir(); + $new = Repository::cloneFrom(self::repositoryUrl(), $newDir, 'new-feature', driver: self::getDriver()); + self::registerDeletion($new); + + $head = $new->getHead(); + $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); + $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + } + + public function testCloneFromAnUnknownBranchFails(): void + { + $this->expectException(DriverException::class); + + Repository::cloneBareFrom(self::repositoryUrl(), self::createTempDir().'/clone', 'unknown', driver: self::getDriver()); + } + + #[DataProvider('provideFoobar')] + public function testMirrorFrom(Repository $repository): void + { + $newDir = self::createTempDir(); + $new = Repository::mirrorFrom($repository->getGitDir(), $newDir, driver: self::getDriver()); + self::registerDeletion($new); + + $newRefs = array_keys($new->getReferences()->getAll()); + + $this->assertContains('refs/heads/master', $newRefs); + $this->assertContains('refs/tags/0.1', $newRefs); + $this->assertEquals($newDir, $new->getGitDir()); + + if ($repository->isBare()) { + $this->assertContains('refs/heads/new-feature', $newRefs); + } else { + $this->assertContains('refs/remotes/origin/new-feature', $newRefs); + } + + $this->assertSame('true', trim(self::git($new, 'config', ['remote.origin.mirror']))); + $this->assertSame('+refs/*:refs/*', trim(self::git($new, 'config', ['remote.origin.fetch']))); + $this->assertSame('refs/heads/master', trim(self::git($new, 'symbolic-ref', ['HEAD']))); + } + + #[DataProvider('provideFoobar')] + public function testCheckValidRepository(Repository $repository): void + { + $url = $repository->getGitDir(); + $this->assertTrue(Repository::isValidRepository($url, self::getDriver())); + } + + public function testCheckInvalidRepository(): void + { + $url = self::createTempDir().'/invalid.git'; + mkdir($url); + + $this->assertFalse(Repository::isValidRepository($url, self::getDriver())); + } + + #[DataProvider('provideFoobar')] + public function testCheckValidRepositoryAndBranch(Repository $repository): void + { + $url = $repository->getGitDir(); + $this->assertTrue(Repository::isValidRepositoryAndBranch($url, 'master', self::getDriver())); + } + + #[DataProvider('provideFoobar')] + public function testCheckInvalidRepositoryAndBranch(Repository $repository): void + { + $url = $repository->getGitDir(); + $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'invalid-branch-name', self::getDriver())); + } + + #[DataProvider('provideFoobar')] + public function testCheckRepositoryAndBranchOnlyMatchesBranches(Repository $repository): void + { + foreach (['0.1', 'HEAD', 'feature'] as $name) { + $this->assertFalse(Repository::isValidRepositoryAndBranch($repository->getGitDir(), $name, self::getDriver()), $name); + } + } + public function testNullProcessTimeoutDisablesTheTimeout(): void { $this->skipUnlessCliDriver(); @@ -233,8 +413,52 @@ public function testSetLoggerReopensTheRepositoryWithIt(): void public function testUnknownOptionsAreRefused(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown options "command", "process_timeout": expected "logger" or "working_dir". Configure git through the driver argument, a CliDriver by default.'); + $this->expectExceptionMessage('Unknown options "foo", "bar": expected "logger" or "working_dir".'); + + new Repository(self::createTempDir(), ['foo' => 1, 'bar' => 2]); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGitOptionsConfigureACliDriver(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing "command", "process_timeout" in the options of "Gitonomy\Git\Repository::__construct()" is deprecated, pass a "Gitonomy\Git\Driver\Cli\CliDriver" as driver argument instead.'); + + $logger = $this->createStub(LoggerInterface::class); + $repository = new Repository(self::createFoobarRepository()->getGitDir(), [ + 'command' => $_SERVER['GIT_COMMAND'] ?? 'git', + 'logger' => $logger, + 'process_timeout' => 42, + ]); + + $this->assertSame($logger, $repository->getLogger()); + $this->assertSame(42.0, $repository->runProcess('rev-parse', ['HEAD'])->getTimeout()); + } + + public function testLegacyGitOptionsCantBeCombinedWithADriver(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The driver argument can\'t be combined with "process_timeout" in the options: configure the driver instead.'); + + new Repository(self::createTempDir(), ['process_timeout' => 60], self::getDriver()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyDebugOption(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing "debug" in the options of "Gitonomy\Git\Repository::__construct()" is deprecated, failing git operations always throw.'); + + $repository = new Repository(self::createFoobarRepository()->getGitDir(), ['debug' => true], self::getDriver()); + + $this->assertSame(self::LONGFILE_COMMIT, $repository->getCommit(self::LONGFILE_COMMIT)->getHash()); + } + + public function testDebugOptionSetToFalseIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "debug" option set to false is not supported anymore: a failing git operation always throws, catch its exception instead.'); - new Repository(self::createTempDir(), ['command' => 'git', 'process_timeout' => 60]); + new Repository(self::createTempDir(), ['debug' => false]); } } diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 5926f05d..ac63c791 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -12,7 +12,6 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Admin; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\LogicException; use Gitonomy\Git\Exception\RuntimeException; @@ -26,7 +25,7 @@ public function testNoWorkingCopyInBare(): void $this->expectException(LogicException::class); $path = self::createTempDir(); - $repo = Admin::init($path, true, driver: self::getDriver()); + $repo = Repository::initBare($path, driver: self::getDriver()); $repo->getWorkingCopy(); } From 7375099afb87a121b8a408c5e075a4b4da346f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 09:19:14 +0200 Subject: [PATCH 088/150] Parse only diffs with their raw information Parsing a diff without its raw information has been deprecated since 1.6.0 (#229): Diff::parse() now throws an InvalidArgumentException for it. The raw information gives the full hashes of the files, even empty ones; gitlib always asks git for it. --- CHANGELOG.md | 4 ++ UPGRADE-2.0.md | 4 ++ src/Gitonomy/Git/Parser/DiffParser.php | 5 +- tests/Gitonomy/Git/Tests/DiffTest.php | 98 ++++++++------------------ 4 files changed, 42 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a62e6a9..75b9320d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,10 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). ### Minor +- Parse only diffs with their raw information, the output of `git diff --raw --patch`. + **Breaking:** `Diff::parse()` throws an `InvalidArgumentException` for a diff without it, + deprecated since 1.6.0 ([#229](https://github.com/gitonomy/gitlib/pull/229)). + ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Require git 1.8.4 or later, the oldest version the test suite passes with: it simplifies the log of a range filtered by path like the current versions, and `Log::count()` needs git 1.7.2. **Breaking:** git 1.7 and 1.8 before 1.8.4 are no longer supported. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 4377133e..9dda0e4c 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -228,6 +228,10 @@ Data returned Other changes ------------- + * `Diff::parse()` throws an `InvalidArgumentException` for a diff without + its raw information, deprecated since 1.6.0: parse the output of + `git diff --raw --patch`, which gives the full hashes of the files, even + empty ones. * Relative paths, in the arguments of `Repository::run()` and in the paths of a log, are relative to the repository instead of the current directory of PHP. diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index 4003691d..dc9461fd 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -14,6 +14,7 @@ use Gitonomy\Git\Diff\File; use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Exception\InvalidArgumentException; final class DiffParser extends ParserBase { @@ -32,7 +33,7 @@ protected function doParse(): void { $this->files = []; $indexes = []; - // Diff contains raw information + // The raw information gives the full hashes, even for empty files. if (str_starts_with($this->content, ':')) { while ($this->expects(':')) { $this->consumeRegexp('/\d{6} \d{6} /'); @@ -45,7 +46,7 @@ protected function doParse(): void } $this->consumeNewLine(); } elseif (!$this->isFinished()) { - @trigger_error('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', \E_USER_DEPRECATED); + throw new InvalidArgumentException('The diff has no raw information: Diff::parse() parses the output of "git diff --raw --patch".'); } $fileIndex = 0; diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 8dac404d..ec0970a5 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -14,10 +14,9 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; class DiffTest extends AbstractTestCase { @@ -154,13 +153,11 @@ public function testUnquotesNames(): void $this->assertSame('café "au lait"', $files[1]->getNewName()); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testDeleteFileWithoutRaw(): void + public function testDeleteFile(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - $diff = Diff::parse(<<<'DIFF' + :100644 000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 D test + diff --git a/test b/test deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 @@ -175,34 +172,8 @@ public function testDeleteFileWithoutRaw(): void $this->assertNull($firstFile->getNewIndex()); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testModeChangeFileWithoutRaw(): void + public function testModeChangeFile(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - - $diff = Diff::parse(<<<'DIFF' - diff --git a/a.out b/a.out - old mode 100755 - new mode 100644 - - DIFF); - $firstFile = $diff->getFiles()[0]; - - $this->assertFalse($firstFile->isCreation()); - $this->assertFalse($firstFile->isDeletion()); - $this->assertTrue($firstFile->isChangeMode()); - $this->assertSame('', $firstFile->getOldIndex()); - $this->assertSame('', $firstFile->getNewIndex()); - } - - public function testModeChangeFileWithRaw(): void - { - $deprecationCalled = false; - set_error_handler(static function (int $errno, string $errstr) use (&$deprecationCalled): void { - $deprecationCalled = true; - }, \E_USER_DEPRECATED); - $diff = Diff::parse(<<<'DIFF' :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M testfile :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 M testfile2 @@ -219,10 +190,6 @@ public function testModeChangeFileWithRaw(): void $firstFile = $files[0]; $secondFile = $files[1]; - restore_error_handler(); - - $this->assertFalse($deprecationCalled); - $this->assertFalse($firstFile->isCreation()); $this->assertFalse($firstFile->isDeletion()); $this->assertTrue($firstFile->isChangeMode()); @@ -236,13 +203,11 @@ public function testModeChangeFileWithRaw(): void $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getNewIndex()); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testCopyFileWithoutRaw(): void + public function testCopyFile(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - $diff = Diff::parse(<<<'DIFF' + :100644 100644 1234567890123456789012345678901234567890 89abcdef0123456789abcdef0123456789abcdef C097 style.css common.css + diff --git a/style.css b/common.css similarity index 97% copy from style.css @@ -268,13 +233,11 @@ public function testCopyFileWithoutRaw(): void $this->assertSame(1, $firstFile->getAdditions()); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testPureCopyFileWithoutRaw(): void + public function testPureCopyFile(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - $diff = Diff::parse(<<<'DIFF' + :100644 100644 1234567890123456789012345678901234567890 1234567890123456789012345678901234567890 C100 style.css common.css + diff --git a/style.css b/common.css similarity index 100% copy from style.css @@ -289,6 +252,19 @@ public function testPureCopyFileWithoutRaw(): void $this->assertSame('common.css', $firstFile->getNewName()); } + public function testParseRequiresTheRawInformation(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The diff has no raw information: Diff::parse() parses the output of "git diff --raw --patch".'); + + Diff::parse("diff --git a/a.out b/a.out\nold mode 100755\nnew mode 100644\n"); + } + + public function testParseAnEmptyDiff(): void + { + $this->assertSame([], Diff::parse('')->getFiles()); + } + public function testThrowErrorOnBlobGetWithoutIndex(): void { $repository = self::createEmptyRepository(); @@ -314,16 +290,14 @@ public function testThrowErrorOnBlobGetWithoutIndex(): void $this->assertSame('', $file->getNewIndex()); } - #[Group('legacy')] - #[IgnoreDeprecations] public function testMnemonicPrefix(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - // With `diff.mnemonicPrefix` enabled, git replaces the default "a/" and "b/" // prefixes with context-specific ones, e.g. "c/" (commit) and "i/" (index) // for `git diff --cached`. See https://github.com/gitonomy/gitlib/issues/114. $diff = Diff::parse(<<<'DIFF' + :100644 100644 0000000000000000000000000000000000000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 M composer.json + diff --git c/composer.json i/composer.json index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 --- c/composer.json @@ -336,13 +310,9 @@ public function testMnemonicPrefix(): void $this->assertSame('composer.json', $firstFile->getNewName()); } - #[Group('legacy')] - #[IgnoreDeprecations] public function testEmptyNewFile(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - - $diff = Diff::parse("diff --git a/test b/test\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n"); + $diff = Diff::parse(":000000 100644 0000000000000000000000000000000000000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 A\ttest\n\ndiff --git a/test b/test\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n"); $firstFile = $diff->getFiles()[0]; $this->assertTrue($firstFile->isCreation()); @@ -351,13 +321,9 @@ public function testEmptyNewFile(): void $this->assertNull($firstFile->getOldName()); } - #[Group('legacy')] - #[IgnoreDeprecations] public function testEmptyOldFile(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - - $diff = Diff::parse("diff --git a/test b/test\ndeleted file mode 100644\nindex e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000\n"); + $diff = Diff::parse(":100644 000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 D\ttest\n\ndiff --git a/test b/test\ndeleted file mode 100644\nindex e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000\n"); $firstFile = $diff->getFiles()[0]; $this->assertFalse($firstFile->isCreation()); @@ -366,13 +332,11 @@ public function testEmptyOldFile(): void $this->assertSame('test', $firstFile->getOldName()); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testDirtySubmoduleWithoutRaw(): void + public function testDirtySubmodule(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - $diff = Diff::parse(<<<'DIFF' + :160000 160000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 M sub + diff --git a/sub b/sub --- a/sub +++ b/sub From 46ca11d8e07f79db8a89bdfe1037c6a562f4288b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 10:58:08 +0200 Subject: [PATCH 089/150] Make the tests clean up after themselves, in any order Each test deletes the directories it created, instead of a shutdown function that leaked every directory given to a failing call, and the local fixture of a class is deleted after its last test. The data providers give the "bare" argument instead of cloning every repository when the suite loads, and the local fixture is always bare: a test class run alone, or in random order, no longer depends on the class that ran first. deleteDir() no longer follows symbolic links out of the directory. The legacy test of Admin::cloneRepository() passed --bare with --origin, which git only accepts since 2.38.2: it passes --config instead. --- tests/Gitonomy/Git/Tests/AbstractTestCase.php | 112 ++++++++------- tests/Gitonomy/Git/Tests/AdminTest.php | 17 +-- tests/Gitonomy/Git/Tests/BlameTest.php | 16 ++- tests/Gitonomy/Git/Tests/BlobTest.php | 35 +++-- tests/Gitonomy/Git/Tests/CommitTest.php | 130 +++++++++++------- tests/Gitonomy/Git/Tests/DiffTest.php | 46 ++++--- tests/Gitonomy/Git/Tests/HooksTest.php | 51 ++++--- .../Gitonomy/Git/Tests/LibGit2DriverTest.php | 7 +- tests/Gitonomy/Git/Tests/LogTest.php | 30 ++-- .../Gitonomy/Git/Tests/PushReferenceTest.php | 16 ++- tests/Gitonomy/Git/Tests/ReferenceBagTest.php | 16 ++- tests/Gitonomy/Git/Tests/ReferenceTest.php | 81 ++++++----- tests/Gitonomy/Git/Tests/RepositoryTest.php | 58 ++++---- tests/Gitonomy/Git/Tests/RevisionTest.php | 16 ++- tests/Gitonomy/Git/Tests/TreeTest.php | 31 +++-- 15 files changed, 374 insertions(+), 288 deletions(-) diff --git a/tests/Gitonomy/Git/Tests/AbstractTestCase.php b/tests/Gitonomy/Git/Tests/AbstractTestCase.php index ed10c77f..3fd1bbd2 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTestCase.php +++ b/tests/Gitonomy/Git/Tests/AbstractTestCase.php @@ -16,6 +16,8 @@ use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\AfterClass; use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; @@ -37,7 +39,8 @@ abstract class AbstractTestCase extends TestCase public const SIGNED_COMMIT = 'e1a83f16ed61ae3807e5652c7ef894692c813513'; /** - * Local clone of remote URL. Avoids network call on each test. + * Bare clone of REPOSITORY_URL, the fixture repositories are cloned from: + * for the tests of a class, as each class deletes it. */ private static ?Repository $localRepository = null; @@ -50,36 +53,40 @@ abstract class AbstractTestCase extends TestCase private static ?string $materializedRepositoryUrl = null; /** - * Creates an empty git repository and returns instance. + * Directories created by createTempDir() for the current test. + * + * @var string[] */ - public static function createEmptyRepository(bool $bare = true): Repository - { - $dir = self::createTempDir(); - $repository = $bare ? Repository::initBare($dir, driver: self::getDriver()) : Repository::init($dir, driver: self::getDriver()); - self::registerDeletion($repository); + private static array $tempDirs = []; - return $repository; - } + /** + * Directories kept for the tests of a class. + * + * @var string[] + */ + private static array $classTempDirs = []; /** - * Can be used as data provider to get bare/not-bare repositories. + * Creates an empty git repository and returns instance. */ - public static function provideFoobar(): array + public static function createEmptyRepository(bool $bare = true): Repository { - return [ - [self::createFoobarRepository()], - [self::createFoobarRepository(false)], - ]; + $dir = self::createTempDir(); + + return $bare ? Repository::initBare($dir, driver: self::getDriver()) : Repository::init($dir, driver: self::getDriver()); } /** - * Can be used as data provider to get bare/not-bare repositories. + * Data provider of the "bare" argument of createFoobarRepository() and + * createEmptyRepository(). + * + * @return array */ - public static function provideEmpty(): array + public static function provideBare(): array { return [ - [self::createEmptyRepository()], - [self::createEmptyRepository(false)], + 'bare' => [true], + 'working copy' => [false], ]; } @@ -89,39 +96,51 @@ public static function provideEmpty(): array public static function createFoobarRepository(bool $bare = true): Repository { if (null === self::$localRepository) { - self::$localRepository = self::cloneIntoTempDir(self::repositoryUrl(), $bare); + $dir = self::createTempDir(); + // Moved to the directories of the class, kept until its last test. + self::$classTempDirs[] = array_pop(self::$tempDirs); + self::$localRepository = Repository::cloneBareFrom(self::repositoryUrl(), $dir, driver: self::getDriver()); } - $repository = self::cloneIntoTempDir(self::$localRepository->getGitDir(), $bare); - self::registerDeletion($repository); - - return $repository; - } + $dir = self::createTempDir(); + $url = self::$localRepository->getGitDir(); - public static function registerDeletion(Repository $repository): void - { - register_shutdown_function(static function () use ($repository): void { - if ($repository->getWorkingDir()) { - $dir = $repository->getWorkingDir(); - } else { - $dir = $repository->getGitDir(); - } - self::deleteDir($dir); - }); + return $bare ? Repository::cloneBareFrom($url, $dir, driver: self::getDriver()) : Repository::cloneFrom($url, $dir, driver: self::getDriver()); } /** - * Created an empty directory and return path to it. + * Creates an empty directory, deleted after the current test. */ public static function createTempDir(): string { $tmpDir = tempnam(sys_get_temp_dir(), 'gitlib_'); unlink($tmpDir); mkdir($tmpDir); + self::$tempDirs[] = $tmpDir; return $tmpDir; } + #[After] + public function deleteTempDirs(): void + { + foreach (self::$tempDirs as $dir) { + self::deleteDir($dir); + } + self::$tempDirs = []; + } + + #[AfterClass] + public static function deleteClassTempDirs(): void + { + foreach (self::$classTempDirs as $dir) { + self::deleteDir($dir); + } + self::$classTempDirs = []; + self::$localRepository = null; + self::$materializedRepositoryUrl = null; + } + /** * Returns the source to clone the fixture from: the raw .bundle file for * drivers that understand it (the `git` CLI does natively), or a @@ -135,6 +154,7 @@ protected static function repositoryUrl(): string if (null === self::$materializedRepositoryUrl) { $dir = self::createTempDir(); + self::$classTempDirs[] = array_pop(self::$tempDirs); $command = $_SERVER['GIT_COMMAND'] ?? 'git'; $process = new Process([$command, 'clone', '-q', '--bare', self::REPOSITORY_URL, $dir]); $process->mustRun(); @@ -149,15 +169,17 @@ protected static function repositoryUrl(): string */ protected static function deleteDir(string $dir): void { - $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS); + // Without FOLLOW_SYMLINKS: a link to a directory is deleted, never what it points to. + $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::CHILD_FIRST); foreach ($iterator as $file) { - if (!is_link($file)) { + if (is_link($file)) { + unlink($file); + } elseif (is_dir($file)) { chmod($file, 0o777); - } - if (is_dir($file)) { rmdir($file); } else { + chmod($file, 0o777); unlink($file); } } @@ -230,14 +252,4 @@ protected static function getCliDriver(array $environmentVariables = [], ?float return new CliDriver($_SERVER['GIT_COMMAND'] ?? 'git', [...$envs, ...$environmentVariables], processTimeout: $processTimeout); } - - /** - * Clones $url into a new temporary directory, with the driver under test. - */ - private static function cloneIntoTempDir(string $url, bool $bare): Repository - { - $dir = self::createTempDir(); - - return $bare ? Repository::cloneBareFrom($url, $dir, driver: self::getDriver()) : Repository::cloneFrom($url, $dir, driver: self::getDriver()); - } } diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index abd3aefc..bd27673c 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -33,7 +33,6 @@ public function testInit(): void $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::initBare()" instead.'); $repository = Admin::init(self::createTempDir(), options: self::getOptions()); - self::registerDeletion($repository); $this->assertTrue($repository->isBare()); } @@ -43,7 +42,6 @@ public function testInitNotBare(): void $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::init()" instead.'); $repository = Admin::init(self::createTempDir(), false, self::getOptions()); - self::registerDeletion($repository); $this->assertFalse($repository->isBare()); } @@ -68,7 +66,6 @@ public function testCloneTo(): void $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" instead, which takes the URL before the path.'); $repository = Admin::cloneTo(self::createTempDir(), self::repositoryUrl(), options: self::getOptions()); - self::registerDeletion($repository); $this->assertTrue($repository->isBare()); $this->assertContains('refs/heads/new-feature', array_keys($repository->getReferences()->getAll())); @@ -79,7 +76,6 @@ public function testCloneToNotBare(): void $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" instead, which takes the URL before the path.'); $repository = Admin::cloneTo(self::createTempDir(), self::repositoryUrl(), false, self::getOptions()); - self::registerDeletion($repository); $this->assertFalse($repository->isBare()); } @@ -89,7 +85,6 @@ public function testCloneBranchTo(): void $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with its "branch" argument instead, which takes the URL before the path.'); $repository = Admin::cloneBranchTo(self::createTempDir(), self::repositoryUrl(), 'new-feature', options: self::getOptions()); - self::registerDeletion($repository); $this->assertTrue($repository->isBare()); $head = $repository->getHead(); @@ -102,7 +97,6 @@ public function testCloneBranchToNotBare(): void $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with its "branch" argument instead, which takes the URL before the path.'); $repository = Admin::cloneBranchTo(self::createTempDir(), self::repositoryUrl(), 'new-feature', false, self::getOptions()); - self::registerDeletion($repository); $this->assertFalse($repository->isBare()); $head = $repository->getHead(); @@ -115,7 +109,6 @@ public function testMirrorTo(): void $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::mirrorTo()" method is deprecated, use "Gitonomy\Git\Repository::mirrorFrom()" instead, which takes the URL before the path.'); $repository = Admin::mirrorTo(self::createTempDir(), self::repositoryUrl(), self::getOptions()); - self::registerDeletion($repository); $this->assertSame('true', trim($repository->run('config', ['remote.origin.mirror']))); } @@ -124,11 +117,11 @@ public function testCloneRepositoryPassesItsArgumentsToGit(): void { $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneRepository()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()", "Gitonomy\Git\Repository::cloneBareFrom()" or "Gitonomy\Git\Repository::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".'); - $repository = Admin::cloneRepository(self::createTempDir(), self::repositoryUrl(), ['--bare', '--origin', 'upstream'], self::getOptions()); - self::registerDeletion($repository); + // --config is the only argument --bare accepts on every git version. + $repository = Admin::cloneRepository(self::createTempDir(), self::repositoryUrl(), ['--bare', '--config', 'gitlib.test=yes'], self::getOptions()); $this->assertTrue($repository->isBare()); - $this->assertSame('upstream', trim($repository->run('remote'))); + $this->assertSame('yes', trim($repository->run('config', ['gitlib.test']))); } public function testOptionsConfigureGitAndTheRepository(): void @@ -136,7 +129,6 @@ public function testOptionsConfigureGitAndTheRepository(): void $logger = $this->createStub(LoggerInterface::class); $repository = Admin::init(self::createTempDir(), options: [...self::getOptions(), 'logger' => $logger, 'process_timeout' => 42]); - self::registerDeletion($repository); $this->assertSame($logger, $repository->getLogger()); $this->assertSame(42.0, $repository->runProcess('config', ['core.bare'])->getTimeout()); @@ -163,7 +155,6 @@ public function testRepositoryCloneTo(): void $repository = self::createFoobarRepository(); $new = $repository->cloneTo(self::createTempDir()); - self::registerDeletion($new); $this->assertTrue($new->isBare()); $this->assertSame($repository->getHeadCommit()->getHash(), $new->getHeadCommit()->getHash()); @@ -175,7 +166,6 @@ public function testRepositoryCloneToNotBare(): void $repository = self::createFoobarRepository(); $new = $repository->cloneTo(self::createTempDir(), false); - self::registerDeletion($new); $this->assertFalse($new->isBare()); $this->assertSame($repository->getHeadCommit()->getHash(), $new->getHeadCommit()->getHash()); @@ -185,7 +175,6 @@ public function testRepositoryCloneToWithGitOptions(): void { $repository = self::createFoobarRepository(); $new = $repository->cloneTo(self::createTempDir(), options: [...self::getOptions(), 'process_timeout' => 42]); - self::registerDeletion($new); $this->assertSame(42.0, $new->runProcess('config', ['core.bare'])->getTimeout()); } diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index f95b00ea..23647e77 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -12,14 +12,14 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; class BlameTest extends AbstractTestCase { - #[DataProvider('provideFoobar')] - public function testBlame(Repository $repository): void + #[DataProvider('provideBare')] + public function testBlame(bool $bare): void { + $repository = self::createFoobarRepository($bare); $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md'); $this->assertCount(7, $blame); @@ -33,9 +33,10 @@ public function testBlame(Repository $repository): void $this->assertNotEquals(self::INITIAL_COMMIT, $blame->getLine(5)->getCommit()->getHash()); } - #[DataProvider('provideFoobar')] - public function testGroupedBlame(Repository $repository): void + #[DataProvider('provideBare')] + public function testGroupedBlame(bool $bare): void { + $repository = self::createFoobarRepository($bare); $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getGroupedLines(); $this->assertCount(3, $blame); @@ -43,9 +44,10 @@ public function testGroupedBlame(Repository $repository): void $this->assertEquals(self::INITIAL_COMMIT, $blame[0][0]->getHash()); } - #[DataProvider('provideFoobar')] - public function testBlameLineRange(Repository $repository): void + #[DataProvider('provideBare')] + public function testBlameLineRange(bool $bare): void { + $repository = self::createFoobarRepository($bare); $full = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md'); foreach (['3,+3', '3,5'] as $lineRange) { diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 3a2a6ebd..8ee69724 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -31,17 +31,19 @@ public function getImageBlob(Repository $repository): Blob return $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('image.jpg'); } - #[DataProvider('provideFoobar')] - public function testGetContent(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetContent(bool $bare): void { + $repository = self::createFoobarRepository($bare); $blob = $this->getReadmeBlob($repository); $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); } - #[DataProvider('provideFoobar')] - public function testGetContentSiblingToSubmodule(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetContentSiblingToSubmodule(bool $bare): void { + $repository = self::createFoobarRepository($bare); // Regression test for https://github.com/gitonomy/gitlib/issues/12: // a submodule entry in a tree used to break parsing of that tree, // making sibling blobs unreadable. @@ -54,44 +56,49 @@ public function testGetContentSiblingToSubmodule(Repository $repository): void $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); } - #[DataProvider('provideFoobar')] - public function testNotExisting(Repository $repository): void + #[DataProvider('provideBare')] + public function testNotExisting(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(RuntimeException::class); $blob = $repository->getBlob('foobar'); $blob->getContent(); } - #[DataProvider('provideFoobar')] - public function testGetMimetype(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetMimetype(bool $bare): void { + $repository = self::createFoobarRepository($bare); $blob = $this->getReadmeBlob($repository); $this->assertMatchesRegularExpression('#text/plain#', $blob->getMimetype()); } - #[DataProvider('provideFoobar')] - public function testIsText(Repository $repository): void + #[DataProvider('provideBare')] + public function testIsText(bool $bare): void { + $repository = self::createFoobarRepository($bare); $readmeBlob = $this->getReadmeBlob($repository); $this->assertTrue($readmeBlob->isText()); $imageBlob = $this->getImageBlob($repository); $this->assertFalse($imageBlob->isText()); } - #[DataProvider('provideFoobar')] - public function testIsBinary(Repository $repository): void + #[DataProvider('provideBare')] + public function testIsBinary(bool $bare): void { + $repository = self::createFoobarRepository($bare); $readmeBlob = $this->getReadmeBlob($repository); $this->assertFalse($readmeBlob->isBinary()); $imageBlob = $this->getImageBlob($repository); $this->assertTrue($imageBlob->isBinary()); } - #[DataProvider('provideFoobar')] - public function testAbbreviatedHash(Repository $repository): void + #[DataProvider('provideBare')] + public function testAbbreviatedHash(bool $bare): void { + $repository = self::createFoobarRepository($bare); $hash = '412a0dad051163dcf9f9ce2fc04a141ffcb69479'; $this->assertSame($repository->getBlob($hash)->getContent(), $repository->getBlob(substr($hash, 0, 8))->getContent()); diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 8a88a6d3..8cd810d9 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -22,9 +22,10 @@ class CommitTest extends AbstractTestCase { - #[DataProvider('provideFoobar')] - public function testGetDiff(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetDiff(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $diff = $commit->getDiff(); @@ -32,42 +33,47 @@ public function testGetDiff(Repository $repository): void $this->assertInstanceOf(Diff::class, $diff, 'getDiff() returns a Diff object'); } - #[DataProvider('provideFoobar')] - public function testGetHash(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetHash(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals(self::LONGFILE_COMMIT, $commit->getHash()); } - #[DataProvider('provideFoobar')] - public function testInvalideHashThrowException(Repository $repository): void + #[DataProvider('provideBare')] + public function testInvalideHashThrowException(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(ReferenceNotFoundException::class); $this->expectExceptionMessage('Reference not found: "that-hash-doest-not-exists"'); new Commit($repository, 'that-hash-doest-not-exists'); } - #[DataProvider('provideFoobar')] - public function testGetShortHash(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetShortHash(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('4f17752', $commit->getShortHash(), 'Short hash'); } - #[DataProvider('provideFoobar')] - public function testGetParentHashesWithNoParent(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetParentHashesWithNoParent(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::INITIAL_COMMIT); $this->assertCount(0, $commit->getParentHashes(), 'No parent on initial commit'); } - #[DataProvider('provideFoobar')] - public function testGetParentHashesWithOneParent(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetParentHashesWithOneParent(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $parents = $commit->getParentHashes(); @@ -75,9 +81,10 @@ public function testGetParentHashesWithOneParent(Repository $repository): void $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $parents[0], 'Parent hash is correct'); } - #[DataProvider('provideFoobar')] - public function testGetParentsWithOneParent(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetParentsWithOneParent(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $parents = $commit->getParents(); @@ -86,90 +93,101 @@ public function testGetParentsWithOneParent(Repository $repository): void $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $parents[0]->getHash(), "First parents's hash is correct"); } - #[DataProvider('provideFoobar')] - public function testGetTreeHash(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetTreeHash(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTreeHash(), 'Tree hash is correct'); } - #[DataProvider('provideFoobar')] - public function testGetTree(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetTree(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertInstanceOf(Tree::class, $commit->getTree(), 'Tree is a tree'); $this->assertEquals('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTree()->getHash(), 'Tree hash is correct'); } - #[DataProvider('provideFoobar')] - public function testGetAuthorName(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetAuthorName(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('alice', $commit->getAuthorName(), 'Author name'); } - #[DataProvider('provideFoobar')] - public function testGetAuthorEmail(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetAuthorEmail(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('alice@example.org', $commit->getAuthorEmail(), 'Author email'); } - #[DataProvider('provideFoobar')] - public function testGetAuthorDate(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetAuthorDate(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('2012-12-31 14:21:03', $commit->getAuthorDate()->format('Y-m-d H:i:s'), 'Author date'); } - #[DataProvider('provideFoobar')] - public function testGetCommitterName(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetCommitterName(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('alice', $commit->getCommitterName(), 'Committer name'); } - #[DataProvider('provideFoobar')] - public function testGetCommitterEmail(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetCommitterEmail(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('alice@example.org', $commit->getCommitterEmail(), 'Committer email'); } - #[DataProvider('provideFoobar')] - public function testGetCommitterDate(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetCommitterDate(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('2012-12-31 14:21:03', $commit->getCommitterDate()->format('Y-m-d H:i:s'), 'Committer date'); } - #[DataProvider('provideFoobar')] - public function testGetMessage(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetMessage(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $this->assertEquals('add a long file'."\n", $commit->getMessage()); } - #[DataProvider('provideFoobar')] - public function testGetEmptyMessage(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetEmptyMessage(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::NO_MESSAGE_COMMIT); $this->assertEquals('', $commit->getMessage()); } - #[DataProvider('provideFoobar')] - public function testGetEmptyMessageFromLog(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetEmptyMessageFromLog(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::NO_MESSAGE_COMMIT); $commitMessageFromLog = $commit->getLog()->getCommits()[0]->getMessage(); @@ -180,16 +198,18 @@ public function testGetEmptyMessageFromLog(Repository $repository): void * This test ensures that GPG signed commits does not break the reading of a commit * message. */ - #[DataProvider('provideFoobar')] - public function testGetSignedMessage(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetSignedMessage(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::SIGNED_COMMIT); $this->assertEquals('signed commit'."\n", $commit->getMessage()); } - #[DataProvider('provideFoobar')] - public function testGetShortMessage(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetShortMessage(bool $bare): void { + $repository = self::createFoobarRepository($bare); // tests with a multi-line message $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); @@ -205,9 +225,10 @@ public function testGetShortMessage(Repository $repository): void $this->assertEquals('Add!!!', $commit->getShortMessage(1, true, '!!!')); } - #[DataProvider('provideFoobar')] - public function testGetBodyMessage(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetBodyMessage(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); $nl = \chr(10); $message = "If you want to know everything,{$nl}I ran something like `chmox +x test.sh`{$nl}{$nl}Hello and good bye.{$nl}"; @@ -218,9 +239,10 @@ public function testGetBodyMessage(Repository $repository): void $this->assertEquals('', $commit->getBodyMessage()); } - #[DataProvider('provideFoobar')] - public function testGetIncludingBranchesException(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetIncludingBranchesException(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(InvalidArgumentException::class); $commit = $repository->getCommit(self::INITIAL_COMMIT); @@ -228,9 +250,10 @@ public function testGetIncludingBranchesException(Repository $repository): void $commit->getIncludingBranches(false, false); } - #[DataProvider('provideFoobar')] - public function testGetIncludingBranches(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetIncludingBranches(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::INITIAL_COMMIT); $branches = $commit->getIncludingBranches(true, false); @@ -243,9 +266,10 @@ public function testGetIncludingBranches(Repository $repository): void $this->assertCount(\count($repository->getReferences()->getRemoteBranches()), $branches); } - #[DataProvider('provideFoobar')] - public function testGetLastModification(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetLastModification(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $lastModification = $commit->getLastModification('image.jpg'); @@ -254,17 +278,19 @@ public function testGetLastModification(Repository $repository): void $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $lastModification->getHash(), 'Last modification is current commit'); } - #[DataProvider('provideFoobar')] - public function testMergeCommit(Repository $repository): void + #[DataProvider('provideBare')] + public function testMergeCommit(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::MERGE_COMMIT); $this->assertEquals("Merge branch 'authors'", $commit->getSubjectMessage()); } - #[DataProvider('provideFoobar')] - public function testEncoding(Repository $repository): void + #[DataProvider('provideBare')] + public function testEncoding(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::ENCODING_COMMIT); $this->assertEquals('contribute to AUTHORS file', $commit->getSubjectMessage()); diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index ec0970a5..f9c7b00c 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -15,7 +15,6 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Diff\File; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; class DiffTest extends AbstractTestCase @@ -26,25 +25,28 @@ class DiffTest extends AbstractTestCase public const CHANGEMODE_COMMIT = '93da965f58170f13017477b9a608657e87e23230'; public const FILE_WITH_UMLAUTS_COMMIT = '8defb9217692dc1f4c18e05e343ca91cf5047702'; - #[DataProvider('provideFoobar')] - public function testSerialization(Repository $repository): void + #[DataProvider('provideBare')] + public function testSerialization(bool $bare): void { + $repository = self::createFoobarRepository($bare); $data = $repository->getCommit(self::CREATE_COMMIT)->getDiff()->toArray(); $diff = Diff::fromArray($data); $this->verifyCreateCommitDiff($diff); } - #[DataProvider('provideFoobar')] - public function testGetFilesAddition(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFilesAddition(bool $bare): void { + $repository = self::createFoobarRepository($bare); $diff = $repository->getCommit(self::CREATE_COMMIT)->getDiff(); $this->verifyCreateCommitDiff($diff); } - #[DataProvider('provideFoobar')] - public function testGetFilesModification(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFilesModification(bool $bare): void { + $repository = self::createFoobarRepository($bare); $files = $repository->getCommit(self::BEFORE_LONGFILE_COMMIT)->getDiff()->getFiles(); $this->assertCount(1, $files, '1 files in diff'); @@ -61,9 +63,10 @@ public function testGetFilesModification(Repository $repository): void $this->assertEquals(0, $files[0]->getDeletions(), '0 lines deleted'); } - #[DataProvider('provideFoobar')] - public function testGetFilesDeletion(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFilesDeletion(bool $bare): void { + $repository = self::createFoobarRepository($bare); $files = $repository->getCommit(self::DELETE_COMMIT)->getDiff()->getFiles(); $this->assertCount(1, $files, '1 files modified'); @@ -73,9 +76,10 @@ public function testGetFilesDeletion(Repository $repository): void $this->assertEquals(1, $files[0]->getDeletions(), '1 line deleted'); } - #[DataProvider('provideFoobar')] - public function testGetFilesRename(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFilesRename(bool $bare): void { + $repository = self::createFoobarRepository($bare); $files = $repository->getCommit(self::RENAME_COMMIT)->getDiff()->getFiles(); $this->assertCount(1, $files, '1 files modified'); @@ -87,9 +91,10 @@ public function testGetFilesRename(Repository $repository): void $this->assertFalse($files[0]->isChangeMode()); } - #[DataProvider('provideFoobar')] - public function testGetFilesChangemode(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFilesChangemode(bool $bare): void { + $repository = self::createFoobarRepository($bare); $files = $repository->getCommit(self::CHANGEMODE_COMMIT)->getDiff()->getFiles(); $this->assertCount(1, $files, '1 files modified'); @@ -101,9 +106,10 @@ public function testGetFilesChangemode(Repository $repository): void $this->assertFalse($files[0]->isRename()); } - #[DataProvider('provideFoobar')] - public function testDiffRangeParse(Repository $repository): void + #[DataProvider('provideBare')] + public function testDiffRangeParse(bool $bare): void { + $repository = self::createFoobarRepository($bare); $files = $repository->getCommit(self::CREATE_COMMIT)->getDiff()->getFiles(); $changes = $files[0]->getChanges(); @@ -115,9 +121,10 @@ public function testDiffRangeParse(Repository $repository): void $this->assertSame(1, $changes[0]->getRangeNewCount()); } - #[DataProvider('provideFoobar')] - public function testRootCommitDiffListsTheFilesItAdds(Repository $repository): void + #[DataProvider('provideBare')] + public function testRootCommitDiffListsTheFilesItAdds(bool $bare): void { + $repository = self::createFoobarRepository($bare); $files = $repository->getCommit(self::INITIAL_COMMIT)->getDiff()->getFiles(); $this->assertCount(1, $files); @@ -125,9 +132,10 @@ public function testRootCommitDiffListsTheFilesItAdds(Repository $repository): v $this->assertSame('README', $files[0]->getNewName()); } - #[DataProvider('provideFoobar')] - public function testWorksWithUmlauts(Repository $repository): void + #[DataProvider('provideBare')] + public function testWorksWithUmlauts(bool $bare): void { + $repository = self::createFoobarRepository($bare); $files = $repository->getCommit(self::FILE_WITH_UMLAUTS_COMMIT)->getDiff()->getFiles(); $this->assertSame('file_with_umlauts_äöü', $files[0]->getNewName()); } diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index b1ab8bee..47f2c585 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -27,9 +27,9 @@ public static function setUpWindows(): void { if (\defined('PHP_WINDOWS_VERSION_MAJOR')) { self::$symlinkOnWindows = true; - $originDir = tempnam(sys_get_temp_dir(), 'sl'); - $targetDir = tempnam(sys_get_temp_dir(), 'sl'); - if (true !== @symlink($originDir, $targetDir)) { + $dir = self::createTempDir(); + touch($dir.'/origin'); + if (true !== @symlink($dir.'/origin', $dir.'/target')) { $report = error_get_last(); if (\is_array($report) && str_contains($report['message'], 'error code(1314)')) { self::$symlinkOnWindows = false; @@ -68,33 +68,37 @@ public function assertNoHook(Repository $repository, string $hook): void $this->assertFileDoesNotExist($file, "Hook {$hook} is not present"); } - #[DataProvider('provideFoobar')] - public function testHas(Repository $repository): void + #[DataProvider('provideBare')] + public function testHas(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertNoHook($repository, 'foo'); $this->touchHook($repository, 'foo'); $this->assertHasHook($repository, 'foo'); } - #[DataProvider('provideFoobar')] - public function testGetInvalidNameThrowsException(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetInvalidNameThrowsException(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(InvalidArgumentException::class); $repository->getHooks()->get('foo'); } - #[DataProvider('provideFoobar')] - public function testGet(Repository $repository): void + #[DataProvider('provideBare')] + public function testGet(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->touchHook($repository, 'foo', 'foobar'); $this->assertEquals('foobar', $repository->getHooks()->get('foo')); } - #[DataProvider('provideFoobar')] - public function testSymlink(Repository $repository): void + #[DataProvider('provideBare')] + public function testSymlink(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->markAsSkippedIfSymlinkIsMissing(); $file = $this->touchHook($repository, 'bar', 'barbarbar'); @@ -109,9 +113,10 @@ public function testSymlink(Repository $repository): void ); } - #[DataProvider('provideFoobar')] - public function testSymlinkWithExistingThrowsLogicException(Repository $repository): void + #[DataProvider('provideBare')] + public function testSymlinkWithExistingThrowsLogicException(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(LogicException::class); $this->markAsSkippedIfSymlinkIsMissing(); @@ -125,9 +130,10 @@ public function testSymlinkWithExistingThrowsLogicException(Repository $reposito $repository->getHooks()->setSymlink('foo', $file); } - #[DataProvider('provideFoobar')] - public function testSet(Repository $repository): void + #[DataProvider('provideBare')] + public function testSet(bool $bare): void { + $repository = self::createFoobarRepository($bare); $file = $this->hookPath($repository, 'foo'); $repository->getHooks()->set('foo', 'bar'); @@ -137,9 +143,10 @@ public function testSet(Repository $repository): void $this->assertEquals(\defined('PHP_WINDOWS_VERSION_BUILD') ? 0o666 : 0o700, $perms & 0o777, 'Hook permissions are correct'); } - #[DataProvider('provideFoobar')] - public function testSetExistingThrowsLogicException(Repository $repository): void + #[DataProvider('provideBare')] + public function testSetExistingThrowsLogicException(bool $bare): void { + $repository = self::createFoobarRepository($bare); $repository->getHooks()->set('foo', 'bar'); $this->expectException(LogicException::class); @@ -147,9 +154,10 @@ public function testSetExistingThrowsLogicException(Repository $repository): voi $repository->getHooks()->set('foo', 'bar'); } - #[DataProvider('provideFoobar')] - public function testRemove(Repository $repository): void + #[DataProvider('provideBare')] + public function testRemove(bool $bare): void { + $repository = self::createFoobarRepository($bare); $file = $this->hookPath($repository, 'foo'); touch($file); @@ -158,9 +166,10 @@ public function testRemove(Repository $repository): void $this->assertFileDoesNotExist($file); } - #[DataProvider('provideFoobar')] - public function testRemoveNotExistingThrowsLogicException(Repository $repository): void + #[DataProvider('provideBare')] + public function testRemoveNotExistingThrowsLogicException(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(LogicException::class); $repository->getHooks()->remove('foo'); diff --git a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php index f6c5fbe4..01a65e4c 100644 --- a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php +++ b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php @@ -14,13 +14,12 @@ use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use PHPUnit\Framework\Attributes\RequiresPhpExtension; -use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; /** * @author Grégoire Pineau */ -class LibGit2DriverTest extends TestCase +class LibGit2DriverTest extends AbstractTestCase { private const string AUTOLOAD = __DIR__.'/../../../../vendor/autoload.php'; @@ -51,7 +50,7 @@ public function testPreloadKeepsLibgit2LoadedForTheProcess(): void $this->markTestSkipped('libgit2 1.9 is not installed.'); } - $preload = AbstractTestCase::createTempDir().'/preload.php'; + $preload = self::createTempDir().'/preload.php'; file_put_contents($preload, \sprintf('getHeadCommit()->getHash(); PHP; - $repository = AbstractTestCase::createFoobarRepository(); + $repository = self::createFoobarRepository(); $process = new Process([\PHP_BINARY, '-d', 'opcache.enable_cli=1', '-d', 'opcache.preload='.$preload, '-r', $code, self::AUTOLOAD, $repository->getGitDir()]); $process->mustRun(); diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 4fab5bfd..36445201 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -21,9 +21,10 @@ class LogTest extends AbstractTestCase { - #[DataProvider('provideFoobar')] - public function testRevisionAndPath(Repository $repository): void + #[DataProvider('provideBare')] + public function testRevisionAndPath(bool $bare): void { + $repository = self::createFoobarRepository($bare); $logReadme = $repository->getLog(self::LONGFILE_COMMIT, 'README'); $logImage = $repository->getLog(self::LONGFILE_COMMIT, 'image.jpg'); @@ -31,9 +32,10 @@ public function testRevisionAndPath(Repository $repository): void $this->assertCount(2, $logImage); } - #[DataProvider('provideFoobar')] - public function testGetCommits(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetCommits(bool $bare): void { + $repository = self::createFoobarRepository($bare); $log = $repository->getLog(self::LONGFILE_COMMIT, null, null, 3); $commits = $log->getCommits(); @@ -43,25 +45,28 @@ public function testGetCommits(Repository $repository): void $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commits[1]->getHash(), "Second is longfile parent\\'s"); } - #[DataProvider('provideFoobar')] - public function testCountCommits(Repository $repository): void + #[DataProvider('provideBare')] + public function testCountCommits(bool $bare): void { + $repository = self::createFoobarRepository($bare); $log = $repository->getLog(self::LONGFILE_COMMIT, null, 2, 3); $this->assertEquals(8, $log->countCommits(), '8 commits found in history'); } - #[DataProvider('provideFoobar')] - public function testCountAllCommits(Repository $repository): void + #[DataProvider('provideBare')] + public function testCountAllCommits(bool $bare): void { + $repository = self::createFoobarRepository($bare); $log = $repository->getLog(); $this->assertGreaterThan(100, $log->countCommits(), 'Returns all commits from all branches'); } - #[DataProvider('provideFoobar')] - public function testIterable(Repository $repository): void + #[DataProvider('provideBare')] + public function testIterable(bool $bare): void { + $repository = self::createFoobarRepository($bare); $log = $repository->getLog(self::LONGFILE_COMMIT); $expectedHashes = [self::LONGFILE_COMMIT, self::BEFORE_LONGFILE_COMMIT]; @@ -127,9 +132,10 @@ public function testParsesCommitsWithAndWithoutGitButlerHeaders(): void $this->assertEquals("Another commit without GitButler headers\n", $log[2]->message); } - #[DataProvider('provideFoobar')] - public function testLogOnPathSkipsMergesThatDoNotChangeIt(Repository $repository): void + #[DataProvider('provideBare')] + public function testLogOnPathSkipsMergesThatDoNotChangeIt(bool $bare): void { + $repository = self::createFoobarRepository($bare); $log = $repository->getLog(null, 'README.md'); $expected = ['1917a09c23eb0129d38d4e6d6a492c7830f8d423', 'd8930cd3f7e343195b85d56408a701feb98b95a8']; diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index d965df07..c137b020 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -14,7 +14,6 @@ use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\PushReference; -use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; class PushReferenceTest extends AbstractTestCase @@ -52,9 +51,10 @@ public function testUnknownBeforeIsAnErrorNotAForcePush(): void new PushReference(self::createFoobarRepository(), 'foo', str_repeat('1', 40), self::LONGFILE_COMMIT); } - #[DataProvider('provideFoobar')] - public function testLog(Repository $repository): void + #[DataProvider('provideBare')] + public function testLog(bool $bare): void { + $repository = self::createFoobarRepository($bare); $ref = new PushReference($repository, 'foo', self::INITIAL_COMMIT, self::LONGFILE_COMMIT); $log = $ref->getLog()->getCommits(); @@ -65,18 +65,20 @@ public function testLog(Repository $repository): void /** * This test ensures that GPG signed requests does not break the reading of commit logs. */ - #[DataProvider('provideFoobar')] - public function testSignedLog(Repository $repository): void + #[DataProvider('provideBare')] + public function testSignedLog(bool $bare): void { + $repository = self::createFoobarRepository($bare); $ref = new PushReference($repository, 'foo', self::INITIAL_COMMIT, self::SIGNED_COMMIT); $log = $ref->getLog()->getCommits(); $this->assertCount(16, $log, '16 commits in log'); $this->assertEquals('signed commit', $log[0]->getShortMessage(), 'Last commit is correct'); } - #[DataProvider('provideFoobar')] - public function testLogWithExclude(Repository $repository): void + #[DataProvider('provideBare')] + public function testLogWithExclude(bool $bare): void { + $repository = self::createFoobarRepository($bare); $ref = new PushReference($repository, 'foo', PushReference::ZERO, self::LONGFILE_COMMIT); $log = $ref->getLog([self::INITIAL_COMMIT])->getCommits(); diff --git a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php index cee0abf5..0726afc0 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php @@ -13,14 +13,14 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Reference\Branch; -use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; class ReferenceBagTest extends AbstractTestCase { - #[DataProvider('provideFoobar')] - public function testGetFirstBranch(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFirstBranch(bool $bare): void { + $repository = self::createFoobarRepository($bare); $branch = $repository->getReferences()->getFirstBranch(); $this->assertInstanceOf(Branch::class, $branch); @@ -41,9 +41,10 @@ public function testGetFirstBranchWithNoBranches(): void $this->assertFalse($refs->getFirstBranch(), 'getFirstBranch() must not return a non-branch reference'); } - #[DataProvider('provideFoobar')] - public function testUnknownReference(Repository $repository): void + #[DataProvider('provideBare')] + public function testUnknownReference(bool $bare): void { + $repository = self::createFoobarRepository($bare); $hash = $repository->getLog()->getSingleCommit()->getHash(); self::git($repository, 'update-ref', ['refs/pipelines/1', $hash]); @@ -64,9 +65,10 @@ public function testUnknownReference(Repository $repository): void $this->assertArrayNotHasKey('refs/notes/gtm-data', $refs); } - #[DataProvider('provideEmpty')] - public function testEmptyRepo(Repository $repository): void + #[DataProvider('provideBare')] + public function testEmptyRepo(bool $bare): void { + $repository = self::createEmptyRepository($bare); $refs = $repository->getReferences()->getAll(); $this->assertSame([], $refs); } diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index d4d77e70..27aa6f66 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -15,52 +15,57 @@ use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Tag; -use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; class ReferenceTest extends AbstractTestCase { - #[DataProvider('provideEmpty')] - public function testEmptyRepository(Repository $repository): void + #[DataProvider('provideBare')] + public function testEmptyRepository(bool $bare): void { + $repository = self::createEmptyRepository($bare); $this->assertCount(0, $repository->getReferences()); $this->assertEquals([], $repository->getReferences()->getAll()); } - #[DataProvider('provideFoobar')] - public function testGetBranch(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetBranch(bool $bare): void { + $repository = self::createFoobarRepository($bare); $branch = $repository->getReferences()->getBranch('master'); $this->assertInstanceOf(Branch::class, $branch, 'Branch object is correct type'); $this->assertEquals($branch->getCommitHash(), $branch->getCommit()->getHash(), 'Hash is correctly resolved'); } - #[DataProvider('provideFoobar')] - public function testHasBranch(Repository $repository): void + #[DataProvider('provideBare')] + public function testHasBranch(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertTrue($repository->getReferences()->hasBranch('master'), 'Branch master exists'); $this->assertFalse($repository->getReferences()->hasBranch('foobar'), 'Branch foobar does not exists'); } - #[DataProvider('provideFoobar')] - public function testHasTag(Repository $repository): void + #[DataProvider('provideBare')] + public function testHasTag(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertTrue($repository->getReferences()->hasTag('0.1'), 'Tag 0.1 exists'); $this->assertFalse($repository->getReferences()->hasTag('foobar'), 'Tag foobar does not exists'); } - #[DataProvider('provideFoobar')] - public function testGetBranchNotExistingError(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetBranchNotExistingError(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(ReferenceNotFoundException::class); $repository->getReferences()->getBranch('notexisting'); } - #[DataProvider('provideFoobar')] - public function testGetTag(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetTag(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tag = $repository->getReferences()->getTag('0.1'); $this->assertInstanceOf(Tag::class, $tag, 'Tag object is correct type'); @@ -70,9 +75,10 @@ public function testGetTag(Repository $repository): void $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommit()->getHash(), 'Commit hash is correct'); } - #[DataProvider('provideFoobar')] - public function testAnnotatedTag(Repository $repository): void + #[DataProvider('provideBare')] + public function testAnnotatedTag(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tag = $repository->getReferences()->getTag('annotated'); $this->assertInstanceOf(Tag::class, $tag, 'Tag object is correct type'); @@ -94,9 +100,10 @@ public function testAnnotatedTag(Repository $repository): void $this->assertEquals('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommit()->getHash(), 'Tag commit is correct'); } - #[DataProvider('provideFoobar')] - public function testAnnotatedTagBodyMessageWithBlankLineSeparator(Repository $repository): void + #[DataProvider('provideBare')] + public function testAnnotatedTagBodyMessageWithBlankLineSeparator(bool $bare): void { + $repository = self::createFoobarRepository($bare); self::git($repository, 'config', ['user.email', 'test@example.com']); self::git($repository, 'config', ['user.name', 'Test']); @@ -109,17 +116,19 @@ public function testAnnotatedTagBodyMessageWithBlankLineSeparator(Repository $re $this->assertEquals("Body line 1\nBody line 2", $tag->getBodyMessage(), 'Message body has no leading blank line'); } - #[DataProvider('provideFoobar')] - public function testGetTagNotExistingError(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetTagNotExistingError(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(ReferenceNotFoundException::class); $repository->getReferences()->getTag('notexisting'); } - #[DataProvider('provideFoobar')] - public function testResolve(Repository $repository): void + #[DataProvider('provideBare')] + public function testResolve(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getReferences()->getTag('0.1')->getCommit(); $resolved = $repository->getReferences()->resolve($commit->getHash()); @@ -127,9 +136,10 @@ public function testResolve(Repository $repository): void $this->assertInstanceOf(Tag::class, reset($resolved), 'Resolved object is a tag'); } - #[DataProvider('provideFoobar')] - public function testResolveTags(Repository $repository): void + #[DataProvider('provideBare')] + public function testResolveTags(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getReferences()->getTag('0.1')->getCommit(); $resolved = $repository->getReferences()->resolveTags($commit->getHash()); @@ -137,9 +147,10 @@ public function testResolveTags(Repository $repository): void $this->assertInstanceOf(Tag::class, reset($resolved), 'Resolved object is a tag'); } - #[DataProvider('provideFoobar')] - public function testResolveBranches(Repository $repository): void + #[DataProvider('provideBare')] + public function testResolveBranches(bool $bare): void { + $repository = self::createFoobarRepository($bare); $master = $repository->getReferences()->getBranch('master'); $resolved = $repository->getReferences()->resolveBranches($master->getCommitHash()); @@ -153,15 +164,17 @@ public function testResolveBranches(Repository $repository): void $this->assertInstanceOf(Branch::class, reset($resolved), 'Resolved object is a branch'); } - #[DataProvider('provideFoobar')] - public function testCountable(Repository $repository): void + #[DataProvider('provideBare')] + public function testCountable(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertGreaterThanOrEqual(2, \count($repository->getReferences()), 'At least two references in repository'); } - #[DataProvider('provideFoobar')] - public function testIterable(Repository $repository): void + #[DataProvider('provideBare')] + public function testIterable(bool $bare): void { + $repository = self::createFoobarRepository($bare); $i = 0; foreach ($repository->getReferences() as $ref) { ++$i; @@ -169,9 +182,10 @@ public function testIterable(Repository $repository): void $this->assertGreaterThanOrEqual(2, $i, 'At least two references in repository'); } - #[DataProvider('provideFoobar')] - public function testCreateAndDeleteTag(Repository $repository): void + #[DataProvider('provideBare')] + public function testCreateAndDeleteTag(bool $bare): void { + $repository = self::createFoobarRepository($bare); $references = $repository->getReferences(); $tag = $references->createTag('0.0', self::INITIAL_COMMIT); @@ -183,9 +197,10 @@ public function testCreateAndDeleteTag(Repository $repository): void $this->assertFalse($references->hasTag('0.0'), 'Tag 0.0 removed'); } - #[DataProvider('provideFoobar')] - public function testCreateAndDeleteBranch(Repository $repository): void + #[DataProvider('provideBare')] + public function testCreateAndDeleteBranch(bool $bare): void { + $repository = self::createFoobarRepository($bare); $references = $repository->getReferences(); $branch = $references->createBranch('foobar', self::INITIAL_COMMIT); diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index e86ca065..2c92d36b 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -36,7 +36,6 @@ public function testInitBare(): void { $dir = self::createTempDir(); $repository = Repository::initBare($dir, driver: self::getDriver()); - self::registerDeletion($repository); $this->assertTrue($repository->isBare(), 'Repository is bare'); $this->assertDirectoryExists($dir.'/objects', 'objects/ folder is present'); @@ -48,7 +47,6 @@ public function testInit(): void { $dir = self::createTempDir(); $repository = Repository::init($dir, driver: self::getDriver()); - self::registerDeletion($repository); $this->assertFalse($repository->isBare(), 'Repository is not bare'); $this->assertDirectoryExists($dir.'/.git/objects', 'objects/ folder is present'); @@ -71,7 +69,6 @@ public function testNamedConstructorsGiveTheLoggerToTheRepository(): void $logger = $this->createStub(LoggerInterface::class); $repository = Repository::initBare(self::createTempDir(), $logger, self::getDriver()); - self::registerDeletion($repository); $this->assertSame($logger, $repository->getLogger()); } @@ -82,7 +79,6 @@ public function testCloneBareFrom(): void $newDir = self::createTempDir(); $new = Repository::cloneBareFrom($repository->getGitDir(), $newDir, driver: self::getDriver()); - self::registerDeletion($new); $newRefs = array_keys($new->getReferences()->getAll()); @@ -100,7 +96,6 @@ public function testCloneFrom(): void $newDir = self::createTempDir(); $new = Repository::cloneFrom($repository->getGitDir(), $newDir, driver: self::getDriver()); - self::registerDeletion($new); $newRefs = array_keys($new->getReferences()->getAll()); @@ -118,7 +113,6 @@ public function testCloneBareFromABranch(): void $newDir = self::createTempDir(); $new = Repository::cloneBareFrom(self::repositoryUrl(), $newDir, 'new-feature', driver: self::getDriver()); - self::registerDeletion($new); $head = $new->getHead(); $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); @@ -133,7 +127,6 @@ public function testCloneFromABranch(): void $newDir = self::createTempDir(); $new = Repository::cloneFrom(self::repositoryUrl(), $newDir, 'new-feature', driver: self::getDriver()); - self::registerDeletion($new); $head = $new->getHead(); $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); @@ -147,12 +140,12 @@ public function testCloneFromAnUnknownBranchFails(): void Repository::cloneBareFrom(self::repositoryUrl(), self::createTempDir().'/clone', 'unknown', driver: self::getDriver()); } - #[DataProvider('provideFoobar')] - public function testMirrorFrom(Repository $repository): void + #[DataProvider('provideBare')] + public function testMirrorFrom(bool $bare): void { + $repository = self::createFoobarRepository($bare); $newDir = self::createTempDir(); $new = Repository::mirrorFrom($repository->getGitDir(), $newDir, driver: self::getDriver()); - self::registerDeletion($new); $newRefs = array_keys($new->getReferences()->getAll()); @@ -171,9 +164,10 @@ public function testMirrorFrom(Repository $repository): void $this->assertSame('refs/heads/master', trim(self::git($new, 'symbolic-ref', ['HEAD']))); } - #[DataProvider('provideFoobar')] - public function testCheckValidRepository(Repository $repository): void + #[DataProvider('provideBare')] + public function testCheckValidRepository(bool $bare): void { + $repository = self::createFoobarRepository($bare); $url = $repository->getGitDir(); $this->assertTrue(Repository::isValidRepository($url, self::getDriver())); } @@ -186,23 +180,26 @@ public function testCheckInvalidRepository(): void $this->assertFalse(Repository::isValidRepository($url, self::getDriver())); } - #[DataProvider('provideFoobar')] - public function testCheckValidRepositoryAndBranch(Repository $repository): void + #[DataProvider('provideBare')] + public function testCheckValidRepositoryAndBranch(bool $bare): void { + $repository = self::createFoobarRepository($bare); $url = $repository->getGitDir(); $this->assertTrue(Repository::isValidRepositoryAndBranch($url, 'master', self::getDriver())); } - #[DataProvider('provideFoobar')] - public function testCheckInvalidRepositoryAndBranch(Repository $repository): void + #[DataProvider('provideBare')] + public function testCheckInvalidRepositoryAndBranch(bool $bare): void { + $repository = self::createFoobarRepository($bare); $url = $repository->getGitDir(); $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'invalid-branch-name', self::getDriver())); } - #[DataProvider('provideFoobar')] - public function testCheckRepositoryAndBranchOnlyMatchesBranches(Repository $repository): void + #[DataProvider('provideBare')] + public function testCheckRepositoryAndBranchOnlyMatchesBranches(bool $bare): void { + $repository = self::createFoobarRepository($bare); foreach (['0.1', 'HEAD', 'feature'] as $name) { $this->assertFalse(Repository::isValidRepositoryAndBranch($repository->getGitDir(), $name, self::getDriver()), $name); } @@ -255,18 +252,20 @@ public function testGetShortHashOfAnUnknownCommitThrowsReferenceNotFound(): void $commit->getShortHash(); } - #[DataProvider('provideFoobar')] - public function testGetBlobWithExistingWorks(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetBlobWithExistingWorks(bool $bare): void { + $repository = self::createFoobarRepository($bare); $blob = $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('README.md'); $this->assertInstanceOf(Blob::class, $blob, 'getBlob() returns a Blob object'); $this->assertStringContainsString('Foo Bar project', $blob->getContent(), 'file is correct'); } - #[DataProvider('provideFoobar')] - public function testGetSize(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetSize(bool $bare): void { + $repository = self::createFoobarRepository($bare); $size = $repository->getSize(); // The exact figure depends on git's packing/compression, which varies across versions. $this->assertGreaterThanOrEqual(40, $size, 'Repository is at least 40KB'); @@ -282,15 +281,17 @@ public function testIsBare(): void $this->assertFalse($notBare->isBare(), 'Working copy is not bare'); } - #[DataProvider('provideFoobar')] - public function testGetDescription(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetDescription(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertSame("Unnamed repository; edit this file 'description' to name the repository.\n", $repository->getDescription()); } - #[DataProvider('provideFoobar')] - public function testLoggerOk(Repository $repository): void + #[DataProvider('provideBare')] + public function testLoggerOk(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->skipUnlessCliDriver(); $logger = $this->createMock(LoggerInterface::class); @@ -308,9 +309,10 @@ public function testLoggerOk(Repository $repository): void $repository->run('remote'); } - #[DataProvider('provideFoobar')] - public function testLoggerNOk(Repository $repository): void + #[DataProvider('provideBare')] + public function testLoggerNOk(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->skipUnlessCliDriver(); $this->expectException(RuntimeException::class); diff --git a/tests/Gitonomy/Git/Tests/RevisionTest.php b/tests/Gitonomy/Git/Tests/RevisionTest.php index 1dbbbfc5..14d60601 100644 --- a/tests/Gitonomy/Git/Tests/RevisionTest.php +++ b/tests/Gitonomy/Git/Tests/RevisionTest.php @@ -15,15 +15,15 @@ use Gitonomy\Git\Commit; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Log; -use Gitonomy\Git\Repository; use Gitonomy\Git\Revision; use PHPUnit\Framework\Attributes\DataProvider; class RevisionTest extends AbstractTestCase { - #[DataProvider('provideFoobar')] - public function testGetCommit(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetCommit(bool $bare): void { + $repository = self::createFoobarRepository($bare); $revision = $repository->getRevision(self::LONGFILE_COMMIT.'^'); $this->assertInstanceOf(Revision::class, $revision, 'Revision object type'); @@ -35,18 +35,20 @@ public function testGetCommit(Repository $repository): void $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commit->getHash(), 'Resolution is correct'); } - #[DataProvider('provideFoobar')] - public function testGetFailingReference(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFailingReference(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(ReferenceNotFoundException::class); $this->expectExceptionMessage('Can not find revision "non-existent-commit"'); $repository->getRevision('non-existent-commit')->getCommit(); } - #[DataProvider('provideFoobar')] - public function testGetLog(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetLog(bool $bare): void { + $repository = self::createFoobarRepository($bare); $revision = $repository->getRevision(self::LONGFILE_COMMIT); $log = $revision->getLog(null, 2, 3); diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index fdc4b1d6..c56eac66 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -14,16 +14,16 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\CommitReference; -use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; class TreeTest extends AbstractTestCase { public const PATH_RESOLVING_COMMIT = 'cc06ac171d884282202dff88c1ded499a1f89420'; - #[DataProvider('provideFoobar')] - public function testGetEntries(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetEntries(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::LONGFILE_COMMIT)->getTree(); $entries = $tree->getEntries(); @@ -35,9 +35,10 @@ public function testGetEntries(Repository $repository): void $this->assertTrue($entries['README.md'][1] instanceof Blob, 'README.md is a Blob'); } - #[DataProvider('provideFoobar')] - public function testGetEntriesWithNonAsciiNames(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetEntriesWithNonAsciiNames(bool $bare): void { + $repository = self::createFoobarRepository($bare); $entries = $repository->getCommit(DiffTest::FILE_WITH_UMLAUTS_COMMIT)->getTree()->getEntries(); $this->assertArrayHasKey('file_with_umlauts_äöü', $entries); @@ -57,9 +58,10 @@ public function testGetEntriesWithSpecialCharactersInNames(): void $this->assertArrayHasKey('tab\t"quote"', $entries); } - #[DataProvider('provideFoobar')] - public function testGetCommitReferenceEntries(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetCommitReferenceEntries(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); $commits = $tree->getCommitReferenceEntries(); @@ -68,9 +70,10 @@ public function testGetCommitReferenceEntries(Repository $repository): void $this->assertTrue($commits['barbaz'][1] instanceof CommitReference, 'barbaz is a Commit'); } - #[DataProvider('provideFoobar')] - public function testGetTreeEntries(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetTreeEntries(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); $trees = $tree->getTreeEntries(); @@ -78,9 +81,10 @@ public function testGetTreeEntries(Repository $repository): void $this->assertEmpty($trees); } - #[DataProvider('provideFoobar')] - public function testGetBlobEntries(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetBlobEntries(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); $blobs = $tree->getBlobEntries(); @@ -89,9 +93,10 @@ public function testGetBlobEntries(Repository $repository): void $this->assertTrue($blobs['README.md'][1] instanceof Blob, 'README.md is a blob'); } - #[DataProvider('provideFoobar')] - public function testResolvePath(Repository $repository): void + #[DataProvider('provideBare')] + public function testResolvePath(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::PATH_RESOLVING_COMMIT)->getTree(); $path = 'test/a/b/c'; From 1290154eeb35d1266ba093ca88102fee190fb30e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 14:07:52 +0200 Subject: [PATCH 090/150] Redesign the domain API for 2.0 Repositories - The logger configures CliDriver, which logs every git command it runs, init, clone and ls-remote included. The constructor of Repository takes the working directory as second argument: an array of options there is deprecated, its logger and git options configuring a CliDriver, and so are setLogger() and getLogger(). - CliDriver takes no inheritEnvironmentVariables, which passed $_SERVER, the credentials of an HTTP request included, to git: the deprecated option applies to the processes of a repository, like in 1.x, and not to the commands of Admin, which ignored it. - Both drivers find the git directory and the working tree with the same code, a ".git" file included: linked worktrees and submodules open. A directory that isn't a repository throws an InvalidArgumentException right away, and a SHA-256 repository an UnsupportedOperationException. HEAD is read through the driver, not from the HEAD file, which a reftable repository doesn't use. - isValidRepositoryAndBranch() matches the name of the branch exactly: "release" matched "feature/release". isValidRepository() reads a relative path as a path, not as a remote of PHP's working directory, and throws when git can't run. A clone checks a tag given as branch out, HEAD detached, with both drivers. References - ReferenceBag keeps one list, read again after every write gitlib makes (its own, a checkout, a merge, run()...): a tag it created missed from getTags(), a deleted branch stayed, and HEAD pointed to an unknown branch after a checkout. Passing $reload to getReferences() is deprecated. - A reference holds the commit it points to, through an annotated tag (show-ref --dereference): resolveTags(), resolve() and Commit::resolveReferences() find annotated tags, and resolve() takes a revision name too. The getters of a tag return null instead of false, and SSH and X.509 signatures are recognized. - createBranch() and createTag() refuse an existing reference instead of moving it, and take a revision. Branch::moveTo() replaces ReferenceBag::update(), and isMergedInto() isMergedTo(); both are deprecated, as the arguments of Commit::getIncludingBranches(). A branch named "remotes/x" is local, and only the HEAD of a remote, named "foo/bar" or not, is skipped, not every branch ending with HEAD. Deleting a missing reference does nothing, like git update-ref -d. Diffs - Repository::getDiff() takes the commits to compare, $from and $to, and getMergeBase() gives the base of "a...b": calling getDiff() with one argument is deprecated, as Log::getDiff(), which ignored the paths of the log. WorkingCopy::getDiff() compares the working tree with a revision. The diff of a commit is against its first parent, with both drivers: the CLI one threw for most merges, and the libgit2 one concatenated a diff for each parent. - DiffParser reads the files from the raw information (with -z or not), and the hunks only from the patches: names with spaces were split at the wrong place, hashes were shifted after a type change, a hunk of one line was counted as empty, and diffs threw during a conflict. During a conflict, WorkingCopy::getDiff() compares the unmerged files to the revision, like git diff HEAD, and the other diffs leave them out. - The drivers return DiffData, which the domain builds a Diff from, with its repository. Diff, File and FileChange are given their changes when constructed: setRepository() and addChange() are deprecated. The lines of a hunk are DiffLine objects, with a DiffLineType; reading them as arrays is deprecated, as FileChange::getCount() and its LINE_* constants, File::isChangeMode(), isDelete() and getAnchor(). Logs - Log resolves its revisions, ranges, names to exclude and "--all" once, and gives the driver commits to include and to exclude: the libgit2 driver no longer reimplements git's syntax, and an option is refused instead of being passed to git. The driver returns a list of CommitData, each with its hash. - withOffset() and withLimit() return another Log: setOffset() and setLimit() are deprecated. count() is the number of commits the log iterates. - The log parser reads the headers of a commit by their format: the merge of an unsigned or SSH-signed tag threw, or swallowed the commits up to the next PGP signature. Blames, trees and the working copy - getBlame() takes the first and the last lines to blame, numbered from the blamed file, a range as a string being deprecated, and getHunks() returns Blame\Hunk objects. Like git, the lines 5 to 3 are the lines 3 to 5. LibGit2Driver counted one line too many in a file not ending with a line break. - A tree lists TreeEntry objects, with a Blob, a Tree or a Submodule, which CommitReference becomes, typed by an ObjectType enum. Reading an entry as an array is deprecated. resolvePath() explains why a path doesn't resolve. - WorkingCopy::checkout() takes a Revision: a local Branch attaches HEAD. checkoutNewBranch() replaces its second argument, and merge() takes a FastForward enum instead of arguments of git merge, both deprecated. Consistency - Both drivers throw a ReferenceNotFoundException, now an InvalidArgumentException of gitlib, when an object doesn't exist or is of another type, a ProcessTimedOutException on timeout, and a DriverException for the other failures of Symfony Process: no exception of PHP or Symfony leaks anymore, a date out of range included, and every message is a sentence. - An argument holding a NUL byte throws an InvalidArgumentException: libgit2 read "README.md\0.php" as README.md, and deleting "refs/heads/tmp\0x" deleted refs/heads/tmp. - getTree() and getBlob() take hashes only, abbreviated or not, of an object of the right type: CliDriver reads objects with cat-file --batch, which gives their type, where ls-tree read the tree of a commit. The caches of commits, trees and blobs keep weak references, and forget the hashes of the objects freed. - Blob::getMimetype() returns the mimetype alone, or throws, instead of false. - The subject of a commit or a tag is its first paragraph, and its body what follows, like git log --format=%s and %b. - Hooks create the hooks directory, write those of a linked worktree in its repository, and throw a RuntimeException when they fail. - PushReference runs git only when isForce() needs it; getFullname() and getRange() replace getReference() and getRevision(). - Repository::shell(), Commit::getShortMessage() and getFixedShortHash() are deprecated. LibGit2Driver, aligned with git where it differed - checkout() checks out a branch before a tag of the same name, and validates everything before changing the working tree; - clones refuse a directory with files and clean up when they fail; a mirror doesn't fetch tags twice, and no clone leaves a FETCH_HEAD; - a merge refuses unrelated histories and staged changes, and signs with GIT_AUTHOR_* and GIT_COMMITTER_*; - diffs use the indent heuristic, network operations the proxy git would use, references are sorted by name, and "." is the root; - a repository removed meanwhile throws, instead of having no reference, and so does an absolute path outside the working tree; - a libgit2 built for SHA-256 is refused. Tests - A test compares what both drivers read from the same repository: references, history, trees, blobs, tags, diffs and blames. - The tests disable the background maintenance of git, which wrote in a repository a test was deleting. --- src/Gitonomy/Git/Admin.php | 42 +- src/Gitonomy/Git/Blame.php | 101 ++- src/Gitonomy/Git/Blame/Hunk.php | 45 + src/Gitonomy/Git/Blame/Line.php | 34 +- src/Gitonomy/Git/Blob.php | 17 +- src/Gitonomy/Git/Commit.php | 92 +- src/Gitonomy/Git/CommitReference.php | 17 +- src/Gitonomy/Git/Diff/Diff.php | 59 +- src/Gitonomy/Git/Diff/DiffLine.php | 77 ++ src/Gitonomy/Git/Diff/DiffLineType.php | 26 + src/Gitonomy/Git/Diff/File.php | 162 ++-- src/Gitonomy/Git/Diff/FileChange.php | 75 +- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 103 ++- .../Git/Driver/Cli/CliRepositoryDriver.php | 294 +++++-- .../Git/Driver/Cli/GitProcessFactory.php | 56 -- src/Gitonomy/Git/Driver/Cli/GitRunner.php | 94 ++ src/Gitonomy/Git/Driver/Cli/LegacyOptions.php | 86 +- .../Git/Driver/Cli/Parser/BlameParser.php | 10 +- .../Git/Driver/Cli/Parser/LogParser.php | 84 +- .../Git/Driver/Cli/Parser/ReferenceParser.php | 10 + .../Git/Driver/Cli/Parser/TagParser.php | 20 +- .../Git/Driver/Cli/Parser/TreeParser.php | 32 +- .../Git/Driver/Data/BlameLineData.php | 9 +- src/Gitonomy/Git/Driver/Data/CommitData.php | 1 + src/Gitonomy/Git/Driver/Data/DiffData.php | 31 + src/Gitonomy/Git/Driver/Data/DiffFileData.php | 55 ++ .../{TreeEntryType.php => ObjectType.php} | 7 +- .../Git/Driver/Data/ReferenceData.php | 5 + src/Gitonomy/Git/Driver/Data/TagData.php | 28 +- .../Git/Driver/Data/TreeEntryData.php | 2 +- src/Gitonomy/Git/Driver/DriverInterface.php | 34 +- src/Gitonomy/Git/Driver/Ffi/LibGit2.php | 28 +- src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 215 ++++- .../Driver/Ffi/LibGit2RepositoryDriver.php | 808 ++++++++++-------- src/Gitonomy/Git/Driver/Ffi/PathFilter.php | 4 +- src/Gitonomy/Git/Driver/Ffi/libgit2.h | 11 +- .../Git/Driver/RepositoryDriverInterface.php | 117 ++- src/Gitonomy/Git/Driver/RepositoryPaths.php | 85 ++ .../Exception/InvalidArgumentException.php | 2 +- .../Exception/ProcessTimedOutException.php | 22 + .../Exception/ReferenceNotFoundException.php | 9 +- src/Gitonomy/Git/FastForward.php | 36 + src/Gitonomy/Git/Hooks.php | 86 +- src/Gitonomy/Git/Log.php | 159 +++- src/Gitonomy/Git/Parser/DiffParser.php | 320 ++++--- src/Gitonomy/Git/Parser/ParserBase.php | 54 +- src/Gitonomy/Git/PushReference.php | 67 +- src/Gitonomy/Git/Reference.php | 36 +- src/Gitonomy/Git/Reference/Branch.php | 53 +- src/Gitonomy/Git/Reference/Tag.php | 149 +--- src/Gitonomy/Git/ReferenceBag.php | 258 +++--- src/Gitonomy/Git/Repository.php | 428 ++++++---- src/Gitonomy/Git/Revision.php | 14 +- src/Gitonomy/Git/RevisionList.php | 8 +- src/Gitonomy/Git/Submodule.php | 38 + src/Gitonomy/Git/Tree.php | 132 ++- src/Gitonomy/Git/TreeEntry.php | 82 ++ src/Gitonomy/Git/Util/Message.php | 39 + src/Gitonomy/Git/Util/NulByte.php | 38 + src/Gitonomy/Git/Util/WeakCache.php | 62 ++ src/Gitonomy/Git/WorkingCopy.php | 109 ++- tests/Gitonomy/Git/Tests/AbstractTestCase.php | 97 ++- tests/Gitonomy/Git/Tests/AdminTest.php | 28 +- tests/Gitonomy/Git/Tests/BlameTest.php | 81 +- tests/Gitonomy/Git/Tests/BlobTest.php | 61 +- tests/Gitonomy/Git/Tests/CommitTest.php | 124 ++- tests/Gitonomy/Git/Tests/DiffTest.php | 274 +++++- tests/Gitonomy/Git/Tests/DriverParityTest.php | 150 ++++ tests/Gitonomy/Git/Tests/HooksTest.php | 53 +- .../Gitonomy/Git/Tests/LibGit2HeaderTest.php | 4 +- tests/Gitonomy/Git/Tests/LogTest.php | 146 +++- tests/Gitonomy/Git/Tests/NulByteTest.php | 68 ++ .../Gitonomy/Git/Tests/PushReferenceTest.php | 21 +- tests/Gitonomy/Git/Tests/ReferenceBagTest.php | 19 +- tests/Gitonomy/Git/Tests/ReferenceTest.php | 200 ++++- tests/Gitonomy/Git/Tests/RepositoryTest.php | 371 +++++++- tests/Gitonomy/Git/Tests/RevisionTest.php | 19 +- tests/Gitonomy/Git/Tests/TreeTest.php | 78 +- tests/Gitonomy/Git/Tests/WeakCacheTest.php | 48 ++ tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 132 ++- tests/bootstrap.php | 2 +- 81 files changed, 5126 insertions(+), 2027 deletions(-) create mode 100644 src/Gitonomy/Git/Blame/Hunk.php create mode 100644 src/Gitonomy/Git/Diff/DiffLine.php create mode 100644 src/Gitonomy/Git/Diff/DiffLineType.php delete mode 100644 src/Gitonomy/Git/Driver/Cli/GitProcessFactory.php create mode 100644 src/Gitonomy/Git/Driver/Cli/GitRunner.php create mode 100644 src/Gitonomy/Git/Driver/Data/DiffData.php create mode 100644 src/Gitonomy/Git/Driver/Data/DiffFileData.php rename src/Gitonomy/Git/Driver/Data/{TreeEntryType.php => ObjectType.php} (84%) create mode 100644 src/Gitonomy/Git/Driver/RepositoryPaths.php create mode 100644 src/Gitonomy/Git/Exception/ProcessTimedOutException.php create mode 100644 src/Gitonomy/Git/FastForward.php create mode 100644 src/Gitonomy/Git/Submodule.php create mode 100644 src/Gitonomy/Git/TreeEntry.php create mode 100644 src/Gitonomy/Git/Util/Message.php create mode 100644 src/Gitonomy/Git/Util/NulByte.php create mode 100644 src/Gitonomy/Git/Util/WeakCache.php create mode 100644 tests/Gitonomy/Git/Tests/DriverParityTest.php create mode 100644 tests/Gitonomy/Git/Tests/NulByteTest.php create mode 100644 tests/Gitonomy/Git/Tests/WeakCacheTest.php diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 309eef21..3799838c 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -20,13 +20,14 @@ * * Its methods behave like in 1.x: the git options of 1.x (command, * environment_variables, inherit_environment_variables and process_timeout) - * configure the CliDriver they run, and the repositories they return. + * configure the CliDriver they run, and the repositories they return; + * inherit_environment_variables only applies to these repositories. * * @author Alexandre Salomé * * @deprecated since gitonomy/gitlib 2.0, use the static methods of Repository instead * - * @phpstan-import-type Options from Repository + * @phpstan-import-type Options from LegacyOptions */ final class Admin { @@ -45,14 +46,15 @@ public static function init(string $path, bool $bare = true, array $options = [] { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" instead.', __METHOD__, Repository::class, $bare ? 'initBare' : 'init'); - $driver = LegacyOptions::createDriver($options); + $options = new LegacyOptions($options); + $driver = $options->adminDriver() ?? new CliDriver(); if ($bare) { $driver->initBare($path); } else { $driver->init($path); } - return self::createRepository($path, $options, $driver); + return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); } /** @@ -73,7 +75,7 @@ public static function isValidRepository(string $url, array $options = []): bool { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class); - return LegacyOptions::createDriver($options)->isValidRepository($url); + return (new LegacyOptions($options)->adminDriver() ?? new CliDriver())->isValidRepository($url); } /** @@ -96,7 +98,7 @@ public static function isValidRepositoryAndBranch(string $url, string $branchNam { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class); - return LegacyOptions::createDriver($options)->isValidRepositoryAndBranch($url, $branchName); + return (new LegacyOptions($options)->adminDriver() ?? new CliDriver())->isValidRepositoryAndBranch($url, $branchName); } /** @@ -113,14 +115,15 @@ public static function cloneTo(string $path, string $url, bool $bare = true, arr { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); - $driver = LegacyOptions::createDriver($options); + $options = new LegacyOptions($options); + $driver = $options->adminDriver() ?? new CliDriver(); if ($bare) { $driver->cloneBareFrom($url, $path, null); } else { $driver->cloneFrom($url, $path, null); } - return self::createRepository($path, $options, $driver); + return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); } /** @@ -138,14 +141,15 @@ public static function cloneBranchTo(string $path, string $url, string $branch, { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); - $driver = LegacyOptions::createDriver($options); + $options = new LegacyOptions($options); + $driver = $options->adminDriver() ?? new CliDriver(); if ($bare) { $driver->cloneBareFrom($url, $path, $branch); } else { $driver->cloneFrom($url, $path, $branch); } - return self::createRepository($path, $options, $driver); + return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); } /** @@ -161,10 +165,11 @@ public static function mirrorTo(string $path, string $url, array $options = []): { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class); - $driver = LegacyOptions::createDriver($options); + $options = new LegacyOptions($options); + $driver = $options->adminDriver() ?? new CliDriver(); $driver->mirrorFrom($url, $path); - return self::createRepository($path, $options, $driver); + return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); } /** @@ -181,17 +186,10 @@ public static function cloneRepository(string $path, string $url, array $args = { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class); - $driver = LegacyOptions::createDriver($options); + $options = new LegacyOptions($options); + $driver = $options->adminDriver() ?? new CliDriver(); $driver->cloneRepository($path, $url, $args); - return self::createRepository($path, $options, $driver); - } - - /** - * @param Options $options - */ - private static function createRepository(string $path, array $options, CliDriver $driver): Repository - { - return new Repository($path, LegacyOptions::remove($options), $driver); + return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); } } diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index 2660818d..7901ed38 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Blame\Hunk; use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Exception\InvalidArgumentException; @@ -21,71 +22,87 @@ final class Blame implements \Countable { /** - * @var Line[]|null + * @var array|null By number */ private ?array $lines = null; + private readonly ?int $firstLine; + + private readonly ?int $lastLine; + /** - * @param string $file the file the blame is about - * @param string|null $lineRange argument to pass to git blame (-L). - * Can be a line range (40,60 or 40,+21) - * or a regexp ('/^function$/') + * Like "git blame -L 5,3", a last line before the first one blames the lines 3 to 5. + * + * @param string $file the file the blame is about + * @param int|null $firstLine The first line to blame, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null + * + * @throws InvalidArgumentException An invalid range of lines */ public function __construct( private readonly Repository $repository, private readonly Revision $revision, private readonly string $file, - private readonly ?string $lineRange = null, + ?int $firstLine = null, + ?int $lastLine = null, ) { + if ((null !== $firstLine && $firstLine < 1) || (null !== $lastLine && $lastLine < 1)) { + throw new InvalidArgumentException(\sprintf('Invalid range of lines "%s,%s": the lines are numbered from 1.', $firstLine, $lastLine)); + } + + [$this->firstLine, $this->lastLine] = null !== $firstLine && null !== $lastLine && $lastLine < $firstLine ? [$lastLine, $firstLine] : [$firstLine, $lastLine]; } + /** + * @param int $number The number of the line in the file + * + * @throws InvalidArgumentException The blame has no line $number + */ public function getLine(int $number): Line { - if ($number < 1) { - throw new InvalidArgumentException('Line number should be at least 1'); - } + return $this->getLines()[$number] ?? throw new InvalidArgumentException(\sprintf('The blame of "%s" has no line %d.', $this->file, $number)); + } - $lines = $this->getLines(); + /** + * @return list The consecutive lines last changed by the same commit + */ + public function getHunks(): array + { + $hunks = []; + $commit = null; + $lines = []; + foreach ($this->getLines() as $number => $line) { + if ($commit !== $line->getCommit() && [] !== $lines) { + $hunks[] = new Hunk($commit, $lines); + $lines = []; + } + $commit = $line->getCommit(); + $lines[$number] = $line; + } - if (!isset($lines[$number])) { - throw new InvalidArgumentException('Line does not exist'); + if (null !== $commit) { + $hunks[] = new Hunk($commit, $lines); } - return $lines[$number]; + return $hunks; } /** * Returns lines grouped by commit. * - * @return array a list of two-elements array (commit, lines) + * @return list}> + * + * @deprecated since gitonomy/gitlib 2.0, use getHunks() instead */ public function getGroupedLines(): array { - $result = []; - $commit = null; - $current = []; - - foreach ($this->getLines() as $lineNumber => $line) { - if ($commit !== $line->getCommit()) { - if (\count($current)) { - $result[] = [$commit, $current]; - } - $commit = $line->getCommit(); - $current = []; - } + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getHunks()" instead.', __METHOD__); - $current[$lineNumber] = $line; - } - - if (\count($current)) { - $result[] = [$commit, $current]; - } - - return $result; + return array_map(static fn (Hunk $hunk): array => [$hunk->getCommit(), $hunk->getLines()], $this->getHunks()); } /** - * @return Line[] all lines of the blame + * @return array The lines of the blame, by number */ public function getLines(): array { @@ -93,18 +110,10 @@ public function getLines(): array return $this->lines; } - $lineDataList = $this->repository->getRepositoryDriver()->getBlameLines($this->revision->getRevision(), $this->file, $this->lineRange); - + $hash = $this->revision->getCommit()->getHash(); $this->lines = []; - $commits = []; - $lineNumber = 1; - foreach ($lineDataList as $lineData) { - if (!isset($commits[$lineData->commitHash])) { - $commits[$lineData->commitHash] = $this->repository->getCommit($lineData->commitHash); - } - - $this->lines[$lineNumber] = new Line($commits[$lineData->commitHash], $lineData->sourceLine, $lineData->targetLine, $lineData->blockLine, $lineData->content); - ++$lineNumber; + foreach ($this->repository->getRepositoryDriver()->getBlameLines($hash, $this->file, $this->firstLine, $this->lastLine) as $data) { + $this->lines[$data->finalLine] = new Line($this->repository->getCommit($data->commitHash), $data->finalLine, $data->originalLine, $data->content); } return $this->lines; diff --git a/src/Gitonomy/Git/Blame/Hunk.php b/src/Gitonomy/Git/Blame/Hunk.php new file mode 100644 index 00000000..457e8952 --- /dev/null +++ b/src/Gitonomy/Git/Blame/Hunk.php @@ -0,0 +1,45 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Blame; + +use Gitonomy\Git\Commit; + +/** + * Consecutive lines of a blame, last changed by the same commit. + * + * @author Grégoire Pineau + */ +final readonly class Hunk +{ + /** + * @param array $lines By number + */ + public function __construct( + private Commit $commit, + private array $lines, + ) { + } + + public function getCommit(): Commit + { + return $this->commit; + } + + /** + * @return array By number + */ + public function getLines(): array + { + return $this->lines; + } +} diff --git a/src/Gitonomy/Git/Blame/Line.php b/src/Gitonomy/Git/Blame/Line.php index d252e3ea..6254b2cd 100644 --- a/src/Gitonomy/Git/Blame/Line.php +++ b/src/Gitonomy/Git/Blame/Line.php @@ -20,13 +20,13 @@ final readonly class Line { /** - * Instanciates a new Line object. + * @param int $number The number of the line in the blamed file + * @param int $originalNumber The number of the line in the file of $commit */ public function __construct( private Commit $commit, - private string $sourceLine, - private string $targetLine, - private ?string $blockLine, + private int $number, + private int $originalNumber, private string $content, ) { } @@ -36,11 +36,35 @@ public function getContent(): string return $this->content; } + /** + * @return int The number of the line in the blamed file + */ + public function getNumber(): int + { + return $this->number; + } + + /** + * @return int The number of the line in the file of the commit that last changed it + */ + public function getOriginalNumber(): int + { + return $this->originalNumber; + } + + /** + * @deprecated since gitonomy/gitlib 2.0, use getOriginalNumber() instead + */ public function getLine(): string { - return $this->sourceLine; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getOriginalNumber()" instead.', __METHOD__); + + return (string) $this->originalNumber; } + /** + * @return Commit The commit that last changed the line + */ public function getCommit(): Commit { return $this->commit; diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index 290845c4..c857a2ae 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -12,6 +12,8 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\RuntimeException; + /** * Representation of a Blob commit. * @@ -26,7 +28,7 @@ final class Blob private ?string $content = null; - private string|false|null $mimetype = null; + private ?string $mimetype = null; private ?bool $text = null; @@ -60,16 +62,13 @@ public function getContent(): string } /** - * Determine the mimetype of the blob. + * @return string The mimetype of the content, like "text/plain" + * + * @throws RuntimeException fileinfo can't read the content */ - public function getMimetype(): string|false + public function getMimetype(): string { - if (null === $this->mimetype) { - $finfo = new \finfo(\FILEINFO_MIME); - $this->mimetype = $finfo->buffer($this->getContent()); - } - - return $this->mimetype; + return $this->mimetype ??= new \finfo(\FILEINFO_MIME_TYPE)->buffer($this->getContent()) ?: throw new RuntimeException(\sprintf('Unable to read the mimetype of the blob "%s".', $this->hash)); } /** diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 0adcbc46..4a1af38f 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -14,10 +14,10 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Driver\Data\CommitData; -use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Util\Message; use Symfony\Component\String\CodePointString; /** @@ -35,8 +35,6 @@ final class Commit extends Revision // Values derived from the fields above, computed and cached independently. private ?string $shortHash = null; private ?Tree $tree = null; - private ?string $subjectMessage = null; - private ?string $bodyMessage = null; /** * @param Repository $repository Repository of the commit @@ -45,7 +43,7 @@ final class Commit extends Revision public function __construct(Repository $repository, string $hash, ?CommitData $data = null) { if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { - throw new ReferenceNotFoundException($hash); + throw new ReferenceNotFoundException(\sprintf('"%s" is not the hash of a commit: get a commit by its name with getRevision(...)->getCommit().', $hash)); } parent::__construct($repository, $hash); @@ -53,17 +51,21 @@ public function __construct(Repository $repository, string $hash, ?CommitData $d $this->data = $data; } + /** + * @internal + */ public function setData(CommitData $data): void { $this->data = $data; } + /** + * Returns the changes of the commit: against its first parent, like GitHub + * shows a merge commit, or against nothing for a root commit. + */ public function getDiff(): Diff { - $diff = $this->repository->getRepositoryDriver()->getCommitDiff($this->revision); - $diff->setRepository($this->repository); - - return $diff; + return Diff::fromData($this->repository->getRepositoryDriver()->getDiff($this->getParentHashes()[0] ?? null, $this->revision), $this->repository); } /** @@ -79,22 +81,18 @@ public function getHash(): string */ public function getShortHash(): string { - if (null === $this->shortHash) { - try { - $this->shortHash = $this->repository->getRepositoryDriver()->getShortHash($this->revision); - } catch (DriverException $e) { - throw new ReferenceNotFoundException($this->revision, 0, $e); - } - } - - return $this->shortHash; + return $this->shortHash ??= $this->repository->getRepositoryDriver()->getShortHash($this->revision); } /** * Returns a fixed-with short hash. + * + * @deprecated since gitonomy/gitlib 2.0, use substr($commit->getHash(), 0, $length) instead */ public function getFixedShortHash(int $length = 6): string { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.', __METHOD__); + return new CodePointString($this->revision)->slice(0, $length)->toString(); } @@ -149,9 +147,13 @@ public function getLastModification(?string $path = null): self * Returns the first line of the commit, and the first 50 characters. * * Ported from https://github.com/fabpot/Twig-extensions/blob/d67bc7e69788795d7905b52d31188bbc1d390e01/lib/Twig/Extensions/Extension/Text.php#L52-L109 + * + * @deprecated since gitonomy/gitlib 2.0, use symfony/string instead: u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve) */ public function getShortMessage(int $length = 50, bool $preserve = false, string $separator = '...'): string { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".', __METHOD__); + $message = $this->getSubjectMessage(); $codePointMessage = new CodePointString($message); @@ -178,29 +180,29 @@ public function resolveReferences(): array } /** - * Find branch containing the commit. + * Returns the local and remote branches whose history contains the commit. * - * @param bool $local set true to try to locate a commit on local repository - * @param bool $remote set true to try to locate a commit on remote repository + * Passing arguments, to keep local or remote branches only, is deprecated. * - * @return Reference[]|Branch[] An array of Reference\Branch + * @return Branch[] */ public function getIncludingBranches(bool $local = true, bool $remote = true): array { + if (\func_num_args() > 0) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing arguments to "%s()" is deprecated, filter the branches it returns with "%s::isLocal()" or "isRemote()" instead.', __METHOD__, Branch::class); + } + if (!$local && !$remote) { - throw new InvalidArgumentException('You should a least set one argument to true'); + throw new InvalidArgumentException('You should a least set one argument to true.'); } $references = $this->repository->getReferences(); $branches = []; foreach ($this->repository->getRepositoryDriver()->getIncludingBranches($this->revision) as $fullname) { - if (str_starts_with($fullname, 'refs/heads/')) { - if ($local) { - $branches[] = $references->getBranch(substr($fullname, \strlen('refs/heads/'))); - } - } elseif ($remote) { - $branches[] = $references->getRemoteBranch(substr($fullname, \strlen('refs/remotes/'))); + $branch = $references->get($fullname); + if ($branch instanceof Branch && ($branch->isLocal() ? $local : $remote)) { + $branches[] = $branch; } } @@ -248,7 +250,7 @@ public function getCommitterEmail(): string } /** - * Returns the authoring date. + * Returns the committing date. */ public function getCommitterDate(): \DateTimeImmutable { @@ -264,33 +266,19 @@ public function getMessage(): string } /** - * Returns the subject message (the first line). + * @return string The first paragraph of the message, on one line, like "git log --format=%s" */ public function getSubjectMessage(): string { - if (null === $this->subjectMessage) { - $lines = explode("\n", $this->getMessage()); - $this->subjectMessage = reset($lines); - } - - return $this->subjectMessage; + return Message::subject($this->getMessage()); } /** - * Return the body message. + * @return string What follows the subject and its empty lines, like "git log --format=%b" */ public function getBodyMessage(): string { - if (null === $this->bodyMessage) { - $lines = explode("\n", $this->getMessage()); - - array_shift($lines); - array_shift($lines); - - $this->bodyMessage = implode("\n", $lines); - } - - return $this->bodyMessage; + return Message::body($this->getMessage()); } public function getCommit(): self @@ -300,14 +288,6 @@ public function getCommit(): self private function data(): CommitData { - if (null === $this->data) { - try { - $this->data = $this->repository->getRepositoryDriver()->getCommitData($this->revision); - } catch (DriverException $e) { - throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision), 0, $e); - } - } - - return $this->data; + return $this->data ??= $this->repository->getRepositoryDriver()->getCommitData($this->revision); } } diff --git a/src/Gitonomy/Git/CommitReference.php b/src/Gitonomy/Git/CommitReference.php index 6233e2b4..5a10b217 100644 --- a/src/Gitonomy/Git/CommitReference.php +++ b/src/Gitonomy/Git/CommitReference.php @@ -12,14 +12,17 @@ namespace Gitonomy\Git; -final readonly class CommitReference -{ - public function __construct(private string $hash) - { - } +trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s" class is deprecated, use "%s" instead.', CommitReference::class, Submodule::class); + +// Declares the alias, unless Submodule already did. +class_exists(Submodule::class); - public function getHash(): string +// @phpstan-ignore if.alwaysFalse (Declares the class for IDEs only.) +if (false) { + /** + * @deprecated since gitonomy/gitlib 2.0, use Submodule instead + */ + final readonly class CommitReference { - return $this->hash; } } diff --git a/src/Gitonomy/Git/Diff/Diff.php b/src/Gitonomy/Git/Diff/Diff.php index c94dc427..43e13920 100644 --- a/src/Gitonomy/Git/Diff/Diff.php +++ b/src/Gitonomy/Git/Diff/Diff.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Diff; +use Gitonomy\Git\Driver\Data\DiffData; +use Gitonomy\Git\Driver\Data\DiffFileData; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Parser\DiffParser; use Gitonomy\Git\Repository; @@ -19,13 +22,13 @@ * Representation of a diff. * * @author Alexandre Salomé + * + * @phpstan-import-type FileArray from File */ final readonly class Diff { /** - * Constructs a new diff for a given revision. - * - * @param File[] $files The files + * @param File[] $files */ public function __construct( private array $files, @@ -33,18 +36,39 @@ public function __construct( ) { } - public static function parse(string $rawDiff): self + /** + * Parses the output of ``git diff --raw --patch``, ``-z`` or not. + * + * @param Repository|null $repository The repository to read the blobs of the files from + * + * @throws InvalidArgumentException The diff has no raw information + */ + public static function parse(string $rawDiff, ?Repository $repository = null): self { $parser = new DiffParser(); $parser->parse($rawDiff); - return new self($parser->files, $rawDiff); + return new self(array_map(static fn (DiffFileData $file): File => File::fromData($file, $repository), $parser->files), $rawDiff); + } + + /** + * @internal + */ + public static function fromData(DiffData $data, Repository $repository): self + { + return new self(array_map(static fn (DiffFileData $file): File => File::fromData($file, $repository), $data->files), $data->rawDiff); } + /** + * @deprecated since gitonomy/gitlib 2.0, the diffs gitlib returns have their repository already + */ public function setRepository(Repository $repository): void { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); + foreach ($this->files as $file) { - $file->setRepository($repository); + // Without one more deprecation for each file. + \Closure::bind(static fn () => $file->repository = $repository, null, File::class)(); } } @@ -68,33 +92,24 @@ public function getRawDiff(): string /** * Export a diff as array. + * + * @return array{rawDiff: string, files: list} */ public function toArray(): array { return [ 'rawDiff' => $this->rawDiff, - 'files' => array_map( - static function (File $file) { - return $file->toArray(); - }, - $this->files - ), + 'files' => array_map(static fn (File $file): array => $file->toArray(), array_values($this->files)), ]; } /** * Create a new instance of Diff from an array. + * + * @param array{rawDiff: string, files: list} $array */ - public static function fromArray(array $array): self + public static function fromArray(array $array, ?Repository $repository = null): self { - return new self( - array_map( - static function ($array) { - return File::fromArray($array); - }, - $array['files'] - ), - $array['rawDiff'] - ); + return new self(array_map(static fn (array $file): File => File::fromArray($file, $repository), $array['files']), $array['rawDiff']); } } diff --git a/src/Gitonomy/Git/Diff/DiffLine.php b/src/Gitonomy/Git/Diff/DiffLine.php new file mode 100644 index 00000000..303d8bc9 --- /dev/null +++ b/src/Gitonomy/Git/Diff/DiffLine.php @@ -0,0 +1,77 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Diff; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; + +/** + * A line of a hunk. + * + * Reading it as an array, [$type, $content] with the type as one of the + * FileChange::LINE_* constants, is deprecated. + * + * @author Grégoire Pineau + * + * @implements \ArrayAccess + */ +final readonly class DiffLine implements \ArrayAccess +{ + public function __construct( + private DiffLineType $type, + private string $content, + ) { + } + + public function getType(): DiffLineType + { + return $this->type; + } + + /** + * @return string The line, without its "+", "-" or " " prefix, nor its line break + */ + public function getContent(): string + { + return $this->content; + } + + public function offsetExists(mixed $offset): bool + { + return 0 === $offset || 1 === $offset; + } + + /** + * @deprecated since gitonomy/gitlib 2.0, use getType() and getContent() instead + */ + public function offsetGet(mixed $offset): int|string + { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Reading a "%s" as an array is deprecated, use its "getType()" and "getContent()" methods instead.', self::class); + + return match ($offset) { + 0 => $this->type->value, + 1 => $this->content, + default => throw new InvalidArgumentException(\sprintf('A diff line has no offset %s.', var_export($offset, true))), + }; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new LogicException('A diff line is immutable.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new LogicException('A diff line is immutable.'); + } +} diff --git a/src/Gitonomy/Git/Diff/DiffLineType.php b/src/Gitonomy/Git/Diff/DiffLineType.php new file mode 100644 index 00000000..b7a88a70 --- /dev/null +++ b/src/Gitonomy/Git/Diff/DiffLineType.php @@ -0,0 +1,26 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Diff; + +/** + * The kind of a line of a hunk, its values those of the deprecated + * FileChange::LINE_* constants. + * + * @author Grégoire Pineau + */ +enum DiffLineType: int +{ + case Addition = 1; + case Context = 0; + case Deletion = -1; +} diff --git a/src/Gitonomy/Git/Diff/File.php b/src/Gitonomy/Git/Diff/File.php index 8a73748c..516195b9 100644 --- a/src/Gitonomy/Git/Diff/File.php +++ b/src/Gitonomy/Git/Diff/File.php @@ -13,22 +13,32 @@ namespace Gitonomy\Git\Diff; use Gitonomy\Git\Blob; +use Gitonomy\Git\Driver\Data\DiffFileData; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; /** * @author Alexandre Salomé + * + * @phpstan-import-type FileChangeArray from FileChange + * + * @phpstan-type FileArray array{old_name: string|null, new_name: string|null, old_mode: string|null, new_mode: string|null, old_index: string|null, new_index: string|null, is_binary: bool, is_copy: bool, changes: list} */ final class File { - /** - * @var FileChange[] - */ - private array $changes = []; - - private ?Repository $repository = null; + private readonly string $name; /** - * Instanciates a new File object. + * @param string|null $oldName Null for a creation + * @param string|null $newName Null for a deletion + * @param string|null $oldIndex The hash of the old blob, null when there is none (a creation...) + * @param string|null $newIndex The hash of the new blob, null when there is none (a deletion, a modified submodule...); the hash of a file of the working tree isn't in the repository + * @param FileChange[] $changes + * @param Repository|null $repository The repository to read the blobs from + * + * @throws InvalidArgumentException Both names are null */ public function __construct( private readonly ?string $oldName, @@ -39,11 +49,19 @@ public function __construct( private readonly ?string $newIndex, private readonly bool $isBinary, private readonly bool $isCopy = false, + private array $changes = [], + private ?Repository $repository = null, ) { + $this->name = $newName ?? $oldName ?? throw new InvalidArgumentException('A file of a diff has an old name, a new name, or both.'); } + /** + * @deprecated since gitonomy/gitlib 2.0, give the changes to the constructor instead + */ public function addChange(FileChange $change): void { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, give the changes to the constructor instead.', __METHOD__); + $this->changes[] = $change; } @@ -86,11 +104,21 @@ public function isCopy(): bool /** * Indicates if the file mode has changed. */ - public function isChangeMode(): bool + public function isModeChange(): bool { return $this->isModification() && $this->oldMode !== $this->newMode; } + /** + * @deprecated since gitonomy/gitlib 2.0, use isModeChange() instead + */ + public function isChangeMode(): bool + { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "isModeChange()" instead.', __METHOD__); + + return $this->isModeChange(); + } + /** * Indicates if this diff file is a deletion. */ @@ -100,11 +128,13 @@ public function isDeletion(): bool } /** - * Indicates if this diff file is a deletion. + * @deprecated since gitonomy/gitlib 2.0, use isDeletion() instead */ public function isDelete(): bool { - return null === $this->newName; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); + + return $this->isDeletion(); } /** @@ -112,12 +142,7 @@ public function isDelete(): bool */ public function getAdditions(): int { - $result = 0; - foreach ($this->changes as $change) { - $result += $change->getCount(FileChange::LINE_ADD); - } - - return $result; + return array_sum(array_map(static fn (FileChange $change): int => $change->getAdditions(), $this->changes)); } /** @@ -125,12 +150,7 @@ public function getAdditions(): int */ public function getDeletions(): int { - $result = 0; - foreach ($this->changes as $change) { - $result += $change->getCount(FileChange::LINE_REMOVE); - } - - return $result; + return array_sum(array_map(static fn (FileChange $change): int => $change->getDeletions(), $this->changes)); } public function getOldName(): ?string @@ -143,13 +163,12 @@ public function getNewName(): ?string return $this->newName; } - public function getName(): ?string + /** + * @return string The new name, or the old one for a deletion + */ + public function getName(): string { - if (null === $this->newName) { - return $this->oldName; - } - - return $this->newName; + return $this->name; } public function getOldMode(): ?string @@ -185,6 +204,9 @@ public function getChanges(): array return $this->changes; } + /** + * @return FileArray + */ public function toArray(): array { return [ @@ -196,25 +218,45 @@ public function toArray(): array 'new_index' => $this->newIndex, 'is_binary' => $this->isBinary, 'is_copy' => $this->isCopy, - 'changes' => array_map(static function (FileChange $change) { - return $change->toArray(); - }, $this->changes), + 'changes' => array_map(static fn (FileChange $change): array => $change->toArray(), array_values($this->changes)), ]; } - public static function fromArray(array $array): self + /** + * @internal + */ + public static function fromData(DiffFileData $data, ?Repository $repository): self { - $file = new self($array['old_name'], $array['new_name'], $array['old_mode'], $array['new_mode'], $array['old_index'], $array['new_index'], $array['is_binary'], $array['is_copy'] ?? false); - - foreach ($array['changes'] as $change) { - $file->addChange(FileChange::fromArray($change)); - } + return new self($data->oldName, $data->newName, $data->oldMode, $data->newMode, $data->oldIndex, $data->newIndex, $data->isBinary, $data->isCopy, $data->changes, $repository); + } - return $file; + /** + * @param array{old_name: string|null, new_name: string|null, old_mode: string|null, new_mode: string|null, old_index: string|null, new_index: string|null, is_binary: bool, is_copy?: bool, changes: list} $array Without "is_copy" from 1.x + */ + public static function fromArray(array $array, ?Repository $repository = null): self + { + return new self( + $array['old_name'], + $array['new_name'], + $array['old_mode'], + $array['new_mode'], + // 1.x used an empty string for a missing hash. + '' === $array['old_index'] ? null : $array['old_index'], + '' === $array['new_index'] ? null : $array['new_index'], + $array['is_binary'], + $array['is_copy'] ?? false, + array_map(FileChange::fromArray(...), $array['changes']), + $repository, + ); } + /** + * @deprecated since gitonomy/gitlib 2.0, a presentation concern: use substr($file->getNewIndex(), 0, 12) instead + */ public function getAnchor(): string { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.', __METHOD__); + return substr($this->newIndex ?? '', 0, 12); } @@ -223,42 +265,52 @@ public function getRepository(): ?Repository return $this->repository; } + /** + * @deprecated since gitonomy/gitlib 2.0, the diffs gitlib returns have their repository already + */ public function setRepository(Repository $repository): void { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); + $this->repository = $repository; } + /** + * @throws LogicException The file has no repository, or is a creation + * @throws RuntimeException The diff doesn't know the hash of the old blob + */ public function getOldBlob(): Blob { - if (null === $this->repository) { - throw new \RuntimeException('Repository is missing to return Blob object.'); - } - if ($this->isCreation()) { - throw new \LogicException('Can\'t return old Blob on a creation'); + throw new LogicException(\sprintf('"%s" is a creation: it has no old blob.', $this->name)); } - if ('' === $this->oldIndex) { - throw new \RuntimeException('Index is missing to return Blob object.'); - } - - return $this->repository->getBlob($this->oldIndex); + return $this->blob($this->oldIndex, 'old'); } + /** + * @throws LogicException The file has no repository, or is a deletion + * @throws RuntimeException The diff doesn't know the hash of the new blob, like for a modified submodule + */ public function getNewBlob(): Blob { - if (null === $this->repository) { - throw new \RuntimeException('Repository is missing to return Blob object.'); + if ($this->isDeletion()) { + throw new LogicException(\sprintf('"%s" is a deletion: it has no new blob.', $this->name)); } - if ($this->isDeletion()) { - throw new \LogicException('Can\'t return new Blob on a deletion'); + return $this->blob($this->newIndex, 'new'); + } + + private function blob(?string $hash, string $side): Blob + { + if (null === $this->repository) { + throw new LogicException('A diff without repository can\'t read blobs: get it from a repository.'); } - if ('' === $this->newIndex) { - throw new \RuntimeException('Index is missing to return Blob object.'); + if (null === $hash) { + throw new RuntimeException(\sprintf('The diff doesn\'t know the hash of the %s blob of "%s".', $side, $this->name)); } - return $this->repository->getBlob($this->newIndex); + return $this->repository->getBlob($hash); } } diff --git a/src/Gitonomy/Git/Diff/FileChange.php b/src/Gitonomy/Git/Diff/FileChange.php index 1e7bc914..08f28236 100644 --- a/src/Gitonomy/Git/Diff/FileChange.php +++ b/src/Gitonomy/Git/Diff/FileChange.php @@ -12,14 +12,32 @@ namespace Gitonomy\Git\Diff; +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * A hunk of a file of a diff. + * + * @phpstan-type FileChangeArray array{range_old_start: int, range_old_count: int, range_new_start: int, range_new_count: int, lines: list} + */ final readonly class FileChange { + /** + * @deprecated since gitonomy/gitlib 2.0, use DiffLineType::Context instead + */ public const int LINE_CONTEXT = 0; + + /** + * @deprecated since gitonomy/gitlib 2.0, use DiffLineType::Deletion instead + */ public const int LINE_REMOVE = -1; + + /** + * @deprecated since gitonomy/gitlib 2.0, use DiffLineType::Addition instead + */ public const int LINE_ADD = 1; /** - * @param array $lines + * @param DiffLine[] $lines */ public function __construct( private int $rangeOldStart, @@ -30,16 +48,24 @@ public function __construct( ) { } + /** + * @deprecated since gitonomy/gitlib 2.0, use getAdditions() or getDeletions() instead + */ public function getCount(int $type): int { - $result = 0; - foreach ($this->lines as $line) { - if ($line[0] === $type) { - ++$result; - } - } - - return $result; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.', __METHOD__); + + return $this->count(self::lineType($type)); + } + + public function getAdditions(): int + { + return $this->count(DiffLineType::Addition); + } + + public function getDeletions(): int + { + return $this->count(DiffLineType::Deletion); } public function getRangeOldStart(): int @@ -63,13 +89,16 @@ public function getRangeNewCount(): int } /** - * @return array + * @return DiffLine[] */ public function getLines(): array { return $this->lines; } + /** + * @return FileChangeArray + */ public function toArray(): array { return [ @@ -77,12 +106,34 @@ public function toArray(): array 'range_old_count' => $this->rangeOldCount, 'range_new_start' => $this->rangeNewStart, 'range_new_count' => $this->rangeNewCount, - 'lines' => $this->lines, + 'lines' => array_map(static fn (DiffLine $line): array => [$line->getType()->value, $line->getContent()], array_values($this->lines)), ]; } + /** + * @param FileChangeArray $array + */ public static function fromArray(array $array): self { - return new self($array['range_old_start'], $array['range_old_count'], $array['range_new_start'], $array['range_new_count'], $array['lines']); + return new self( + $array['range_old_start'], + $array['range_old_count'], + $array['range_new_start'], + $array['range_new_count'], + array_map(static fn (array $line): DiffLine => new DiffLine(self::lineType($line[0]), $line[1]), $array['lines']), + ); + } + + /** + * @throws InvalidArgumentException $type is none of the LINE_* constants + */ + private static function lineType(int $type): DiffLineType + { + return DiffLineType::tryFrom($type) ?? throw new InvalidArgumentException(\sprintf('Unknown type of line %d: the types are FileChange::LINE_ADD, LINE_CONTEXT and LINE_REMOVE.', $type)); + } + + private function count(DiffLineType $type): int + { + return \count(array_filter($this->lines, static fn (DiffLine $line): bool => $line->getType() === $type)); } } diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index a6f44844..773555f9 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Driver\Cli; use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\ProcessException; use Psr\Log\LoggerInterface; @@ -23,36 +24,37 @@ */ final readonly class CliDriver implements DriverInterface { - private GitProcessFactory $processFactory; - /** - * @param string $command The git binary - * @param array $environmentVariables Set on every git process, false removing an inherited one - * @param bool $inheritEnvironmentVariables Also pass every entry of $_SERVER to git processes - * @param float|null $processTimeout In seconds, for the whole process and without output; null for none + * git inherits the environment variables of PHP, like any process run by Symfony Process. + * + * @param string $command The git binary + * @param array $environmentVariables Set on every git process, false removing an inherited one + * @param float|null $processTimeout In seconds, for the whole process and without output; null for none + * @param LoggerInterface|null $logger Receives every git command run, with its output */ public function __construct( - string $command = 'git', - array $environmentVariables = [], - bool $inheritEnvironmentVariables = false, - ?float $processTimeout = 3600, + private string $command = 'git', + private array $environmentVariables = [], + private ?float $processTimeout = 3600, + private ?LoggerInterface $logger = null, ) { - $this->processFactory = new GitProcessFactory($command, $environmentVariables, $inheritEnvironmentVariables, $processTimeout); } - public function open(string $gitDir, ?string $workingDir, ?LoggerInterface $logger): CliRepositoryDriver + public function open(string $dir, ?string $workingDir): CliRepositoryDriver { - return new CliRepositoryDriver($this->processFactory, $gitDir, $workingDir, $logger); + $paths = RepositoryPaths::resolve($dir, $workingDir); + + return new CliRepositoryDriver($this->runner(), $paths->gitDir, $paths->workingDir); } public function init(string $path): void { - $this->mustRun(['init', '-q', $path]); + $this->mustRun('init', ['-q', $path]); } public function initBare(string $path): void { - $this->mustRun(['init', '-q', '--bare', $path]); + $this->mustRun('init', ['-q', '--bare', $path]); } public function cloneFrom(string $url, string $path, ?string $branch): void @@ -72,15 +74,24 @@ public function mirrorFrom(string $url, string $path): void public function isValidRepository(string $url): bool { - return 0 === $this->processFactory->create(['ls-remote', $url])->run(); + return $this->runner()->run('ls-remote', [self::absoluteUrl($url)])->isSuccessful(); } - public function isValidRepositoryAndBranch(string $url, string $branchName): bool + public function isValidRepositoryAndBranch(string $url, string $branch): bool { - $process = $this->processFactory->create(['ls-remote', '--heads', $url, $branchName]); + // git lists the branches whose name ends with the pattern: "release" matches "feature/release". + $process = $this->runner()->run('ls-remote', ['--heads', self::absoluteUrl($url), 'refs/heads/'.$branch]); + if (!$process->isSuccessful()) { + return false; + } - // git only lists the branches matching $branchName. - return 0 === $process->run() && '' !== $process->getOutput(); + foreach (explode("\n", $process->getOutput()) as $line) { + if (str_ends_with($line, "\trefs/heads/".$branch)) { + return true; + } + } + + return false; } /** @@ -92,18 +103,62 @@ public function isValidRepositoryAndBranch(string $url, string $branchName): boo */ public function cloneRepository(string $path, string $url, array $args): void { - $this->mustRun(['clone', '-q', ...$args, $url, $path]); + $this->mustRun('clone', ['-q', ...$args, $url, $path]); + } + + /** + * @internal For the deprecated Repository::getLogger(), to be removed in 3.0 + */ + public function getLogger(): ?LoggerInterface + { + return $this->logger; + } + + /** + * @internal For the deprecated Repository::setLogger(), to be removed in 3.0 + */ + public function withLogger(LoggerInterface $logger): self + { + return new self($this->command, $this->environmentVariables, $this->processTimeout, $logger); + } + + private function runner(): GitRunner + { + return new GitRunner($this->command, $this->environmentVariables, $this->processTimeout, $this->logger); } /** * @param string[] $args */ - private function mustRun(array $args): void + private function mustRun(string $command, array $args): void { - $process = $this->processFactory->create($args); + $process = $this->runner()->run($command, $args); - if (0 !== $process->run()) { + if (!$process->isSuccessful()) { throw new ProcessException($process); } } + + /** + * Run in a repository, ls-remote reads a relative path as the name of a + * remote first: "origin" would be the URL of the remote of PHP's working + * directory. + */ + private static function absoluteUrl(string $url): string + { + if (str_starts_with($url, '/') || 1 === preg_match('#^[a-zA-Z]:[\\\/]#', $url)) { + return $url; + } + + // Like git: a URL, or an address like "host:path", has a colon before any slash. + $colon = strpos($url, ':'); + $slash = strpos($url, '/'); + if (false !== $colon && (false === $slash || $colon < $slash)) { + return $url; + } + + $cwd = getcwd(); + + return false === $cwd ? $url : $cwd.'/'.$url; + } } diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index 284a2a43..2d0a98e7 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -12,18 +12,20 @@ namespace Gitonomy\Git\Driver\Cli; -use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Driver\Cli\Parser\BlameParser; use Gitonomy\Git\Driver\Cli\Parser\LogParser; use Gitonomy\Git\Driver\Cli\Parser\ReferenceParser; use Gitonomy\Git\Driver\Cli\Parser\TagParser; use Gitonomy\Git\Driver\Cli\Parser\TreeParser; use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Driver\Data\DiffData; +use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Driver\Data\TagData; use Gitonomy\Git\Driver\RepositoryDriverInterface; -use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\ProcessException; -use Psr\Log\LoggerInterface; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\FastForward; +use Gitonomy\Git\Parser\DiffParser; use Symfony\Component\Process\Process; /** @@ -35,96 +37,146 @@ */ final readonly class CliRepositoryDriver implements RepositoryDriverInterface { + // -z: the raw information gives the names as they are, without quoting. // --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". - private const array DIFF_ARGS = ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--no-abbrev']; + private const array DIFF_ARGS = ['-r', '-p', '--raw', '-z', '-M', '--full-index', '--no-abbrev']; + + /** + * The tree without any file: git knows it, even when no commit has it. + */ + private const string EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; public function __construct( - private GitProcessFactory $processFactory, + private GitRunner $runner, private string $gitDir, private ?string $workingDir, - private ?LoggerInterface $logger, ) { } - public function resolveRevision(string $revision): string + public function getGitDir(): string { - return trim($this->run('rev-parse', ['--verify', $revision])); + return $this->gitDir; } - public function getObjectType(string $revision): string + public function getWorkingDir(): ?string { - return trim($this->run('cat-file', ['-t', $revision])); + return $this->workingDir; } - public function getShortHash(string $revision): string + public function getHeadReference(): ?string { - return trim($this->run('log', ['--abbrev-commit', '--format=%h', '-n', '1', $revision])); + $process = $this->execute('symbolic-ref', ['-q', 'HEAD']); + + // -q: git exits with 1, without output, when HEAD is detached. + if (1 === $process->getExitCode() && '' === $process->getOutput()) { + return null; + } + + if (!$process->isSuccessful()) { + throw new ProcessException($process); + } + + return trim($process->getOutput()); } - public function getCommitData(string $hash): CommitData + public function resolveRevision(string $revision): string { - return $this->getCommitLog([$hash], [], null, 1)[$hash] ?? throw new DriverException(\sprintf('Commit "%s" not found.', $hash)); + $process = $this->execute('rev-parse', ['--verify', '--quiet', $revision]); + + // --quiet: git exits with 1, without output, when nothing has this name. + if (1 === $process->getExitCode() && '' === $process->getOutput()) { + throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist.', $revision)); + } + + if (!$process->isSuccessful()) { + throw new ProcessException($process); + } + + return trim($process->getOutput()); } - public function getCommitLog(?array $revisions, array $paths, ?int $offset, ?int $limit): array + public function getShortHash(string $hash): string { - // Re-encodes the commits whose "encoding" header isn't UTF-8. - $args = ['--encoding=UTF-8', '--format=raw']; + return trim($this->runOnObject($hash, null, 'log', ['--abbrev-commit', '--format=%h', '-n', '1', $hash])); + } - if (null !== $offset) { - $args[] = '--skip='.$offset; + public function getCommitData(string $hash): CommitData + { + $parser = new LogParser(); + $parser->parse($this->runOnObject($hash, ObjectType::Commit, 'log', ['--encoding=UTF-8', '--format=raw', '-n', '1', $hash])); + + $commit = $parser->commits[0] ?? null; + // git log peels a tag to its commit. + if (null === $commit || !str_starts_with($commit->hash, $hash)) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a commit.', $hash)); + } + + return $commit; + } + + public function getCommitLog(?array $includes, array $excludes, array $paths, int $offset, ?int $limit): array + { + if ([] === $includes) { + return []; } + // Re-encodes the commits whose "encoding" header isn't UTF-8. + $args = ['--encoding=UTF-8', '--format=raw', '--skip='.$offset]; + if (null !== $limit) { $args[] = '-n'; $args[] = (string) $limit; } $parser = new LogParser(); - $parser->parse($this->run('log', [...$args, ...$revisions ?? ['--all'], '--', ...$paths])); + $parser->parse($this->run('log', [...$args, ...self::revisions($includes, $excludes), '--', ...$paths])); return $parser->commits; } - public function countCommits(?array $revisions, array $paths): int + public function countCommits(?array $includes, array $excludes, array $paths): int { - return (int) $this->run('rev-list', ['--count', ...$revisions ?: ['--all'], '--', ...$paths]); + if ([] === $includes) { + return 0; + } + + return (int) $this->run('rev-list', ['--count', ...self::revisions($includes, $excludes), '--', ...$paths]); } - public function getMergeBase(string $revision1, string $revision2): ?string + public function getMergeBases(string $hash1, string $hash2): array { - $process = $this->execute('merge-base', [$revision1, $revision2]); + $process = $this->execute('merge-base', ['--all', $hash1, $hash2]); // git exits with 1 and no output when there is no common ancestor; any // other failure (unknown revision, corrupt repository...) is an error. if (1 === $process->getExitCode() && '' === $process->getOutput()) { - return null; + return []; } if (!$process->isSuccessful()) { throw new ProcessException($process); } - return trim($process->getOutput()); + return explode("\n", trim($process->getOutput())); } public function getTreeEntries(string $hash): array { $parser = new TreeParser(); - $parser->parse($this->run('ls-tree', ['-z', $hash])); + $parser->parse($this->readObject($hash, ObjectType::Tree)); return $parser->entries; } public function getBlobContent(string $hash): string { - return $this->run('cat-file', ['blob', $hash]); + return $this->readObject($hash, ObjectType::Blob); } - public function getTagData(string $revision): TagData + public function getTagData(string $hash): TagData { $parser = new TagParser(); - $parser->parse($this->run('cat-file', ['tag', $revision])); + $parser->parse($this->readObject($hash, ObjectType::Tag)); return $parser->data; } @@ -132,7 +184,7 @@ public function getTagData(string $revision): TagData public function getReferences(): array { // Unlike for-each-ref in git 1.x, show-ref skips a dangling symbolic reference. - $process = $this->execute('show-ref', []); + $process = $this->execute('show-ref', ['--dereference']); // git exits with 1 and no output when there is no reference. if (1 === $process->getExitCode() && '' === $process->getOutput()) { @@ -149,6 +201,12 @@ public function getReferences(): array return $parser->references; } + public function createReference(string $fullname, string $hash): void + { + // Zeros as the old value: git refuses to update a reference that exists. + $this->run('update-ref', [$fullname, $hash, str_repeat('0', 40)]); + } + public function updateReference(string $fullname, string $hash): void { $this->run('update-ref', [$fullname, $hash]); @@ -162,53 +220,42 @@ public function deleteReference(string $fullname): void public function getIncludingBranches(string $hash): array { // for-each-ref only accepts --contains since git 2.7. - $output = $this->run('branch', ['-a', '--no-color', '--contains', $hash]); - - $branches = []; - foreach (explode("\n", rtrim($output, "\n")) as $line) { - // "* " flags the current branch, "(HEAD detached at ...)" a detached - // HEAD, and "origin/HEAD -> origin/main" a symbolic reference. - $name = substr($line, 2); - if ('' === $name || str_starts_with($name, '(') || str_contains($name, ' -> ')) { - continue; - } - - $branches[] = str_starts_with($name, 'remotes/') ? 'refs/'.$name : 'refs/heads/'.$name; - } - - return $branches; + return [ + ...$this->listBranches(['--contains', $hash], 'refs/heads/'), + ...$this->listBranches(['--remotes', '--contains', $hash], 'refs/remotes/'), + ]; } - public function getDiff(array $revisions): Diff + public function getDiff(?string $from, string $to): DiffData { - return Diff::parse($this->run('diff', [...self::DIFF_ARGS, '--no-commit-id', ...$revisions])); + return $this->diff('diff-tree', [$from ?? self::EMPTY_TREE, $to]); } - public function getCommitDiff(string $hash): Diff + public function getWorkingTreeDiff(string $hash): DiffData { - return Diff::parse($this->run('diff-tree', [...self::DIFF_ARGS, '--no-commit-id', '--root', $hash])); + return $this->diff('diff', [$hash]); } - public function getDiffPending(): Diff + public function getDiffPending(): DiffData { - return Diff::parse($this->run('diff', self::DIFF_ARGS)); + return $this->diff('diff', []); } - public function getDiffStaged(): Diff + public function getDiffStaged(): DiffData { - return Diff::parse($this->run('diff', [...self::DIFF_ARGS, '--staged'])); + return $this->diff('diff', ['--staged']); } - public function getBlameLines(string $revision, string $file, ?string $lineRange): array + public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array { $args = ['-p']; - if (null !== $lineRange) { + if (null !== $firstLine || null !== $lastLine) { $args[] = '-L'; - $args[] = $lineRange; + $args[] = ($firstLine ?? 1).','.$lastLine; } $parser = new BlameParser(); - $parser->parse($this->run('blame', [...$args, $revision, '--', $file])); + $parser->parse($this->run('blame', [...$args, $hash, '--', $path])); return $parser->lines; } @@ -230,26 +277,33 @@ public function checkout(string $revision): void $this->run('checkout', [$revision]); } - public function checkoutNewBranch(string $branch, string $revision): void + public function checkoutNewBranch(string $branch, string $startHash): void { - $this->run('checkout', ['-b', $branch, $revision]); + $this->run('checkout', ['-b', $branch, $startHash]); } - public function merge(string $revision, array $args): void + public function merge(string $revision, FastForward $fastForward): void { - $this->run('merge', [...$args, $revision]); + $option = match ($fastForward) { + FastForward::Allow => '--ff', + FastForward::Never => '--no-ff', + FastForward::Only => '--ff-only', + }; + + $this->run('merge', [$option, $revision]); } /** * Runs any git command, for what no driver method covers. * * @param array $args + * @param string|null $input The standard input of git * * @throws ProcessException The command failed */ - public function runProcess(string $command, array $args = []): Process + public function runProcess(string $command, array $args = [], ?string $input = null): Process { - $process = $this->execute($command, array_map(strval(...), $args)); + $process = $this->execute($command, array_map(strval(...), $args), $input); if (!$process->isSuccessful()) { throw new ProcessException($process); @@ -271,30 +325,110 @@ private function run(string $command, array $args): string /** * @param string[] $args */ - private function execute(string $command, array $args): Process + private function diff(string $command, array $args): DiffData { - $base = ['--git-dir', $this->gitDir]; - if (null !== $this->workingDir) { - $base = [...$base, '--work-tree', $this->workingDir]; - } + $output = $this->run($command, [...self::DIFF_ARGS, ...$args]); - // Relative paths, in arguments as in pathspecs, resolve against the repository. - $process = $this->processFactory->create([...$base, $command, ...$args], $this->workingDir ?? $this->gitDir); + $parser = new DiffParser(); + $parser->parse($output); - $this->logger?->info(\sprintf('run command: %s "%s" ', $command, implode(' ', $args))); - $before = microtime(true); + return new DiffData($parser->files, $output); + } - $process->run(); + /** + * Reads the content of the object $hash, in the format git stores it. + * + * Unlike ``git cat-file `` or ``git ls-tree``, refuses an object + * of another type, instead of reading the tree of a commit for instance. + * + * @throws ReferenceNotFoundException $hash doesn't exist, or isn't of the type $type + */ + private function readObject(string $hash, ObjectType $type): string + { + $output = $this->runProcess('cat-file', ['--batch'], $hash."\n")->getOutput(); + + // " ", or " missing". + $header = explode(' ', (string) strstr($output, "\n", true)); + if (3 !== \count($header)) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" does not exist.', $hash)); + } + if ($type->value !== $header[1]) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value)); + } - if (null !== $this->logger) { - $this->logger->debug(\sprintf('last command (%s) duration: %.2fms', $command, (microtime(true) - $before) * 1000)); - $this->logger->debug(\sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); - $this->logger->debug(\sprintf('last command (%s) output: %s', $command, $process->getOutput())); + return substr($output, \strlen(implode(' ', $header)) + 1, (int) $header[2]); + } + + /** + * Runs a command reading the object $hash. + * + * @param ObjectType|null $type The type $hash must have, null for any + * @param string[] $args + * + * @throws ReferenceNotFoundException $hash doesn't exist, or isn't of the type $type + */ + private function runOnObject(string $hash, ?ObjectType $type, string $command, array $args): string + { + try { + return $this->run($command, $args); + } catch (ProcessException $e) { + // Only checked when the command fails: one more process. + $process = $this->execute('cat-file', ['-t', $hash]); if (!$process->isSuccessful()) { - $this->logger->debug(\sprintf('last command (%s) error output: %s', $command, $process->getErrorOutput())); + throw new ReferenceNotFoundException(\sprintf('The object "%s" does not exist.', $hash), 0, $e); } + if (null !== $type && $type->value !== trim($process->getOutput())) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value), 0, $e); + } + + throw $e; } + } - return $process; + /** + * @param list|null $includes + * @param list $excludes + * + * @return list + */ + private static function revisions(?array $includes, array $excludes): array + { + return [...$includes ?? ['--all'], ...array_map(static fn (string $hash): string => '^'.$hash, $excludes)]; + } + + /** + * @param string[] $args Arguments of ``git branch`` + * + * @return string[] The full names of the branches git lists + */ + private function listBranches(array $args, string $prefix): array + { + $branches = []; + foreach (explode("\n", rtrim($this->run('branch', ['--no-color', ...$args]), "\n")) as $line) { + // "* " flags the current branch, "(HEAD detached at ...)" a detached + // HEAD, and "origin/HEAD -> origin/main" a symbolic reference. + $name = substr($line, 2); + if ('' === $name || str_starts_with($name, '(') || str_contains($name, ' -> ')) { + continue; + } + + $branches[] = $prefix.$name; + } + + return $branches; + } + + /** + * @param string[] $args + */ + private function execute(string $command, array $args, ?string $input = null): Process + { + $globalOptions = ['--git-dir', $this->gitDir]; + if (null !== $this->workingDir) { + $globalOptions = [...$globalOptions, '--work-tree', $this->workingDir]; + } + + // Relative paths, in arguments as in pathspecs, resolve against the repository. + return $this->runner->run($command, $args, $globalOptions, $this->workingDir ?? $this->gitDir, $input); } } diff --git a/src/Gitonomy/Git/Driver/Cli/GitProcessFactory.php b/src/Gitonomy/Git/Driver/Cli/GitProcessFactory.php deleted file mode 100644 index acafa5c7..00000000 --- a/src/Gitonomy/Git/Driver/Cli/GitProcessFactory.php +++ /dev/null @@ -1,56 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Driver\Cli; - -use Symfony\Component\Process\Process; - -/** - * Creates the processes running git, as configured on CliDriver. - * - * @author Grégoire Pineau - * - * @internal - */ -final readonly class GitProcessFactory -{ - /** - * @param array $environmentVariables - */ - public function __construct( - private string $command, - private array $environmentVariables, - private bool $inheritEnvironmentVariables, - private ?float $timeout, - ) { - } - - /** - * @param string[] $args - */ - public function create(array $args, ?string $workingDirectory = null): Process - { - $process = new Process([$this->command, ...$args], $workingDirectory); - - if ($this->inheritEnvironmentVariables) { - // $_SERVER also holds entries that aren't environment variables (argv, REQUEST_TIME...) - $process->setEnv(array_replace(array_filter($_SERVER, \is_string(...)), $this->environmentVariables)); - } else { - $process->setEnv($this->environmentVariables); - } - - $process->setTimeout($this->timeout); - $process->setIdleTimeout($this->timeout); - - return $process; - } -} diff --git a/src/Gitonomy/Git/Driver/Cli/GitRunner.php b/src/Gitonomy/Git/Driver/Cli/GitRunner.php new file mode 100644 index 00000000..9637221f --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/GitRunner.php @@ -0,0 +1,94 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli; + +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ProcessTimedOutException; +use Gitonomy\Git\Util\NulByte; +use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException; +use Symfony\Component\Process\Exception\RuntimeException as SymfonyRuntimeException; +use Symfony\Component\Process\Process; + +/** + * Runs the git processes, as configured on CliDriver, and logs them. + * + * @author Grégoire Pineau + * + * @internal + */ +final readonly class GitRunner +{ + /** + * @param array $environmentVariables + */ + public function __construct( + private string $command, + private array $environmentVariables, + private ?float $timeout, + private ?LoggerInterface $logger, + ) { + } + + /** + * Runs a git command, successful or not. + * + * @param string[] $args Arguments of $command + * @param string[] $globalOptions Options of git itself, before $command (--git-dir...) + * @param string|null $input The standard input of git + * + * @throws InvalidArgumentException An argument contains a NUL byte + * @throws ProcessException The git binary can't be run + */ + public function run(string $command, array $args, array $globalOptions = [], ?string $workingDirectory = null, ?string $input = null): Process + { + NulByte::assertNone(...$globalOptions, ...$args); + + $process = new Process([$this->command, ...$globalOptions, $command, ...$args], $workingDirectory); + $process->setEnv($this->environmentVariables); + $process->setInput($input); + + $process->setTimeout($this->timeout); + $process->setIdleTimeout($this->timeout); + + $this->logger?->info(\sprintf('run command: %s "%s" ', $command, implode(' ', $args))); + $before = microtime(true); + + try { + $process->run(); + } catch (SymfonyProcessTimedOutException $e) { + throw new ProcessTimedOutException(\sprintf('The git command "%s" ran longer than %s seconds.', $command, $this->timeout), 0, $e); + } catch (SymfonyRuntimeException $e) { + // A working directory removed since the repository was opened... + throw new DriverException(\sprintf('Unable to run the git command "%s": %s', $command, $e->getMessage()), 0, $e); + } + + // Symfony Process runs git through a shell, which exits with them when it can't find or run git. + if (\in_array($process->getExitCode(), [126, 127], true)) { + throw new ProcessException($process); + } + + if (null !== $this->logger) { + $this->logger->debug(\sprintf('last command (%s) duration: %.2fms', $command, (microtime(true) - $before) * 1000)); + $this->logger->debug(\sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); + $this->logger->debug(\sprintf('last command (%s) output: %s', $command, $process->getOutput())); + if (!$process->isSuccessful()) { + $this->logger->debug(\sprintf('last command (%s) error output: %s', $command, $process->getErrorOutput())); + } + } + + return $process; + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php b/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php index 746cb610..5c9bb75d 100644 --- a/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php +++ b/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php @@ -12,60 +12,100 @@ namespace Gitonomy\Git\Driver\Cli; -use Gitonomy\Git\Repository; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Psr\Log\LoggerInterface; /** - * The options of Repository and Admin configuring git in 1.x, deprecated in - * favor of the arguments of CliDriver. + * The options of Repository and Admin in 1.x, deprecated in favor of the + * "workingDir" argument of Repository, and of the arguments of CliDriver. * * @author Grégoire Pineau * * @internal To be removed in 3.0, with these options * - * @phpstan-import-type Options from Repository + * @phpstan-type Options array{command?: string, debug?: bool, environment_variables?: array, inherit_environment_variables?: bool, logger?: LoggerInterface|null, process_timeout?: float|null, working_dir?: string|null} */ -final class LegacyOptions +final readonly class LegacyOptions { - private const array NAMES = ['command', 'environment_variables', 'inherit_environment_variables', 'process_timeout']; + /** + * The options configuring CliDriver. + */ + private const array DRIVER_OPTIONS = ['command', 'environment_variables', 'inherit_environment_variables', 'logger', 'process_timeout']; + + public ?string $workingDir; + + /** + * @var Options + */ + private array $options; /** * @param Options $options * - * @return list The names of the options configuring git in $options + * @throws InvalidArgumentException An unknown option, or the "debug" one set to false */ - public static function find(array $options): array + public function __construct(array $options) { - return array_values(array_intersect(array_keys($options), self::NAMES)); + if (\array_key_exists('debug', $options) && !$options['debug']) { + throw new InvalidArgumentException('The "debug" option set to false is not supported anymore: a failing git operation always throws, catch its exception instead.'); + } + + $unknownOptions = array_diff(array_keys($options), [...self::DRIVER_OPTIONS, 'debug', 'working_dir']); + if ([] !== $unknownOptions) { + throw new InvalidArgumentException(\sprintf('Unknown options "%s".', implode('", "', $unknownOptions))); + } + + // PHP doesn't check the types of the options, unlike PHPStan. + if (null !== ($options['logger'] ?? null) && !$options['logger'] instanceof LoggerInterface) { // @phpstan-ignore instanceof.alwaysTrue, booleanAnd.alwaysFalse + throw new InvalidArgumentException(\sprintf('The "logger" option must be a Psr\Log\LoggerInterface, "%s" given.', get_debug_type($options['logger']))); + } + + $this->workingDir = $options['working_dir'] ?? null; + $this->options = $options; } /** - * @param Options $options + * The CliDriver running the git processes of a repository, null when no option configures one. * - * @return Options $options without the options configuring git + * Like in 1.x, "inherit_environment_variables" also gives them the entries of $_SERVER. */ - public static function remove(array $options): array + public function repositoryDriver(): ?CliDriver { - foreach (self::NAMES as $name) { - unset($options[$name]); + $environmentVariables = $this->options['environment_variables'] ?? []; + if ($this->options['inherit_environment_variables'] ?? false) { + // $_SERVER also holds entries that aren't environment variables (argv, REQUEST_TIME...). + $environmentVariables = array_replace(array_filter($_SERVER, \is_string(...)), $environmentVariables); } - return $options; + return $this->driver($environmentVariables); } /** - * Creates the CliDriver the options of $options configure, with the - * defaults of 1.x. + * The CliDriver running the commands of Admin (init, clone, ls-remote), null when no option configures one. * - * @param Options $options + * Like in 1.x, it ignores "inherit_environment_variables": these commands + * reach remotes, and $_SERVER holds the credentials of an HTTP request. + */ + public function adminDriver(): ?CliDriver + { + return $this->driver($this->options['environment_variables'] ?? []); + } + + /** + * @param array $environmentVariables */ - public static function createDriver(array $options): CliDriver + private function driver(array $environmentVariables): ?CliDriver { + if ([] === array_intersect(array_keys($this->options), self::DRIVER_OPTIONS)) { + return null; + } + return new CliDriver( - $options['command'] ?? 'git', - $options['environment_variables'] ?? [], - $options['inherit_environment_variables'] ?? false, + $this->options['command'] ?? 'git', + $environmentVariables, // null disables the timeout. - \array_key_exists('process_timeout', $options) ? $options['process_timeout'] : 3600, + \array_key_exists('process_timeout', $this->options) ? $this->options['process_timeout'] : 3600, + $this->options['logger'] ?? null, ); } } diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php index d4e46da5..20281f45 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php @@ -35,14 +35,11 @@ protected function doParse(): void $this->lines = []; $this->seenHashes = []; - $line = 1; while (!$this->isFinished()) { $hash = $this->consumeHash(); $this->consume(' '); - $vars = $this->consumeRegexp('/(\d+) (\d+)( (\d+))?/A'); - $sourceLine = $vars[1]; - $targetLine = $vars[2]; - $blockLine = $vars[4] ?? null; + // The number of lines of the group follows on its first line. + $vars = $this->consumeRegexp('/(\d+) (\d+)/A'); $this->consumeTo("\n"); $this->consumeNewLine(); @@ -72,8 +69,7 @@ protected function doParse(): void $content = $this->consumeTo("\n"); $this->consumeNewLine(); - $this->lines[$line] = new BlameLineData($hash, $sourceLine, $targetLine, $blockLine, $content); - ++$line; + $this->lines[] = new BlameLineData($hash, (int) $vars[1], (int) $vars[2], $content); } } } diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php index a5752ae5..ac729fd0 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Driver\Cli\Parser; use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Parser\ParserBase; /** @@ -21,7 +22,7 @@ final class LogParser extends ParserBase { /** - * @var array Indexed by hash + * @var list */ public array $commits = []; @@ -34,62 +35,59 @@ protected function doParse(): void $hash = $this->consumeHash(); $this->consumeNewLine(); - $this->consume('tree '); - $treeHash = $this->consumeHash(); - $this->consumeNewLine(); - + $treeHash = null; $parentHashes = []; - while ($this->expects('parent ')) { - $parentHashes[] = $this->consumeHash(); + $author = null; + $committer = null; + // The headers end with an empty line, unless the commit is the + // last one and has no message. The lines following a header, of + // a signature (gpgsig) or a merged tag (mergetag), start with a + // space. + while (!$this->isFinished() && !$this->expects("\n")) { + [$name, $value] = explode(' ', $this->consumeTo("\n"), 2) + [1 => '']; $this->consumeNewLine(); - } - - $this->consume('author '); - [$authorName, $authorEmail, $authorDate] = $this->consumeNameEmailDate(); - $this->consumeNewLine(); - - $this->consume('committer '); - [$committerName, $committerEmail, $committerDate] = $this->consumeNameEmailDate(); - - $this->consumeMergeTag(); - - // will consume an GPG signed commit if there is one - $this->consumeGPGSignature(); + while ($this->expects(' ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } - $this->consumeNewLine(); - $this->consumeUnsupportedLinesToNewLine(); - if ($this->cursor < \strlen($this->content)) { - $this->consumeNewLine(); + match ($name) { + 'author' => $author = self::parseNameEmailDate($value), + 'committer' => $committer = self::parseNameEmailDate($value), + 'parent' => $parentHashes[] = $value, + 'tree' => $treeHash = $value, + // encoding, gpgsig, mergetag, and the headers of other tools. + default => null, + }; } - $message = ''; - if ($this->expects(' ')) { - $this->cursor -= \strlen(' '); - - while ($this->expects(' ')) { - $message .= $this->consumeTo("\n")."\n"; - $this->consumeNewLine(); - } - } else { - --$this->cursor; + if (null === $treeHash || null === $author || null === $committer) { + throw new RuntimeException(\sprintf('The commit "%s" lacks a tree, an author or a committer.', $hash)); } - if (!$this->isFinished()) { + // git indents every line of the message. + $message = ''; + while ($this->expects(' ')) { + $message .= $this->consumeTo("\n")."\n"; $this->consumeNewLine(); } - $this->commits[$hash] = new CommitData($treeHash, $parentHashes, $authorName, $authorEmail, $authorDate, $committerName, $committerEmail, $committerDate, $message); + // An empty line separates the commits. + $this->expects("\n"); + + $this->commits[] = new CommitData($hash, $treeHash, $parentHashes, ...$author, ...$committer, message: $message); } } - private function consumeUnsupportedLinesToNewLine(): void + /** + * @return array{string, string, \DateTimeImmutable} + */ + private static function parseNameEmailDate(string $value): array { - // Consume any unsupported lines that may appear in the log output. For - // example, gitbutler headers or other custom metadata but this should - // work regardless of the content. - while (!$this->isFinished() && "\n" !== substr($this->content, $this->cursor, 1)) { - $this->consumeTo("\n"); - $this->consumeNewLine(); + if (!preg_match('/^(.*) <(.*)> (\d+) [+-]\d{4}$/', $value, $vars)) { + throw new RuntimeException(\sprintf('Unable to parse the name, email and date of "%s".', $value)); } + + return [$vars[1], $vars[2], self::timestamp($vars[3])]; } } diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php index a32bb965..16e5a0a7 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Driver\Cli\Parser; use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Parser\ParserBase; /** @@ -34,6 +35,15 @@ protected function doParse(): void $this->consume(' '); $fullname = $this->consumeTo("\n"); $this->consumeNewLine(); + + // With --dereference, an annotated tag is followed by what it points to. + if (str_ends_with($fullname, '^{}')) { + $tag = array_pop($this->references) ?? throw new RuntimeException(\sprintf('No tag precedes "%s".', $fullname)); + $this->references[] = new ReferenceData($tag->fullname, $hash, $tag->commitHash); + + continue; + } + $this->references[] = new ReferenceData($fullname, $hash); } } diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php index 18bb6431..bd14f349 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php @@ -12,11 +12,13 @@ namespace Gitonomy\Git\Driver\Cli\Parser; +use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Driver\Data\TagData; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Parser\ParserBase; /** - * Parses the output of ``git cat-file tag``. + * Parses a tag object, as ``git cat-file --batch`` gives it. */ final class TagParser extends ParserBase { @@ -29,21 +31,23 @@ protected function doParse(): void $this->consumeNewLine(); $this->consume('type '); - $type = $this->consumeTo("\n"); + $typeName = $this->consumeTo("\n"); + $type = ObjectType::tryFrom($typeName) ?? throw new RuntimeException(\sprintf('The tag points to an object of the unknown type "%s".', $typeName)); $this->consumeNewLine(); $this->consume('tag '); $tag = $this->consumeTo("\n"); $this->consumeNewLine(); - $this->consume('tagger '); - [$taggerName, $taggerEmail, $taggerDate] = $this->consumeNameEmailDate(); + // The oldest tags, like the first ones of git itself, have no tagger. + [$taggerName, $taggerEmail, $taggerDate] = [null, null, null]; + if ($this->expects('tagger ')) { + [$taggerName, $taggerEmail, $taggerDate] = $this->consumeNameEmailDate(); + $this->consumeNewLine(); + } $this->consumeNewLine(); - $this->consumeNewLine(); - - [$message, $gpgSignature] = TagData::splitSignature($this->consumeAll()); - $this->data = new TagData($object, $type, $tag, $taggerName, $taggerEmail, $taggerDate, $message, $gpgSignature); + $this->data = TagData::fromSignedMessage($object, $type, $tag, $taggerName, $taggerEmail, $taggerDate, $this->consumeAll()); } } diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php index e68216b4..54c769f9 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php @@ -12,12 +12,14 @@ namespace Gitonomy\Git\Driver\Cli\Parser; +use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Driver\Data\TreeEntryData; -use Gitonomy\Git\Driver\Data\TreeEntryType; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Parser\ParserBase; /** - * Parses the output of ``git ls-tree -z``, whose names are never quoted. + * Parses a tree object, as ``git cat-file --batch`` gives it: for each + * entry, its mode, a space, its name, a NUL byte, and its hash in binary. */ final class TreeParser extends ParserBase { @@ -31,21 +33,27 @@ protected function doParse(): void $this->entries = []; while (!$this->isFinished()) { - $vars = $this->consumeRegexp('/\d{6}/A'); - $mode = $vars[0]; + // git stores the mode of a tree as "40000". + $mode = str_pad($this->consumeRegexp('/[0-7]{5,6}/')[0], 6, '0', \STR_PAD_LEFT); $this->consume(' '); - $vars = $this->consumeRegexp('/(blob|tree|commit)/A'); - $type = TreeEntryType::from($vars[0]); - $this->consume(' '); - - $hash = $this->consumeHash(); - $this->consume("\t"); - $name = $this->consumeTo("\0"); $this->consume("\0"); - $this->entries[] = new TreeEntryData($mode, $type, $hash, $name); + $hash = substr($this->content, $this->cursor, 20); + if (20 !== \strlen($hash)) { + throw new RuntimeException(\sprintf('The tree entry "%s" has no hash.', $name)); + } + $this->cursor += 20; + + $type = match ($mode) { + '040000' => ObjectType::Tree, + // A submodule: the hash is a commit of another repository. + '160000' => ObjectType::Commit, + default => ObjectType::Blob, + }; + + $this->entries[] = new TreeEntryData($mode, $type, bin2hex($hash), $name); } } } diff --git a/src/Gitonomy/Git/Driver/Data/BlameLineData.php b/src/Gitonomy/Git/Driver/Data/BlameLineData.php index edf02ad8..31ed9ce7 100644 --- a/src/Gitonomy/Git/Driver/Data/BlameLineData.php +++ b/src/Gitonomy/Git/Driver/Data/BlameLineData.php @@ -21,11 +21,14 @@ */ final readonly class BlameLineData { + /** + * @param int $originalLine The number of the line in the file of $commitHash + * @param int $finalLine The number of the line in the blamed file + */ public function __construct( public string $commitHash, - public string $sourceLine, - public string $targetLine, - public ?string $blockLine, + public int $originalLine, + public int $finalLine, public string $content, ) { } diff --git a/src/Gitonomy/Git/Driver/Data/CommitData.php b/src/Gitonomy/Git/Driver/Data/CommitData.php index 0a57678f..f71aef3f 100644 --- a/src/Gitonomy/Git/Driver/Data/CommitData.php +++ b/src/Gitonomy/Git/Driver/Data/CommitData.php @@ -24,6 +24,7 @@ * @param string[] $parentHashes */ public function __construct( + public string $hash, public string $treeHash, public array $parentHashes, public string $authorName, diff --git a/src/Gitonomy/Git/Driver/Data/DiffData.php b/src/Gitonomy/Git/Driver/Data/DiffData.php new file mode 100644 index 00000000..bcdccf36 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/DiffData.php @@ -0,0 +1,31 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * A diff, as computed by a Driver. + * + * @author Grégoire Pineau + */ +final readonly class DiffData +{ + /** + * @param list $files + * @param string $rawDiff The output of git the diff was parsed from, empty when no git command computed it + */ + public function __construct( + public array $files, + public string $rawDiff, + ) { + } +} diff --git a/src/Gitonomy/Git/Driver/Data/DiffFileData.php b/src/Gitonomy/Git/Driver/Data/DiffFileData.php new file mode 100644 index 00000000..4ce29b66 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/DiffFileData.php @@ -0,0 +1,55 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * A file of a diff, as computed by a Driver. + * + * Its changes are FileChange objects already: unlike a file, they need no + * repository. + * + * @author Grégoire Pineau + */ +final readonly class DiffFileData +{ + /** + * The new name, or the old one for a deletion. + */ + public string $name; + + /** + * @param string|null $oldName Null for a creation + * @param string|null $newName Null for a deletion + * @param string|null $oldIndex The hash of the old blob, null when there is none + * @param string|null $newIndex The hash of the new blob, null when there is none, or for a file of the working tree + * @param list $changes + * + * @throws InvalidArgumentException Both names are null + */ + public function __construct( + public ?string $oldName, + public ?string $newName, + public ?string $oldMode, + public ?string $newMode, + public ?string $oldIndex, + public ?string $newIndex, + public bool $isBinary, + public bool $isCopy, + public array $changes, + ) { + $this->name = $newName ?? $oldName ?? throw new InvalidArgumentException('A file of a diff has an old name, a new name, or both.'); + } +} diff --git a/src/Gitonomy/Git/Driver/Data/TreeEntryType.php b/src/Gitonomy/Git/Driver/Data/ObjectType.php similarity index 84% rename from src/Gitonomy/Git/Driver/Data/TreeEntryType.php rename to src/Gitonomy/Git/Driver/Data/ObjectType.php index 4c708896..17f93c86 100644 --- a/src/Gitonomy/Git/Driver/Data/TreeEntryType.php +++ b/src/Gitonomy/Git/Driver/Data/ObjectType.php @@ -13,11 +13,14 @@ namespace Gitonomy\Git\Driver\Data; /** + * The type of a git object. + * * @author Grégoire Pineau */ -enum TreeEntryType: string +enum ObjectType: string { case Blob = 'blob'; - case Tree = 'tree'; case Commit = 'commit'; + case Tag = 'tag'; + case Tree = 'tree'; } diff --git a/src/Gitonomy/Git/Driver/Data/ReferenceData.php b/src/Gitonomy/Git/Driver/Data/ReferenceData.php index 35974d11..94603c63 100644 --- a/src/Gitonomy/Git/Driver/Data/ReferenceData.php +++ b/src/Gitonomy/Git/Driver/Data/ReferenceData.php @@ -19,9 +19,14 @@ */ final readonly class ReferenceData { + /** + * @param string $commitHash What the reference points to, through an annotated tag: a commit, unless a tag points to a tree or a blob + * @param string|null $tagHash The annotated tag object the reference points to, null for a branch or a lightweight tag + */ public function __construct( public string $fullname, public string $commitHash, + public ?string $tagHash = null, ) { } } diff --git a/src/Gitonomy/Git/Driver/Data/TagData.php b/src/Gitonomy/Git/Driver/Data/TagData.php index 26963ce1..03b5983f 100644 --- a/src/Gitonomy/Git/Driver/Data/TagData.php +++ b/src/Gitonomy/Git/Driver/Data/TagData.php @@ -19,29 +19,35 @@ */ final readonly class TagData { + /** + * The armors git signs tags with: GPG, then X.509 (gpgsm), then SSH. + */ + private const string SIGNATURE = '-----BEGIN (PGP SIGNATURE|PGP MESSAGE|SIGNED MESSAGE|SSH SIGNATURE)-----'; + + /** + * @param string|null $taggerName Null for a tag without a tagger, like the oldest tags of git itself + */ public function __construct( public string $objectHash, - public string $objectType, + public ObjectType $objectType, public string $tagName, - public string $taggerName, - public string $taggerEmail, - public \DateTimeImmutable $taggerDate, + public ?string $taggerName, + public ?string $taggerEmail, + public ?\DateTimeImmutable $taggerDate, public string $message, public ?string $gpgSignature, ) { } /** - * Splits the message of a tag object from the PGP signature git appends to it. - * - * @return array{string, string|null} The message, and the signature between its armor lines + * @param string $signedMessage The message of the tag object, followed by the signature git appends to it, if any */ - public static function splitSignature(string $message): array + public static function fromSignedMessage(string $objectHash, ObjectType $objectType, string $tagName, ?string $taggerName, ?string $taggerEmail, ?\DateTimeImmutable $taggerDate, string $signedMessage): self { - if (!preg_match('/^(.*?)-----BEGIN PGP SIGNATURE-----(.*?)-----END PGP SIGNATURE-----/s', $message, $vars)) { - return [$message, null]; + if (!preg_match('/^(.*?)'.self::SIGNATURE.'(.*?)-----END \2-----/s', $signedMessage, $vars)) { + return new self($objectHash, $objectType, $tagName, $taggerName, $taggerEmail, $taggerDate, $signedMessage, null); } - return [$vars[1], $vars[2]]; + return new self($objectHash, $objectType, $tagName, $taggerName, $taggerEmail, $taggerDate, $vars[1], $vars[3]); } } diff --git a/src/Gitonomy/Git/Driver/Data/TreeEntryData.php b/src/Gitonomy/Git/Driver/Data/TreeEntryData.php index 9cf04e7b..d2506dba 100644 --- a/src/Gitonomy/Git/Driver/Data/TreeEntryData.php +++ b/src/Gitonomy/Git/Driver/Data/TreeEntryData.php @@ -21,7 +21,7 @@ { public function __construct( public string $mode, - public TreeEntryType $type, + public ObjectType $type, public string $hash, public string $name, ) { diff --git a/src/Gitonomy/Git/Driver/DriverInterface.php b/src/Gitonomy/Git/Driver/DriverInterface.php index d786384d..ea5c76cb 100644 --- a/src/Gitonomy/Git/Driver/DriverInterface.php +++ b/src/Gitonomy/Git/Driver/DriverInterface.php @@ -14,7 +14,8 @@ use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\DriverException; -use Psr\Log\LoggerInterface; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\UnsupportedOperationException; /** * A configured git backend: opens repositories, and runs the operations that @@ -23,14 +24,24 @@ * Pass one to Repository, which uses {@see CliDriver} by default, to * configure the backend, or to replace it in tests. * + * Every method throws an {@see InvalidArgumentException} when an argument + * contains a NUL byte, which git doesn't accept. + * * @author Grégoire Pineau */ interface DriverInterface { /** - * @param LoggerInterface|null $logger Receives the git commands the driver runs, if it runs any + * Opens the repository in $dir: a bare repository, or a working tree with + * its ".git" directory or file (a linked worktree, a submodule...). + * + * @param string|null $workingDir The working tree, when $dir is a git directory kept apart from it + * + * @throws InvalidArgumentException $dir doesn't exist, or isn't a repository + * @throws DriverException The driver can't open the repository + * @throws UnsupportedOperationException The repository uses SHA-256 hashes */ - public function open(string $gitDir, ?string $workingDir, ?LoggerInterface $logger): RepositoryDriverInterface; + public function open(string $dir, ?string $workingDir): RepositoryDriverInterface; /** * @throws DriverException @@ -44,7 +55,8 @@ public function initBare(string $path): void; /** * Clones $url with a working directory in $path, checking out $branch, - * or the default branch of $url when null. + * or the default branch of $url when null; like git, a tag named $branch + * is checked out with HEAD detached, when $url has no such branch. * * @throws DriverException */ @@ -53,7 +65,8 @@ public function cloneFrom(string $url, string $path, ?string $branch): void; /** * Clones the branches and the tags of $url into a bare repository in * $path, with HEAD pointing to $branch, or to the default branch of $url - * when null. + * when null; like git, HEAD is detached at a tag named $branch, when $url + * has no such branch. * * @throws DriverException */ @@ -67,7 +80,16 @@ public function cloneBareFrom(string $url, string $path, ?string $branch): void; */ public function mirrorFrom(string $url, string $path): void; + /** + * @param string $url A URL, or the path of a repository: never the name of a remote + * + * @return bool Whether $url can be reached + */ public function isValidRepository(string $url): bool; - public function isValidRepositoryAndBranch(string $url, string $branchName): bool; + /** + * @param string $url Same as {@see isValidRepository()} + * @param string $branch The name of the branch, exactly: "release" doesn't name "feature/release" + */ + public function isValidRepositoryAndBranch(string $url, string $branch): bool; } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php index 9c10874a..ad8a5704 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php @@ -25,6 +25,10 @@ */ final class LibGit2 { + public const int GIT_ENOTFOUND = -3; + + private const int GIT_FEATURE_SHA256 = 1 << 11; + /** * The only libgit2 version libgit2.h is written for: libgit2 changes its * struct layouts on every minor release, so loading any other version @@ -73,7 +77,7 @@ public static function preload(?string $libraryPath): void $header = tempnam(sys_get_temp_dir(), 'gitlib-libgit2-'); try { - file_put_contents($header, \sprintf("#define FFI_SCOPE \"%s\"\n#define FFI_LIB \"%s\"\n", self::scope($libraryPath), $path).file_get_contents(__DIR__.'/libgit2.h')); + file_put_contents($header, \sprintf("#define FFI_SCOPE \"%s\"\n#define FFI_LIB \"%s\"\n", self::scope($libraryPath), $path).self::header()); $ffi = \FFI::load($header); } catch (\FFI\Exception $e) { throw new UnsupportedOperationException(\sprintf('Unable to preload libgit2 from "%s".', $path), 0, $e); @@ -148,12 +152,25 @@ private static function define(string $libraryPath): \FFI self::assertSupportedVersion($libraryPath); try { - return \FFI::cdef(file_get_contents(__DIR__.'/libgit2.h'), $libraryPath); + return \FFI::cdef(self::header(), $libraryPath); } catch (\FFI\Exception $e) { throw new UnsupportedOperationException(\sprintf('Unable to load libgit2 from "%s"; use CliDriver.', $libraryPath), 0, $e); } } + /** + * @return string The declarations of libgit2 the drivers call + */ + private static function header(): string + { + $header = file_get_contents(__DIR__.'/libgit2.h'); + if (false === $header) { + throw new UnsupportedOperationException(\sprintf('Unable to read "%s".', __DIR__.'/libgit2.h')); + } + + return $header; + } + private static function scope(?string $libraryPath): string { return 'gitonomy/gitlib:'.($libraryPath ?? ''); @@ -176,7 +193,7 @@ private static function assertFfiIsAvailable(): void private static function assertSupportedVersion(string $libraryPath): void { try { - $ffi = \FFI::cdef('int git_libgit2_version(int *major, int *minor, int *rev);', $libraryPath); + $ffi = \FFI::cdef('int git_libgit2_version(int *major, int *minor, int *rev); int git_libgit2_features(void);', $libraryPath); } catch (\FFI\Exception $e) { throw new UnsupportedOperationException(\sprintf('Unable to load libgit2 from "%s"; use CliDriver.', $libraryPath), 0, $e); } @@ -188,6 +205,11 @@ private static function assertSupportedVersion(string $libraryPath): void if (self::SUPPORTED_VERSION !== [$major->cdata, $minor->cdata]) { throw new UnsupportedOperationException(\sprintf('LibGit2Driver requires libgit2 %s, but "%s" is libgit2 %d.%d.%d; use CliDriver.', implode('.', self::SUPPORTED_VERSION), $libraryPath, $major->cdata, $minor->cdata, $rev->cdata)); } + + // Its experimental SHA-256 build changes git_oid, and every structure holding one. + if (0 !== ($ffi->git_libgit2_features() & self::GIT_FEATURE_SHA256)) { + throw new UnsupportedOperationException(\sprintf('LibGit2Driver requires libgit2 built for SHA-1 only, but "%s" supports SHA-256; use CliDriver.', $libraryPath)); + } } private static function initialize(\FFI $ffi): void diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index 56f4b91c..ef69d86e 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -14,9 +14,10 @@ use FFI\CData; use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\UnsupportedOperationException; -use Psr\Log\LoggerInterface; +use Gitonomy\Git\Util\NulByte; /** * Runs libgit2 1.9 in-process, through PHP's FFI extension. @@ -27,7 +28,10 @@ */ final readonly class LibGit2Driver implements DriverInterface { - private const int GIT_ENOTFOUND = -3; + private const int GIT_CHECKOUT_FORCE = 1 << 1; + private const int GIT_CHECKOUT_NONE = 1 << 30; + private const int GIT_DIRECTION_FETCH = 0; + private const int GIT_PROXY_AUTO = 1; private const int GIT_REMOTE_DOWNLOAD_TAGS_ALL = 3; private \FFI $ffi; @@ -73,72 +77,102 @@ public static function isSupported(): bool } } - /** - * LibGit2Driver runs no command, so it logs nothing. - */ - public function open(string $gitDir, ?string $workingDir, ?LoggerInterface $logger): LibGit2RepositoryDriver + public function open(string $dir, ?string $workingDir): LibGit2RepositoryDriver { - return new LibGit2RepositoryDriver($this->ffi, $gitDir, $workingDir); + $paths = RepositoryPaths::resolve($dir, $workingDir); + + return new LibGit2RepositoryDriver($this->ffi, $paths->gitDir, $paths->workingDir); } public function init(string $path): void { + NulByte::assertNone($path); + $this->ffi->git_repository_free($this->initRepository($path, false)); } public function initBare(string $path): void { + NulByte::assertNone($path); + $this->ffi->git_repository_free($this->initRepository($path, true)); } public function cloneFrom(string $url, string $path, ?string $branch): void { + NulByte::assertNone($url, $path, $branch ?? ''); + + // git_clone() only checks a branch out, where git also checks a tag out, HEAD detached. + $tag = null !== $branch && $this->isTagOnly($url, $branch) ? $branch : null; + $options = $this->ffi->new('git_clone_options'); LibGit2::check($this->ffi, $this->ffi->git_clone_options_init(\FFI::addr($options), 1), 'Unable to initialize clone options'); + // Like git, through the proxy that http.proxy or the environment configures. + $options->fetch_opts->proxy_opts->type = self::GIT_PROXY_AUTO; // The buffer must stay alive until git_clone() returns. $branchBuffer = null; - if (null !== $branch) { + if (null !== $tag) { + $options->checkout_opts->checkout_strategy = self::GIT_CHECKOUT_NONE; + } elseif (null !== $branch) { $branchBuffer = LibGit2::cString($this->ffi, $branch); $options->checkout_branch = $this->ffi->cast('const char *', $branchBuffer); } + $existing = is_dir($path); $repository = $this->ffi->new('git_repository*'); LibGit2::check($this->ffi, $this->ffi->git_clone(\FFI::addr($repository), $url, $path, \FFI::addr($options)), \sprintf('Unable to clone "%s"', $url)); - $this->ffi->git_repository_free($repository); + + try { + try { + if (null !== $tag) { + $this->checkOutTag($repository, $tag); + } + } finally { + $this->ffi->git_repository_free($repository); + } + } catch (DriverException $e) { + // Like git, which removes what it created when a clone fails. + self::remove($path, $existing); + + throw $e; + } + + self::removeFetchHead($path.'/.git'); } public function cloneBareFrom(string $url, string $path, ?string $branch): void { + NulByte::assertNone($url, $path, $branch ?? ''); + // Like git, only the branches (and the tags). $this->fetchInto($path, $url, '+refs/heads/*:refs/heads/*', $branch, ['remote.origin.url' => $url]); } public function mirrorFrom(string $url, string $path): void { + NulByte::assertNone($url, $path); + $this->fetchInto($path, $url, '+refs/*:refs/*', null, [ 'remote.origin.url' => $url, 'remote.origin.fetch' => '+refs/*:refs/*', 'remote.origin.mirror' => 'true', + 'remote.origin.tagopt' => '--no-tags', ]); } public function isValidRepository(string $url): bool { + NulByte::assertNone($url); + return null !== $this->listRemoteReferences($url); } - public function isValidRepositoryAndBranch(string $url, string $branchName): bool + public function isValidRepositoryAndBranch(string $url, string $branch): bool { - foreach ($this->listRemoteReferences($url) ?? [] as $fullname) { - // Same matching as `git ls-remote --heads `: - // only branches, matched on whole trailing path components. - if (str_starts_with($fullname, 'refs/heads/') && ($fullname === 'refs/heads/'.$branchName || str_ends_with($fullname, '/'.$branchName))) { - return true; - } - } + NulByte::assertNone($url, $branch); - return false; + return \in_array('refs/heads/'.$branch, $this->listRemoteReferences($url) ?? [], true); } private function initRepository(string $path, bool $bare): CData @@ -157,13 +191,116 @@ private function initRepository(string $path, bool $bare): CData */ private function fetchInto(string $path, string $url, string $refspec, ?string $branch, array $config): void { + // Like git, which refuses to clone into a directory with files. + $existing = is_dir($path); + if ($existing && [] !== array_diff(scandir($path) ?: [], ['.', '..'])) { + throw new DriverException(\sprintf('Unable to clone "%s": "%s" exists and is not an empty directory.', $url, $path)); + } + $repository = $this->initRepository($path, true); try { - $this->fetch($repository, $url, $refspec, $branch); - $this->setConfig($repository, $config); + try { + $this->fetch($repository, $url, $refspec, $branch); + $this->setConfig($repository, $config); + } finally { + $this->ffi->git_repository_free($repository); + } + } catch (DriverException $e) { + // Like git, which removes what it created when a clone fails. + self::remove($path, $existing); + + throw $e; + } + + self::removeFetchHead($path); + } + + /** + * libgit2 writes an empty FETCH_HEAD, even when told not to, where a clone with git writes none. + */ + private static function removeFetchHead(string $gitDir): void + { + if (is_file($gitDir.'/FETCH_HEAD')) { + unlink($gitDir.'/FETCH_HEAD'); + } + } + + /** + * @return bool Whether $url has a tag named $name, and no branch of this name + */ + private function isTagOnly(string $url, string $name): bool + { + $references = $this->listRemoteReferences($url) ?? []; + + return !\in_array('refs/heads/'.$name, $references, true) && \in_array('refs/tags/'.$name, $references, true); + } + + /** + * Checks the commit of the tag $tag out, HEAD detached, and removes the + * local branch git_clone() created: git creates none. + */ + private function checkOutTag(CData $repository, string $tag): void + { + $head = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_lookup(\FFI::addr($head), $repository, 'HEAD'), 'Unable to read HEAD'); + + try { + $branch = $this->ffi->git_reference_type($head) === $this->ffi->GIT_REFERENCE_SYMBOLIC ? $this->ffi->git_reference_symbolic_target($head) : null; + } finally { + $this->ffi->git_reference_free($head); + } + + $commit = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_revparse_single(\FFI::addr($commit), $repository, 'refs/tags/'.$tag.'^{commit}'), \sprintf('Unable to resolve the tag "%s"', $tag)); + + try { + $options = $this->ffi->new('git_checkout_options'); + LibGit2::check($this->ffi, $this->ffi->git_checkout_options_init(\FFI::addr($options), 1), 'Unable to initialize checkout options'); + // The clone wrote no file. + $options->checkout_strategy = self::GIT_CHECKOUT_FORCE; + LibGit2::check($this->ffi, $this->ffi->git_checkout_tree($repository, $commit, \FFI::addr($options)), \sprintf('Unable to checkout the tag "%s"', $tag)); + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($repository, $this->ffi->git_object_id($commit)), 'Unable to detach HEAD'); + } finally { + $this->ffi->git_object_free($commit); + } + + if (null === $branch) { + return; + } + + $reference = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($reference), $repository, $branch); + // An empty remote has no default branch. + if (LibGit2::GIT_ENOTFOUND === $rc) { + return; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up "%s"', $branch)); + + try { + LibGit2::check($this->ffi, $this->ffi->git_branch_delete($reference), \sprintf('Unable to delete "%s"', $branch)); } finally { - $this->ffi->git_repository_free($repository); + $this->ffi->git_reference_free($reference); + } + } + + /** + * Removes $path, or only its content when $keep. + */ + private static function remove(string $path, bool $keep): void + { + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + /** @var \SplFileInfo $file */ + foreach ($files as $file) { + if (is_dir($file) && !is_link($file)) { + rmdir($file); + } else { + unlink($file); + } + } + + if (!$keep) { + rmdir($path); } } @@ -180,6 +317,9 @@ private function fetch(CData $repository, string $url, string $refspec, ?string $options = $this->ffi->new('git_fetch_options'); LibGit2::check($this->ffi, $this->ffi->git_fetch_options_init(\FFI::addr($options), 1), 'Unable to initialize fetch options'); $options->download_tags = self::GIT_REMOTE_DOWNLOAD_TAGS_ALL; + $options->proxy_opts->type = self::GIT_PROXY_AUTO; + // A clone writes no FETCH_HEAD. + $options->update_fetchhead = 0; $refspecBuffer = LibGit2::cString($this->ffi, $refspec); $refspecs = $this->ffi->new('char*[1]'); @@ -201,8 +341,11 @@ private function fetch(CData $repository, string $url, string $refspec, ?string $reference = $this->ffi->new('git_reference*'); $rc = $this->ffi->git_reference_lookup(\FFI::addr($reference), $repository, $head); - if (self::GIT_ENOTFOUND === $rc) { - throw new DriverException(\sprintf('Remote branch "%s" not found in "%s".', $branch ?? $head, $url)); + if (LibGit2::GIT_ENOTFOUND === $rc && null !== $branch) { + // Like git, HEAD detached at the commit of a tag of this name. + $this->detachHeadAtTag($repository, $branch, $url); + + return; } LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up "%s"', $head)); $this->ffi->git_reference_free($reference); @@ -210,6 +353,25 @@ private function fetch(CData $repository, string $url, string $refspec, ?string LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($repository, $head), 'Unable to update HEAD'); } + /** + * @throws DriverException $repository has no tag named $tag + */ + private function detachHeadAtTag(CData $repository, string $tag, string $url): void + { + $commit = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_revparse_single(\FFI::addr($commit), $repository, 'refs/tags/'.$tag.'^{commit}'); + if (LibGit2::GIT_ENOTFOUND === $rc) { + throw new DriverException(\sprintf('Remote branch "%s" not found in "%s".', $tag, $url)); + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve the tag "%s"', $tag)); + + try { + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($repository, $this->ffi->git_object_id($commit)), 'Unable to detach HEAD'); + } finally { + $this->ffi->git_object_free($commit); + } + } + /** * @return string|null The full name of the branch HEAD points to on the remote, or null for an empty repository */ @@ -217,7 +379,7 @@ private function defaultBranch(CData $remote): ?string { $buffer = $this->ffi->new('git_buf'); $rc = $this->ffi->git_remote_default_branch(\FFI::addr($buffer), $remote); - if (self::GIT_ENOTFOUND === $rc) { + if (LibGit2::GIT_ENOTFOUND === $rc) { return null; } LibGit2::check($this->ffi, $rc, 'Unable to find the default branch of the remote'); @@ -257,7 +419,10 @@ private function listRemoteReferences(string $url): ?array } try { - if ($this->ffi->git_remote_connect($remote, 0, null, null, null) < 0) { // GIT_DIRECTION_FETCH + $proxy = $this->ffi->new('git_proxy_options'); + $proxy->version = 1; + $proxy->type = self::GIT_PROXY_AUTO; + if ($this->ffi->git_remote_connect($remote, self::GIT_DIRECTION_FETCH, null, \FFI::addr($proxy), null) < 0) { return null; } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index 2141ff7e..f6dfcea7 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -13,18 +13,22 @@ namespace Gitonomy\Git\Driver\Ffi; use FFI\CData; -use Gitonomy\Git\Diff\Diff; -use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\DiffLineType; use Gitonomy\Git\Diff\FileChange; use Gitonomy\Git\Driver\Data\BlameLineData; use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Driver\Data\DiffData; +use Gitonomy\Git\Driver\Data\DiffFileData; +use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Driver\Data\ReferenceData; use Gitonomy\Git\Driver\Data\TagData; use Gitonomy\Git\Driver\Data\TreeEntryData; -use Gitonomy\Git\Driver\Data\TreeEntryType; use Gitonomy\Git\Driver\RepositoryDriverInterface; use Gitonomy\Git\Exception\DriverException; -use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\FastForward; +use Gitonomy\Git\Util\NulByte; use Symfony\Component\String\ByteString; use Symfony\Component\String\Exception\InvalidArgumentException; @@ -38,9 +42,9 @@ * * @internal */ -final class LibGit2RepositoryDriver implements RepositoryDriverInterface +final readonly class LibGit2RepositoryDriver implements RepositoryDriverInterface { - private const int GIT_ENOTFOUND = -3; + private const int GIT_EAMBIGUOUS = -5; private const int GIT_EUNBORNBRANCH = -9; private const int GIT_EINVALIDSPEC = -12; private const int GIT_EPEEL = -19; @@ -48,20 +52,31 @@ final class LibGit2RepositoryDriver implements RepositoryDriverInterface private const int GIT_SORT_NONE = 0; private const int GIT_SORT_TOPOLOGICAL = 1 << 0; + private const int GIT_DELTA_ADDED = 1; + private const int GIT_DELTA_CONFLICTED = 10; + private const int GIT_DELTA_COPIED = 5; + private const int GIT_DELTA_DELETED = 2; + private const int GIT_DIFF_DISABLE_PATHSPEC_MATCH = 1 << 12; + private const int GIT_DIFF_FLAG_BINARY = 1 << 0; + private const int GIT_DIFF_INDENT_HEURISTIC = 1 << 18; private const int GIT_DIFF_FIND_RENAMES = 1 << 0; private const int GIT_STATUS_OPT_INCLUDE_UNTRACKED = 1 << 0; private const int GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS = 1 << 4; private const int GIT_STATUS_WT_NEW = 1 << 7; - private readonly CData $repo; + private CData $repo; + /** + * @param string $gitDir The git directory + * @param string|null $workingDir The working tree, null for a bare repository + */ public function __construct( - private readonly \FFI $ffi, - string $gitDir, - private readonly ?string $workingDir, + private \FFI $ffi, + private string $gitDir, + private ?string $workingDir, ) { $out = $this->ffi->new('git_repository*'); - LibGit2::check($this->ffi, $this->ffi->git_repository_open(\FFI::addr($out), $gitDir), 'Unable to open repository'); + LibGit2::check($this->ffi, $this->ffi->git_repository_open(\FFI::addr($out), $gitDir), \sprintf('Unable to open the repository "%s"', $gitDir)); if (null !== $workingDir) { try { @@ -89,23 +104,45 @@ private function __clone() { } - public function resolveRevision(string $revision): string + public function getGitDir(): string + { + return $this->gitDir; + } + + public function getWorkingDir(): ?string { - $object = $this->revparse($revision); + return $this->workingDir; + } + + public function getHeadReference(): ?string + { + $head = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_lookup(\FFI::addr($head), $this->repo, 'HEAD'), 'Unable to read HEAD'); try { - return $this->oidToHex($this->ffi->git_object_id($object)); + if ($this->ffi->git_reference_type($head) !== $this->ffi->GIT_REFERENCE_SYMBOLIC) { + return null; + } + + return $this->ffi->git_reference_symbolic_target($head); } finally { - $this->ffi->git_object_free($object); + $this->ffi->git_reference_free($head); } } - public function getObjectType(string $revision): string + public function resolveRevision(string $revision): string { - $object = $this->revparse($revision); + NulByte::assertNone($revision); + + $object = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_revparse_single(\FFI::addr($object), $this->repo, $revision); + if (\in_array($rc, [LibGit2::GIT_ENOTFOUND, self::GIT_EAMBIGUOUS, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { + throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist.', $revision)); + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve "%s"', $revision)); try { - return $this->objectTypeName($this->ffi->git_object_type($object)); + return $this->oidToHex($this->ffi->git_object_id($object)); } finally { $this->ffi->git_object_free($object); } @@ -113,6 +150,8 @@ public function getObjectType(string $revision): string public function getCommitData(string $hash): CommitData { + NulByte::assertNone($hash); + $commit = $this->lookupCommit($hash); try { @@ -122,22 +161,24 @@ public function getCommitData(string $hash): CommitData } } - public function getCommitLog(?array $revisions, array $paths, ?int $offset, ?int $limit): array + public function getCommitLog(?array $includes, array $excludes, array $paths, int $offset, ?int $limit): array { + NulByte::assertNone(...$includes ?? [], ...$excludes, ...$paths); + $result = []; if (0 === $limit) { return $result; } $skipped = 0; - foreach ($this->walk($revisions, $paths) as $hash) { - if (null !== $offset && $skipped < $offset) { + foreach ($this->walk($includes, $excludes, $paths) as $hash) { + if ($skipped < $offset) { ++$skipped; continue; } - $result[$hash] = $this->getCommitData($hash); + $result[] = $this->getCommitData($hash); // Stops before walking to the next commit: it can be far away in the history. if (null !== $limit && \count($result) >= $limit) { @@ -148,15 +189,43 @@ public function getCommitLog(?array $revisions, array $paths, ?int $offset, ?int return $result; } - public function countCommits(?array $revisions, array $paths): int + public function countCommits(?array $includes, array $excludes, array $paths): int { - // Like CliDriver's `rev-list --count`, which counts everything when given no revision. - return iterator_count($this->walk([] === $revisions ? null : $revisions, $paths)); + NulByte::assertNone(...$includes ?? [], ...$excludes, ...$paths); + + return iterator_count($this->walk($includes, $excludes, $paths)); + } + + public function getMergeBases(string $hash1, string $hash2): array + { + NulByte::assertNone($hash1, $hash2); + + $oneOid = $this->hexToOid($hash1); + $twoOid = $this->hexToOid($hash2); + $bases = $this->ffi->new('git_oidarray'); + $rc = $this->ffi->git_merge_bases(\FFI::addr($bases), $this->repo, \FFI::addr($oneOid), \FFI::addr($twoOid)); + if (LibGit2::GIT_ENOTFOUND === $rc) { + return []; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to compute the merge bases of "%s" and "%s"', $hash1, $hash2)); + + try { + $hashes = []; + for ($i = 0; $i < $bases->count; ++$i) { + $hashes[] = $this->oidToHex(\FFI::addr($bases->ids[$i])); + } + + return $hashes; + } finally { + $this->ffi->git_oidarray_dispose(\FFI::addr($bases)); + } } - public function getShortHash(string $revision): string + public function getShortHash(string $hash): string { - $object = $this->revparse($revision); + NulByte::assertNone($hash); + + $object = $this->lookupObject($hash); try { $buf = $this->ffi->new('git_buf'); @@ -174,6 +243,8 @@ public function getShortHash(string $revision): string public function getTreeEntries(string $hash): array { + NulByte::assertNone($hash); + $tree = $this->lookupTree($hash); try { @@ -183,7 +254,7 @@ public function getTreeEntries(string $hash): array $entry = $this->ffi->git_tree_entry_byindex($tree, $i); $result[] = new TreeEntryData( \sprintf('%06o', $this->ffi->git_tree_entry_filemode($entry)), - $this->treeEntryType($this->ffi->git_tree_entry_type($entry)), + $this->objectType($this->ffi->git_tree_entry_type($entry)), $this->oidToHex($this->ffi->git_tree_entry_id($entry)), $this->ffi->git_tree_entry_name($entry), ); @@ -197,9 +268,11 @@ public function getTreeEntries(string $hash): array public function getBlobContent(string $hash): string { + NulByte::assertNone($hash); + $oid = $this->hexToOid($hash); $blob = $this->ffi->new('git_blob*'); - LibGit2::check($this->ffi, $this->ffi->git_blob_lookup_prefix(\FFI::addr($blob), $this->repo, \FFI::addr($oid), \strlen($hash)), \sprintf('Unable to find blob "%s"', $hash)); + $this->checkFound($this->ffi->git_blob_lookup_prefix(\FFI::addr($blob), $this->repo, \FFI::addr($oid), \strlen($hash)), $hash, ObjectType::Blob, 'Unable to read the blob'); try { return \FFI::string($this->ffi->git_blob_rawcontent($blob), $this->ffi->git_blob_rawsize($blob)); @@ -208,13 +281,15 @@ public function getBlobContent(string $hash): string } } - public function getTagData(string $revision): TagData + public function getTagData(string $hash): TagData { - $object = $this->revparse($revision); + NulByte::assertNone($hash); + + $object = $this->lookupObject($hash); try { if ($this->ffi->git_object_type($object) !== $this->ffi->GIT_OBJECT_TAG) { - throw new DriverException(\sprintf('"%s" is not an annotated tag.', $revision)); + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a tag.', $hash)); } $tag = $this->ffi->new('git_tag*'); @@ -224,22 +299,17 @@ public function getTagData(string $revision): TagData } try { + // The oldest tags, like the first ones of git itself, have no tagger. $tagger = $this->ffi->git_tag_tagger($tag); - if (null === $tagger) { - throw new DriverException(\sprintf('Tag "%s" has no tagger.', $revision)); - } - [$message, $gpgSignature] = TagData::splitSignature($this->ffi->git_tag_message($tag) ?? ''); - - return new TagData( + return TagData::fromSignedMessage( $this->oidToHex($this->ffi->git_tag_target_id($tag)), - $this->objectTypeName($this->ffi->git_tag_target_type($tag)), + $this->objectType($this->ffi->git_tag_target_type($tag)), $this->ffi->git_tag_name($tag), - \FFI::string($tagger->name), - \FFI::string($tagger->email), - $this->signatureDate($tagger), - $message, - $gpgSignature, + null === $tagger ? null : \FFI::string($tagger->name), + null === $tagger ? null : \FFI::string($tagger->email), + null === $tagger ? null : $this->signatureDate($tagger), + $this->ffi->git_tag_message($tag) ?? '', ); } finally { $this->ffi->git_tag_free($tag); @@ -251,16 +321,36 @@ public function getReferences(): array $result = []; foreach ($this->iterateReferences() as $ref) { $hash = $this->referenceTargetHex($ref); - if (null !== $hash) { - $result[] = new ReferenceData($this->ffi->git_reference_name($ref), $hash); + if (null === $hash) { + continue; } + + $fullname = $this->ffi->git_reference_name($ref); + // Only a tag may point to an annotated tag. + $commitHash = str_starts_with($fullname, 'refs/tags/') ? $this->peeledHex($ref) : $hash; + $result[$fullname] = new ReferenceData($fullname, $commitHash, $commitHash === $hash ? null : $hash); } - return $result; + // Like git, sorted by name: libgit2 lists the loose references first. + ksort($result, \SORT_STRING); + + return array_values($result); + } + + public function createReference(string $fullname, string $hash): void + { + NulByte::assertNone($fullname, $hash); + + $oid = $this->hexToOid($hash); + $ref = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_create(\FFI::addr($ref), $this->repo, $fullname, \FFI::addr($oid), 0, null), \sprintf('Unable to create reference "%s"', $fullname)); + $this->ffi->git_reference_free($ref); } public function updateReference(string $fullname, string $hash): void { + NulByte::assertNone($fullname, $hash); + $oid = $this->hexToOid($hash); $ref = $this->ffi->new('git_reference*'); LibGit2::check($this->ffi, $this->ffi->git_reference_create(\FFI::addr($ref), $this->repo, $fullname, \FFI::addr($oid), 1, null), \sprintf('Unable to update reference "%s"', $fullname)); @@ -269,8 +359,15 @@ public function updateReference(string $fullname, string $hash): void public function deleteReference(string $fullname): void { + NulByte::assertNone($fullname); + $ref = $this->ffi->new('git_reference*'); - LibGit2::check($this->ffi, $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname), \sprintf('Unable to find reference "%s"', $fullname)); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname); + // Like git update-ref -d, deleting a missing reference does nothing. + if (LibGit2::GIT_ENOTFOUND === $rc) { + return; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to find reference "%s"', $fullname)); try { LibGit2::check($this->ffi, $this->ffi->git_reference_delete($ref), \sprintf('Unable to delete reference "%s"', $fullname)); @@ -281,10 +378,14 @@ public function deleteReference(string $fullname): void public function getIncludingBranches(string $hash): array { + NulByte::assertNone($hash); + $tips = []; foreach ($this->iterateBranches() as [$fullname, $ref]) { $tips[$fullname] = $this->oidToHex($this->ffi->git_reference_target($ref)); } + // Like git: the local branches, then the remote ones, sorted by name. + ksort($tips, \SORT_STRING); // One walk from every branch down to $hash, instead of a merge base // per branch: parents first, a commit reaches $hash when one of its @@ -304,25 +405,14 @@ public function getIncludingBranches(string $hash): array return array_keys(array_filter($tips, static fn (string $tip): bool => isset($reaching[$tip]))); } - public function getDiff(array $revisions): Diff + public function getDiff(?string $from, string $to): DiffData { - if (2 === \count($revisions) && !str_contains($revisions[0], '..')) { - [$old, $new] = $revisions; - } elseif (1 === \count($revisions) && str_contains($revisions[0], '...')) { - [$a, $new] = $this->rangeEnds($revisions[0], '...'); - $old = $this->getMergeBase($a, $new) ?? throw new DriverException(\sprintf('"%s" and "%s" have no common ancestor.', $a, $new)); - } elseif (1 === \count($revisions) && str_contains($revisions[0], '..')) { - [$old, $new] = $this->rangeEnds($revisions[0], '..'); - } elseif (1 === \count($revisions)) { - return $this->diffToWorkdir($revisions[0]); - } else { - throw new UnsupportedOperationException(\sprintf('LibGit2Driver cannot diff "%s".', implode(' ', $revisions))); - } + NulByte::assertNone($from ?? '', $to); - $oldTree = $this->treeForRevision($old); + $oldTree = null === $from ? null : $this->treeForRevision($from); try { - $newTree = $this->treeForRevision($new); + $newTree = $this->treeForRevision($to); try { return $this->diffTrees($oldTree, $newTree); @@ -330,45 +420,46 @@ public function getDiff(array $revisions): Diff $this->ffi->git_tree_free($newTree); } } finally { - $this->ffi->git_tree_free($oldTree); + if (null !== $oldTree) { + $this->ffi->git_tree_free($oldTree); + } } } - public function getCommitDiff(string $hash): Diff + public function getWorkingTreeDiff(string $hash): DiffData { - $commit = $this->lookupCommit($hash); + NulByte::assertNone($hash); - try { - $tree = $this->commitTree($commit); - - try { - $parentCount = $this->ffi->git_commit_parentcount($commit); - if (0 === $parentCount) { - return $this->diffTrees(null, $tree); - } + $tree = $this->treeForRevision($hash); - // Like `git diff-tree -m`: one diff per parent of a merge commit. - $files = []; - for ($i = 0; $i < $parentCount; ++$i) { - $parentTree = $this->parentTree($commit, $i); - - try { - $files = [...$files, ...$this->diffTrees($parentTree, $tree)->getFiles()]; - } finally { - $this->ffi->git_tree_free($parentTree); - } - } - - return new Diff($files, ''); - } finally { - $this->ffi->git_tree_free($tree); + try { + $diff = $this->ffi->new('git_diff*'); + $opts = $this->diffOptions(); + LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_workdir_with_index(\FFI::addr($diff), $this->repo, $tree, \FFI::addr($opts)), 'Unable to compute working copy diff'); + $unmerged = $this->unmergedPaths($diff); + $files = $this->buildDiff($diff)->files; + + if ([] !== $unmerged) { + // Like git diff , which compares the unmerged files, conflict markers included, to the commit. + $unmergedDiff = $this->ffi->new('git_diff*'); + $opts = $this->diffOptions(); + // Keeps the C strings of the pathspec alive until the diff is computed. + $pathspec = $this->pathspec($opts, $unmerged); + $opts->flags |= self::GIT_DIFF_DISABLE_PATHSPEC_MATCH; + LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_workdir(\FFI::addr($unmergedDiff), $this->repo, $tree, \FFI::addr($opts)), 'Unable to compute the diff of the unmerged files'); + + $files = [...$files, ...$this->buildDiff($unmergedDiff)->files]; + // Sorted by name, like git. + usort($files, static fn (DiffFileData $a, DiffFileData $b): int => strcmp($a->name, $b->name)); } } finally { - $this->ffi->git_commit_free($commit); + $this->ffi->git_tree_free($tree); } + + return new DiffData($files, ''); } - public function getDiffPending(): Diff + public function getDiffPending(): DiffData { $diff = $this->ffi->new('git_diff*'); $opts = $this->diffOptions(); @@ -377,7 +468,7 @@ public function getDiffPending(): Diff return $this->buildDiff($diff); } - public function getDiffStaged(): Diff + public function getDiffStaged(): DiffData { $tree = $this->headTree(); @@ -394,36 +485,31 @@ public function getDiffStaged(): Diff return $this->buildDiff($diff); } - public function getMergeBase(string $revision1, string $revision2): ?string + public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array { - $one = $this->hexToOid($this->resolveCommit($revision1)); - $two = $this->hexToOid($this->resolveCommit($revision2)); - $out = $this->ffi->new('git_oid'); + NulByte::assertNone($hash, $path); - $rc = $this->ffi->git_merge_base(\FFI::addr($out), $this->repo, \FFI::addr($one), \FFI::addr($two)); - if (self::GIT_ENOTFOUND === $rc) { - return null; - } - LibGit2::check($this->ffi, $rc, 'Unable to compute merge base'); - - return $this->oidToHex(\FFI::addr($out)); - } - - public function getBlameLines(string $revision, string $file, ?string $lineRange): array - { - [$firstLine, $lastLine] = $this->parseLineRange($lineRange); + $range = null !== $firstLine || null !== $lastLine; + $firstLine ??= 1; $options = $this->ffi->new('git_blame_options'); LibGit2::check($this->ffi, $this->ffi->git_blame_options_init(\FFI::addr($options), 1), 'Unable to initialize blame options'); - $options->newest_commit = $this->hexToOid($this->resolveCommit($revision)); - $options->min_line = $firstLine; - $options->max_line = $lastLine ?? 0; // 0: up to the end of the file + $options->newest_commit = $this->hexToOid($this->resolveCommit($hash)); $blame = $this->ffi->new('git_blame*'); - LibGit2::check($this->ffi, $this->ffi->git_blame_file(\FFI::addr($blame), $this->repo, $file, \FFI::addr($options)), \sprintf('Unable to blame "%s"', $file)); + LibGit2::check($this->ffi, $this->ffi->git_blame_file(\FFI::addr($blame), $this->repo, $this->toRepoRelativePath($path), \FFI::addr($options)), \sprintf('Unable to blame "%s"', $path)); try { $count = $this->ffi->git_blame_linecount($blame); + // git_blame_linecount() counts one more line for a file not ending with a newline. + if ($count > 0 && null === $this->ffi->git_blame_line_byindex($blame, $count)) { + --$count; + } + // Like git, which stops a range at the end of the file, but refuses one starting after it. + if ($range && $firstLine > $count) { + throw new DriverException(\sprintf('The file "%s" has only %d lines.', $path, $count)); + } + $result = []; for ($i = $firstLine; $i <= min($lastLine ?? $count, $count); ++$i) { $hunk = $this->ffi->git_blame_hunk_byline($blame, $i); @@ -438,10 +524,9 @@ public function getBlameLines(string $revision, string $file, ?string $lineRange } $content = rtrim(\FFI::string($line->ptr, $line->len), "\n"); - $sourceLine = (string) ($hunk->orig_start_line_number + ($i - $hunk->final_start_line_number)); - $blockLine = ($i === $hunk->final_start_line_number || $i === $firstLine) ? (string) ($hunk->lines_in_hunk - ($i - $hunk->final_start_line_number)) : null; + $originalLine = $hunk->orig_start_line_number + ($i - $hunk->final_start_line_number); - $result[] = new BlameLineData($this->oidToHex(\FFI::addr($hunk->final_commit_id)), $sourceLine, (string) $i, $blockLine, $content); + $result[] = new BlameLineData($this->oidToHex(\FFI::addr($hunk->final_commit_id)), $originalLine, $i, $content); } return $result; @@ -477,30 +562,44 @@ public function getUntrackedFiles(): array public function checkout(string $revision): void { - $this->checkoutRevision($revision); + NulByte::assertNone($revision); // `git checkout ` attaches HEAD when names a - // local branch, and detaches it for anything else (a tag, a hash, a - // remote branch, a full reference name...). - if ($this->referenceExists('refs/heads/'.$revision)) { - LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, 'refs/heads/'.$revision), 'Unable to update HEAD'); + // local branch, before a tag of the same name, and detaches it for + // anything else (a tag, a hash, a remote branch, a full reference name...). + $branch = $this->referenceExists('refs/heads/'.$revision) ? 'refs/heads/'.$revision : null; + // Resolved before changing anything. + $hash = $this->resolveCommit($branch ?? $revision); + + $this->checkoutRevision($hash); + + if (null !== $branch) { + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, $branch), 'Unable to update HEAD'); } else { - $oid = $this->hexToOid($this->resolveCommit($revision)); + $oid = $this->hexToOid($hash); LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($this->repo, \FFI::addr($oid)), 'Unable to detach HEAD'); } } - public function checkoutNewBranch(string $branch, string $revision): void + public function checkoutNewBranch(string $branch, string $startHash): void { + NulByte::assertNone($branch, $startHash); + if ($this->referenceExists('refs/heads/'.$branch)) { throw new DriverException(\sprintf('A branch named "%s" already exists.', $branch)); } - $this->checkoutRevision($revision); - - $commit = $this->lookupCommit($this->resolveCommit($revision)); + // Validated before changing anything. + $valid = $this->ffi->new('int'); + LibGit2::check($this->ffi, $this->ffi->git_branch_name_is_valid(\FFI::addr($valid), $branch), 'Unable to validate the branch name'); + if (0 === $valid->cdata) { + throw new DriverException(\sprintf('"%s" is not a valid branch name.', $branch)); + } + $commit = $this->lookupCommit($this->resolveCommit($startHash)); try { + $this->checkoutRevision($startHash); + $reference = $this->ffi->new('git_reference*'); LibGit2::check($this->ffi, $this->ffi->git_branch_create(\FFI::addr($reference), $this->repo, $branch, $commit, 0), \sprintf('Unable to create branch "%s"', $branch)); $this->ffi->git_reference_free($reference); @@ -511,17 +610,11 @@ public function checkoutNewBranch(string $branch, string $revision): void LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, 'refs/heads/'.$branch), 'Unable to update HEAD'); } - public function merge(string $revision, array $args): void + public function merge(string $revision, FastForward $fastForward): void { - $noFastForward = false; - foreach ($args as $arg) { - if ('--no-ff' !== $arg) { - throw new UnsupportedOperationException(\sprintf('LibGit2Driver does not support the "%s" merge argument; use CliDriver.', $arg)); - } - $noFastForward = true; - } + NulByte::assertNone($revision); - $branch = $this->currentBranch(); + $branch = $this->getHeadReference(); $headHash = $this->resolveCommit('HEAD'); $theirHash = $this->resolveCommit($revision); $head = $this->hexToOid($headHash); @@ -532,7 +625,12 @@ public function merge(string $revision, array $args): void return; } - if (!$noFastForward && $this->isDescendantOf($their, $head)) { + $canFastForward = $this->isDescendantOf($their, $head); + if (FastForward::Only === $fastForward && !$canFastForward) { + throw new DriverException(\sprintf('Unable to fast-forward to "%s".', $revision)); + } + + if (FastForward::Never !== $fastForward && $canFastForward) { $target = $this->revparse($theirHash); try { @@ -545,6 +643,14 @@ public function merge(string $revision, array $args): void return; } + // Like git. + if ([] === $this->getMergeBases($headHash, $theirHash)) { + throw new DriverException(\sprintf('Refusing to merge "%s", whose history is unrelated.', $revision)); + } + if ([] !== $this->getDiffStaged()->files) { + throw new DriverException(\sprintf('Unable to merge "%s": commit or stash the staged changes first.', $revision)); + } + $tree = $this->mergeTrees($headHash, $theirHash, $revision); try { @@ -563,19 +669,18 @@ public function merge(string $revision, array $args): void * yields the commits that change them, and simplifies the history like * git does. * - * @param string[]|null $revisions null for every reference - * @param string[] $paths + * @param list|null $includes null for every reference + * @param list $excludes + * @param string[] $paths * * @return \Generator Commit hashes */ - private function walk(?array $revisions, array $paths): \Generator + private function walk(?array $includes, array $excludes, array $paths): \Generator { - $tips = []; - $bottoms = []; - foreach ([] === $revisions ? ['HEAD'] : $revisions ?? ['--all'] as $spec) { - [$specTips, $specBottoms] = $this->revspecEnds($spec); - array_push($tips, ...$specTips); - array_push($bottoms, ...$specBottoms); + $tips = $includes ?? $this->referencedCommits(); + $bottoms = $excludes; + if ([] === $tips) { + return; } $pathFilter = $this->pathFilter($paths); @@ -631,73 +736,6 @@ private function revwalk(array $tips, array $bottoms, int $sorting = self::GIT_S } } - /** - * @return array{list, list} The commits a revision walks from, and the ones it hides along with their ancestors - */ - private function revspecEnds(string $spec): array - { - if (str_contains($spec, '...')) { - // Commits reachable from either end but not from both. - [$a, $b] = $this->rangeEnds($spec, '...'); - $one = $this->resolveCommit($a); - $two = $this->resolveCommit($b); - - return [[$one, $two], $this->mergeBases($one, $two)]; - } - - if (str_contains($spec, '..')) { - [$a, $b] = $this->rangeEnds($spec, '..'); - - return [[$this->resolveCommit($b)], [$this->resolveCommit($a)]]; - } - - if (str_starts_with($spec, '^')) { - return [[], [$this->resolveCommit(substr($spec, 1))]]; - } - - if ('--all' === $spec) { - return [$this->referencedCommits(), []]; - } - - return [[$this->resolveCommit($spec)], []]; - } - - /** - * @return array{string, string} Both ends of an "a..b" or "a...b" range, an omitted end meaning HEAD - */ - private function rangeEnds(string $range, string $separator): array - { - [$a, $b] = explode($separator, $range, 2); - - return ['' === $a ? 'HEAD' : $a, '' === $b ? 'HEAD' : $b]; - } - - /** - * @return list No merge base when the commits have no common ancestor - */ - private function mergeBases(string $one, string $two): array - { - $oneOid = $this->hexToOid($one); - $twoOid = $this->hexToOid($two); - $bases = $this->ffi->new('git_oidarray'); - $rc = $this->ffi->git_merge_bases(\FFI::addr($bases), $this->repo, \FFI::addr($oneOid), \FFI::addr($twoOid)); - if (self::GIT_ENOTFOUND === $rc) { - return []; - } - LibGit2::check($this->ffi, $rc, \sprintf('Unable to compute the merge bases of "%s" and "%s"', $one, $two)); - - try { - $hashes = []; - for ($i = 0; $i < $bases->count; ++$i) { - $hashes[] = $this->oidToHex(\FFI::addr($bases->ids[$i])); - } - - return $hashes; - } finally { - $this->ffi->git_oidarray_dispose(\FFI::addr($bases)); - } - } - /** * @return list The commits the references point to; like `git log --all`, ignores the references to other objects and the dangling ones */ @@ -707,7 +745,7 @@ private function referencedCommits(): array foreach ($this->iterateReferences() as $ref) { $commit = $this->ffi->new('git_object*'); $rc = $this->ffi->git_reference_peel(\FFI::addr($commit), $ref, $this->ffi->GIT_OBJECT_COMMIT); - if (\in_array($rc, [self::GIT_ENOTFOUND, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { + if (\in_array($rc, [LibGit2::GIT_ENOTFOUND, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { continue; } LibGit2::check($this->ffi, $rc, \sprintf('Unable to peel "%s"', $this->ffi->git_reference_name($ref))); @@ -727,12 +765,14 @@ private function referencedCommits(): array */ private function pathFilter(array $paths): ?PathFilter { - $paths = array_values(array_filter( - array_map(fn (string $path): string => trim($this->toRepoRelativePath($path), '/'), $paths), - static fn (string $path): bool => '' !== $path, - )); + if ([] === $paths) { + return null; + } + + // Like git, "." and "./x" are relative to the root, which "" names. + $paths = array_map(fn (string $path): string => trim((string) preg_replace('#^\.(/|$)#', '', trim($this->toRepoRelativePath($path), '/')), '/'), $paths); - return [] === $paths ? null : new PathFilter($this->ffi, $this->repo, $paths, $this->diffOptions()); + return new PathFilter($this->ffi, $this->repo, array_values($paths), $this->diffOptions()); } /** @@ -768,6 +808,7 @@ private function simplifiedHistory(array $tips, array $bottoms, ?array $interest } while (!$queue->isEmpty()) { + /** @var string $hash SplPriorityQueue extracts the data alone by default. */ $hash = $queue->extract(); [$parents, $changed] = $this->simplifyCommit($hash, $pathFilter, $relevant); @@ -833,34 +874,26 @@ private function simplifyCommit(string $hash, PathFilter $pathFilter, ?array $re /** * git also accepts the absolute paths of files in the working tree, where * libgit2 only deals with paths relative to the repository. + * + * @throws DriverException $path is an absolute path outside the working tree */ private function toRepoRelativePath(string $path): string { - if (null !== $this->workingDir && str_starts_with($path, $this->workingDir.'/')) { + if (!str_starts_with($path, '/')) { + return $path; + } + + if (null !== $this->workingDir && ($path === $this->workingDir || str_starts_with($path, $this->workingDir.'/'))) { return substr($path, \strlen($this->workingDir) + 1); } - return $path; + // Like git. + throw new DriverException(\sprintf('The path "%s" is outside the repository.', $path)); } // -- diff ---------------------------------------------------------------- - private function diffToWorkdir(string $revision): Diff - { - $tree = $this->treeForRevision($revision); - - try { - $diff = $this->ffi->new('git_diff*'); - $opts = $this->diffOptions(); - LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_workdir_with_index(\FFI::addr($diff), $this->repo, $tree, \FFI::addr($opts)), 'Unable to compute working copy diff'); - } finally { - $this->ffi->git_tree_free($tree); - } - - return $this->buildDiff($diff); - } - - private function diffTrees(?CData $oldTree, CData $newTree): Diff + private function diffTrees(?CData $oldTree, CData $newTree): DiffData { $diff = $this->ffi->new('git_diff*'); $opts = $this->diffOptions(); @@ -873,15 +906,55 @@ private function diffOptions(): CData { $opts = $this->ffi->new('git_diff_options'); LibGit2::check($this->ffi, $this->ffi->git_diff_options_init(\FFI::addr($opts), 1), 'Unable to initialize diff options'); - $opts->context_lines = 3; + // git's default since 2.14. + $opts->flags |= self::GIT_DIFF_INDENT_HEURISTIC; return $opts; } /** - * Converts a git_diff to a Diff, and frees it. + * @return list The paths a merge conflict left unmerged, which buildDiff() leaves out */ - private function buildDiff(CData $diff): Diff + private function unmergedPaths(CData $diff): array + { + $paths = []; + for ($i = 0, $count = $this->ffi->git_diff_num_deltas($diff); $i < $count; ++$i) { + $delta = $this->ffi->git_diff_get_delta($diff, $i); + if (self::GIT_DELTA_CONFLICTED === $delta->status) { + $paths[] = $delta->new_file->path; + } + } + + return $paths; + } + + /** + * Restricts a diff to $paths, matched as they are. + * + * @param list $paths + * + * @return list The C strings of the pathspec, to keep alive as long as $opts + */ + private function pathspec(CData $opts, array $paths): array + { + $buffers = []; + $strings = $this->ffi->new('char*['.\count($paths).']'); + foreach ($paths as $i => $path) { + $buffers[] = $buffer = LibGit2::cString($this->ffi, $path); + $strings[$i] = $this->ffi->cast('char *', $buffer); + } + $buffers[] = $strings; + + $opts->pathspec->strings = $this->ffi->cast('char **', $strings); + $opts->pathspec->count = \count($paths); + + return $buffers; + } + + /** + * Converts a git_diff, and frees it. + */ + private function buildDiff(CData $diff): DiffData { try { // Like CliDriver's `-M`. @@ -893,29 +966,45 @@ private function buildDiff(CData $diff): Diff $files = []; $count = $this->ffi->git_diff_num_deltas($diff); for ($i = 0; $i < $count; ++$i) { - $files[] = $this->patchToFile($diff, $i); + // Like git, leave the unmerged paths of a conflict out. + if (self::GIT_DELTA_CONFLICTED === $this->ffi->git_diff_get_delta($diff, $i)->status) { + continue; + } + + $file = $this->patchToFile($diff, $i); + + // libgit2 splits a type change into a creation, then a deletion: git into a deletion, then a creation. + $previous = [] === $files ? null : $files[\count($files) - 1]; + if (null !== $previous && null === $file->newName && null === $previous->oldName && $previous->newName === $file->oldName) { + array_pop($files); + $files[] = $file; + $files[] = $previous; + + continue; + } + + $files[] = $file; } - return new Diff($files, ''); + return new DiffData($files, ''); } finally { $this->ffi->git_diff_free($diff); } } - private function patchToFile(CData $diff, int $index): File + private function patchToFile(CData $diff, int $index): DiffFileData { $patch = $this->ffi->new('git_patch*'); LibGit2::check($this->ffi, $this->ffi->git_patch_from_diff(\FFI::addr($patch), $diff, $index), 'Unable to compute patch'); + // libgit2 documents no patch for a binary or unchanged file, though + // 1.9 creates one: computing it flagged the delta accordingly. if (\FFI::isNull($patch)) { - // No patch for a binary or unchanged file: computing it flagged - // the delta accordingly. - return $this->deltaToFile($this->ffi->git_diff_get_delta($diff, $index)); + return $this->deltaToFile($this->ffi->git_diff_get_delta($diff, $index), []); } try { - $file = $this->deltaToFile($this->ffi->git_patch_get_delta($patch)); - + $changes = []; $hunkCount = $this->ffi->git_patch_num_hunks($patch); for ($hunkIndex = 0; $hunkIndex < $hunkCount; ++$hunkIndex) { $hunk = $this->ffi->new('git_diff_hunk*'); @@ -930,33 +1019,36 @@ private function patchToFile(CData $diff, int $index): File // ->origin is a scalar `char`, which PHP FFI represents // as a single-character PHP string. $type = match ($line->origin) { - '+' => FileChange::LINE_ADD, - '-' => FileChange::LINE_REMOVE, - ' ' => FileChange::LINE_CONTEXT, + '+' => DiffLineType::Addition, + '-' => DiffLineType::Deletion, + ' ' => DiffLineType::Context, default => null, }; if (null !== $type) { - $lines[] = [$type, rtrim(\FFI::string($line->content, $line->content_len), "\n")]; + $lines[] = new DiffLine($type, rtrim(\FFI::string($line->content, $line->content_len), "\n")); } } - $file->addChange(new FileChange($hunk->old_start, $hunk->old_lines, $hunk->new_start, $hunk->new_lines, $lines)); + $changes[] = new FileChange($hunk->old_start, $hunk->old_lines, $hunk->new_start, $hunk->new_lines, $lines); } - return $file; + return $this->deltaToFile($this->ffi->git_patch_get_delta($patch), $changes); } finally { $this->ffi->git_patch_free($patch); } } - private function deltaToFile(CData $delta): File + /** + * @param list $changes + */ + private function deltaToFile(CData $delta, array $changes): DiffFileData { $status = $delta->status; - $isCopy = 5 === $status; // GIT_DELTA_COPIED - $isAdded = 1 === $status; // GIT_DELTA_ADDED - $isDeleted = 2 === $status; // GIT_DELTA_DELETED - $isBinary = 0 !== ($delta->flags & (1 << 0)); // GIT_DIFF_FLAG_BINARY + $isCopy = self::GIT_DELTA_COPIED === $status; + $isAdded = self::GIT_DELTA_ADDED === $status; + $isDeleted = self::GIT_DELTA_DELETED === $status; + $isBinary = 0 !== ($delta->flags & self::GIT_DIFF_FLAG_BINARY); $oldName = $isAdded ? null : $delta->old_file->path; $newName = $isDeleted ? null : $delta->new_file->path; @@ -967,7 +1059,7 @@ private function deltaToFile(CData $delta): File $oldIndex = $this->ffi->git_oid_is_zero(\FFI::addr($delta->old_file->id)) ? null : $this->oidToHex(\FFI::addr($delta->old_file->id)); $newIndex = $this->ffi->git_oid_is_zero(\FFI::addr($delta->new_file->id)) ? null : $this->oidToHex(\FFI::addr($delta->new_file->id)); - return new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary, $isCopy); + return new DiffFileData($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary, $isCopy, $changes); } // -- working copy -------------------------------------------------------- @@ -1049,8 +1141,10 @@ private function mergeTrees(string $oursHash, string $theirsHash, string $revisi private function createMergeCommit(CData $tree, string $oursHash, string $theirsHash, string $message): string { - $signature = $this->ffi->new('git_signature*'); - LibGit2::check($this->ffi, $this->ffi->git_signature_default(\FFI::addr($signature), $this->repo), 'Unable to determine the merge commit author (configure user.name and user.email)'); + // Like git, GIT_AUTHOR_* and GIT_COMMITTER_* before the configuration. + $author = $this->ffi->new('git_signature*'); + $committer = $this->ffi->new('git_signature*'); + LibGit2::check($this->ffi, $this->ffi->git_signature_default_from_env(\FFI::addr($author), \FFI::addr($committer), $this->repo), 'Unable to determine the merge commit author (configure user.name and user.email)'); try { $ours = $this->lookupCommit($oursHash); @@ -1064,7 +1158,7 @@ private function createMergeCommit(CData $tree, string $oursHash, string $theirs $parents[1] = $theirs; $oid = $this->ffi->new('git_oid'); - LibGit2::check($this->ffi, $this->ffi->git_commit_create(\FFI::addr($oid), $this->repo, null, $signature, $signature, null, $message, $tree, 2, $parents), 'Unable to create merge commit'); + LibGit2::check($this->ffi, $this->ffi->git_commit_create(\FFI::addr($oid), $this->repo, null, $author, $committer, null, $message, $tree, 2, $parents), 'Unable to create merge commit'); return $this->oidToHex(\FFI::addr($oid)); } finally { @@ -1074,7 +1168,8 @@ private function createMergeCommit(CData $tree, string $oursHash, string $theirs $this->ffi->git_commit_free($ours); } } finally { - $this->ffi->git_signature_free($signature); + $this->ffi->git_signature_free($author); + $this->ffi->git_signature_free($committer); } } @@ -1100,7 +1195,9 @@ private function mergeMessage(string $revision, ?string $branch): string $message ??= \sprintf("Merge commit '%s'", $revision); // git leaves out the destination when merging into main or master. - if (null !== $branch && !\in_array($branch, ['refs/heads/main', 'refs/heads/master'], true)) { + if (null === $branch) { + $message .= ' into HEAD'; + } elseif (!\in_array($branch, ['refs/heads/main', 'refs/heads/master'], true)) { $message .= ' into '.substr($branch, \strlen('refs/heads/')); } @@ -1109,32 +1206,11 @@ private function mergeMessage(string $revision, ?string $branch): string // -- references ---------------------------------------------------------- - /** - * @return string|null The full name of the checked out branch, or null when HEAD is detached - */ - private function currentBranch(): ?string - { - $detached = $this->ffi->git_repository_head_detached($this->repo); - LibGit2::check($this->ffi, $detached, 'Unable to resolve HEAD'); - if (1 === $detached) { - return null; - } - - $head = $this->ffi->new('git_reference*'); - LibGit2::check($this->ffi, $this->ffi->git_repository_head(\FFI::addr($head), $this->repo), 'Unable to resolve HEAD'); - - try { - return $this->ffi->git_reference_name($head); - } finally { - $this->ffi->git_reference_free($head); - } - } - private function referenceExists(string $fullname): bool { $ref = $this->ffi->new('git_reference*'); $rc = $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname); - if (self::GIT_ENOTFOUND === $rc || self::GIT_EINVALIDSPEC === $rc) { + if (LibGit2::GIT_ENOTFOUND === $rc || self::GIT_EINVALIDSPEC === $rc) { return false; } LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up reference "%s"', $fullname)); @@ -1148,6 +1224,8 @@ private function referenceExists(string $fullname): bool */ private function iterateReferences(): \Generator { + $this->assertGitDirExists(); + $iter = $this->ffi->new('git_reference_iterator*'); LibGit2::check($this->ffi, $this->ffi->git_reference_iterator_glob_new(\FFI::addr($iter), $this->repo, 'refs/*'), 'Unable to create reference iterator'); @@ -1171,6 +1249,8 @@ private function iterateReferences(): \Generator */ private function iterateBranches(): \Generator { + $this->assertGitDirExists(); + $iter = $this->ffi->new('git_branch_iterator*'); LibGit2::check($this->ffi, $this->ffi->git_branch_iterator_new(\FFI::addr($iter), $this->repo, $this->ffi->GIT_BRANCH_ALL), 'Unable to create branch iterator'); @@ -1192,28 +1272,55 @@ private function iterateBranches(): \Generator } } - private function referenceTargetHex(CData $ref): ?string + /** + * libgit2 lists no reference, instead of failing, once the repository is removed. + * + * @throws DriverException + */ + private function assertGitDirExists(): void { - if ($this->ffi->git_reference_type($ref) === $this->ffi->GIT_REFERENCE_SYMBOLIC) { - $resolved = $this->ffi->new('git_reference*'); - if (0 !== $this->ffi->git_reference_resolve(\FFI::addr($resolved), $ref)) { - // Dangling symbolic reference. - return null; - } + if (!is_dir($this->gitDir)) { + throw new DriverException(\sprintf('The repository "%s" does not exist anymore.', $this->gitDir)); + } + } - try { - return $this->oidToHex($this->ffi->git_reference_target($resolved)); - } finally { - $this->ffi->git_reference_free($resolved); - } + /** + * @return string|null The object $ref points to, null for a dangling symbolic reference + */ + private function referenceTargetHex(CData $ref): ?string + { + if ($this->ffi->git_reference_type($ref) !== $this->ffi->GIT_REFERENCE_SYMBOLIC) { + return $this->oidToHex($this->ffi->git_reference_target($ref)); } - $target = $this->ffi->git_reference_target($ref); - if (null === $target || \FFI::isNull($target)) { + $resolved = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_resolve(\FFI::addr($resolved), $ref); + // Like git show-ref, which skips it. + if (LibGit2::GIT_ENOTFOUND === $rc) { return null; } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve "%s"', $this->ffi->git_reference_name($ref))); - return $this->oidToHex($target); + try { + return $this->oidToHex($this->ffi->git_reference_target($resolved)); + } finally { + $this->ffi->git_reference_free($resolved); + } + } + + /** + * @return string The object $ref points to, through annotated tags + */ + private function peeledHex(CData $ref): string + { + $object = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_peel(\FFI::addr($object), $ref, $this->ffi->GIT_OBJECT_ANY), \sprintf('Unable to peel "%s"', $this->ffi->git_reference_name($ref))); + + try { + return $this->oidToHex($this->ffi->git_object_id($object)); + } finally { + $this->ffi->git_object_free($object); + } } private function isDescendantOf(CData $commit, CData $ancestor): bool @@ -1239,14 +1346,20 @@ private function revparse(string $spec): CData */ private function resolveCommit(string $revision): string { - return $this->resolveRevision($revision.'^{commit}'); + $object = $this->revparse($revision.'^{commit}'); + + try { + return $this->oidToHex($this->ffi->git_object_id($object)); + } finally { + $this->ffi->git_object_free($object); + } } private function lookupCommit(string $hash): CData { $oid = $this->hexToOid($hash); $out = $this->ffi->new('git_commit*'); - LibGit2::check($this->ffi, $this->ffi->git_commit_lookup_prefix(\FFI::addr($out), $this->repo, \FFI::addr($oid), \strlen($hash)), \sprintf('Unable to find commit "%s"', $hash)); + $this->checkFound($this->ffi->git_commit_lookup_prefix(\FFI::addr($out), $this->repo, \FFI::addr($oid), \strlen($hash)), $hash, ObjectType::Commit, 'Unable to read the commit'); return $out; } @@ -1285,7 +1398,7 @@ private function lookupTree(string $hash): CData { $oid = $this->hexToOid($hash); $out = $this->ffi->new('git_tree*'); - LibGit2::check($this->ffi, $this->ffi->git_tree_lookup_prefix(\FFI::addr($out), $this->repo, \FFI::addr($oid), \strlen($hash)), \sprintf('Unable to find tree "%s"', $hash)); + $this->checkFound($this->ffi->git_tree_lookup_prefix(\FFI::addr($out), $this->repo, \FFI::addr($oid), \strlen($hash)), $hash, ObjectType::Tree, 'Unable to read the tree'); return $out; } @@ -1337,7 +1450,7 @@ private function headTree(): ?CData { $head = $this->ffi->new('git_reference*'); $rc = $this->ffi->git_repository_head(\FFI::addr($head), $this->repo); - if (self::GIT_EUNBORNBRANCH === $rc || self::GIT_ENOTFOUND === $rc) { + if (self::GIT_EUNBORNBRANCH === $rc || LibGit2::GIT_ENOTFOUND === $rc) { return null; } LibGit2::check($this->ffi, $rc, 'Unable to resolve HEAD'); @@ -1369,6 +1482,7 @@ private function commitToData(CData $commit): CommitData $encoding = $this->ffi->git_commit_message_encoding($commit); return new CommitData( + $this->oidToHex($this->ffi->git_commit_id($commit)), $this->oidToHex($this->ffi->git_commit_tree_id($commit)), $parents, $this->toUtf8(\FFI::string($author->name), $encoding), @@ -1387,6 +1501,7 @@ private function commitToData(CData $commit): CommitData */ private static function cleanMessage(string $message): string { + // @phpstan-ignore argument.type (Without the "u" modifier, preg_replace() fails on no subject.) $message = trim(preg_replace('/[ \t\r]+$/m', '', $message), "\n"); return '' === $message ? '' : $message."\n"; @@ -1415,43 +1530,13 @@ private function signatureDate(CData $signature): \DateTimeImmutable return new \DateTimeImmutable('@'.$signature->when->time)->setTimezone(new \DateTimeZone('UTC')); } - /** - * @return array{int, int|null} The first line, and the last one (null for the end of the file) - */ - private function parseLineRange(?string $lineRange): array - { - if (null === $lineRange) { - return [1, null]; - } - - if (!preg_match('/^(\d+),(\+?)(\d*)$/', $lineRange, $vars)) { - throw new UnsupportedOperationException(\sprintf('LibGit2Driver only supports "," and ",+" line ranges, got "%s"; use CliDriver.', $lineRange)); - } - - $first = (int) $vars[1]; - if ('' === $vars[3]) { - return [$first, null]; - } - - return [$first, '+' === $vars[2] ? $first + (int) $vars[3] - 1 : (int) $vars[3]]; - } - - private function treeEntryType(int $type): TreeEntryType - { - return match ($type) { - 2 => TreeEntryType::Tree, - 3 => TreeEntryType::Blob, - default => TreeEntryType::Commit, - }; - } - - private function objectTypeName(int $type): string + private function objectType(int $type): ObjectType { return match ($type) { - 1 => 'commit', - 2 => 'tree', - 3 => 'blob', - 4 => 'tag', + $this->ffi->GIT_OBJECT_BLOB => ObjectType::Blob, + $this->ffi->GIT_OBJECT_COMMIT => ObjectType::Commit, + $this->ffi->GIT_OBJECT_TAG => ObjectType::Tag, + $this->ffi->GIT_OBJECT_TREE => ObjectType::Tree, default => throw new DriverException(\sprintf('Unknown git object type %d.', $type)), }; } @@ -1465,8 +1550,51 @@ private function oidToHex(CData $oidPtr): string } /** - * Also accepts an abbreviated hash: lookups then go through the *_lookup_prefix() functions. + * @param string $hash A hash, abbreviated or not + * + * @throws ReferenceNotFoundException The object $hash doesn't exist + */ + private function lookupObject(string $hash): CData + { + $oid = $this->hexToOid($hash); + $object = $this->ffi->new('git_object*'); + $this->checkFound($this->ffi->git_object_lookup_prefix(\FFI::addr($object), $this->repo, \FFI::addr($oid), \strlen($hash), $this->ffi->GIT_OBJECT_ANY), $hash, null, 'Unable to read the object'); + + return $object; + } + + /** + * @param ObjectType|null $type The type the lookup asked for, null for any + * + * @throws ReferenceNotFoundException The object $hash doesn't exist, or isn't of the type $type */ + private function checkFound(int $rc, string $hash, ?ObjectType $type, string $context): void + { + if (LibGit2::GIT_ENOTFOUND === $rc) { + // libgit2 reports an object of another type as not found. + if (null !== $type && $this->objectExists($hash)) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value)); + } + + throw new ReferenceNotFoundException(\sprintf('The object "%s" does not exist.', $hash)); + } + LibGit2::check($this->ffi, $rc, \sprintf('%s "%s"', $context, $hash)); + } + + private function objectExists(string $hash): bool + { + $oid = $this->hexToOid($hash); + $object = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_object_lookup_prefix(\FFI::addr($object), $this->repo, \FFI::addr($oid), \strlen($hash), $this->ffi->GIT_OBJECT_ANY); + if (LibGit2::GIT_ENOTFOUND === $rc) { + return false; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to read the object "%s"', $hash)); + $this->ffi->git_object_free($object); + + return true; + } + private function hexToOid(string $hash): CData { $oid = $this->ffi->new('git_oid'); diff --git a/src/Gitonomy/Git/Driver/Ffi/PathFilter.php b/src/Gitonomy/Git/Driver/Ffi/PathFilter.php index 40220126..3ce1f8ab 100644 --- a/src/Gitonomy/Git/Driver/Ffi/PathFilter.php +++ b/src/Gitonomy/Git/Driver/Ffi/PathFilter.php @@ -23,8 +23,6 @@ */ final readonly class PathFilter { - private const int GIT_ENOTFOUND = -3; - /** * The part of each path before its first wildcard. * @@ -111,7 +109,7 @@ private function entryId(?CData $tree, string $path): ?string $entry = $this->ffi->new('git_tree_entry*'); $rc = $this->ffi->git_tree_entry_bypath(\FFI::addr($entry), $tree, $path); - if (self::GIT_ENOTFOUND === $rc) { + if (LibGit2::GIT_ENOTFOUND === $rc) { return null; } LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up "%s"', $path)); diff --git a/src/Gitonomy/Git/Driver/Ffi/libgit2.h b/src/Gitonomy/Git/Driver/Ffi/libgit2.h index da14ee85..309cc566 100644 --- a/src/Gitonomy/Git/Driver/Ffi/libgit2.h +++ b/src/Gitonomy/Git/Driver/Ffi/libgit2.h @@ -80,11 +80,12 @@ typedef struct git_config git_config; int git_libgit2_init(void); int git_libgit2_version(int *major, int *minor, int *rev); +int git_libgit2_features(void); /* -- buffers / signatures / arrays -- */ void git_buf_dispose(git_buf *buffer); -int git_signature_default(git_signature **out, git_repository *repo); +int git_signature_default_from_env(git_signature **author_out, git_signature **committer_out, git_repository *repo); void git_signature_free(git_signature *sig); void git_oidarray_dispose(git_oidarray *array); @@ -105,7 +106,6 @@ int git_repository_set_workdir(git_repository *repo, const char *workdir, int up void git_repository_free(git_repository *repo); int git_repository_init(git_repository **out, const char *path, unsigned is_bare); int git_repository_head(git_reference **out, git_repository *repo); -int git_repository_head_detached(git_repository *repo); int git_repository_set_head(git_repository *repo, const char *refname); int git_repository_set_head_detached(git_repository *repo, const git_oid *committish); int git_repository_config(git_config **out, git_repository *repo); @@ -117,6 +117,7 @@ void git_config_free(git_config *cfg); /* -- object / commit / tree / blob / tag -- */ +int git_object_lookup_prefix(git_object **object_out, git_repository *repo, const git_oid *id, size_t len, git_object_t type); const git_oid *git_object_id(const git_object *obj); git_object_t git_object_type(const git_object *obj); int git_object_short_id(git_buf *out, const git_object *obj); @@ -125,6 +126,7 @@ void git_object_free(git_object *object); int git_commit_lookup_prefix(git_commit **commit, git_repository *repo, const git_oid *id, size_t len); void git_commit_free(git_commit *commit); +const git_oid *git_commit_id(const git_commit *commit); const char *git_commit_message(const git_commit *commit); const char *git_commit_message_encoding(const git_commit *commit); const git_signature *git_commit_committer(const git_commit *commit); @@ -181,6 +183,7 @@ void git_revwalk_free(git_revwalk *walk); int git_reference_lookup(git_reference **out, git_repository *repo, const char *name); int git_reference_resolve(git_reference **out, const git_reference *ref); const git_oid *git_reference_target(const git_reference *ref); +const char *git_reference_symbolic_target(const git_reference *ref); git_reference_t git_reference_type(const git_reference *ref); const char *git_reference_name(const git_reference *ref); int git_reference_create(git_reference **out, git_repository *repo, const char *name, const git_oid *id, int force, const char *log_message); @@ -192,6 +195,8 @@ void git_reference_iterator_free(git_reference_iterator *iter); int git_reference_peel(git_object **out, const git_reference *ref, git_object_t type); int git_branch_create(git_reference **out, git_repository *repo, const char *branch_name, const git_commit *target, int force); +int git_branch_delete(git_reference *branch); +int git_branch_name_is_valid(int *valid, const char *name); int git_branch_iterator_new(git_branch_iterator **out, git_repository *repo, git_branch_t list_flags); int git_branch_next(git_reference **out, git_branch_t *out_type, git_branch_iterator *iter); void git_branch_iterator_free(git_branch_iterator *iter); @@ -202,7 +207,6 @@ int git_graph_descendant_of(git_repository *repo, const git_oid *commit, const g /* -- merge_base -- */ -int git_merge_base(git_oid *out, git_repository *repo, const git_oid *one, const git_oid *two); int git_merge_bases(git_oidarray *out, git_repository *repo, const git_oid *one, const git_oid *two); /* -- diff -- */ @@ -280,6 +284,7 @@ int git_diff_options_init(git_diff_options *opts, unsigned int version); void git_diff_free(git_diff *diff); int git_diff_tree_to_tree(git_diff **diff, git_repository *repo, git_tree *old_tree, git_tree *new_tree, const git_diff_options *opts); int git_diff_tree_to_index(git_diff **diff, git_repository *repo, git_tree *old_tree, git_index *index, const git_diff_options *opts); +int git_diff_tree_to_workdir(git_diff **diff, git_repository *repo, git_tree *old_tree, const git_diff_options *opts); int git_diff_tree_to_workdir_with_index(git_diff **diff, git_repository *repo, git_tree *old_tree, const git_diff_options *opts); int git_diff_index_to_workdir(git_diff **diff, git_repository *repo, git_index *index, const git_diff_options *opts); int git_diff_find_options_init(git_diff_find_options *opts, unsigned int version); diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index 39621dab..2d60586f 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -12,14 +12,17 @@ namespace Gitonomy\Git\Driver; -use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Driver\Data\BlameLineData; use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Driver\Data\DiffData; use Gitonomy\Git\Driver\Data\ReferenceData; use Gitonomy\Git\Driver\Data\TagData; use Gitonomy\Git\Driver\Data\TreeEntryData; use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\FastForward; /** * Operations on one repository, opened by {@see DriverInterface::open()}. @@ -28,62 +31,104 @@ * and the domain objects (Commit, Tree, Reference...) build on it. * * Every method throws a {@see DriverException} when git reports a failure (an - * unknown revision, a conflict...), and an {@see UnsupportedOperationException} - * when the driver can't perform the operation at all. + * unknown revision, a conflict...), an {@see UnsupportedOperationException} + * when the driver can't perform the operation at all, and an + * {@see InvalidArgumentException} when an argument contains a NUL byte, which + * git doesn't accept. * * @author Grégoire Pineau */ interface RepositoryDriverInterface { + /** + * @return string The absolute path of the git directory + */ + public function getGitDir(): string; + + /** + * @return string|null The working tree, null for a bare repository + */ + public function getWorkingDir(): ?string; + + /** + * @return string|null The full name of the reference HEAD points to (a branch, which may have no commit yet), or null when HEAD is detached + */ + public function getHeadReference(): ?string; + /** * Resolves a revision to the hash of the object it names (``rev-parse --verify``). + * + * @throws ReferenceNotFoundException Nothing has this name */ public function resolveRevision(string $revision): string; /** - * @return string The type of the object $revision names: ``commit``, ``tree``, ``blob`` or ``tag`` + * @throws ReferenceNotFoundException $hash doesn't exist */ - public function getObjectType(string $revision): string; - - public function getShortHash(string $revision): string; + public function getShortHash(string $hash): string; + /** + * @throws ReferenceNotFoundException $hash doesn't exist, or isn't a commit + */ public function getCommitData(string $hash): CommitData; /** - * @param string[]|null $revisions Revisions or ranges (``master``, ``a..b``, ``a...b``, ``^a``), or null for every reference - * @param string[] $paths Paths, relative to the root of the repository + * @param list|null $includes The commits whose history to list, null for every reference and HEAD + * @param list $excludes The commits whose history to leave out + * @param string[] $paths Paths, relative to the root of the repository or absolute in the working tree, with git's wildcards + * + * @return list Newest first * - * @return array Indexed by hash, newest first + * @throws DriverException An absolute path is outside the working tree */ - public function getCommitLog(?array $revisions, array $paths, ?int $offset, ?int $limit): array; + public function getCommitLog(?array $includes, array $excludes, array $paths, int $offset, ?int $limit): array; /** - * @param string[]|null $revisions Same as {@see getCommitLog()} - * @param string[] $paths + * @param list|null $includes Same as {@see getCommitLog()} + * @param list $excludes + * @param string[] $paths */ - public function countCommits(?array $revisions, array $paths): int; + public function countCommits(?array $includes, array $excludes, array $paths): int; /** - * @return string|null The best common ancestor, or null if the revisions have none + * @return list The best common ancestors of both commits, none when they have no common history */ - public function getMergeBase(string $revision1, string $revision2): ?string; + public function getMergeBases(string $hash1, string $hash2): array; /** * @return TreeEntryData[] + * + * @throws ReferenceNotFoundException $hash doesn't exist, or isn't a tree */ public function getTreeEntries(string $hash): array; + /** + * @throws ReferenceNotFoundException $hash doesn't exist, or isn't a blob + */ public function getBlobContent(string $hash): string; - public function getTagData(string $revision): TagData; + /** + * @param string $hash An annotated tag object + * + * @throws ReferenceNotFoundException $hash doesn't exist, or isn't an annotated tag + */ + public function getTagData(string $hash): TagData; /** * @return ReferenceData[] Every reference under ``refs/``, none for an empty repository */ public function getReferences(): array; + /** + * @throws DriverException $fullname exists already + */ + public function createReference(string $fullname, string $hash): void; + public function updateReference(string $fullname, string $hash): void; + /** + * Like ``git update-ref -d``, deleting a missing reference does nothing. + */ public function deleteReference(string $fullname): void; /** @@ -92,44 +137,54 @@ public function deleteReference(string $fullname): void; public function getIncludingBranches(string $hash): array; /** - * @param string[] $revisions One revision (compared with the working tree), two revisions, or one range + * Changes from the tree of $from to the one of $to. + * + * @param string|null $from A commit, null for the empty tree + * @param string $to A commit */ - public function getDiff(array $revisions): Diff; + public function getDiff(?string $from, string $to): DiffData; /** - * Changes introduced by a commit: against each of its parents, or against - * nothing for a root commit. + * Changes of the working tree, staged or not, since a commit. + * + * During a merge conflict, like ``git diff ``, the unmerged + * files compare their content, conflict markers included, to the commit. */ - public function getCommitDiff(string $hash): Diff; + public function getWorkingTreeDiff(string $hash): DiffData; /** - * Changes of the working tree not staged yet. + * Changes of the working tree not staged yet, the unmerged files of a conflict left out. */ - public function getDiffPending(): Diff; + public function getDiffPending(): DiffData; /** - * Changes staged in the index. + * Changes staged in the index, the unmerged files of a conflict left out. */ - public function getDiffStaged(): Diff; + public function getDiffStaged(): DiffData; /** - * @param string|null $lineRange ``,`` or ``,+``; CliDriver accepts anything ``git blame -L`` does + * @param int|null $firstLine The first line to blame, from 1, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null * * @return BlameLineData[] */ - public function getBlameLines(string $revision, string $file, ?string $lineRange): array; + public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array; /** * @return string[] Paths of the untracked files, relative to the root of the working tree */ public function getUntrackedFiles(): array; + /** + * Checks a revision out: HEAD points to the branch when $revision is the + * name of a local branch, and to the commit otherwise. + */ public function checkout(string $revision): void; - public function checkoutNewBranch(string $branch, string $revision): void; + public function checkoutNewBranch(string $branch, string $startHash): void; /** - * @param string[] $args Extra arguments of ``git merge`` (e.g. ``--no-ff``) + * Merges $revision, a name the message of the merge commit names, into the current branch. */ - public function merge(string $revision, array $args): void; + public function merge(string $revision, FastForward $fastForward): void; } diff --git a/src/Gitonomy/Git/Driver/RepositoryPaths.php b/src/Gitonomy/Git/Driver/RepositoryPaths.php new file mode 100644 index 00000000..7cede834 --- /dev/null +++ b/src/Gitonomy/Git/Driver/RepositoryPaths.php @@ -0,0 +1,85 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver; + +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\NulByte; + +/** + * The git directory and the working tree of a repository, found the same way by every driver. + * + * @internal + */ +final readonly class RepositoryPaths +{ + private function __construct( + public string $gitDir, + public ?string $workingDir, + ) { + } + + /** + * @param string $dir A bare repository, a working tree, or a git directory when $workingDir is given + * @param string|null $workingDir The working tree of the git directory $dir + * + * @throws InvalidArgumentException $dir doesn't exist, or isn't a repository, or a path contains a NUL byte + * @throws DriverException The ".git" file of $dir doesn't point to a git directory + * @throws UnsupportedOperationException The repository uses SHA-256 hashes + */ + public static function resolve(string $dir, ?string $workingDir): self + { + NulByte::assertNone($dir, $workingDir ?? ''); + + $realDir = realpath($dir); + if (false === $realDir || !is_dir($realDir)) { + throw new InvalidArgumentException(\sprintf('Directory "%s" does not exist or is not a directory.', $dir)); + } + + $paths = match (true) { + null !== $workingDir => new self($realDir, $workingDir), + is_dir($realDir.'/.git') => new self($realDir.'/.git', $realDir), + // In a linked worktree or a submodule, ".git" is a file pointing to the git directory. + is_file($realDir.'/.git') => new self(self::readGitFile($realDir), $realDir), + default => new self($realDir, null), + }; + + // Like git, which requires it in any git directory. + if (!is_file($paths->gitDir.'/HEAD')) { + throw new InvalidArgumentException(\sprintf('Directory "%s" is not a git repository.', $dir)); + } + + $config = is_file($paths->gitDir.'/config') ? file_get_contents($paths->gitDir.'/config') : false; + if (false !== $config && preg_match('/^\s*objectformat\s*=\s*sha256\s*$/mi', $config)) { + throw new UnsupportedOperationException(\sprintf('The repository "%s" uses SHA-256 hashes, which gitlib doesn\'t support yet.', $dir)); + } + + return $paths; + } + + /** + * @return string The git directory the ".git" file of $dir points to + */ + private static function readGitFile(string $dir): string + { + $content = file_get_contents($dir.'/.git'); + if (false === $content || !preg_match('/^gitdir: (.+)$/m', $content, $match)) { + throw new DriverException(\sprintf('The "%s/.git" file doesn\'t point to a git directory.', $dir)); + } + + $gitDir = str_starts_with($match[1], '/') || preg_match('/^[a-zA-Z]:[\\\\\/]/', $match[1]) ? $match[1] : $dir.'/'.$match[1]; + + return realpath($gitDir) ?: throw new DriverException(\sprintf('The git directory "%s" of "%s" does not exist.', $match[1], $dir)); + } +} diff --git a/src/Gitonomy/Git/Exception/InvalidArgumentException.php b/src/Gitonomy/Git/Exception/InvalidArgumentException.php index 119aad2b..ed1dcc39 100644 --- a/src/Gitonomy/Git/Exception/InvalidArgumentException.php +++ b/src/Gitonomy/Git/Exception/InvalidArgumentException.php @@ -12,6 +12,6 @@ namespace Gitonomy\Git\Exception; -final class InvalidArgumentException extends \InvalidArgumentException implements GitExceptionInterface +class InvalidArgumentException extends \InvalidArgumentException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Exception/ProcessTimedOutException.php b/src/Gitonomy/Git/Exception/ProcessTimedOutException.php new file mode 100644 index 00000000..1af2780f --- /dev/null +++ b/src/Gitonomy/Git/Exception/ProcessTimedOutException.php @@ -0,0 +1,22 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Exception; + +/** + * A git process ran longer than the timeout of CliDriver. + * + * @author Grégoire Pineau + */ +final class ProcessTimedOutException extends DriverException +{ +} diff --git a/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php b/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php index 134c17e2..fcfe134e 100644 --- a/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php +++ b/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php @@ -12,10 +12,9 @@ namespace Gitonomy\Git\Exception; -final class ReferenceNotFoundException extends \InvalidArgumentException implements GitExceptionInterface +/** + * A revision, a reference or an object doesn't exist. + */ +final class ReferenceNotFoundException extends InvalidArgumentException { - public function __construct(string $reference, int $code = 0, ?\Throwable $previous = null) - { - parent::__construct(\sprintf('Reference not found: "%s"', $reference), $code, $previous); - } } diff --git a/src/Gitonomy/Git/FastForward.php b/src/Gitonomy/Git/FastForward.php new file mode 100644 index 00000000..4b27e32f --- /dev/null +++ b/src/Gitonomy/Git/FastForward.php @@ -0,0 +1,36 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git; + +/** + * Whether a merge fast-forwards the branch, when its history allows it. + * + * @author Grégoire Pineau + */ +enum FastForward +{ + /** + * Fast-forwards when possible, merges otherwise, like "git merge --ff". + */ + case Allow; + + /** + * Always creates a merge commit, like "git merge --no-ff". + */ + case Never; + + /** + * Refuses anything but a fast-forward, like "git merge --ff-only". + */ + case Only; +} diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index 6b6835c3..c673f919 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -13,7 +13,6 @@ namespace Gitonomy\Git; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\LogicException; use Gitonomy\Git\Exception\RuntimeException; /** @@ -45,15 +44,21 @@ public function has(string $name): bool * * @return string Content of the hook * - * @throws InvalidArgumentException Hook does not exist + * @throws InvalidArgumentException The hook does not exist + * @throws RuntimeException The hook can't be read */ public function get(string $name): string { if (!$this->has($name)) { - throw new InvalidArgumentException(\sprintf('Hook named "%s" is not present', $name)); + throw new InvalidArgumentException(\sprintf('The hook "%s" does not exist.', $name)); } - return file_get_contents($this->getPath($name)); + $content = file_get_contents($this->getPath($name)); + if (false === $content) { + throw new RuntimeException(\sprintf('Unable to read the hook "%s".', $name)); + } + + return $content; } /** @@ -62,18 +67,14 @@ public function get(string $name): string * @param string $name Name of the hook to insert * @param string $file Target of symlink * - * @throws LogicException Hook is already present - * @throws RuntimeException Error on symlink creation + * @throws RuntimeException The hook exists already, or can't be created */ public function setSymlink(string $name, string $file): void { - if ($this->has($name)) { - throw new LogicException(\sprintf('A hook "%s" is already defined', $name)); - } + $path = $this->createPath($name); - $path = $this->getPath($name); - if (false === symlink($file, $path)) { - throw new RuntimeException(\sprintf('Unable to create hook "%s" (%s)', $name, $path)); + if (false === @symlink($file, $path)) { + throw new RuntimeException(\sprintf('Unable to create the hook "%s" (%s).', $name, $path)); } } @@ -83,17 +84,15 @@ public function setSymlink(string $name, string $file): void * @param string $name Name of the hook * @param string $content Content of the hook * - * @throws LogicException The hook is already defined + * @throws RuntimeException The hook exists already, or can't be created */ public function set(string $name, string $content): void { - if ($this->has($name)) { - throw new LogicException(\sprintf('A hook "%s" is already defined', $name)); - } + $path = $this->createPath($name); - $path = $this->getPath($name); - file_put_contents($path, $content); - chmod($path, 0o700); + if (false === @file_put_contents($path, $content) || false === @chmod($path, 0o700)) { + throw new RuntimeException(\sprintf('Unable to create the hook "%s" (%s).', $name, $path)); + } } /** @@ -101,19 +100,60 @@ public function set(string $name, string $content): void * * @param string $name Name of the hook * - * @throws LogicException The hook is not present + * @throws RuntimeException The hook doesn't exist, or can't be removed */ public function remove(string $name): void { if (!$this->has($name)) { - throw new LogicException(\sprintf('The hook "%s" was not found', $name)); + throw new RuntimeException(\sprintf('The hook "%s" does not exist.', $name)); + } + + if (false === @unlink($this->getPath($name))) { + throw new RuntimeException(\sprintf('Unable to remove the hook "%s".', $name)); + } + } + + /** + * @throws RuntimeException The hook exists already, or the repository has no hooks directory + */ + private function createPath(string $name): string + { + if ($this->has($name)) { + throw new RuntimeException(\sprintf('The hook "%s" exists already.', $name)); + } + + // A repository created with an empty template has none. + $directory = $this->getDirectory(); + if (!is_dir($directory) && !@mkdir($directory) && !is_dir($directory)) { + throw new RuntimeException(\sprintf('Unable to create the hooks directory "%s".', $directory)); } - unlink($this->getPath($name)); + return $this->getPath($name); } private function getPath(string $name): string { - return $this->repository->getGitDir().'/hooks/'.$name; + return $this->getDirectory().'/'.$name; + } + + /** + * @throws RuntimeException The "commondir" file of a linked worktree can't be read + */ + private function getDirectory(): string + { + $gitDir = $this->repository->getGitDir(); + // Like git, a linked worktree runs the hooks of the repository it belongs to. + if (!is_file($gitDir.'/commondir')) { + return $gitDir.'/hooks'; + } + + $commonDir = file_get_contents($gitDir.'/commondir'); + if (false === $commonDir) { + throw new RuntimeException(\sprintf('Unable to read "%s/commondir".', $gitDir)); + } + + $commonDir = rtrim($commonDir, "\r\n"); + + return (preg_match('#^(/|[a-zA-Z]:[\\\/])#', $commonDir) ? $commonDir : $gitDir.'/'.$commonDir).'/hooks'; } } diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index de63a4d9..d2065ead 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -13,16 +13,21 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; -use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; /** * @author Alexandre Salomé + * + * @implements \IteratorAggregate */ final class Log implements \Countable, \IteratorAggregate { private ?RevisionList $revisions; + /** + * @var string[] + */ private array $paths; private ?int $offset; @@ -32,11 +37,14 @@ final class Log implements \Countable, \IteratorAggregate /** * Instanciates a git log object. * - * @param Repository $repository the repository where log occurs - * @param RevisionList|Revision|string|array|null $revisions a list of revisions or null if you want all history - * @param array|string|null $paths paths to filter on - * @param int|null $offset start list from a given position - * @param int|null $limit limit number of fetched elements + * The revisions are names, ranges ("a..b", "a...b"), names to exclude + * ("^a"), or "--all". + * + * @param Repository $repository the repository where log occurs + * @param RevisionList|Revision|string|array|null $revisions a list of revisions or null if you want all history + * @param string[]|string|null $paths paths to filter on, with git's wildcards + * @param int|null $offset start list from a given position + * @param int|null $limit limit number of fetched elements */ public function __construct( private readonly Repository $repository, @@ -61,9 +69,14 @@ public function __construct( $this->limit = $limit; } + /** + * @deprecated since gitonomy/gitlib 2.0, use Repository::getDiff() instead: it ignored the paths of the log + */ public function getDiff(): Diff { - return $this->repository->getDiff($this->revisions); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::getDiff()" instead.', __METHOD__, Repository::class); + + return $this->repository->getLegacyDiff($this->revisions); } public function getRevisions(): ?RevisionList @@ -71,6 +84,9 @@ public function getRevisions(): ?RevisionList return $this->revisions; } + /** + * @return string[] + */ public function getPaths(): array { return $this->paths; @@ -81,8 +97,24 @@ public function getOffset(): ?int return $this->offset; } + /** + * @return self The log, starting from the commit $offset + */ + public function withOffset(?int $offset): self + { + $log = clone $this; + $log->offset = $offset; + + return $log; + } + + /** + * @deprecated since gitonomy/gitlib 2.0, use withOffset() instead + */ public function setOffset(?int $offset): static { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "withOffset()" instead.', __METHOD__); + $this->offset = $offset; return $this; @@ -93,25 +125,35 @@ public function getLimit(): ?int return $this->limit; } + /** + * @return self The log, of $limit commits at most + */ + public function withLimit(?int $limit): self + { + $log = clone $this; + $log->limit = $limit; + + return $log; + } + + /** + * @deprecated since gitonomy/gitlib 2.0, use withLimit() instead + */ public function setLimit(?int $limit): static { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "withLimit()" instead.', __METHOD__); + $this->limit = $limit; return $this; } + /** + * @throws ReferenceNotFoundException The log is empty + */ public function getSingleCommit(): Commit { - $limit = $this->limit; - $this->limit = 1; - $commits = $this->getCommits(); - $this->setLimit($limit); - - if (0 === \count($commits)) { - throw new ReferenceNotFoundException('The log is empty'); - } - - return array_pop($commits); + return $this->withLimit(1)->getCommits()[0] ?? throw new ReferenceNotFoundException('The log has no commit.'); } /** @@ -119,19 +161,12 @@ public function getSingleCommit(): Commit */ public function getCommits(): array { - $revisions = $this->revisions?->getAsTextArray(); - - try { - $commitLog = $this->repository->getRepositoryDriver()->getCommitLog($revisions, $this->paths, $this->offset, $this->limit); - } catch (DriverException $e) { - $revisionsText = null !== $revisions ? implode(' ', $revisions) : '--all'; - - throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $revisionsText), 0, $e); - } + [$includes, $excludes] = $this->range(); + $commitLog = $this->repository->getRepositoryDriver()->getCommitLog($includes, $excludes, $this->paths, $this->offset ?? 0, $this->limit); $result = []; - foreach ($commitLog as $hash => $commitData) { - $commit = $this->repository->getCommit($hash); + foreach ($commitLog as $commitData) { + $commit = $this->repository->getCommit($commitData->hash); $commit->setData($commitData); $result[] = $commit; @@ -142,14 +177,20 @@ public function getCommits(): array /** * @see Countable + * + * @return int The number of commits of the log, after its offset and up to its limit: as many as getCommits() returns */ public function count(): int { - return $this->countCommits(); + $count = max(0, $this->countCommits() - ($this->offset ?? 0)); + + return null === $this->limit ? $count : min($count, $this->limit); } /** * @see IteratorAggregate + * + * @return \ArrayIterator */ public function getIterator(): \ArrayIterator { @@ -161,6 +202,64 @@ public function getIterator(): \ArrayIterator */ public function countCommits(): int { - return $this->repository->getRepositoryDriver()->countCommits($this->revisions?->getAsTextArray(), $this->paths); + [$includes, $excludes] = $this->range(); + + return $this->repository->getRepositoryDriver()->countCommits($includes, $excludes, $this->paths); + } + + /** + * Resolves the revisions to the commits whose history to list, and the + * ones whose history to leave out. + * + * @return array{list|null, list} Null for every reference and HEAD + * + * @throws ReferenceNotFoundException A revision names no commit + */ + private function range(): array + { + if (null === $this->revisions) { + return [null, []]; + } + + $all = false; + $includes = []; + $excludes = []; + foreach ($this->revisions->getAll() as $revision) { + if ($revision instanceof Commit || $revision instanceof Reference) { + $includes[] = $revision->getCommit()->getHash(); + + continue; + } + + $name = $revision->getRevision(); + if ('--all' === $name) { + $all = true; + } elseif (str_starts_with($name, '^')) { + $excludes[] = $this->commitHash(substr($name, 1)); + } elseif (str_contains($name, '...')) { + // Reachable from either end, but not from both. + [$one, $two] = array_map($this->commitHash(...), explode('...', $name, 2)); + array_push($includes, $one, $two); + array_push($excludes, ...$this->repository->getRepositoryDriver()->getMergeBases($one, $two)); + } elseif (str_contains($name, '..')) { + [$from, $to] = array_map($this->commitHash(...), explode('..', $name, 2)); + $includes[] = $to; + $excludes[] = $from; + } elseif (str_starts_with($name, '-')) { + throw new InvalidArgumentException(\sprintf('The log of "%s" is not supported: pass revisions, ranges, names to exclude or "--all".', $name)); + } else { + $includes[] = $this->commitHash($name); + } + } + + return [$all ? null : $includes, $excludes]; + } + + /** + * @param string $revision An end of a range, HEAD when empty + */ + private function commitHash(string $revision): string + { + return $this->repository->getRevision('' === $revision ? 'HEAD' : $revision)->getCommit()->getHash(); } } diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index dc9461fd..c5540658 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -12,168 +12,234 @@ namespace Gitonomy\Git\Parser; -use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\DiffLineType; use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Driver\Data\DiffFileData; use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\RuntimeException; +/** + * Parses the output of ``git diff --raw --patch``, with ``-z`` or not: the + * names, modes, hashes and status of the files come from the raw + * information, and the hunks from the patches. + * + * Unmerged paths, during a conflict, are left out. + */ final class DiffParser extends ParserBase { + private const string HASH = '([0-9a-f]{7,64})(?:\.\.\.)?'; + /** - * git quotes the names holding special or non-ASCII characters (unless - * core.quotePath is false, for the latter) like C strings. + * The extended header lines of a patch, but the "index" one. */ - private const string QUOTED_NAME = '"(?:[^"\\\]|\\\.)*"'; + private const array HEADERS = ['copy from ', 'copy to ', 'deleted file mode ', 'dissimilarity index ', 'new file mode ', 'new mode ', 'old mode ', 'rename from ', 'rename to ', 'similarity index ']; /** - * @var File[] + * @var list */ public array $files = []; protected function doParse(): void { $this->files = []; - $indexes = []; - // The raw information gives the full hashes, even for empty files. - if (str_starts_with($this->content, ':')) { - while ($this->expects(':')) { - $this->consumeRegexp('/\d{6} \d{6} /'); - $oldIndex = $this->consumeShortHash(); - $this->consume(' '); - $newIndex = $this->consumeShortHash(); - $this->consumeTo("\n"); - $this->consumeNewLine(); - $indexes[] = [$oldIndex, $newIndex]; - } - $this->consumeNewLine(); - } elseif (!$this->isFinished()) { + + if ($this->isFinished()) { + return; + } + + if (!str_starts_with($this->content, ':')) { throw new InvalidArgumentException('The diff has no raw information: Diff::parse() parses the output of "git diff --raw --patch".'); } - $fileIndex = 0; - while (!$this->isFinished()) { - // 1. title - // The prefix is normally "a" and "b", but git's diff.mnemonicPrefix option - // (used by tools such as GrumPHP) can produce other single-letter prefixes - // ("c"ommit, "i"ndex, "o"bject, "w"ork tree), or "1"/"2" with --no-index. - $vars = $this->consumeRegexp('/diff --git ('.self::QUOTED_NAME.'|[^\/\s"]\/.*?) ('.self::QUOTED_NAME.'|[^\/\s"]\/.*?)\n/'); - $oldName = self::unquote($vars[1]); - $newName = self::unquote($vars[2]); - // Get indexes from raw if it exists - $oldIndex = isset($indexes[$fileIndex]) ? $indexes[$fileIndex][0] : null; - $newIndex = isset($indexes[$fileIndex]) ? $indexes[$fileIndex][1] : null; - ++$fileIndex; - $oldMode = null; - $newMode = null; - - // 2. mode - if ($this->expects('new file mode ')) { - $newMode = $this->consumeTo("\n"); - $this->consumeNewLine(); - $oldMode = null; - $oldName = '/dev/null'; - } - if ($this->expects('old mode ')) { - $oldMode = $this->consumeTo("\n"); - $this->consumeNewLine(); - $this->consume('new mode '); - $newMode = $this->consumeTo("\n"); - $this->consumeNewLine(); + foreach ($this->consumeRawEntries() as $entry) { + // A type change (a file replaced by a symbolic link...) has the patch of a deletion, then of a creation. + if ('T' === $entry['status']) { + $this->files[] = $this->consumePatch($entry['oldName'], null, $entry['oldMode'], null, $entry['oldHash'], null, false); + $this->files[] = $this->consumePatch(null, $entry['newName'], null, $entry['newMode'], null, $entry['newHash'], false); + + continue; } - if ($this->expects('deleted file mode ')) { - $oldMode = $this->consumeTo("\n"); - $newMode = null; - $newName = '/dev/null'; - $this->consumeNewLine(); + + $this->files[] = $this->consumePatch( + 'A' === $entry['status'] ? null : $entry['oldName'], + 'D' === $entry['status'] ? null : $entry['newName'], + $entry['oldMode'], + $entry['newMode'], + $entry['oldHash'], + $entry['newHash'], + 'C' === $entry['status'], + ); + } + } + + /** + * @return list The entries of merged paths + */ + private function consumeRawEntries(): array + { + $entries = []; + $nul = null; + while ($this->expects(':')) { + // A combined entry, "::" and more, is an unmerged path. + $parents = 1; + while ($this->expects(':')) { + ++$parents; } - $isCopy = false; - if ($this->expects('similarity index ')) { - $this->consumeRegexp('/\d{1,3}%\n/'); - $isCopy = $this->expects('copy from '); - if (!$isCopy) { - $this->consume('rename from '); + $modes = []; + for ($i = 0; $i <= $parents; ++$i) { + $modes[] = $this->consumeRegexp('/(\d{6}) /')[1]; + } + $hashes = []; + for ($i = 0; $i <= $parents; ++$i) { + $hashes[] = $this->consumeRegexp('/'.self::HASH.' /')[1]; + } + $status = $this->consumeRegexp('/([A-Z]+)\d*/')[1]; + + // -z ends the fields with NUL characters, and doesn't quote names. + $nul ??= "\0" === ($this->content[$this->cursor] ?? ''); + $this->consume($nul ? "\0" : "\t"); + $oldName = $this->consumeName($nul); + $newName = $oldName; + if (\in_array($status, ['C', 'R'], true)) { + if (!$nul) { + $this->consume("\t"); } - $this->consumeTo("\n"); - $this->consumeNewLine(); - $this->consume($isCopy ? 'copy to ' : 'rename to '); - $this->consumeTo("\n"); + $newName = $this->consumeName($nul); + } + if (!$nul) { $this->consumeNewLine(); } - // 4. File informations - $isBinary = false; - if ($this->expects('index ')) { - $oldIndex = $this->consumeShortHash(); - $this->consume('..'); - $newIndex = $this->consumeShortHash(); - if ($this->expects(' ')) { - $vars = $this->consumeRegexp('/\d{6}/'); - $newMode = $oldMode = $vars[0]; - } - $this->consumeNewLine(); + if (1 === $parents && 'U' !== $status) { + $entries[] = [ + 'status' => $status, + 'oldName' => $oldName, + 'newName' => $newName, + 'oldMode' => self::nullIfZeros($modes[0]), + 'newMode' => self::nullIfZeros($modes[1]), + 'oldHash' => self::nullIfZeros($hashes[0]), + 'newHash' => self::nullIfZeros($hashes[1]), + ]; } + } - // verifying if the file was deleted or created - // Note: a dirty submodule diff has no "index " line, so this must - // not be nested inside the block above. - if ($this->expects('--- ')) { - $oldName = '/dev/null' === $this->consumeTo("\n") ? '/dev/null' : $oldName; - $this->consumeNewLine(); - $this->consume('+++ '); - $newName = '/dev/null' === $this->consumeTo("\n") ? '/dev/null' : $newName; - $this->consumeNewLine(); - } elseif ($this->expects('Binary files ')) { - $vars = $this->consumeRegexp('/('.self::QUOTED_NAME.'|.*?) and ('.self::QUOTED_NAME.'|.*?) differ\n/'); - $isBinary = true; - $oldName = self::unquote($vars[1]); - $newName = self::unquote($vars[2]); + // The raw information ends with an empty field, or line, when the patches follow. + if (!$this->isFinished()) { + $this->consume($nul ? "\0" : "\n"); + } + + return $entries; + } + + private function consumeName(bool $nul): string + { + if ($nul) { + $name = $this->consumeTo("\0"); + $this->consume("\0"); + + return $name; + } + + return self::unquote($this->consumeRegexp('/("(?:[^"\\\]|\\\.)*"|[^\t\n]*)/')[1]); + } + + private function consumePatch(?string $oldName, ?string $newName, ?string $oldMode, ?string $newMode, ?string $oldHash, ?string $newHash, bool $isCopy): DiffFileData + { + while ($this->expects('* Unmerged path ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + + // Without --patch. + if (!$this->expects('diff --git ')) { + return new DiffFileData($oldName, $newName, $oldMode, $newMode, $oldHash, $newHash, false, $isCopy, []); + } + // The names of the header are ambiguous with spaces: the raw information gives them. + $this->consumeTo("\n"); + $this->consumeNewLine(); + + while (true) { + // The hashes of the files of the working tree, which the raw information leaves out. + if ($this->expects('index ')) { + $vars = $this->consumeRegexp('/'.self::HASH.'\.\.'.self::HASH.'/'); + $oldHash = self::nullIfZeros($vars[1]); + $newHash = self::nullIfZeros($vars[2]); + } elseif (!$this->expectsOneOf(self::HEADERS)) { + break; } - $oldName = '/dev/null' === $oldName ? null : substr($oldName, 2); - $newName = '/dev/null' === $newName ? null : substr($newName, 2); - - $oldIndex = null === $oldIndex ? '' : $oldIndex; - $newIndex = null === $newIndex ? '' : $newIndex; - $oldIndex = preg_match('/^0+$/', $oldIndex) ? null : $oldIndex; - $newIndex = preg_match('/^0+$/', $newIndex) ? null : $newIndex; - $file = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary, $isCopy); - - // 5. Diff - while ($this->expects('@@ ')) { - $vars = $this->consumeRegexp('/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?/'); - $rangeOldStart = (int) $vars[1]; - $rangeOldCount = (int) ($vars[2] ?? 1); - $rangeNewStart = (int) $vars[3]; - $rangeNewCount = (int) ($vars[4] ?? 1); - $this->consume(' @@'); - $this->consumeTo("\n"); - $this->consumeNewLine(); + $this->consumeTo("\n"); + $this->consumeNewLine(); + } - // 6. Lines - $lines = []; - while (true) { - if ($this->expects(' ')) { - $lines[] = [FileChange::LINE_CONTEXT, $this->consumeTo("\n")]; - } elseif ($this->expects('+')) { - $lines[] = [FileChange::LINE_ADD, $this->consumeTo("\n")]; - } elseif ($this->expects('-')) { - $lines[] = [FileChange::LINE_REMOVE, $this->consumeTo("\n")]; - } elseif ($this->expects('\ No newline at end of file')) { - // Ignore this case... - } else { - break; - } - - $this->consumeNewLine(); - } + $isBinary = false; + if ($this->expects('--- ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + $this->consume('+++ '); + $this->consumeTo("\n"); + $this->consumeNewLine(); + } elseif ($this->expects('Binary files ')) { + $isBinary = true; + $this->consumeTo("\n"); + $this->consumeNewLine(); + } - $change = new FileChange($rangeOldStart, $rangeOldCount, $rangeNewStart, $rangeNewCount, $lines); + $changes = []; + while ($this->expects('@@ ')) { + $changes[] = $this->consumeHunk(); + } - $file->addChange($change); + return new DiffFileData($oldName, $newName, $oldMode, $newMode, $oldHash, $newHash, $isBinary, $isCopy, $changes); + } + + private function consumeHunk(): FileChange + { + if (!preg_match('/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[^\n]*\n/A', $this->content, $vars, \PREG_UNMATCHED_AS_NULL, $this->cursor)) { + throw new RuntimeException(\sprintf('Unable to parse the hunk header "%s".', substr($this->content, $this->cursor - 3, 50))); + } + $this->cursor += \strlen($vars[0]); + + $lines = []; + while (true) { + if ($this->expects(' ')) { + $lines[] = new DiffLine(DiffLineType::Context, $this->consumeTo("\n")); + } elseif ($this->expects('+')) { + $lines[] = new DiffLine(DiffLineType::Addition, $this->consumeTo("\n")); + } elseif ($this->expects('-')) { + $lines[] = new DiffLine(DiffLineType::Deletion, $this->consumeTo("\n")); + } elseif ($this->expects('\ No newline at end of file')) { + // The previous line has no line break. + } else { + break; } - $this->files[] = $file; + $this->consumeNewLine(); } + + // A count is left out when it is 1. + return new FileChange((int) $vars[1], (int) ($vars[2] ?? 1), (int) $vars[3], (int) ($vars[4] ?? 1), $lines); + } + + /** + * @param string[] $prefixes + */ + private function expectsOneOf(array $prefixes): bool + { + foreach ($prefixes as $prefix) { + if ($this->expects($prefix)) { + return true; + } + } + + return false; + } + + private static function nullIfZeros(string $value): ?string + { + return '' === trim($value, '0') ? null : $value; } private static function unquote(string $name): string diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index ac59d69b..fd61640c 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -20,10 +20,10 @@ abstract class ParserBase protected string $content; protected int $length; - public function parse(?string $content): void + public function parse(string $content): void { $this->cursor = 0; - $this->content = $content ?? ''; + $this->content = $content; $this->length = \strlen($this->content); $this->doParse(); @@ -44,6 +44,11 @@ protected function consumeAll(): string return $rest; } + /** + * Consumes $expected if the content continues with it. + * + * @phpstan-impure + */ protected function expects(string $expected): bool { $length = \strlen($expected); @@ -60,7 +65,7 @@ protected function expects(string $expected): bool protected function consumeShortHash(): string { if (!preg_match('/([A-Za-z0-9]{7,40})/A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('No short hash found: '.substr($this->content, $this->cursor, 7)); + throw new RuntimeException(\sprintf('Expected a hash, got "%s".', substr($this->content, $this->cursor, 7))); } $this->cursor += \strlen($vars[1]); @@ -71,7 +76,7 @@ protected function consumeShortHash(): string protected function consumeHash(): string { if (!preg_match('/([A-Za-z0-9]{40})/A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('No hash found: '.substr($this->content, $this->cursor, 40)); + throw new RuntimeException(\sprintf('Expected a hash, got "%s".', substr($this->content, $this->cursor, 40))); } $this->cursor += 40; @@ -82,7 +87,7 @@ protected function consumeHash(): string protected function consumeRegexp(string $regexp): array { if (!preg_match($regexp.'A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('No match for regexp '.$regexp.' Upcoming: '.substr($this->content, $this->cursor, 500)); + throw new RuntimeException(\sprintf('Expected %s, got "%s".', $regexp, substr($this->content, $this->cursor, 500))); } $this->cursor += \strlen($vars[0]); @@ -95,7 +100,7 @@ protected function consumeTo(string $text): string $pos = strpos($this->content, $text, $this->cursor); if (false === $pos) { - throw new RuntimeException(\sprintf('Unable to find "%s"', $text)); + throw new RuntimeException(\sprintf('Expected "%s" after "%s".', $text, substr($this->content, $this->cursor, 100))); } $result = substr($this->content, $this->cursor, $pos - $this->cursor); @@ -109,7 +114,7 @@ protected function consume(string $expected): string $length = \strlen($expected); $actual = substr($this->content, $this->cursor, $length); if ($actual !== $expected) { - throw new RuntimeException(\sprintf('Expected "%s", but got "%s" (%s)', $expected, $actual, substr($this->content, $this->cursor, 10))); + throw new RuntimeException(\sprintf('Expected "%s", got "%s".', $expected, substr($this->content, $this->cursor, 10))); } $this->cursor += $length; @@ -132,33 +137,20 @@ protected function consumeNameEmailDate(): array $this->cursor += \strlen($vars[1]); - return [$vars[2], $vars[3], new \DateTimeImmutable('@'.$vars[4])->setTimezone(new \DateTimeZone('UTC'))]; - } - - protected function consumeGPGSignature(): string - { - $expected = "\ngpgsig "; - $length = \strlen($expected); - $actual = substr($this->content, $this->cursor, $length); - if ($actual != $expected) { - return ''; - } - $this->cursor += $length; - - return $this->consumeTo("\n\n"); + return [$vars[2], $vars[3], self::timestamp($vars[4])]; } - protected function consumeMergeTag(): void + /** + * @param string $timestamp Seconds since the epoch, as git writes them + * + * @throws RuntimeException The date is out of range, in a corrupt object + */ + protected static function timestamp(string $timestamp): \DateTimeImmutable { - $expected = "\nmergetag "; - $length = \strlen($expected); - $actual = substr($this->content, $this->cursor, $length); - if ($actual != $expected) { - return; + try { + return new \DateTimeImmutable('@'.$timestamp)->setTimezone(new \DateTimeZone('UTC')); + } catch (\DateMalformedStringException $e) { + throw new RuntimeException(\sprintf('The date "%s" is out of range.', $timestamp), 0, $e); } - $this->cursor += $length; - - $this->consumeTo('-----END PGP SIGNATURE-----'); - $this->consume('-----END PGP SIGNATURE-----'); } } diff --git a/src/Gitonomy/Git/PushReference.php b/src/Gitonomy/Git/PushReference.php index e477bc6e..3cc87c94 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -20,19 +20,23 @@ * * @author Julien DIDIER */ -final readonly class PushReference +final class PushReference { public const string ZERO = '0000000000000000000000000000000000000000'; - private bool $isForce; + private ?bool $isForce = null; + /** + * @param string $reference The full name of the pushed reference + * @param string $before Its commit before the push, ZERO for a creation + * @param string $after Its commit after the push, ZERO for a deletion + */ public function __construct( - private Repository $repository, - private string $reference, - private string $before, - private string $after, + private readonly Repository $repository, + private readonly string $reference, + private readonly string $before, + private readonly string $after, ) { - $this->isForce = $this->getForce(); } public function getRepository(): Repository @@ -40,8 +44,21 @@ public function getRepository(): Repository return $this->repository; } + /** + * @return string The full name of the pushed reference + */ + public function getFullname(): string + { + return $this->reference; + } + + /** + * @deprecated since gitonomy/gitlib 2.0, use getFullname() instead + */ public function getReference(): string { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getFullname()" instead.', __METHOD__); + return $this->reference; } @@ -60,18 +77,28 @@ public function getAfter(): string */ public function getLog(array $excludes = []): Log { - return $this->repository->getLog(array_merge( - [$this->getRevision()], - array_map(static function ($e) { - return '^'.$e; - }, $excludes) - )); + return $this->repository->getLog([$this->getRange(), ...array_map(static fn (string $exclude): string => '^'.$exclude, $excludes)]); } + /** + * @deprecated since gitonomy/gitlib 2.0, use getRange() instead + */ public function getRevision(): string + { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getRange()" instead.', __METHOD__); + + return $this->getRange(); + } + + /** + * @return string The pushed commits: "before..after", or "after" for a creation + * + * @throws LogicException The push deletes the reference + */ + public function getRange(): string { if ($this->isDelete()) { - throw new LogicException('No revision for deletion'); + throw new LogicException('A deletion pushes no commit.'); } if ($this->isCreate()) { @@ -93,7 +120,7 @@ public function isDelete(): bool public function isForce(): bool { - return $this->isForce; + return $this->isForce ??= $this->getForce(); } public function isFastForward(): bool @@ -112,13 +139,7 @@ private function getForce(): bool return false; } - $result = $this->repository->getRepositoryDriver()->getMergeBase($this->before, $this->after); - - if (null === $result) { - // No common ancestor could be established: treat it as a force update. - return true; - } - - return $this->before !== $result; + // Without a common history either, it is a force update. + return !\in_array($this->before, $this->repository->getRepositoryDriver()->getMergeBases($this->before, $this->after), true); } } diff --git a/src/Gitonomy/Git/Reference.php b/src/Gitonomy/Git/Reference.php index df4a28f1..751fd199 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -12,9 +12,6 @@ namespace Gitonomy\Git; -use Gitonomy\Git\Exception\DriverException; -use Gitonomy\Git\Exception\ReferenceNotFoundException; - /** * Reference in a Git repository. * @@ -23,13 +20,15 @@ */ abstract class Reference extends Revision { - protected ?string $commitHash; - - public function __construct(Repository $repository, string $revision, ?string $commitHash = null) - { - parent::__construct($repository, $revision); - - $this->commitHash = $commitHash; + /** + * @param string $commitHash What the reference points to, through an annotated tag + */ + public function __construct( + Repository $repository, + string $fullname, + protected readonly string $commitHash, + ) { + parent::__construct($repository, $fullname); } public function getFullname(): string @@ -42,19 +41,12 @@ public function delete(): void $this->repository->getReferences()->delete($this->getFullname()); } + /** + * @return string The commit the reference points to, when it was read: the commit an annotated tag points to, for one + */ public function getCommitHash(): string { - if (null !== $this->commitHash) { - return $this->commitHash; - } - - try { - $hash = $this->repository->getRepositoryDriver()->resolveRevision($this->revision); - } catch (DriverException $e) { - throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision), 0, $e); - } - - return $this->commitHash = $hash; + return $this->commitHash; } /** @@ -62,7 +54,7 @@ public function getCommitHash(): string */ public function getCommit(): Commit { - return $this->repository->getCommit($this->getCommitHash()); + return $this->repository->getCommit($this->commitHash); } public function getLastModification(?string $path = null): Commit diff --git a/src/Gitonomy/Git/Reference/Branch.php b/src/Gitonomy/Git/Reference/Branch.php index fd43fc35..1ac791da 100644 --- a/src/Gitonomy/Git/Reference/Branch.php +++ b/src/Gitonomy/Git/Reference/Branch.php @@ -12,9 +12,9 @@ namespace Gitonomy\Git\Reference; -use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference; +use Gitonomy\Git\Revision; /** * Representation of a branch reference. @@ -23,8 +23,6 @@ */ final class Branch extends Reference { - private ?bool $local = null; - public function getName(): string { $fullname = $this->getFullname(); @@ -37,48 +35,59 @@ public function getName(): string return $vars['remote'].'/'.$vars['name']; } - throw new RuntimeException(\sprintf('Cannot extract branch name from "%s"', $fullname)); + throw new RuntimeException(\sprintf('Cannot extract branch name from "%s".', $fullname)); } public function isRemote(): bool { - $this->detectBranchType(); - - return !$this->local; + return str_starts_with($this->getFullname(), 'refs/remotes/'); } public function isLocal(): bool { - $this->detectBranchType(); + return !$this->isRemote(); + } + + /** + * Checks if the history of $revision contains this branch. + */ + public function isMergedInto(Revision|string $revision): bool + { + $destination = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); - return $this->local; + return \in_array($this->commitHash, $this->repository->getRepositoryDriver()->getMergeBases($this->commitHash, $destination), true); } /** * Check if this branch is merged to a destination branch * Optionally, check only with remote branches. + * + * @deprecated since gitonomy/gitlib 2.0, use isMergedInto() instead */ public function isMergedTo(string $destinationBranchName = 'master', bool $compareOnlyWithRemote = false): bool { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isMergedInto()" instead.', __METHOD__, self::class); + if ($compareOnlyWithRemote && $this->isLocal()) { return false; } - try { - // The commit hash this object holds may be outdated. - $tip = $this->repository->getRepositoryDriver()->resolveRevision($this->getFullname()); - - // Merged: the destination contains the tip of this branch. - return $tip === $this->repository->getRepositoryDriver()->getMergeBase($tip, $destinationBranchName); - } catch (DriverException $e) { - throw new RuntimeException(\sprintf('Cannot determine if merged to the branch "%s"', $destinationBranchName), $e->getCode(), $e); - } + return $this->isMergedInto($destinationBranchName); } - private function detectBranchType(): void + /** + * Points the branch to $revision. + * + * @return self The branch, pointing to $revision + */ + public function moveTo(Revision|string $revision): self { - if (null === $this->local) { - $this->local = !preg_match('#^refs/remotes/(?[^/]*)/(?.*)$#', $this->getFullname()); - } + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); + $this->repository->getRepositoryDriver()->updateReference($this->getFullname(), $hash); + + $references = $this->repository->getReferences(); + $references->invalidate(); + + return $this->isLocal() ? $references->getBranch($this->getName()) : $references->getRemoteBranch($this->getName()); } } diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index 8d9ebd96..17066c33 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -12,13 +12,11 @@ namespace Gitonomy\Git\Reference; -use Gitonomy\Git\Commit; use Gitonomy\Git\Driver\Data\TagData; -use Gitonomy\Git\Exception\DriverException; -use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference; +use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\Message; /** * Representation of a tag reference. @@ -29,20 +27,27 @@ final class Tag extends Reference { /** - * Raw annotated-tag data, as returned by the Driver. Lazily loaded. + * The annotated tag object, read through the driver when needed. */ private ?TagData $data = null; - private ?bool $annotated = null; - - // Values derived from the fields above, computed and cached independently. - private ?string $subjectMessage = null; - private ?string $bodyMessage = null; + /** + * @param string $commitHash What the tag points to, through its annotated tag object + * @param string|null $tagHash The annotated tag object, null for a lightweight tag + */ + public function __construct( + Repository $repository, + string $fullname, + string $commitHash, + private readonly ?string $tagHash = null, + ) { + parent::__construct($repository, $fullname, $commitHash); + } public function getName(): string { if (!preg_match('#^refs/tags/(.*)$#', $this->revision, $vars)) { - throw new RuntimeException(\sprintf('Cannot extract tag name from "%s"', $this->revision)); + throw new RuntimeException(\sprintf('Cannot extract tag name from "%s".', $this->revision)); } return $vars[1]; @@ -53,144 +58,80 @@ public function getName(): string */ public function isAnnotated(): bool { - return $this->annotated ??= 'tag' === $this->repository->getRepositoryDriver()->getObjectType($this->revision); + return null !== $this->tagHash; } /** - * Returns the actual commit associated with the tag, and not the hash of the tag if annotated. + * @return string|null The name of the tagger, null for a lightweight tag or a tag without tagger */ - public function getCommit(): Commit + public function getTaggerName(): ?string { - try { - $hash = $this->repository->getRepositoryDriver()->resolveRevision($this->revision.'^{commit}'); - } catch (DriverException $e) { - throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision), 0, $e); - } - - return $this->repository->getCommit($hash); + return $this->data()?->taggerName; } /** - * Returns the tagger name. + * @return string|null The email of the tagger, null for a lightweight tag or a tag without tagger */ - public function getTaggerName(): string|false + public function getTaggerEmail(): ?string { - if (!$this->isAnnotated()) { - return false; - } - - return $this->data()->taggerName; + return $this->data()?->taggerEmail; } /** - * Returns the comitter email. + * @return \DateTimeImmutable|null The tagging date, null for a lightweight tag or a tag without tagger */ - public function getTaggerEmail(): string|false + public function getTaggerDate(): ?\DateTimeImmutable { - if (!$this->isAnnotated()) { - return false; - } - - return $this->data()->taggerEmail; + return $this->data()?->taggerDate; } /** - * Returns the authoring date. + * @return string|null The message of the annotated tag, without its signature; null for a lightweight tag */ - public function getTaggerDate(): \DateTimeImmutable|false + public function getMessage(): ?string { - if (!$this->isAnnotated()) { - return false; - } - - return $this->data()->taggerDate; + return $this->data()?->message; } /** - * Returns the message of the commit. + * @return string|null The first paragraph of the message, on one line, null for a lightweight tag */ - public function getMessage(): string|false + public function getSubjectMessage(): ?string { - if (!$this->isAnnotated()) { - return false; - } + $message = $this->getMessage(); - return $this->data()->message; + return null === $message ? null : Message::subject($message); } /** - * Returns the subject message (the first line). + * @return string|null What follows the subject and its empty lines, null for a lightweight tag */ - public function getSubjectMessage(): string|false + public function getBodyMessage(): ?string { - if (!$this->isAnnotated()) { - return false; - } - - if (null === $this->subjectMessage) { - $lines = explode("\n", $this->data()->message); - $this->subjectMessage = reset($lines); - } + $message = $this->getMessage(); - return $this->subjectMessage; + return null === $message ? null : Message::body($message); } /** - * Return the body message. + * @return string|null The signature (GPG, X.509 or SSH) between its armor lines, null for an unsigned or lightweight tag */ - public function getBodyMessage(): string|false + public function getGpgSignature(): ?string { - if (!$this->isAnnotated()) { - return false; - } - - if (null === $this->bodyMessage) { - $lines = explode("\n", $this->data()->message); - - // Drop the subject line, then the blank separator line if the - // message follows the "subject\n\nbody" convention. - array_shift($lines); - if (isset($lines[0]) && '' === $lines[0]) { - array_shift($lines); - } - if ([] !== $lines && '' === end($lines)) { - array_pop($lines); - } - - $this->bodyMessage = implode("\n", $lines); - } - - return $this->bodyMessage; + return $this->data()?->gpgSignature; } - /** - * Return the GPG signature. - * - * @throws InvalidArgumentException The tag isn't signed - */ - public function getGPGSignature(): string|false - { - if (!$this->isAnnotated()) { - return false; - } - - return $this->data()->gpgSignature ?? throw new InvalidArgumentException(\sprintf('Tag "%s" is not signed.', $this->getName())); - } - - /** - * Check whether tag is signed. - */ public function isSigned(): bool { - return $this->isAnnotated() && null !== $this->data()->gpgSignature; + return null !== $this->getGpgSignature(); } - private function data(): TagData + private function data(): ?TagData { - if (null === $this->data) { - $this->data = $this->repository->getRepositoryDriver()->getTagData($this->revision); + if (null === $this->tagHash) { + return null; } - return $this->data; + return $this->data ??= $this->repository->getRepositoryDriver()->getTagData($this->tagHash); } } diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 5c75d68a..9cfa972b 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Stash; @@ -22,34 +23,17 @@ * * @author Alexandre Salomé * @author Julien DIDIER + * + * @implements \IteratorAggregate */ final class ReferenceBag implements \Countable, \IteratorAggregate { /** - * Associative array of fullname references. - * - * @var array - */ - private array $references = []; - - /** - * List with all tags. + * The references, by full name: null until read, and after every write. * - * @var Tag[] + * @var array|null */ - private array $tags = []; - - /** - * List with all branches. - * - * @var Branch[] - */ - private array $branches = []; - - /** - * A boolean indicating if the bag is already initialized. - */ - private bool $initialized = false; + private ?array $references = null; public function __construct( private readonly Repository $repository, @@ -63,20 +47,12 @@ public function __construct( */ public function get(string $fullname): Reference { - $this->initialize(); - - if (!isset($this->references[$fullname])) { - throw new ReferenceNotFoundException($fullname); - } - - return $this->references[$fullname]; + return $this->references()[$fullname] ?? throw new ReferenceNotFoundException(\sprintf('The reference "%s" does not exist.', $fullname)); } public function has(string $fullname): bool { - $this->initialize(); - - return isset($this->references[$fullname]); + return isset($this->references()[$fullname]); } /** @@ -85,45 +61,50 @@ public function has(string $fullname): bool * @param T $reference * * @return T + * + * @deprecated since gitonomy/gitlib 2.0, use Branch::moveTo() instead */ public function update(Reference $reference): Reference { - $fullname = $reference->getFullname(); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::moveTo()" instead.', __METHOD__, Branch::class); - $this->initialize(); - $this->repository->getRepositoryDriver()->updateReference($fullname, $reference->getCommitHash()); - - $this->references[$fullname] = $reference; + $this->repository->getRepositoryDriver()->updateReference($reference->getFullname(), $reference->getCommitHash()); + $this->invalidate(); return $reference; } - public function createBranch(string $name, string $commitHash): Branch + /** + * @throws InvalidArgumentException The branch exists already + */ + public function createBranch(string $name, Revision|string $revision): Branch { - $branch = new Branch($this->repository, 'refs/heads/'.$name, $commitHash); + $this->create('refs/heads/'.$name, $revision); - return $this->update($branch); + return $this->getBranch($name); } - public function createTag(string $name, string $commitHash): Tag + /** + * Creates a lightweight tag. + * + * @throws InvalidArgumentException The tag exists already + */ + public function createTag(string $name, Revision|string $revision): Tag { - $tag = new Tag($this->repository, 'refs/tags/'.$name, $commitHash); + $this->create('refs/tags/'.$name, $revision); - return $this->update($tag); + return $this->getTag($name); } public function delete(string $fullname): void { $this->repository->getRepositoryDriver()->deleteReference($fullname); - - unset($this->references[$fullname]); + $this->invalidate(); } public function hasBranches(): bool { - $this->initialize(); - - return \count($this->branches) > 0; + return [] !== $this->getBranches(); } public function hasBranch(string $name): bool @@ -141,74 +122,39 @@ public function hasTag(string $name): bool return $this->has('refs/tags/'.$name); } - public function getFirstBranch(): Reference|false + public function getFirstBranch(): ?Branch { - $this->initialize(); - - return reset($this->branches); + return $this->getBranches()[0] ?? null; } /** - * @return Tag[] An array of Tag objects + * @return Tag[] The tags pointing to $commit, annotated or not */ - public function resolveTags(Commit|string $hash): array + public function resolveTags(Commit|string $commit): array { - $this->initialize(); - - if ($hash instanceof Commit) { - $hash = $hash->getHash(); - } - - $tags = []; - foreach ($this->references as $reference) { - if ($reference instanceof Tag && $reference->getCommitHash() === $hash) { - $tags[] = $reference; - } - } - - return $tags; + return array_values(array_filter($this->resolve($commit), static fn (Reference $reference): bool => $reference instanceof Tag)); } /** - * @return Branch[] An array of Branch objects + * @return Branch[] The branches pointing to $commit */ - public function resolveBranches(Commit|string $hash): array + public function resolveBranches(Commit|string $commit): array { - $this->initialize(); - - if ($hash instanceof Commit) { - $hash = $hash->getHash(); - } - - $branches = []; - foreach ($this->references as $reference) { - if ($reference instanceof Branch && $reference->getCommitHash() === $hash) { - $branches[] = $reference; - } - } - - return $branches; + return array_values(array_filter($this->resolve($commit), static fn (Reference $reference): bool => $reference instanceof Branch)); } /** - * @return Reference[] An array of references + * @return Reference[] The references pointing to $commit, through annotated tags */ - public function resolve(Commit|string $hash): array + public function resolve(Commit|string $commit): array { - $this->initialize(); + $hash = match (true) { + $commit instanceof Commit => $commit->getHash(), + 1 === preg_match('/^[0-9a-f]{40}$/', $commit) => $commit, + default => $this->repository->getRevision($commit)->getCommit()->getHash(), + }; - if ($hash instanceof Commit) { - $hash = $hash->getHash(); - } - - $result = []; - foreach ($this->references as $k => $reference) { - if ($reference->getCommitHash() === $hash) { - $result[] = $reference; - } - } - - return $result; + return array_values(array_filter($this->references(), static fn (Reference $reference): bool => $reference->getCommitHash() === $hash)); } /** @@ -216,9 +162,7 @@ public function resolve(Commit|string $hash): array */ public function getTags(): array { - $this->initialize(); - - return $this->tags; + return array_values(array_filter($this->references(), static fn (Reference $reference): bool => $reference instanceof Tag)); } /** @@ -226,16 +170,7 @@ public function getTags(): array */ public function getBranches(): array { - $this->initialize(); - - $result = []; - foreach ($this->references as $reference) { - if ($reference instanceof Branch) { - $result[] = $reference; - } - } - - return $result; + return array_values(array_filter($this->references(), static fn (Reference $reference): bool => $reference instanceof Branch)); } /** @@ -243,14 +178,7 @@ public function getBranches(): array */ public function getLocalBranches(): array { - $result = []; - foreach ($this->getBranches() as $branch) { - if ($branch->isLocal()) { - $result[] = $branch; - } - } - - return $result; + return array_values(array_filter($this->getBranches(), static fn (Branch $branch): bool => $branch->isLocal())); } /** @@ -258,14 +186,7 @@ public function getLocalBranches(): array */ public function getRemoteBranches(): array { - $result = []; - foreach ($this->getBranches() as $branch) { - if ($branch->isRemote()) { - $result[] = $branch; - } - } - - return $result; + return array_values(array_filter($this->getBranches(), static fn (Branch $branch): bool => $branch->isRemote())); } /** @@ -273,29 +194,21 @@ public function getRemoteBranches(): array */ public function getAll(): array { - $this->initialize(); - - return $this->references; + return $this->references(); } public function getTag(string $name): Tag { - $this->initialize(); - return $this->getAs('refs/tags/'.$name, Tag::class); } public function getBranch(string $name): Branch { - $this->initialize(); - return $this->getAs('refs/heads/'.$name, Branch::class); } public function getRemoteBranch(string $name): Branch { - $this->initialize(); - return $this->getAs('refs/remotes/'.$name, Branch::class); } @@ -304,19 +217,42 @@ public function getRemoteBranch(string $name): Branch */ public function count(): int { - $this->initialize(); - - return \count($this->references); + return \count($this->references()); } /** * @see IteratorAggregate + * + * @return \ArrayIterator */ public function getIterator(): \ArrayIterator { - $this->initialize(); + return new \ArrayIterator($this->references()); + } + + /** + * Reads the references again when next needed: gitlib calls it after + * every write, through this bag or not (checkout, merge, run()...). + * + * @internal + */ + public function invalidate(): void + { + $this->references = null; + } - return new \ArrayIterator($this->references); + /** + * @throws InvalidArgumentException $fullname exists already + */ + private function create(string $fullname, Revision|string $revision): void + { + if ($this->has($fullname)) { + throw new InvalidArgumentException(\sprintf('The reference "%s" exists already.', $fullname)); + } + + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); + $this->repository->getRepositoryDriver()->createReference($fullname, $hash); + $this->invalidate(); } /** @@ -331,38 +267,38 @@ private function getAs(string $fullname, string $class): Reference $reference = $this->get($fullname); if (!$reference instanceof $class) { - throw new ReferenceNotFoundException($fullname); + throw new ReferenceNotFoundException(\sprintf('The reference "%s" does not exist.', $fullname)); } return $reference; } - private function initialize(): void + /** + * @return array + */ + private function references(): array { - if (true === $this->initialized) { - return; + if (null !== $this->references) { + return $this->references; } - $this->initialized = true; - foreach ($this->repository->getRepositoryDriver()->getReferences() as $reference) { - $commitHash = $reference->commitHash; - $fullname = $reference->fullname; + $references = []; + foreach ($this->repository->getRepositoryDriver()->getReferences() as $data) { + $fullname = $data->fullname; - if (preg_match('#^refs/(heads|remotes)/(.*)$#', $fullname)) { - if (preg_match('#.*HEAD$#', $fullname)) { + if (str_starts_with($fullname, 'refs/heads/') || str_starts_with($fullname, 'refs/remotes/')) { + // The default branch of a remote, a symbolic reference to one of its branches; a remote may be named "foo/bar". + if (str_ends_with($fullname, '/HEAD') && str_starts_with($fullname, 'refs/remotes/')) { continue; } - $reference = new Branch($this->repository, $fullname, $commitHash); - $this->references[$fullname] = $reference; - $this->branches[] = $reference; - } elseif (preg_match('#^refs/tags/(.*)$#', $fullname)) { - $reference = new Tag($this->repository, $fullname, $commitHash); - $this->references[$fullname] = $reference; - $this->tags[] = $reference; + $references[$fullname] = new Branch($this->repository, $fullname, $data->commitHash); + } elseif (str_starts_with($fullname, 'refs/tags/')) { + $references[$fullname] = new Tag($this->repository, $fullname, $data->commitHash, $data->tagHash); } elseif ('refs/stash' === $fullname) { - $reference = new Stash($this->repository, $fullname, $commitHash); - $this->references[$fullname] = $reference; + $references[$fullname] = new Stash($this->repository, $fullname, $data->commitHash); } } + + return $this->references = $references; } } diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 8cbe1e63..cc599e7e 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -16,13 +16,16 @@ use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Driver\Cli\CliRepositoryDriver; use Gitonomy\Git\Driver\Cli\LegacyOptions; +use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\RepositoryDriverInterface; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\WeakCache; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; @@ -33,7 +36,7 @@ * * @author Alexandre Salomé * - * @phpstan-type Options array{command?: string, debug?: bool, environment_variables?: array, inherit_environment_variables?: bool, logger?: LoggerInterface|null, process_timeout?: float|null, working_dir?: string|null} + * @phpstan-import-type Options from LegacyOptions */ final class Repository { @@ -50,40 +53,35 @@ final class Repository private readonly ?string $workingDir; /** - * Cache of commit objects, indexed by hash. + * The commits, by hash, as long as the application uses them. * - * @var array + * @var WeakCache */ - private array $commits = []; + private readonly WeakCache $commits; /** - * Cache of tree objects, indexed by hash. + * The trees, by hash, as long as the application uses them. * - * @var array + * @var WeakCache */ - private array $trees = []; + private readonly WeakCache $trees; /** - * Cache of blob objects, indexed by hash. + * The blobs, by hash, as long as the application uses them. * - * @var array + * @var WeakCache */ - private array $blobs = []; + private readonly WeakCache $blobs; /** * Reference bag associated to this repository. */ private ?ReferenceBag $referenceBag = null; - /** - * Logger (can be null). - */ - private ?LoggerInterface $logger; - /** * Backend used to run operations. */ - private readonly DriverInterface $driver; + private DriverInterface $driver; /** * Operations on this repository, opened by the driver. @@ -91,69 +89,47 @@ final class Repository private RepositoryDriverInterface $repositoryDriver; /** - * Constructs a new repository. - * - * Available options are: - * - * * working_dir : specify where working copy is located (option --work-tree) - * - * * logger : a logger to use for logging actions (Psr\Log\LoggerInterface) + * Opens the repository in $dir: a bare repository, or a working tree with + * its ".git" directory or file (a linked worktree, a submodule...). * - * The command, environment_variables, inherit_environment_variables and - * process_timeout options of 1.x are deprecated: they configure a - * CliDriver, as the arguments of its constructor. + * An array of options as second argument is deprecated: its "working_dir" + * option is the $workingDir argument, and its "logger", "command", + * "environment_variables", "inherit_environment_variables" and + * "process_timeout" options configure a CliDriver. * - * @param string $dir path to git repository - * @param Options $options array of options values - * @param DriverInterface $driver backend used to run operations + * @param Options|string|null $workingDir The working tree, when $dir is a git directory kept apart from it + * @param DriverInterface $driver Backend running the operations * - * @throws InvalidArgumentException The folder does not exists, or an option is unknown + * @throws InvalidArgumentException $dir doesn't exist or isn't a repository, or an option is unknown + * @throws DriverException The driver can't open the repository + * @throws UnsupportedOperationException The repository uses SHA-256 hashes */ - public function __construct(string $dir, array $options = [], DriverInterface $driver = new CliDriver()) + public function __construct(string $dir, array|string|null $workingDir = null, DriverInterface $driver = new CliDriver()) { - if (\array_key_exists('debug', $options)) { - if (!$options['debug']) { - throw new InvalidArgumentException('The "debug" option set to false is not supported anymore: a failing git operation always throws, catch its exception instead.'); - } + if (\is_array($workingDir)) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing an array of options to "%s()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.', __METHOD__); - trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing "debug" in the options of "%s()" is deprecated, failing git operations always throw.', __METHOD__); - unset($options['debug']); - } + $options = new LegacyOptions($workingDir); + $optionsDriver = $options->repositoryDriver(); + if (null !== $optionsDriver) { + // The driver is the third argument. + if (\func_num_args() > 2) { + throw new InvalidArgumentException('The driver argument can\'t be combined with the "logger" or git options: configure the driver instead.'); + } - $gitOptions = LegacyOptions::find($options); - if ([] !== $gitOptions) { - // The driver is the third argument. - if (\func_num_args() > 2) { - throw new InvalidArgumentException(\sprintf('The driver argument can\'t be combined with "%s" in the options: configure the driver instead.', implode('", "', $gitOptions))); + $driver = $optionsDriver; } - trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing "%s" in the options of "%s()" is deprecated, pass a "%s" as driver argument instead.', implode('", "', $gitOptions), __METHOD__, CliDriver::class); - $driver = LegacyOptions::createDriver($options); - $options = LegacyOptions::remove($options); - } - - $unknownOptions = array_diff(array_keys($options), ['logger', 'working_dir']); - if ([] !== $unknownOptions) { - throw new InvalidArgumentException(\sprintf('Unknown options "%s": expected "logger" or "working_dir".', implode('", "', $unknownOptions))); + $workingDir = $options->workingDir; } - $options = array_merge([ - 'working_dir' => null, - 'logger' => null, - ], $options); - - // PHP doesn't check the types of the options, unlike PHPStan. - if (null !== $options['logger'] && !$options['logger'] instanceof LoggerInterface) { // @phpstan-ignore instanceof.alwaysTrue, booleanAnd.alwaysFalse - throw new InvalidArgumentException(\sprintf('Argument "logger" passed to Repository should be a Psr\Log\LoggerInterface. A %s was provided', get_debug_type($options['logger']))); - } - - $this->logger = $options['logger']; - [$this->gitDir, $this->workingDir] = self::resolveDir($dir, $options['working_dir']); - + $this->commits = new WeakCache(); + $this->trees = new WeakCache(); + $this->blobs = new WeakCache(); $this->driver = $driver; - $this->repositoryDriver = $driver->open($this->gitDir, $this->workingDir, $this->logger); - - $this->logger?->debug(\sprintf('Repository created (git dir: "%s", working dir: "%s")', $this->gitDir, $this->workingDir ?? 'none')); + $this->repositoryDriver = $driver->open($dir, $workingDir); + $this->gitDir = $this->repositoryDriver->getGitDir(); + $this->workingDir = $this->repositoryDriver->getWorkingDir(); } /** @@ -161,11 +137,11 @@ public function __construct(string $dir, array $options = [], DriverInterface $d * * @throws DriverException The directory can't be written, or is already a file */ - public static function init(string $path, ?LoggerInterface $logger = null, DriverInterface $driver = new CliDriver()): self + public static function init(string $path, DriverInterface $driver = new CliDriver()): self { $driver->init($path); - return new self($path, ['logger' => $logger], $driver); + return new self($path, null, $driver); } /** @@ -173,11 +149,11 @@ public static function init(string $path, ?LoggerInterface $logger = null, Drive * * @throws DriverException The directory can't be written, or is already a file */ - public static function initBare(string $path, ?LoggerInterface $logger = null, DriverInterface $driver = new CliDriver()): self + public static function initBare(string $path, DriverInterface $driver = new CliDriver()): self { $driver->initBare($path); - return new self($path, ['logger' => $logger], $driver); + return new self($path, null, $driver); } /** @@ -187,11 +163,11 @@ public static function initBare(string $path, ?LoggerInterface $logger = null, D * * @throws DriverException */ - public static function cloneFrom(string $url, string $path, ?string $branch = null, ?LoggerInterface $logger = null, DriverInterface $driver = new CliDriver()): self + public static function cloneFrom(string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self { $driver->cloneFrom($url, $path, $branch); - return new self($path, ['logger' => $logger], $driver); + return new self($path, null, $driver); } /** @@ -201,11 +177,11 @@ public static function cloneFrom(string $url, string $path, ?string $branch = nu * * @throws DriverException */ - public static function cloneBareFrom(string $url, string $path, ?string $branch = null, ?LoggerInterface $logger = null, DriverInterface $driver = new CliDriver()): self + public static function cloneBareFrom(string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self { $driver->cloneBareFrom($url, $path, $branch); - return new self($path, ['logger' => $logger], $driver); + return new self($path, null, $driver); } /** @@ -214,11 +190,11 @@ public static function cloneBareFrom(string $url, string $path, ?string $branch * * @throws DriverException */ - public static function mirrorFrom(string $url, string $path, ?LoggerInterface $logger = null, DriverInterface $driver = new CliDriver()): self + public static function mirrorFrom(string $url, string $path, DriverInterface $driver = new CliDriver()): self { $driver->mirrorFrom($url, $path); - return new self($path, ['logger' => $logger], $driver); + return new self($path, null, $driver); } /** @@ -232,11 +208,11 @@ public static function isValidRepository(string $url, DriverInterface $driver = /** * Checks, without cloning it, that $url is a repository the driver can - * read, and that it has the $branchName branch. + * read, and that it has the $branch branch. */ - public static function isValidRepositoryAndBranch(string $url, string $branchName, DriverInterface $driver = new CliDriver()): bool + public static function isValidRepositoryAndBranch(string $url, string $branch, DriverInterface $driver = new CliDriver()): bool { - return $driver->isValidRepositoryAndBranch($url, $branchName); + return $driver->isValidRepositoryAndBranch($url, $branch); } /** @@ -272,30 +248,19 @@ public function getHeadCommit(): Commit } /** - * @return Reference|Commit current HEAD object + * @return Reference|Commit The branch HEAD points to, or the commit when HEAD is detached * - * @throws RuntimeException Unable to read the HEAD file + * @throws ReferenceNotFoundException HEAD points to a branch without commits yet */ public function getHead(): Reference|Commit { - $file = $this->gitDir.'/HEAD'; + $reference = $this->repositoryDriver->getHeadReference(); - if (!file_exists($file)) { - throw new RuntimeException(\sprintf('Unable to find HEAD file ("%s").', $file)); + if (null === $reference) { + return $this->getCommit($this->repositoryDriver->resolveRevision('HEAD')); } - $content = trim(file_get_contents($file)); - - $this->logger?->debug('HEAD file read: '.$content); - - if (preg_match('/^ref: (.+)$/', $content, $vars)) { - return $this->getReferences()->get($vars[1]); - } - if (preg_match('/^[0-9a-f]{40}$/', $content)) { - return $this->getCommit($content); - } - - throw new RuntimeException(\sprintf('Unexpected HEAD file content (file: %s). Content of file: %s.', $file, $content)); + return $this->getReferences()->get($reference); } public function isHeadDetached(): bool @@ -305,7 +270,7 @@ public function isHeadDetached(): bool public function isHeadAttached(): bool { - return $this->getHead() instanceof Reference; + return null !== $this->repositoryDriver->getHeadReference(); } /** @@ -349,68 +314,122 @@ public function getWorkingCopy(): WorkingCopy } /** - * Returns the reference list associated to the repository. + * Returns the references of the repository, read again after every write + * gitlib makes. Open a new Repository to see the writes of other + * processes. * - * @param bool $reload Reload references from the filesystem + * Passing true, to read them again, is deprecated. */ public function getReferences(bool $reload = false): ReferenceBag { - if (null === $this->referenceBag || $reload) { - $this->referenceBag = new ReferenceBag($this); + $this->referenceBag ??= new ReferenceBag($this); + + if (\func_num_args() > 0) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing an argument to "%s()" is deprecated, gitlib reads the references again after its writes: open a new Repository to see the writes of other processes.', __METHOD__); + } + + if ($reload) { + $this->referenceBag->invalidate(); } return $this->referenceBag; } /** - * Instanciates a commit object or fetches one from the cache. + * Returns a commit, the same object while the application uses it. * - * @param string $hash A commit hash, with a length of 40 + * @param string $hash The full hash of the commit: getRevision()->getCommit() resolves a name + * + * @throws ReferenceNotFoundException $hash isn't a full hash */ public function getCommit(string $hash): Commit { - if (!isset($this->commits[$hash])) { - $this->commits[$hash] = new Commit($this, $hash); + $commit = $this->commits->get($hash); + if (null === $commit) { + $commit = new Commit($this, $hash); + $this->commits->set($hash, $commit); } - return $this->commits[$hash]; + return $commit; } /** - * Instanciates a tree object or fetches one from the cache. + * Returns a tree, the same object while the application uses it. + * + * @param string $hash The hash of the tree, abbreviated or not * - * @param string $hash A tree hash, with a length of 40 + * @throws ReferenceNotFoundException $hash isn't a hash, or names nothing */ public function getTree(string $hash): Tree { - if (!isset($this->trees[$hash])) { - $this->trees[$hash] = new Tree($this, $hash); + $hash = $this->fullHash($hash, ObjectType::Tree); + $tree = $this->trees->get($hash); + if (null === $tree) { + $tree = new Tree($this, $hash); + $this->trees->set($hash, $tree); } - return $this->trees[$hash]; + return $tree; } /** - * Instanciates a blob object or fetches one from the cache. + * Returns a blob, the same object while the application uses it. + * + * @param string $hash The hash of the blob, abbreviated or not * - * @param string $hash A blob hash, with a length of 40 + * @throws ReferenceNotFoundException $hash isn't a hash, or names nothing */ public function getBlob(string $hash): Blob { - if (!isset($this->blobs[$hash])) { - $this->blobs[$hash] = new Blob($this, $hash); + $hash = $this->fullHash($hash, ObjectType::Blob); + $blob = $this->blobs->get($hash); + if (null === $blob) { + $blob = new Blob($this, $hash); + $this->blobs->set($hash, $blob); } - return $this->blobs[$hash]; + return $blob; } - public function getBlame(string|Revision $revision, string $file, ?string $lineRange = null): Blame + /** + * Returns the best common ancestor of two commits, the one "git diff a...b" compares to. + * + * @return Commit|null Null when they have no common history + */ + public function getMergeBase(Revision|string $one, Revision|string $two): ?Commit { - if (\is_string($revision)) { - $revision = $this->getRevision($revision); + $hash = $this->repositoryDriver->getMergeBases($this->commitHash($one), $this->commitHash($two))[0] ?? null; + + return null === $hash ? null : $this->getCommit($hash); + } + + /** + * Returns the commits that last changed the lines of $file. + * + * A string as third argument, "," or ",+" like + * "git blame -L", is deprecated. + * + * @param int|string|null $firstLine The first line to blame, from 1, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null + */ + public function getBlame(Revision|string $revision, string $file, int|string|null $firstLine = null, ?int $lastLine = null): Blame + { + if (\is_string($firstLine)) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing a range of lines as a string to "%s()" is deprecated, pass the first and the last lines instead.', __METHOD__); + + if (!preg_match('/^(\d+),(\+?)(\d*)$/', $firstLine, $vars)) { + throw new InvalidArgumentException(\sprintf('Unsupported range of lines "%s": pass the first and the last lines instead.', $firstLine)); + } + + $firstLine = (int) $vars[1]; + $lastLine = match (true) { + '' === $vars[3] => null, + '+' === $vars[2] => $firstLine + (int) $vars[3] - 1, + default => (int) $vars[3], + }; } - return new Blame($this, $revision, $file, $lineRange); + return new Blame($this, \is_string($revision) ? $this->getRevision($revision) : $revision, $file, $firstLine, $lastLine); } /** @@ -430,7 +449,38 @@ public function getLog(RevisionList|Revision|string|array|null $revisions = null return new Log($this, $revisions, $paths, $offset, $limit); } - public function getDiff(RevisionList|Revision|string|array|null $revisions): Diff + /** + * Returns the changes from the commit $from to the commit $to. + * + * Passing one argument is deprecated: an "a..b" or "a...b" range, two + * revisions in an array or a RevisionList, or one revision to compare + * with the working tree, like WorkingCopy::getDiff() does. + * + * @param RevisionList|Revision|string|array|null $from + */ + public function getDiff(RevisionList|Revision|string|array|null $from, Revision|string|null $to = null): Diff + { + if (null === $to) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Calling "%s()" with one argument is deprecated, pass the revisions to compare as "from" and "to" arguments, or use "%s::getDiff()" to compare a revision with the working tree.', __METHOD__, WorkingCopy::class); + + return $this->getLegacyDiff($from); + } + + if (\is_array($from) || $from instanceof RevisionList || null === $from) { + throw new InvalidArgumentException('The "from" argument of "Repository::getDiff()" must be a revision, when "to" is given.'); + } + + return Diff::fromData($this->repositoryDriver->getDiff($this->commitHash($from), $this->commitHash($to)), $this); + } + + /** + * For the deprecated forms of getDiff() and Log::getDiff(), to be removed in 3.0. + * + * @param RevisionList|Revision|string|array|null $revisions + * + * @internal + */ + public function getLegacyDiff(RevisionList|Revision|string|array|null $revisions): Diff { if (null === $revisions) { throw new InvalidArgumentException('Cannot compute a diff without revisions.'); @@ -439,11 +489,31 @@ public function getDiff(RevisionList|Revision|string|array|null $revisions): Dif if (!$revisions instanceof RevisionList) { $revisions = new RevisionList($this, $revisions); } + $revisions = $revisions->getAsTextArray(); - $diff = $this->repositoryDriver->getDiff($revisions->getAsTextArray()); - $diff->setRepository($this); + if (2 === \count($revisions)) { + return $this->getDiff($revisions[0], $revisions[1]); + } - return $diff; + if (1 === \count($revisions) && str_contains($revisions[0], '..')) { + $symmetric = str_contains($revisions[0], '...'); + [$from, $to] = explode($symmetric ? '...' : '..', $revisions[0], 2); + $from = '' === $from ? 'HEAD' : $from; + $to = '' === $to ? 'HEAD' : $to; + + if ($symmetric) { + // Like git diff: from the best common ancestor. + $from = $this->getMergeBase($from, $to) ?? throw new InvalidArgumentException(\sprintf('The revisions of "%s" have no common ancestor.', $revisions[0])); + } + + return $this->getDiff($from, $to); + } + + if (1 === \count($revisions)) { + return $this->getWorkingCopy()->getDiff($revisions[0]); + } + + throw new InvalidArgumentException(\sprintf('Cannot compute a diff of "%s".', implode('", "', $revisions))); } /** @@ -454,24 +524,25 @@ public function getDiff(RevisionList|Revision|string|array|null $revisions): Dif public function getSize(): int { $totalBytes = 0; - $path = realpath($this->gitDir); - if ($path && file_exists($path)) { - /** @var \SplFileInfo $object */ - foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)) as $object) { - $totalBytes += $object->getSize(); - } + /** @var \SplFileInfo $file */ + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->gitDir, \FilesystemIterator::SKIP_DOTS)) as $file) { + $totalBytes += $file->getSize(); } - return (int) ($totalBytes / 1000 + 0.5); + return (int) round($totalBytes / 1000); } /** * Executes a shell command on the repository, using PHP pipes. * * @param string $command The command to execute + * + * @deprecated since gitonomy/gitlib 2.0, run "git shell" with Symfony Process instead */ public function shell(string $command, array $env = []): void { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, run "git shell" with Symfony Process instead: it ignores the driver.', __METHOD__); + $argument = \sprintf('%s %s', $command, escapeshellarg($this->gitDir)); $prefix = ''; @@ -498,19 +569,17 @@ public function getHooks(): Hooks public function getDescription(): string { $file = $this->gitDir.'/description'; - $exists = is_file($file); - if (false === $exists) { - $this->logger?->debug(\sprintf('no description file in repository ("%s")', $file)); - } else { - $this->logger?->debug(\sprintf('reading description file in repository ("%s")', $file)); + if (!is_file($file)) { + return self::DEFAULT_DESCRIPTION; } - if (false === $exists) { - return self::DEFAULT_DESCRIPTION; + $description = file_get_contents($file); + if (false === $description) { + throw new RuntimeException(\sprintf('Unable to read "%s".', $file)); } - return file_get_contents($this->gitDir.'/description'); + return $description; } /** @@ -528,10 +597,9 @@ public function hasDescription(): bool */ public function setDescription(string $description): static { - $file = $this->gitDir.'/description'; - - $this->logger?->debug(\sprintf('change description file content to "%s" (file: %s)', $description, $file)); - file_put_contents($file, $description); + if (false === file_put_contents($this->gitDir.'/description', $description)) { + throw new RuntimeException(\sprintf('Unable to write the description of "%s".', $this->gitDir)); + } return $this; } @@ -578,30 +646,47 @@ public function runProcess(string $command, array $args = []): Process throw new UnsupportedOperationException('Only CliDriver can run git commands.'); } - return $this->repositoryDriver->runProcess($command, $args); + try { + return $this->repositoryDriver->runProcess($command, $args); + } finally { + // Any command may write references, even a failing one. + $this->referenceBag?->invalidate(); + } } /** * Set repository logger. * - * @param LoggerInterface $logger A logger - * * @return $this + * + * @throws UnsupportedOperationException The driver isn't a CliDriver + * + * @deprecated since gitonomy/gitlib 2.0, pass a CliDriver configured with the logger as driver argument instead */ public function setLogger(LoggerInterface $logger): static { - $this->logger = $logger; - $this->repositoryDriver = $this->driver->open($this->gitDir, $this->workingDir, $logger); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, pass a "%s" configured with the logger as driver argument instead.', __METHOD__, CliDriver::class); + + if (!$this->driver instanceof CliDriver) { + throw new UnsupportedOperationException('Only CliDriver logs the git commands it runs.'); + } + + $this->driver = $this->driver->withLogger($logger); + $this->repositoryDriver = $this->driver->open($this->gitDir, $this->workingDir); return $this; } /** - * Returns repository logger. + * Returns the logger of the CliDriver. + * + * @deprecated since gitonomy/gitlib 2.0, the logger is an argument of CliDriver */ public function getLogger(): ?LoggerInterface { - return $this->logger; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, the logger is an argument of "%s".', __METHOD__, CliDriver::class); + + return $this->driver instanceof CliDriver ? $this->driver->getLogger() : null; } /** @@ -622,39 +707,44 @@ public function cloneTo(string $path, bool $bare = true, array $options = []): s trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" with "getGitDir()" as URL instead.', __METHOD__, self::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); // Like in 1.x, the options configuring git apply to the clone. - $driver = [] === LegacyOptions::find($options) ? $this->driver : LegacyOptions::createDriver($options); + $options = new LegacyOptions($options); + $driver = $options->adminDriver() ?? $this->driver; if ($bare) { $driver->cloneBareFrom($this->gitDir, $path, null); } else { $driver->cloneFrom($this->gitDir, $path, null); } - return new self($path, LegacyOptions::remove($options), $driver); + return new self($path, $options->workingDir, $options->repositoryDriver() ?? $driver); + } + + private function commitHash(Revision|string $revision): string + { + return ($revision instanceof Revision ? $revision : $this->getRevision($revision))->getCommit()->getHash(); } /** - * Resolves directory attributes for the repository. - * - * @param string $gitDir directory of a working copy with files checked out - * @param string|null $workingDir directory containing git files (objects, config...) + * @param ObjectType $type The type of the object, which an abbreviated hash must name * - * @return array{string, string|null} + * @throws ReferenceNotFoundException $hash isn't a hash, or names no object of this type */ - private static function resolveDir(string $gitDir, ?string $workingDir = null): array + private function fullHash(string $hash, ObjectType $type): string { - $realGitDir = realpath($gitDir); - - if (false === $realGitDir) { - throw new InvalidArgumentException(\sprintf('Directory "%s" does not exist or is not a directory', $gitDir)); + if (!preg_match('/^[0-9a-f]{4,40}$/', $hash)) { + throw new ReferenceNotFoundException(\sprintf('"%s" is not a hash.', $hash)); } - if (!is_dir($realGitDir)) { - throw new InvalidArgumentException(\sprintf('Directory "%s" does not exist or is not a directory', $realGitDir)); + + // The type of an object of a full hash is checked when it is read. + if (40 === \strlen($hash)) { + return $hash; } - if (null === $workingDir && is_dir($realGitDir.'/.git')) { - $workingDir = $realGitDir; - $realGitDir .= '/.git'; + + $fullHash = $this->repositoryDriver->resolveRevision($hash.'^{'.$type->value.'}'); + // "^{tree}" also peels a commit to its tree. + if (!str_starts_with($fullHash, $hash)) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value)); } - return [$realGitDir, $workingDir]; + return $fullHash; } } diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index b6c831b6..15430584 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -12,6 +12,8 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\ReferenceNotFoundException; + /** * @author Alexandre Salomé */ @@ -29,11 +31,19 @@ public function getLog(array|string|null $paths = null, ?int $offset = null, ?in } /** - * Returns the last modification date of the reference. + * Returns the commit the revision names. + * + * @throws ReferenceNotFoundException The revision names no commit */ public function getCommit(): Commit { - return $this->getLog()->getSingleCommit(); + try { + $hash = $this->repository->getRepositoryDriver()->resolveRevision($this->revision.'^{commit}'); + } catch (ReferenceNotFoundException $e) { + throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist, or names no commit.', $this->revision), 0, $e); + } + + return $this->repository->getCommit($hash); } public function getRevision(): string diff --git a/src/Gitonomy/Git/RevisionList.php b/src/Gitonomy/Git/RevisionList.php index 609d354d..c31c0972 100644 --- a/src/Gitonomy/Git/RevisionList.php +++ b/src/Gitonomy/Git/RevisionList.php @@ -12,6 +12,8 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\InvalidArgumentException; + /** * @author Alexandre Salomé */ @@ -35,8 +37,8 @@ public function __construct(Repository $repository, string|Revision|array $revis $revisions = [$revisions]; } - if (0 == \count($revisions)) { - throw new \InvalidArgumentException('Empty revision list not allowed'); + if ([] === $revisions) { + throw new InvalidArgumentException('A revision list needs one revision at least.'); } $normalizedRevisions = []; @@ -44,7 +46,7 @@ public function __construct(Repository $repository, string|Revision|array $revis if (\is_string($revision)) { $revision = new Revision($repository, $revision); } elseif (!$revision instanceof Revision) { - throw new \InvalidArgumentException(\sprintf('Expected a "Revision", got a "%s".', \is_object($revision) ? $revision::class : \gettype($revision))); + throw new InvalidArgumentException(\sprintf('Expected a "Revision", got a "%s".', get_debug_type($revision))); } $normalizedRevisions[] = $revision; diff --git a/src/Gitonomy/Git/Submodule.php b/src/Gitonomy/Git/Submodule.php new file mode 100644 index 00000000..92990d2a --- /dev/null +++ b/src/Gitonomy/Git/Submodule.php @@ -0,0 +1,38 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git; + +/** + * A submodule in a tree: the commit of another repository it points to. + * + * @author Grégoire Pineau + */ +final readonly class Submodule +{ + public function __construct(private string $hash) + { + } + + /** + * @return string The commit of the submodule, in its own repository + */ + public function getHash(): string + { + return $this->hash; + } +} + +// For the deprecated name, like Symfony renames classes. +if (!class_exists(CommitReference::class, false)) { + class_alias(Submodule::class, CommitReference::class); +} diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index 96c66071..2eb8e17b 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -12,26 +12,18 @@ namespace Gitonomy\Git; -use Gitonomy\Git\Driver\Data\TreeEntryType; +use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\UnexpectedValueException; /** * @author Alexandre Salomé */ final class Tree { - private bool $isInitialized = false; - /** - * @var array + * @var array|null By name, null until read */ - private array $entries; - - /** - * @var array{blob: array, tree: array, commit: array} - */ - private array $entriesByType; + private ?array $entries = null; public function __construct( private readonly Repository $repository, @@ -45,108 +37,90 @@ public function getHash(): string } /** - * @return array An associative array name => $object + * @return array By name */ public function getEntries(): array { - $this->initialize(); + if (null !== $this->entries) { + return $this->entries; + } + + $this->entries = []; + foreach ($this->repository->getRepositoryDriver()->getTreeEntries($this->hash) as $data) { + $object = match ($data->type) { + ObjectType::Blob => $this->repository->getBlob($data->hash), + ObjectType::Tree => $this->repository->getTree($data->hash), + // A commit in a tree is a submodule. + default => new Submodule($data->hash), + }; + $this->entries[$data->name] = new TreeEntry($data->name, $data->mode, $object); + } return $this->entries; } /** - * @return array An associative array of name => [mode, commit reference] + * @return array The submodules, by name + */ + public function getSubmoduleEntries(): array + { + return array_filter($this->getEntries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof Submodule); + } + + /** + * @return array The submodules, by name + * + * @deprecated since gitonomy/gitlib 2.0, use getSubmoduleEntries() instead */ public function getCommitReferenceEntries(): array { - $this->initialize(); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getSubmoduleEntries()" instead.', __METHOD__); - return $this->entriesByType['commit']; + return $this->getSubmoduleEntries(); } /** - * @return array An associative array of name => [mode, tree] + * @return array The trees, by name */ public function getTreeEntries(): array { - $this->initialize(); - - return $this->entriesByType['tree']; + return array_filter($this->getEntries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof self); } /** - * @return array An associative array of name => [mode, blob] + * @return array The blobs, by name */ public function getBlobEntries(): array { - $this->initialize(); - - return $this->entriesByType['blob']; + return array_filter($this->getEntries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof Blob); } - public function getEntry(string $name): CommitReference|self|Blob + /** + * @throws InvalidArgumentException The tree has no entry $name + */ + public function getEntry(string $name): Submodule|self|Blob { - $this->initialize(); - - if (!isset($this->entries[$name])) { - throw new InvalidArgumentException('No entry '.$name); - } - - return $this->entries[$name][1]; + return ($this->getEntries()[$name] ?? throw new InvalidArgumentException(\sprintf('The tree "%s" has no entry "%s".', $this->hash, $name)))->getObject(); } - public function resolvePath(string $path): CommitReference|self|Blob + /** + * @param string $path Relative to the tree, "" or "/" for the tree itself + * + * @throws InvalidArgumentException $path doesn't exist in the tree + */ + public function resolvePath(string $path): Submodule|self|Blob { - if ('' == $path) { - return $this; - } - - $path = preg_replace('#^/#', '', $path); - - $segments = explode('/', $path); $element = $this; - foreach ($segments as $segment) { - if ($element instanceof self) { - $element = $element->getEntry($segment); - } elseif ($element instanceof Blob) { - throw new InvalidArgumentException('Unresolvable path'); - } else { - throw new UnexpectedValueException('Unknow type of element'); + $resolved = []; + foreach (array_filter(explode('/', $path), static fn (string $segment): bool => '' !== $segment) as $segment) { + if (!$element instanceof self) { + throw new InvalidArgumentException(\sprintf('Unable to resolve "%s": "%s" is a %s, not a tree.', $path, implode('/', $resolved), $element instanceof Blob ? 'file' : 'submodule')); } - } - - return $element; - } - private function initialize(): void - { - if (true === $this->isInitialized) { - return; + $resolved[] = $segment; + $element = ($element->getEntries()[$segment] ?? throw new InvalidArgumentException(\sprintf('Unable to resolve "%s": "%s" doesn\'t exist.', $path, implode('/', $resolved))))->getObject(); } - $entries = $this->repository->getRepositoryDriver()->getTreeEntries($this->hash); - - $this->entries = []; - $this->entriesByType = [ - 'blob' => [], - 'tree' => [], - 'commit' => [], - ]; - - foreach ($entries as $entry) { - if (TreeEntryType::Blob === $entry->type) { - $treeEntry = [$entry->mode, $this->repository->getBlob($entry->hash)]; - $this->entriesByType['blob'][$entry->name] = $treeEntry; - } elseif (TreeEntryType::Tree === $entry->type) { - $treeEntry = [$entry->mode, $this->repository->getTree($entry->hash)]; - $this->entriesByType['tree'][$entry->name] = $treeEntry; - } else { - $treeEntry = [$entry->mode, new CommitReference($entry->hash)]; - $this->entriesByType['commit'][$entry->name] = $treeEntry; - } - $this->entries[$entry->name] = $treeEntry; - } - - $this->isInitialized = true; + return $element; } } diff --git a/src/Gitonomy/Git/TreeEntry.php b/src/Gitonomy/Git/TreeEntry.php new file mode 100644 index 00000000..d4dd8daa --- /dev/null +++ b/src/Gitonomy/Git/TreeEntry.php @@ -0,0 +1,82 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; + +/** + * An entry of a tree: a blob, a tree or a submodule, with its name and mode. + * + * Reading it as an array, [$mode, $object], is deprecated. + * + * @author Grégoire Pineau + * + * @implements \ArrayAccess + */ +final readonly class TreeEntry implements \ArrayAccess +{ + public function __construct( + private string $name, + private string $mode, + private Blob|Tree|Submodule $object, + ) { + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return string The mode git stores, like "100644" for a file or "040000" for a tree + */ + public function getMode(): string + { + return $this->mode; + } + + public function getObject(): Blob|Tree|Submodule + { + return $this->object; + } + + public function offsetExists(mixed $offset): bool + { + return 0 === $offset || 1 === $offset; + } + + /** + * @deprecated since gitonomy/gitlib 2.0, use getMode() and getObject() instead + */ + public function offsetGet(mixed $offset): string|Blob|Tree|Submodule + { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Reading a "%s" as an array is deprecated, use its "getMode()" and "getObject()" methods instead.', self::class); + + return match ($offset) { + 0 => $this->mode, + 1 => $this->object, + default => throw new InvalidArgumentException(\sprintf('A tree entry has no offset %s.', var_export($offset, true))), + }; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new LogicException('A tree entry is immutable.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new LogicException('A tree entry is immutable.'); + } +} diff --git a/src/Gitonomy/Git/Util/Message.php b/src/Gitonomy/Git/Util/Message.php new file mode 100644 index 00000000..7e671b1d --- /dev/null +++ b/src/Gitonomy/Git/Util/Message.php @@ -0,0 +1,39 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * Splits the message of a commit or a tag like git does. + * + * @author Grégoire Pineau + * + * @internal + */ +final class Message +{ + /** + * @return string The first paragraph of $message, on one line, like "git log --format=%s" + */ + public static function subject(string $message): string + { + return implode(' ', array_map(trim(...), explode("\n", trim(explode("\n\n", $message, 2)[0])))); + } + + /** + * @return string What follows the first paragraph of $message and its empty lines, like "git log --format=%b" + */ + public static function body(string $message): string + { + return ltrim(explode("\n\n", trim($message, "\n")."\n", 2)[1] ?? '', "\n"); + } +} diff --git a/src/Gitonomy/Git/Util/NulByte.php b/src/Gitonomy/Git/Util/NulByte.php new file mode 100644 index 00000000..c6b7a8c7 --- /dev/null +++ b/src/Gitonomy/Git/Util/NulByte.php @@ -0,0 +1,38 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * git and libgit2 read their arguments as C strings, which end at the first + * NUL byte: libgit2 would read "a.txt\0.md" as "a.txt". + * + * @author Grégoire Pineau + * + * @internal + */ +final class NulByte +{ + /** + * @throws InvalidArgumentException One of the values contains a NUL byte + */ + public static function assertNone(string ...$values): void + { + foreach ($values as $value) { + if (str_contains($value, "\0")) { + throw new InvalidArgumentException(\sprintf('"%s" contains a NUL byte, which git doesn\'t accept.', addcslashes($value, "\0"))); + } + } + } +} diff --git a/src/Gitonomy/Git/Util/WeakCache.php b/src/Gitonomy/Git/Util/WeakCache.php new file mode 100644 index 00000000..edaf2c20 --- /dev/null +++ b/src/Gitonomy/Git/Util/WeakCache.php @@ -0,0 +1,62 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * Objects by key, as long as the application uses them. + * + * The keys of the objects freed since are removed once the cache has grown + * twice as big: a long-running process reading millions of commits keeps + * only the ones it uses. + * + * @author Grégoire Pineau + * + * @template T of object + * + * @internal + */ +final class WeakCache +{ + private const int MIN_SWEEP_SIZE = 64; + + /** + * @var array> + */ + private array $references = []; + + private int $sweepSize = self::MIN_SWEEP_SIZE; + + /** + * @return T|null + */ + public function get(string $key): ?object + { + return ($this->references[$key] ?? null)?->get(); + } + + /** + * @param T $object + */ + public function set(string $key, object $object): void + { + $this->references[$key] = \WeakReference::create($object); + + if (\count($this->references) < $this->sweepSize) { + return; + } + + $this->references = array_filter($this->references, static fn (\WeakReference $reference): bool => null !== $reference->get()); + // Each sweep at least halves the cache, or waits for it to double: constant time on average. + $this->sweepSize = max(self::MIN_SWEEP_SIZE, 2 * \count($this->references)); + } +} diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index 61f87f18..988004ba 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -15,6 +15,7 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Reference\Branch; /** * @author Alexandre Salomé @@ -25,7 +26,7 @@ public function __construct( private Repository $repository, ) { if ($this->repository->isBare()) { - throw new LogicException('Can\'t create a working copy on a bare repository'); + throw new LogicException('A bare repository has no working copy.'); } } @@ -37,31 +38,66 @@ public function getUntrackedFiles(): array return $this->repository->getRepositoryDriver()->getUntrackedFiles(); } - public function getDiffPending(): Diff + /** + * Returns the changes of the working tree, staged or not, since $revision. + */ + public function getDiff(Revision|string $revision = 'HEAD'): Diff { - $diff = $this->repository->getRepositoryDriver()->getDiffPending(); - $diff->setRepository($this->repository); + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); - return $diff; + return Diff::fromData($this->repository->getRepositoryDriver()->getWorkingTreeDiff($hash), $this->repository); } + /** + * Returns the changes of the working tree not staged yet. + */ + public function getDiffPending(): Diff + { + return Diff::fromData($this->repository->getRepositoryDriver()->getDiffPending(), $this->repository); + } + + /** + * Returns the changes staged in the index. + */ public function getDiffStaged(): Diff { - $diff = $this->repository->getRepositoryDriver()->getDiffStaged(); - $diff->setRepository($this->repository); + return Diff::fromData($this->repository->getRepositoryDriver()->getDiffStaged(), $this->repository); + } + + /** + * Checks $revision out: HEAD points to it when it is a local branch, to + * its commit otherwise. + * + * Passing a branch to create as second argument is deprecated. + */ + public function checkout(Revision|string $revision, ?string $branch = null): static + { + if (null !== $branch) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing a branch to create to "%s()" is deprecated, use "checkoutNewBranch()" instead.', __METHOD__); + + return $this->checkoutNewBranch($branch, $revision); + } - return $diff; + try { + $this->repository->getRepositoryDriver()->checkout(self::name($revision)); + } finally { + $this->repository->getReferences()->invalidate(); + } + + return $this; } /** - * @param Commit|Reference|string $revision + * Creates the branch $branch at $startPoint, and checks it out. */ - public function checkout($revision, ?string $branch = null): static + public function checkoutNewBranch(string $branch, Revision|string $startPoint = 'HEAD'): static { - if (null === $branch) { - $this->repository->getRepositoryDriver()->checkout($this->revisionToString($revision)); - } else { - $this->repository->getRepositoryDriver()->checkoutNewBranch($branch, $this->revisionToString($revision)); + $startHash = ($startPoint instanceof Revision ? $startPoint : $this->repository->getRevision($startPoint))->getCommit()->getHash(); + + try { + $this->repository->getRepositoryDriver()->checkoutNewBranch($branch, $startHash); + } finally { + $this->repository->getReferences()->invalidate(); } return $this; @@ -70,31 +106,42 @@ public function checkout($revision, ?string $branch = null): static /** * Merges a revision into the current branch. * - * @param Commit|Reference|string $revision - * @param string[] $args extra arguments for the merge command (e.g. ``['--no-ff']``) + * An array of arguments of "git merge" ([], ["--no-ff"] or ["--ff-only"]) is deprecated. + * + * @param FastForward|string[] $fastForward */ - public function merge($revision, array $args = []): static + public function merge(Revision|string $revision, FastForward|array $fastForward = FastForward::Allow): static { - $this->repository->getRepositoryDriver()->merge($this->revisionToString($revision), $args); + if (\is_array($fastForward)) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing arguments of "git merge" to "%s()" is deprecated, pass a "%s" instead.', __METHOD__, FastForward::class); + + $fastForward = match ($fastForward) { + [] => FastForward::Allow, + ['--no-ff'] => FastForward::Never, + ['--ff-only'] => FastForward::Only, + default => throw new InvalidArgumentException(\sprintf('Unsupported arguments "%s": pass a "%s" instead.', implode(' ', $fastForward), FastForward::class)), + }; + } + + try { + $this->repository->getRepositoryDriver()->merge(self::name($revision), $fastForward); + } finally { + $this->repository->getReferences()->invalidate(); + } return $this; } /** - * @param Commit|Reference|string $revision + * @return string The name git checks out or merges: a local branch by its name, to attach HEAD to it */ - private function revisionToString($revision): string + private static function name(Revision|string $revision): string { - if ($revision instanceof Commit) { - $result = $revision->getHash(); - } elseif ($revision instanceof Reference) { - $result = $revision->getFullname(); - } elseif (\is_string($revision)) { - $result = $revision; - } else { - throw new InvalidArgumentException(\sprintf('Unknown type "%s"', \gettype($revision))); - } - - return $result; + return match (true) { + $revision instanceof Branch && $revision->isLocal() => $revision->getName(), + $revision instanceof Reference => $revision->getFullname(), + $revision instanceof Revision => $revision->getRevision(), + default => $revision, + }; } } diff --git a/tests/Gitonomy/Git/Tests/AbstractTestCase.php b/tests/Gitonomy/Git/Tests/AbstractTestCase.php index 3fd1bbd2..d68dc773 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTestCase.php +++ b/tests/Gitonomy/Git/Tests/AbstractTestCase.php @@ -15,10 +15,12 @@ use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\AfterClass; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; abstract class AbstractTestCase extends TestCase @@ -96,10 +98,7 @@ public static function provideBare(): array public static function createFoobarRepository(bool $bare = true): Repository { if (null === self::$localRepository) { - $dir = self::createTempDir(); - // Moved to the directories of the class, kept until its last test. - self::$classTempDirs[] = array_pop(self::$tempDirs); - self::$localRepository = Repository::cloneBareFrom(self::repositoryUrl(), $dir, driver: self::getDriver()); + self::$localRepository = Repository::cloneBareFrom(self::repositoryUrl(), self::createClassTempDir(), driver: self::getDriver()); } $dir = self::createTempDir(); @@ -113,12 +112,7 @@ public static function createFoobarRepository(bool $bare = true): Repository */ public static function createTempDir(): string { - $tmpDir = tempnam(sys_get_temp_dir(), 'gitlib_'); - unlink($tmpDir); - mkdir($tmpDir); - self::$tempDirs[] = $tmpDir; - - return $tmpDir; + return self::$tempDirs[] = self::createDir(); } #[After] @@ -153,10 +147,8 @@ protected static function repositoryUrl(): string } if (null === self::$materializedRepositoryUrl) { - $dir = self::createTempDir(); - self::$classTempDirs[] = array_pop(self::$tempDirs); - $command = $_SERVER['GIT_COMMAND'] ?? 'git'; - $process = new Process([$command, 'clone', '-q', '--bare', self::REPOSITORY_URL, $dir]); + $dir = self::createClassTempDir(); + $process = new Process([self::gitCommand(), 'clone', '-q', '--bare', self::REPOSITORY_URL, $dir]); $process->mustRun(); self::$materializedRepositoryUrl = $dir; } @@ -172,6 +164,7 @@ protected static function deleteDir(string $dir): void // Without FOLLOW_SYMLINKS: a link to a directory is deleted, never what it points to. $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::CHILD_FIRST); + /** @var \SplFileInfo $file */ foreach ($iterator as $file) { if (is_link($file)) { unlink($file); @@ -200,6 +193,17 @@ protected function skipUnlessCliDriver(): void } } + /** + * Returns the branch HEAD points to, failing the test when it is detached. + */ + protected static function headBranch(Repository $repository): Branch + { + $head = $repository->getHead(); + self::assertInstanceOf(Branch::class, $head); + + return $head; + } + /** * Runs a git command on $repository through CliDriver, whatever the * driver under test: for setup (committing, staging, tagging...) that no @@ -211,7 +215,12 @@ protected static function git(Repository $repository, string $command, array $ar { $cli = new Repository($repository->getPath(), driver: self::getCliDriver()); - return $cli->run($command, $args); + try { + return $cli->run($command, $args); + } finally { + // Like a write through $repository itself. + $repository->getReferences()->invalidate(); + } } /** @@ -246,10 +255,62 @@ protected static function getDriver(): DriverInterface /** * @param array $environmentVariables Set on git processes, in addition to the GIT_ENVS environment variable */ - protected static function getCliDriver(array $environmentVariables = [], ?float $processTimeout = 60): CliDriver + protected static function getCliDriver(array $environmentVariables = [], ?float $processTimeout = 60, ?LoggerInterface $logger = null): CliDriver + { + // git 2.29 and later run their maintenance after a commit, in the + // background since 2.46: it would write in the repositories the tests delete. + $envs = [...self::gitEnvs(), 'GIT_CONFIG_COUNT' => '1', 'GIT_CONFIG_KEY_0' => 'maintenance.auto', 'GIT_CONFIG_VALUE_0' => 'false']; + + return new CliDriver(self::gitCommand(), [...$envs, ...$environmentVariables], processTimeout: $processTimeout, logger: $logger); + } + + /** + * @return string The git binary of the tests: the GIT_COMMAND environment variable, or "git" + */ + protected static function gitCommand(): string + { + $command = $_SERVER['GIT_COMMAND'] ?? 'git'; + if (!\is_string($command)) { + throw new \UnexpectedValueException('The GIT_COMMAND environment variable must be a string.'); + } + + return $command; + } + + /** + * @return array The environment variables of git: GIT_ENVS, which the bootstrap sets on Windows + */ + protected static function gitEnvs(): array + { + $envs = []; + $variables = $_SERVER['GIT_ENVS'] ?? []; + if (!\is_array($variables)) { + throw new \UnexpectedValueException('GIT_ENVS must be an array.'); + } + foreach ($variables as $name => $value) { + if (!\is_string($name) || !\is_string($value)) { + throw new \UnexpectedValueException('GIT_ENVS must map names to values.'); + } + $envs[$name] = $value; + } + + return $envs; + } + + /** + * Creates an empty directory, deleted after the last test of the class. + */ + private static function createClassTempDir(): string + { + return self::$classTempDirs[] = self::createDir(); + } + + private static function createDir(): string { - $envs = isset($_SERVER['GIT_ENVS']) ? (array) $_SERVER['GIT_ENVS'] : []; + $dir = tempnam(sys_get_temp_dir(), 'gitlib_') ?: throw new \RuntimeException('Unable to create a temporary file.'); + unlink($dir); + mkdir($dir); - return new CliDriver($_SERVER['GIT_COMMAND'] ?? 'git', [...$envs, ...$environmentVariables], processTimeout: $processTimeout); + return $dir; } } diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index bd27673c..b383fc75 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -141,6 +141,30 @@ public function testCommandOptionRunsAnotherGitBinary(): void Admin::init(self::createTempDir(), options: ['command' => 'not-git']); } + public function testInheritEnvironmentVariablesOnlyAppliesToTheRepository(): void + { + if (\defined('PHP_WINDOWS_VERSION_MAJOR')) { + $this->markTestSkipped('The fake git binary is a shell script.'); + } + + // A git binary logging its first argument, and whether it gets SECRET. + $dir = self::createTempDir(); + file_put_contents($dir.'/git', \sprintf("#!/bin/sh\necho \"\$1 \${GITLIB_TEST_SECRET:-none}\" >> %s\nexec %s \"\$@\"\n", escapeshellarg($dir.'/log'), escapeshellarg(self::gitCommand()))); + chmod($dir.'/git', 0o755); + // Like the credentials of an HTTP request, in $_SERVER only. + $_SERVER['GITLIB_TEST_SECRET'] = 'secret'; + + try { + $repository = Admin::init($dir.'/repository', options: [...self::getOptions(), 'command' => $dir.'/git', 'inherit_environment_variables' => true]); + $repository->run('config', ['core.bare']); + } finally { + unset($_SERVER['GITLIB_TEST_SECRET']); + } + + // Like in 1.x, git init doesn't get $_SERVER, the git processes of the repository do. + $this->assertSame("init none\n--git-dir secret\n", file_get_contents($dir.'/log')); + } + public function testRemovedOptionsAreRefused(): void { $this->expectException(InvalidArgumentException::class); @@ -187,8 +211,8 @@ public function testRepositoryCloneToWithGitOptions(): void private static function getOptions(): array { return [ - 'command' => $_SERVER['GIT_COMMAND'] ?? 'git', - 'environment_variables' => isset($_SERVER['GIT_ENVS']) ? (array) $_SERVER['GIT_ENVS'] : [], + 'command' => self::gitCommand(), + 'environment_variables' => self::gitEnvs(), ]; } } diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index 23647e77..96db58cc 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -12,7 +12,11 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class BlameTest extends AbstractTestCase { @@ -26,22 +30,22 @@ public function testBlame(bool $bare): void $this->assertSame('Foo Bar project', $blame->getLine(1)->getContent()); - $this->assertEquals('alice', $blame->getLine(1)->getCommit()->getAuthorName()); - $this->assertEquals(self::INITIAL_COMMIT, $blame->getLine(1)->getCommit()->getHash()); + $this->assertSame('alice', $blame->getLine(1)->getCommit()->getAuthorName()); + $this->assertSame(self::INITIAL_COMMIT, $blame->getLine(1)->getCommit()->getHash()); - $this->assertEquals('alice', $blame->getLine(5)->getCommit()->getAuthorName()); - $this->assertNotEquals(self::INITIAL_COMMIT, $blame->getLine(5)->getCommit()->getHash()); + $this->assertSame('alice', $blame->getLine(5)->getCommit()->getAuthorName()); + $this->assertNotSame(self::INITIAL_COMMIT, $blame->getLine(5)->getCommit()->getHash()); } #[DataProvider('provideBare')] - public function testGroupedBlame(bool $bare): void + public function testHunks(bool $bare): void { $repository = self::createFoobarRepository($bare); - $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getGroupedLines(); + $hunks = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getHunks(); - $this->assertCount(3, $blame); - - $this->assertEquals(self::INITIAL_COMMIT, $blame[0][0]->getHash()); + $this->assertCount(3, $hunks); + $this->assertSame(self::INITIAL_COMMIT, $hunks[0]->getCommit()->getHash()); + $this->assertSame(range(1, \count($hunks[0]->getLines())), array_keys($hunks[0]->getLines())); } #[DataProvider('provideBare')] @@ -50,15 +54,62 @@ public function testBlameLineRange(bool $bare): void $repository = self::createFoobarRepository($bare); $full = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md'); + $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 3, 5); + + $this->assertCount(3, $blame); + $this->assertSame([3, 4, 5], array_keys($blame->getLines())); + foreach ([3, 4, 5] as $number) { + $this->assertSame($number, $blame->getLine($number)->getNumber()); + $this->assertSame($full->getLine($number)->getOriginalNumber(), $blame->getLine($number)->getOriginalNumber()); + $this->assertSame($full->getLine($number)->getCommit()->getHash(), $blame->getLine($number)->getCommit()->getHash()); + $this->assertSame($full->getLine($number)->getContent(), $blame->getLine($number)->getContent()); + } + $this->assertCount(5, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 3)); + $this->assertCount(2, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', lastLine: 2)); + } + + public function testBlameRefusesAnInvalidRange(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid range of lines "0,3": the lines are numbered from 1.'); + + $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 0, 3); + } + + public function testBlameOfAnInvertedRangeIsTheRangeInOrder(): void + { + $repository = self::createFoobarRepository(); + + // Like git blame -L 5,3. + $this->assertSame([3, 4, 5], array_keys($repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 5, 3)->getLines())); + } + + public function testBlameOfARangeAfterTheEndThrows(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(DriverException::class); + + $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 100)->getLines(); + } + + #[DataProvider('provideBare')] + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyBlameLineRange(bool $bare): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing a range of lines as a string to "Gitonomy\Git\Repository::getBlame()" is deprecated, pass the first and the last lines instead.'); + + $repository = self::createFoobarRepository($bare); + foreach (['3,+3', '3,5'] as $lineRange) { $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', $lineRange); - $this->assertCount(3, $blame, $lineRange); - foreach ([1 => 3, 2 => 4, 3 => 5] as $index => $lineNumber) { - $this->assertSame((string) $lineNumber, $blame->getLine($index)->getLine(), $lineRange); - $this->assertSame($full->getLine($lineNumber)->getCommit()->getHash(), $blame->getLine($index)->getCommit()->getHash(), $lineRange); - $this->assertSame($full->getLine($lineNumber)->getContent(), $blame->getLine($index)->getContent(), $lineRange); - } + $this->assertSame([3, 4, 5], array_keys($blame->getLines()), $lineRange); + $this->assertSame('3', $blame->getLine(3)->getLine(), $lineRange); } + $this->assertSame(self::INITIAL_COMMIT, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getGroupedLines()[0][0]->getHash()); } } diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 8ee69724..8f5c214b 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -13,7 +13,7 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Blob; -use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; @@ -21,21 +21,11 @@ class BlobTest extends AbstractTestCase { public const README_FRAGMENT = 'Foo Bar project'; - public function getReadmeBlob(Repository $repository): Blob - { - return $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('README.md'); - } - - public function getImageBlob(Repository $repository): Blob - { - return $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('image.jpg'); - } - #[DataProvider('provideBare')] public function testGetContent(bool $bare): void { $repository = self::createFoobarRepository($bare); - $blob = $this->getReadmeBlob($repository); + $blob = self::getReadmeBlob($repository); $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); } @@ -49,10 +39,11 @@ public function testGetContentSiblingToSubmodule(bool $bare): void // making sibling blobs unreadable. $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $this->assertNotEmpty($tree->getCommitReferenceEntries(), 'Tree contains a submodule entry'); + $this->assertNotEmpty($tree->getSubmoduleEntries(), 'Tree contains a submodule entry'); $blob = $tree->resolvePath('README.md'); + $this->assertInstanceOf(Blob::class, $blob); $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); } @@ -60,28 +51,38 @@ public function testGetContentSiblingToSubmodule(bool $bare): void public function testNotExisting(bool $bare): void { $repository = self::createFoobarRepository($bare); - $this->expectException(RuntimeException::class); + $blob = $repository->getBlob(str_repeat('1', 40)); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The object "1111111111111111111111111111111111111111" does not exist.'); - $blob = $repository->getBlob('foobar'); $blob->getContent(); } + public function testNotAHash(): void + { + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('"master" is not a hash.'); + + self::createFoobarRepository()->getBlob('master'); + } + #[DataProvider('provideBare')] public function testGetMimetype(bool $bare): void { $repository = self::createFoobarRepository($bare); - $blob = $this->getReadmeBlob($repository); + $blob = self::getReadmeBlob($repository); - $this->assertMatchesRegularExpression('#text/plain#', $blob->getMimetype()); + $this->assertSame('text/plain', $blob->getMimetype()); } #[DataProvider('provideBare')] public function testIsText(bool $bare): void { $repository = self::createFoobarRepository($bare); - $readmeBlob = $this->getReadmeBlob($repository); + $readmeBlob = self::getReadmeBlob($repository); $this->assertTrue($readmeBlob->isText()); - $imageBlob = $this->getImageBlob($repository); + $imageBlob = self::getImageBlob($repository); $this->assertFalse($imageBlob->isText()); } @@ -89,9 +90,9 @@ public function testIsText(bool $bare): void public function testIsBinary(bool $bare): void { $repository = self::createFoobarRepository($bare); - $readmeBlob = $this->getReadmeBlob($repository); + $readmeBlob = self::getReadmeBlob($repository); $this->assertFalse($readmeBlob->isBinary()); - $imageBlob = $this->getImageBlob($repository); + $imageBlob = self::getImageBlob($repository); $this->assertTrue($imageBlob->isBinary()); } @@ -103,4 +104,22 @@ public function testAbbreviatedHash(bool $bare): void $this->assertSame($repository->getBlob($hash)->getContent(), $repository->getBlob(substr($hash, 0, 8))->getContent()); } + + private static function getReadmeBlob(Repository $repository): Blob + { + return self::blob($repository, 'README.md'); + } + + private static function getImageBlob(Repository $repository): Blob + { + return self::blob($repository, 'image.jpg'); + } + + private static function blob(Repository $repository, string $path): Blob + { + $blob = $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath($path); + self::assertInstanceOf(Blob::class, $blob); + + return $blob; + } } diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 8cd810d9..67c813ec 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -13,12 +13,14 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Commit; -use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Diff\File; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; -use Gitonomy\Git\Tree; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class CommitTest extends AbstractTestCase { @@ -30,7 +32,18 @@ public function testGetDiff(bool $bare): void $diff = $commit->getDiff(); - $this->assertInstanceOf(Diff::class, $diff, 'getDiff() returns a Diff object'); + $this->assertSame(['long.php'], array_map(static fn (File $file): string => $file->getName(), $diff->getFiles())); + } + + public function testADateOutOfRangeThrows(): void + { + $repository = self::createFoobarRepository(); + $hash = self::writeObject($repository, 'commit', "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A 99999999999999999999 +0000\ncommitter A 99999999999999999999 +0000\n\nCorrupt\n"); + + // A RuntimeException of gitlib, not of PHP. + $this->expectException(RuntimeException::class); + + $repository->getCommit($hash)->getAuthorDate(); } #[DataProvider('provideBare')] @@ -39,7 +52,7 @@ public function testGetHash(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals(self::LONGFILE_COMMIT, $commit->getHash()); + $this->assertSame(self::LONGFILE_COMMIT, $commit->getHash()); } #[DataProvider('provideBare')] @@ -47,7 +60,7 @@ public function testInvalideHashThrowException(bool $bare): void { $repository = self::createFoobarRepository($bare); $this->expectException(ReferenceNotFoundException::class); - $this->expectExceptionMessage('Reference not found: "that-hash-doest-not-exists"'); + $this->expectExceptionMessage('"that-hash-doest-not-exists" is not the hash of a commit: get a commit by its name with getRevision(...)->getCommit().'); new Commit($repository, 'that-hash-doest-not-exists'); } @@ -58,7 +71,7 @@ public function testGetShortHash(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('4f17752', $commit->getShortHash(), 'Short hash'); + $this->assertSame('4f17752', $commit->getShortHash(), 'Short hash'); } #[DataProvider('provideBare')] @@ -78,7 +91,7 @@ public function testGetParentHashesWithOneParent(bool $bare): void $parents = $commit->getParentHashes(); $this->assertCount(1, $parents, 'One parent found'); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $parents[0], 'Parent hash is correct'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $parents[0], 'Parent hash is correct'); } #[DataProvider('provideBare')] @@ -89,8 +102,7 @@ public function testGetParentsWithOneParent(bool $bare): void $parents = $commit->getParents(); $this->assertCount(1, $parents, 'One parent found'); - $this->assertInstanceOf(Commit::class, $parents[0], 'First parent is a Commit object'); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $parents[0]->getHash(), "First parents's hash is correct"); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $parents[0]->getHash(), "First parents's hash is correct"); } #[DataProvider('provideBare')] @@ -99,7 +111,7 @@ public function testGetTreeHash(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTreeHash(), 'Tree hash is correct'); + $this->assertSame('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTreeHash(), 'Tree hash is correct'); } #[DataProvider('provideBare')] @@ -108,8 +120,7 @@ public function testGetTree(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertInstanceOf(Tree::class, $commit->getTree(), 'Tree is a tree'); - $this->assertEquals('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTree()->getHash(), 'Tree hash is correct'); + $this->assertSame('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTree()->getHash(), 'Tree hash is correct'); } #[DataProvider('provideBare')] @@ -118,7 +129,7 @@ public function testGetAuthorName(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('alice', $commit->getAuthorName(), 'Author name'); + $this->assertSame('alice', $commit->getAuthorName(), 'Author name'); } #[DataProvider('provideBare')] @@ -127,7 +138,7 @@ public function testGetAuthorEmail(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('alice@example.org', $commit->getAuthorEmail(), 'Author email'); + $this->assertSame('alice@example.org', $commit->getAuthorEmail(), 'Author email'); } #[DataProvider('provideBare')] @@ -136,7 +147,7 @@ public function testGetAuthorDate(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('2012-12-31 14:21:03', $commit->getAuthorDate()->format('Y-m-d H:i:s'), 'Author date'); + $this->assertSame('2012-12-31 14:21:03', $commit->getAuthorDate()->format('Y-m-d H:i:s'), 'Author date'); } #[DataProvider('provideBare')] @@ -145,7 +156,7 @@ public function testGetCommitterName(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('alice', $commit->getCommitterName(), 'Committer name'); + $this->assertSame('alice', $commit->getCommitterName(), 'Committer name'); } #[DataProvider('provideBare')] @@ -154,7 +165,7 @@ public function testGetCommitterEmail(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('alice@example.org', $commit->getCommitterEmail(), 'Committer email'); + $this->assertSame('alice@example.org', $commit->getCommitterEmail(), 'Committer email'); } #[DataProvider('provideBare')] @@ -163,7 +174,7 @@ public function testGetCommitterDate(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('2012-12-31 14:21:03', $commit->getCommitterDate()->format('Y-m-d H:i:s'), 'Committer date'); + $this->assertSame('2012-12-31 14:21:03', $commit->getCommitterDate()->format('Y-m-d H:i:s'), 'Committer date'); } #[DataProvider('provideBare')] @@ -172,7 +183,7 @@ public function testGetMessage(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('add a long file'."\n", $commit->getMessage()); + $this->assertSame('add a long file'."\n", $commit->getMessage()); } #[DataProvider('provideBare')] @@ -181,7 +192,7 @@ public function testGetEmptyMessage(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::NO_MESSAGE_COMMIT); - $this->assertEquals('', $commit->getMessage()); + $this->assertSame('', $commit->getMessage()); } #[DataProvider('provideBare')] @@ -191,7 +202,7 @@ public function testGetEmptyMessageFromLog(bool $bare): void $commit = $repository->getCommit(self::NO_MESSAGE_COMMIT); $commitMessageFromLog = $commit->getLog()->getCommits()[0]->getMessage(); - $this->assertEquals('', $commitMessageFromLog); + $this->assertSame('', $commitMessageFromLog); } /** @@ -203,26 +214,41 @@ public function testGetSignedMessage(bool $bare): void { $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::SIGNED_COMMIT); - $this->assertEquals('signed commit'."\n", $commit->getMessage()); + $this->assertSame('signed commit'."\n", $commit->getMessage()); } #[DataProvider('provideBare')] + #[Group('legacy')] + #[IgnoreDeprecations] public function testGetShortMessage(bool $bare): void { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Commit::getShortMessage()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".'); + $repository = self::createFoobarRepository($bare); // tests with a multi-line message $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); - $this->assertEquals('Fixed perm...', $commit->getShortMessage(10)); - $this->assertEquals('Fixed perm!!!', $commit->getShortMessage(10, false, '!!!')); - $this->assertEquals('Fixed permissions!!!', $commit->getShortMessage(10, true, '!!!')); + $this->assertSame('Fixed perm...', $commit->getShortMessage(10)); + $this->assertSame('Fixed perm!!!', $commit->getShortMessage(10, false, '!!!')); + $this->assertSame('Fixed permissions!!!', $commit->getShortMessage(10, true, '!!!')); // tests with a single-line message $commit = $repository->getCommit(self::INITIAL_COMMIT); - $this->assertEquals('Add README', $commit->getShortMessage(20)); - $this->assertEquals('A', $commit->getShortMessage(1, false, '')); - $this->assertEquals('Add!!!', $commit->getShortMessage(1, true, '!!!')); + $this->assertSame('Add README', $commit->getShortMessage(20)); + $this->assertSame('A', $commit->getShortMessage(1, false, '')); + $this->assertSame('Add!!!', $commit->getShortMessage(1, true, '!!!')); + } + + public function testSubjectIsTheFirstParagraph(): void + { + $repository = self::createFoobarRepository(); + $tree = $repository->getCommit(self::INITIAL_COMMIT)->getTreeHash(); + $hash = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\n\nSubject\nsecond line\n\nBody\n"); + $commit = $repository->getCommit($hash); + + $this->assertSame('Subject second line', $commit->getSubjectMessage()); + $this->assertSame("Body\n", $commit->getBodyMessage()); } #[DataProvider('provideBare')] @@ -232,14 +258,16 @@ public function testGetBodyMessage(bool $bare): void $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); $nl = \chr(10); $message = "If you want to know everything,{$nl}I ran something like `chmox +x test.sh`{$nl}{$nl}Hello and good bye.{$nl}"; - $this->assertEquals($message, $commit->getBodyMessage()); + $this->assertSame($message, $commit->getBodyMessage()); $commit = $repository->getCommit(self::INITIAL_COMMIT); - $this->assertEquals('', $commit->getBodyMessage()); + $this->assertSame('', $commit->getBodyMessage()); } #[DataProvider('provideBare')] + #[Group('legacy')] + #[IgnoreDeprecations] public function testGetIncludingBranchesException(bool $bare): void { $repository = self::createFoobarRepository($bare); @@ -256,14 +284,31 @@ public function testGetIncludingBranches(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::INITIAL_COMMIT); - $branches = $commit->getIncludingBranches(true, false); - $this->assertCount(\count($repository->getReferences()->getLocalBranches()), $branches); + $this->assertSame($repository->getReferences()->getBranches(), $commit->getIncludingBranches()); + } + + #[DataProvider('provideBare')] + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetIncludingLocalOrRemoteBranches(bool $bare): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing arguments to "Gitonomy\Git\Commit::getIncludingBranches()" is deprecated, filter the branches it returns with "Gitonomy\Git\Reference\Branch::isLocal()" or "isRemote()" instead.'); + + $repository = self::createFoobarRepository($bare); + $commit = $repository->getCommit(self::INITIAL_COMMIT); + + $this->assertSame($repository->getReferences()->getLocalBranches(), $commit->getIncludingBranches(true, false)); + $this->assertSame($repository->getReferences()->getRemoteBranches(), $commit->getIncludingBranches(false, true)); + } + + public function testGetIncludingBranchesNamedLikeRemoteOnes(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'branch', ['remotes/origin/fake', self::INITIAL_COMMIT]); - $branches = $commit->getIncludingBranches(true, true); - $this->assertCount(\count($repository->getReferences()->getBranches()), $branches); + $branches = $repository->getCommit(self::INITIAL_COMMIT)->getIncludingBranches(); - $branches = $commit->getIncludingBranches(false, true); - $this->assertCount(\count($repository->getReferences()->getRemoteBranches()), $branches); + $this->assertContains($repository->getReferences()->getBranch('remotes/origin/fake'), $branches); } #[DataProvider('provideBare')] @@ -274,8 +319,7 @@ public function testGetLastModification(bool $bare): void $lastModification = $commit->getLastModification('image.jpg'); - $this->assertTrue($lastModification instanceof Commit, 'Last modification is a Commit object'); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $lastModification->getHash(), 'Last modification is current commit'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $lastModification->getHash(), 'Last modification is current commit'); } #[DataProvider('provideBare')] @@ -284,7 +328,7 @@ public function testMergeCommit(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::MERGE_COMMIT); - $this->assertEquals("Merge branch 'authors'", $commit->getSubjectMessage()); + $this->assertSame("Merge branch 'authors'", $commit->getSubjectMessage()); } #[DataProvider('provideBare')] @@ -293,7 +337,7 @@ public function testEncoding(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::ENCODING_COMMIT); - $this->assertEquals('contribute to AUTHORS file', $commit->getSubjectMessage()); + $this->assertSame('contribute to AUTHORS file', $commit->getSubjectMessage()); } public function testReencodesCommitsToUtf8(): void diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index f9c7b00c..7c5e9afe 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -13,9 +13,19 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\DiffLineType; use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class DiffTest extends AbstractTestCase { @@ -53,14 +63,14 @@ public function testGetFilesModification(bool $bare): void $this->assertTrue($files[0]->isModification(), 'image.jpg modified'); - $this->assertEquals('image.jpg', $files[0]->getOldName(), 'Second file name is image.jpg'); - $this->assertEquals('image.jpg', $files[0]->getNewName(), 'Second file name is image.jpg'); - $this->assertEquals('100644', $files[0]->getOldMode(), 'Second file mode is a new file'); - $this->assertEquals('100644', $files[0]->getNewMode(), 'Second file mode is correct'); + $this->assertSame('image.jpg', $files[0]->getOldName(), 'Second file name is image.jpg'); + $this->assertSame('image.jpg', $files[0]->getNewName(), 'Second file name is image.jpg'); + $this->assertSame('100644', $files[0]->getOldMode(), 'Second file mode is a new file'); + $this->assertSame('100644', $files[0]->getNewMode(), 'Second file mode is correct'); $this->assertTrue($files[0]->isBinary(), 'binary file'); - $this->assertEquals(0, $files[0]->getAdditions(), '0 lines added'); - $this->assertEquals(0, $files[0]->getDeletions(), '0 lines deleted'); + $this->assertSame(0, $files[0]->getAdditions(), '0 lines added'); + $this->assertSame(0, $files[0]->getDeletions(), '0 lines deleted'); } #[DataProvider('provideBare')] @@ -72,8 +82,8 @@ public function testGetFilesDeletion(bool $bare): void $this->assertCount(1, $files, '1 files modified'); $this->assertTrue($files[0]->isDeletion(), 'File deletion'); - $this->assertEquals('script_B.php', $files[0]->getOldName(), 'verify old filename'); - $this->assertEquals(1, $files[0]->getDeletions(), '1 line deleted'); + $this->assertSame('script_B.php', $files[0]->getOldName(), 'verify old filename'); + $this->assertSame(1, $files[0]->getDeletions(), '1 line deleted'); } #[DataProvider('provideBare')] @@ -88,7 +98,7 @@ public function testGetFilesRename(bool $bare): void $this->assertTrue($files[0]->isRename()); $this->assertFalse($files[0]->isDeletion()); $this->assertFalse($files[0]->isCreation()); - $this->assertFalse($files[0]->isChangeMode()); + $this->assertFalse($files[0]->isModeChange()); } #[DataProvider('provideBare')] @@ -100,7 +110,7 @@ public function testGetFilesChangemode(bool $bare): void $this->assertCount(1, $files, '1 files modified'); $this->assertTrue($files[0]->isModification()); - $this->assertTrue($files[0]->isChangeMode()); + $this->assertTrue($files[0]->isModeChange()); $this->assertFalse($files[0]->isDeletion()); $this->assertFalse($files[0]->isCreation()); $this->assertFalse($files[0]->isRename()); @@ -175,7 +185,7 @@ public function testDeleteFile(): void $this->assertFalse($firstFile->isCreation()); $this->assertTrue($firstFile->isDeletion()); - $this->assertFalse($firstFile->isChangeMode()); + $this->assertFalse($firstFile->isModeChange()); $this->assertSame('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', $firstFile->getOldIndex()); $this->assertNull($firstFile->getNewIndex()); } @@ -183,8 +193,8 @@ public function testDeleteFile(): void public function testModeChangeFile(): void { $diff = Diff::parse(<<<'DIFF' - :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M testfile - :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 M testfile2 + :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M testfile + :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 M testfile2 diff --git a/testfile b/testfile old mode 100644 @@ -200,13 +210,13 @@ public function testModeChangeFile(): void $this->assertFalse($firstFile->isCreation()); $this->assertFalse($firstFile->isDeletion()); - $this->assertTrue($firstFile->isChangeMode()); + $this->assertTrue($firstFile->isModeChange()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getOldIndex()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getNewIndex()); $this->assertFalse($secondFile->isCreation()); $this->assertFalse($secondFile->isDeletion()); - $this->assertTrue($secondFile->isChangeMode()); + $this->assertTrue($secondFile->isModeChange()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getOldIndex()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getNewIndex()); } @@ -273,29 +283,201 @@ public function testParseAnEmptyDiff(): void $this->assertSame([], Diff::parse('')->getFiles()); } - public function testThrowErrorOnBlobGetWithoutIndex(): void + public function testDiffOfAMergeCommitIsAgainstItsFirstParent(): void { - $repository = self::createEmptyRepository(); - $file = new File('oldName', 'newName', '100755', '100644', '', '', false); - $file->setRepository($repository); + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'base', "base\n"); + self::git($repository, 'checkout', ['-q', '-b', 'side']); + self::commitFile($repository, 'side', "side\n"); + self::git($repository, 'checkout', ['-q', 'master']); + self::commitFile($repository, 'main', "main\n"); + self::git($repository, 'merge', ['-q', '--no-edit', 'side']); + + $files = $repository->getHeadCommit()->getDiff()->getFiles(); + + $this->assertSame(['side'], array_map(static fn (File $file): string => $file->getName(), $files)); + } + + public function testNamesWithSpaces(): void + { + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'Vol 1/chapter.txt', "a\n"); + self::commitFile($repository, 'cats and dogs.bin', "\0a"); + self::commitFile($repository, 'Vol 1/chapter.txt', "a\nb\n"); + self::commitFile($repository, 'cats and dogs.bin', "\0b"); + + $diff = $repository->getDiff('HEAD~2', 'HEAD'); + + $this->assertSame(['Vol 1/chapter.txt', 'cats and dogs.bin'], array_map(static fn (File $file): string => $file->getName(), $diff->getFiles())); + $this->assertTrue($diff->getFiles()[1]->isBinary()); + $this->assertSame("a\nb\n", $diff->getFiles()[0]->getNewBlob()->getContent()); + } + + public function testTypeChangeIsADeletionAndACreation(): void + { + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'link', "target\n"); + self::commitFile($repository, 'renamed', "moved\n"); + unlink($repository->getWorkingDir().'/link'); + symlink('target', $repository->getWorkingDir().'/link'); + self::git($repository, 'mv', ['renamed', 'moved']); + self::git($repository, 'add', ['-A']); + self::git($repository, 'commit', ['-q', '-m', 'Type change']); + + $files = $repository->getHeadCommit()->getDiff()->getFiles(); + + $this->assertCount(3, $files); + $this->assertTrue($files[0]->isDeletion()); + $this->assertSame("target\n", $files[0]->getOldBlob()->getContent()); + $this->assertTrue($files[1]->isCreation()); + $this->assertSame('120000', $files[1]->getNewMode()); + $this->assertSame('target', $files[1]->getNewBlob()->getContent()); + // The hashes of a rename without changes come from the raw information. + $this->assertTrue($files[2]->isRename()); + $this->assertSame("moved\n", $files[2]->getNewBlob()->getContent()); + } + + public function testDiffsOfAConflictLeaveUnmergedPathsOut(): void + { + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'conflict', "base\n"); + self::git($repository, 'checkout', ['-q', '-b', 'side']); + self::commitFile($repository, 'conflict', "side\n"); + self::git($repository, 'checkout', ['-q', 'master']); + self::commitFile($repository, 'conflict', "main\n"); try { - $file->getOldBlob(); - } catch (\RuntimeException $exception) { - $this->assertSame('Index is missing to return Blob object.', $exception->getMessage()); + self::git($repository, 'merge', ['-q', 'side']); + } catch (ProcessException) { + // The conflict. } + file_put_contents($repository->getWorkingDir().'/other', "other\n"); + self::git($repository, 'add', ['other']); - try { - $file->getNewBlob(); - } catch (\RuntimeException $exception) { - $this->assertSame('Index is missing to return Blob object.', $exception->getMessage()); + $this->assertSame(['other'], array_map(static fn (File $file): string => $file->getName(), $repository->getWorkingCopy()->getDiffStaged()->getFiles())); + $this->assertSame([], $repository->getWorkingCopy()->getDiffPending()->getFiles()); + + // Like git diff HEAD, which compares the unmerged file, conflict markers included, to HEAD. + $files = $repository->getWorkingCopy()->getDiff()->getFiles(); + $this->assertSame(['conflict', 'other'], array_map(static fn (File $file): string => $file->getName(), $files)); + $this->assertContains('<<<<<<< HEAD', array_map(static fn (DiffLine $line): string => $line->getContent(), $files[0]->getChanges()[0]->getLines())); + } + + public function testWorkingCopyDiff(): void + { + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'file', "a\n"); + file_put_contents($repository->getWorkingDir().'/file', "a\nb\n"); + + $files = $repository->getWorkingCopy()->getDiff()->getFiles(); + + $this->assertCount(1, $files); + $this->assertSame(1, $files[0]->getAdditions()); + $this->assertSame([DiffLineType::Context, DiffLineType::Addition], array_map(static fn (DiffLine $line): DiffLineType => $line->getType(), $files[0]->getChanges()[0]->getLines())); + $this->assertSame('b', $files[0]->getChanges()[0]->getLines()[1]->getContent()); + } + + public function testHunkWithOneLine(): void + { + $diff = Diff::parse(":100644 100644 7898192261 422c2b7ab3 M\tfile\n\ndiff --git a/file b/file\nindex 7898192261..422c2b7ab3 100644\n--- a/file\n+++ b/file\n@@ -1 +1,2 @@\n a\n+b\n"); + $change = $diff->getFiles()[0]->getChanges()[0]; + + $this->assertSame([1, 1, 1, 2], [$change->getRangeOldStart(), $change->getRangeOldCount(), $change->getRangeNewStart(), $change->getRangeNewCount()]); + } + + public function testParseNulSeparatedRawInformation(): void + { + $raw = ":100644 100644 7898192261 422c2b7ab3 R087\0old name\0new name\0:000000 100644 0000000000 e69de29bb2 A\0new\0"; + $files = Diff::parse($raw)->getFiles(); + + $this->assertSame(['old name', 'new name'], [$files[0]->getOldName(), $files[0]->getNewName()]); + $this->assertTrue($files[0]->isRename()); + $this->assertTrue($files[1]->isCreation()); + $this->assertSame('e69de29bb2', $files[1]->getNewIndex()); + } + + public function testParseQuotedNames(): void + { + $files = Diff::parse(":100644 100644 7898192261 422c2b7ab3 M\t\"caf\\303\\251\\t.txt\"\n")->getFiles(); + + $this->assertSame("caf\u{e9}\t.txt", $files[0]->getName()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetDiffOfARange(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Calling "Gitonomy\Git\Repository::getDiff()" with one argument is deprecated, pass the revisions to compare as "from" and "to" arguments, or use "Gitonomy\Git\WorkingCopy::getDiff()" to compare a revision with the working tree.'); + + $repository = self::createFoobarRepository(); + + $this->assertSame($repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(), $repository->getDiff(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT)->toArray()); + $this->assertSame($repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(), $repository->getDiff([self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT])->toArray()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyLinesAsArrays(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Reading a "Gitonomy\Git\Diff\DiffLine" as an array is deprecated, use its "getType()" and "getContent()" methods instead.'); + + $line = new DiffLine(DiffLineType::Addition, 'added'); + [$type, $content] = $line; + + $this->assertSame([FileChange::LINE_ADD, 'added'], [$type, $content]); + } + + public function testFromArrayRefusesAnUnknownTypeOfLine(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown type of line 2: the types are FileChange::LINE_ADD, LINE_CONTEXT and LINE_REMOVE.'); + + FileChange::fromArray(['range_old_start' => 1, 'range_old_count' => 1, 'range_new_start' => 1, 'range_new_count' => 1, 'lines' => [[2, 'line']]]); + } + + public function testRawDiffIsTheOutputOfGit(): void + { + $diff = self::createFoobarRepository()->getCommit(self::LONGFILE_COMMIT)->getDiff(); + + if (self::getDriver() instanceof LibGit2Driver) { + // No git command computed it. + $this->assertSame('', $diff->getRawDiff()); + } else { + $this->assertStringContainsString("\0diff --git a/long.php b/long.php\n", $diff->getRawDiff()); } + } + + public function testGetBlobWithoutHashThrows(): void + { + $file = new File('sub', 'sub', '160000', '160000', null, null, false, repository: self::createEmptyRepository()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The diff doesn\'t know the hash of the new blob of "sub".'); - $this->assertFalse($file->isCreation()); - $this->assertFalse($file->isDeletion()); - $this->assertTrue($file->isChangeMode()); - $this->assertSame('', $file->getOldIndex()); - $this->assertSame('', $file->getNewIndex()); + $file->getNewBlob(); + } + + public function testGetBlobWithoutRepositoryThrows(): void + { + $file = Diff::parse(":100644 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M\tfile\n")->getFiles()[0]; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('A diff without repository can\'t read blobs: get it from a repository.'); + + $file->getOldBlob(); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacySetRepository(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\File::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); + + $repository = self::createFoobarRepository(); + $file = new File('README.md', 'README.md', '100644', '100644', null, null, false); + $file->setRepository($repository); + + $this->assertSame($repository, $file->getRepository()); } public function testMnemonicPrefix(): void @@ -370,11 +552,33 @@ protected function verifyCreateCommitDiff(Diff $diff): void $this->assertTrue($files[0]->isCreation(), 'script_A.php created'); $this->assertNull($files[0]->getOldName(), 'First file name is a new file'); - $this->assertEquals('script_A.php', $files[0]->getNewName(), 'First file name is script_A.php'); + $this->assertSame('script_A.php', $files[0]->getNewName(), 'First file name is script_A.php'); $this->assertNull($files[0]->getOldMode(), 'First file mode is a new file'); - $this->assertEquals('100644', $files[0]->getNewMode(), 'First file mode is correct'); + $this->assertSame('100644', $files[0]->getNewMode(), 'First file mode is correct'); + + $this->assertSame(1, $files[0]->getAdditions(), '1 line added'); + $this->assertSame(0, $files[0]->getDeletions(), '0 lines deleted'); + } + + private static function createWorkingCopy(): Repository + { + $repository = self::createEmptyRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + // Whatever the default of the git running the tests. + self::git($repository, 'symbolic-ref', ['HEAD', 'refs/heads/master']); - $this->assertEquals(1, $files[0]->getAdditions(), '1 line added'); - $this->assertEquals(0, $files[0]->getDeletions(), '0 lines deleted'); + return $repository; + } + + private static function commitFile(Repository $repository, string $name, string $content): void + { + $path = $repository->getWorkingDir().'/'.$name; + if (!is_dir(\dirname($path))) { + mkdir(\dirname($path), 0o777, true); + } + file_put_contents($path, $content); + self::git($repository, 'add', [$name]); + self::git($repository, 'commit', ['-q', '-m', $name]); } } diff --git a/tests/Gitonomy/Git/Tests/DriverParityTest.php b/tests/Gitonomy/Git/Tests/DriverParityTest.php new file mode 100644 index 00000000..da19c7c1 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/DriverParityTest.php @@ -0,0 +1,150 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Driver\Data\ObjectType; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\Before; + +/** + * Both drivers read the same data from the same repository, but where + * doc/repository.md documents a difference. + */ +class DriverParityTest extends AbstractTestCase +{ + private RepositoryDriverInterface $cli; + + private RepositoryDriverInterface $libgit2; + + /** + * The objects compared already: most commits share them. + * + * @var array + */ + private array $compared = []; + + #[Before] + public function openWithBothDrivers(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not available.'); + } + + $dir = self::createFoobarRepository(false)->getPath(); + $this->cli = new Repository($dir, driver: self::getCliDriver())->getRepositoryDriver(); + $this->libgit2 = new Repository($dir, driver: new LibGit2Driver())->getRepositoryDriver(); + } + + public function testReferences(): void + { + $this->assertSameData($this->cli->getReferences(), $this->libgit2->getReferences()); + $this->assertSame($this->cli->getHeadReference(), $this->libgit2->getHeadReference()); + } + + public function testHistory(): void + { + $this->assertSameData($this->cli->getCommitLog(null, [], [], 0, null), $this->libgit2->getCommitLog(null, [], [], 0, null)); + $this->assertSame($this->cli->countCommits(null, [], []), $this->libgit2->countCommits(null, [], [])); + + $master = $this->cli->resolveRevision('master'); + $this->assertSameData($this->cli->getCommitLog([$master], [], ['README.md'], 0, null), $this->libgit2->getCommitLog([$master], [], ['README.md'], 0, null)); + + foreach ($this->cli->getCommitLog(null, [], [], 0, null) as $commit) { + $this->assertSame($this->cli->getIncludingBranches($commit->hash), $this->libgit2->getIncludingBranches($commit->hash), $commit->hash); + } + } + + public function testObjects(): void + { + foreach ($this->cli->getCommitLog(null, [], [], 0, null) as $commit) { + $this->assertSameTree($commit->treeHash); + } + + foreach ($this->cli->getReferences() as $reference) { + if (null !== $reference->tagHash) { + $this->assertSameData($this->cli->getTagData($reference->tagHash), $this->libgit2->getTagData($reference->tagHash)); + } + } + } + + public function testDiffs(): void + { + foreach ($this->cli->getCommitLog(null, [], [], 0, null) as $commit) { + $from = $commit->parentHashes[0] ?? null; + + // The raw diff is the output of git, which LibGit2Driver doesn't run. + $this->assertSameData($this->cli->getDiff($from, $commit->hash)->files, $this->libgit2->getDiff($from, $commit->hash)->files, $commit->hash); + } + } + + public function testBlame(): void + { + $master = $this->cli->resolveRevision('master'); + + $this->assertSameData($this->cli->getBlameLines($master, 'README.md', null, null), $this->libgit2->getBlameLines($master, 'README.md', null, null)); + $this->assertSameData($this->cli->getBlameLines($master, 'README.md', 2, 3), $this->libgit2->getBlameLines($master, 'README.md', 2, 3)); + } + + private function assertSameTree(string $hash): void + { + if (isset($this->compared[$hash])) { + return; + } + $this->compared[$hash] = true; + + $entries = $this->cli->getTreeEntries($hash); + $this->assertSameData($entries, $this->libgit2->getTreeEntries($hash), $hash); + + foreach ($entries as $entry) { + match ($entry->type) { + ObjectType::Tree => $this->assertSameTree($entry->hash), + ObjectType::Blob => $this->assertSameBlob($entry->hash), + // A submodule: a commit of another repository. + default => null, + }; + } + } + + private function assertSameBlob(string $hash): void + { + if (!isset($this->compared[$hash])) { + $this->compared[$hash] = true; + $this->assertSame($this->cli->getBlobContent($hash), $this->libgit2->getBlobContent($hash), $hash); + } + } + + private function assertSameData(mixed $expected, mixed $actual, string $message = ''): void + { + $this->assertSame(self::normalize($expected), self::normalize($actual), $message); + } + + /** + * Turns the data objects into arrays, which assertSame() compares by value. + */ + private static function normalize(mixed $data): mixed + { + return match (true) { + \is_array($data) => array_map(self::normalize(...), $data), + $data instanceof \DateTimeImmutable => $data->format(\DATE_ATOM), + $data instanceof \UnitEnum => $data->name, + $data instanceof FileChange => self::normalize($data->toArray()), + $data instanceof DiffLine => [$data->getType()->name, $data->getContent()], + \is_object($data) => self::normalize(get_object_vars($data)), + default => $data, + }; + } +} diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 47f2c585..75a5f68a 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -13,7 +13,8 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\BeforeClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -92,7 +93,7 @@ public function testGet(bool $bare): void $repository = self::createFoobarRepository($bare); $this->touchHook($repository, 'foo', 'foobar'); - $this->assertEquals('foobar', $repository->getHooks()->get('foo')); + $this->assertSame('foobar', $repository->getHooks()->get('foo')); } #[DataProvider('provideBare')] @@ -106,18 +107,18 @@ public function testSymlink(bool $bare): void $this->assertTrue(is_link($this->hookPath($repository, 'foo')), 'foo hook is a symlink'); - $this->assertEquals( + $this->assertSame( str_replace('\\', '/', $file), - str_replace('\\', '/', readlink($this->hookPath($repository, 'foo'))), + str_replace('\\', '/', (string) readlink($this->hookPath($repository, 'foo'))), 'target of symlink is correct' ); } #[DataProvider('provideBare')] - public function testSymlinkWithExistingThrowsLogicException(bool $bare): void + public function testSymlinkWithExistingThrows(bool $bare): void { $repository = self::createFoobarRepository($bare); - $this->expectException(LogicException::class); + $this->expectException(RuntimeException::class); $this->markAsSkippedIfSymlinkIsMissing(); @@ -137,19 +138,19 @@ public function testSet(bool $bare): void $file = $this->hookPath($repository, 'foo'); $repository->getHooks()->set('foo', 'bar'); - $this->assertEquals('bar', file_get_contents($file), 'Hook content is correct'); + $this->assertSame('bar', file_get_contents($file), 'Hook content is correct'); $perms = fileperms($file); - $this->assertEquals(\defined('PHP_WINDOWS_VERSION_BUILD') ? 0o666 : 0o700, $perms & 0o777, 'Hook permissions are correct'); + $this->assertSame(\defined('PHP_WINDOWS_VERSION_BUILD') ? 0o666 : 0o700, $perms & 0o777, 'Hook permissions are correct'); } #[DataProvider('provideBare')] - public function testSetExistingThrowsLogicException(bool $bare): void + public function testSetExistingThrows(bool $bare): void { $repository = self::createFoobarRepository($bare); $repository->getHooks()->set('foo', 'bar'); - $this->expectException(LogicException::class); + $this->expectException(RuntimeException::class); $repository->getHooks()->set('foo', 'bar'); } @@ -167,14 +168,42 @@ public function testRemove(bool $bare): void } #[DataProvider('provideBare')] - public function testRemoveNotExistingThrowsLogicException(bool $bare): void + public function testRemoveNotExistingThrows(bool $bare): void { $repository = self::createFoobarRepository($bare); - $this->expectException(LogicException::class); + $this->expectException(RuntimeException::class); $repository->getHooks()->remove('foo'); } + public function testSetCreatesTheHooksDirectory(): void + { + $repository = self::createEmptyRepository(); + self::deleteDir($repository->getGitDir().'/hooks'); + + $repository->getHooks()->set('pre-receive', "#!/bin/sh\nexit 1\n"); + + $this->assertSame("#!/bin/sh\nexit 1\n", $repository->getHooks()->get('pre-receive')); + } + + public function testALinkedWorktreeSharesTheHooksOfItsRepository(): void + { + $repository = self::createFoobarRepository(false); + $dir = self::createTempDir().'/worktree'; + + try { + self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); + } catch (ProcessException) { + $this->markTestSkipped('git worktree requires git 2.5.'); + } + + $worktree = new Repository($dir, driver: self::getDriver()); + $worktree->getHooks()->set('pre-commit', "#!/bin/sh\nexit 1\n"); + + $this->assertHasHook($repository, 'pre-commit'); + $this->assertTrue($worktree->getHooks()->has('pre-commit')); + } + private function markAsSkippedIfSymlinkIsMissing(): void { if (!\function_exists('symlink')) { diff --git a/tests/Gitonomy/Git/Tests/LibGit2HeaderTest.php b/tests/Gitonomy/Git/Tests/LibGit2HeaderTest.php index 2f4fff94..f62ceeaa 100644 --- a/tests/Gitonomy/Git/Tests/LibGit2HeaderTest.php +++ b/tests/Gitonomy/Git/Tests/LibGit2HeaderTest.php @@ -25,8 +25,8 @@ class LibGit2HeaderTest extends TestCase public function testCalledFunctionsAreTheDeclaredOnes(): void { - preg_match_all('/->(git_\w+)\(/', implode('', array_map(file_get_contents(...), glob(self::FFI_DIR.'/*.php'))), $called); - preg_match_all('/^[\w ]+?\**\b(git_\w+)\(/m', file_get_contents(self::FFI_DIR.'/libgit2.h'), $declared); + preg_match_all('/->(git_\w+)\(/', implode('', array_map(file_get_contents(...), glob(self::FFI_DIR.'/*.php') ?: [])), $called); + preg_match_all('/^[\w ]+?\**\b(git_\w+)\(/m', (string) file_get_contents(self::FFI_DIR.'/libgit2.h'), $declared); $called = array_values(array_unique($called[1])); $declared = array_values(array_unique($declared[1])); diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 36445201..358bad7d 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -14,9 +14,14 @@ use Gitonomy\Git\Commit; use Gitonomy\Git\Driver\Cli\Parser\LogParser; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Log; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\Attributes\TestWith; class LogTest extends AbstractTestCase @@ -41,8 +46,8 @@ public function testGetCommits(bool $bare): void $commits = $log->getCommits(); $this->assertCount(3, $commits, '3 commits in log'); - $this->assertEquals(self::LONGFILE_COMMIT, $commits[0]->getHash(), 'First is requested one'); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commits[1]->getHash(), "Second is longfile parent\\'s"); + $this->assertSame(self::LONGFILE_COMMIT, $commits[0]->getHash(), 'First is requested one'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $commits[1]->getHash(), "Second is longfile parent\\'s"); } #[DataProvider('provideBare')] @@ -51,7 +56,7 @@ public function testCountCommits(bool $bare): void $repository = self::createFoobarRepository($bare); $log = $repository->getLog(self::LONGFILE_COMMIT, null, 2, 3); - $this->assertEquals(8, $log->countCommits(), '8 commits found in history'); + $this->assertSame(8, $log->countCommits(), '8 commits found in history'); } #[DataProvider('provideBare')] @@ -72,7 +77,7 @@ public function testIterable(bool $bare): void $expectedHashes = [self::LONGFILE_COMMIT, self::BEFORE_LONGFILE_COMMIT]; foreach ($log as $entry) { $hash = array_shift($expectedHashes); - $this->assertEquals($hash, $entry->getHash()); + $this->assertSame($hash, $entry->getHash()); if (0 == \count($expectedHashes)) { break; } @@ -124,12 +129,12 @@ public function testParsesCommitsWithAndWithoutGitButlerHeaders(): void $parser = new LogParser(); $parser->parse($logContent); - $log = array_values($parser->commits); + $log = $parser->commits; $this->assertCount(3, $log); - $this->assertEquals("First commit message\n", $log[0]->message); - $this->assertEquals("Commit with GitButler headers\n", $log[1]->message); - $this->assertEquals("Another commit without GitButler headers\n", $log[2]->message); + $this->assertSame("First commit message\n", $log[0]->message); + $this->assertSame("Commit with GitButler headers\n", $log[1]->message); + $this->assertSame("Another commit without GitButler headers\n", $log[2]->message); } #[DataProvider('provideBare')] @@ -140,14 +145,14 @@ public function testLogOnPathSkipsMergesThatDoNotChangeIt(bool $bare): void $expected = ['1917a09c23eb0129d38d4e6d6a492c7830f8d423', 'd8930cd3f7e343195b85d56408a701feb98b95a8']; $this->assertSame($expected, array_values(array_map(static fn (Commit $commit) => $commit->getHash(), $log->getCommits()))); - $this->assertSame(2, $log->count()); + $this->assertCount(2, $log); } public function testLogOfRanges(): void { $repository = self::createFoobarRepository(false); - $this->assertSame(21, $repository->getLog('master...origin/new-feature')->count()); + $this->assertCount(21, $repository->getLog('master...origin/new-feature')); $this->assertCount(21, $repository->getLog('master...origin/new-feature')->getCommits()); $this->assertCount(19, $repository->getLog('origin/new-feature..master')->getCommits()); } @@ -212,6 +217,26 @@ public function testLogOfSeveralRevisionsStartsFromTheNewest(array $paths): void $this->assertSame([$old, $sameAsOld], self::hashes($repository->getLog([$old, $sameAsOld], $paths))); } + public function testLogOfAPathOutsideTheRepositoryThrows(): void + { + $repository = self::createFoobarRepository(); + + // Like git. + $this->expectException(DriverException::class); + + $repository->getLog('master', '/README.md')->getCommits(); + } + + public function testLogOfAnAbsolutePathInTheWorkingTree(): void + { + $repository = self::createFoobarRepository(false); + + $this->assertSame( + array_map(static fn (Commit $commit): string => $commit->getHash(), $repository->getLog('master', 'README.md')->getCommits()), + array_map(static fn (Commit $commit): string => $commit->getHash(), $repository->getLog('master', $repository->getWorkingDir().'/README.md')->getCommits()), + ); + } + public function testLogOnWildcardPath(): void { $repository = self::createRepositoryToCommitTo(); @@ -234,6 +259,107 @@ public function testLogOfAllReferencesIgnoresTheOnesToOtherObjects(): void $this->assertSame(1, $repository->getLog()->countCommits()); } + #[DataProvider('provideBare')] + public function testCountIsTheNumberOfCommitsIterated(bool $bare): void + { + $log = self::createFoobarRepository($bare)->getLog(self::LONGFILE_COMMIT); + $total = $log->countCommits(); + + $this->assertCount($total, $log); + $this->assertCount(3, $log->withLimit(3)); + $this->assertCount(3, $log->withLimit(3)); + $this->assertCount($total - 2, $log->withOffset(2)); + $this->assertCount(0, $log->withOffset($total + 1)); + $this->assertSame($total, $log->withLimit(3)->countCommits(), 'countCommits() ignores the offset and the limit'); + } + + #[DataProvider('provideBare')] + public function testWithersReturnAnotherLog(bool $bare): void + { + $log = self::createFoobarRepository($bare)->getLog(self::LONGFILE_COMMIT, offset: 1); + + $limited = $log->withLimit(2); + + $this->assertNull($log->getLimit()); + $this->assertSame(2, $limited->getLimit()); + $this->assertSame(1, $limited->getOffset()); + $this->assertSame(\array_slice(self::hashes($log), 0, 2), self::hashes($limited)); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacySetLimit(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Log::setLimit()" method is deprecated, use "withLimit()" instead.'); + + $log = self::createFoobarRepository()->getLog(self::LONGFILE_COMMIT); + + $this->assertSame($log, $log->setLimit(1)); + $this->assertCount(1, $log->getCommits()); + } + + public function testLogOfExcludedAndSymmetricRevisions(): void + { + $repository = self::createRepositoryToCommitTo(); + $base = self::commit($repository, 'file', 'base'); + self::git($repository, 'checkout', ['-q', '-b', 'side']); + $side = self::commit($repository, 'file', 'side'); + self::git($repository, 'checkout', ['-q', 'main']); + $main = self::commit($repository, 'other', 'main'); + + $this->assertSame([$main], self::hashes($repository->getLog(['main', '^side']))); + $this->assertEqualsCanonicalizing([$main, $side], self::hashes($repository->getLog('main...side'))); + $this->assertSame([$side], self::hashes($repository->getLog('main..side'))); + $this->assertCount(3, $repository->getLog('--all')); + $this->assertSame([], self::hashes($repository->getLog('^main'))); + $this->assertSame([$main, $base], self::hashes($repository->getLog('main'))); + } + + public function testLogOfAnUnknownRevisionNamesIt(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('"unknown"'); + + $repository->getLog('master..unknown', 'README.md')->getCommits(); + } + + public function testLogRefusesOptions(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(InvalidArgumentException::class); + + $repository->getLog('--output=/dev/null')->getCommits(); + } + + public function testParsesCommitsMergingTagsAndSignedOnes(): void + { + $repository = self::createRepositoryToCommitTo(); + $tree = trim(self::git($repository, 'write-tree')); + $signed = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n signature\n -----END PGP SIGNATURE-----\n\nSigned\n"); + $other = self::writeObject($repository, 'commit', "tree {$tree}\nparent {$signed}\nauthor A 1000000001 +0000\ncommitter A 1000000001 +0000\n\nOther\n"); + // An unsigned tag: no "END PGP SIGNATURE" line in its "mergetag" header. + $merge = self::writeObject($repository, 'commit', "tree {$tree}\nparent {$other}\nparent {$signed}\nauthor A 1000000002 +0000\ncommitter A 1000000002 +0000\nmergetag object {$signed}\n type commit\n tag v1\n tagger A 1000000000 +0000\n \n Tag v1\n\nMerge tag v1\n"); + self::git($repository, 'update-ref', ['refs/heads/main', $merge]); + + $commits = $repository->getLog('main')->getCommits(); + + $this->assertSame([$merge, $other, $signed], array_map(static fn (Commit $commit): string => $commit->getHash(), $commits)); + $this->assertSame("Merge tag v1\n", $commits[0]->getMessage()); + $this->assertSame("Signed\n", $commits[2]->getMessage()); + } + + #[DataProvider('provideBare')] + public function testLogOfTheRoot(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + + $this->assertSame(self::hashes($repository->getLog(self::LONGFILE_COMMIT, ['README.md', 'long.php'])), self::hashes($repository->getLog(self::LONGFILE_COMMIT, ['./README.md', './long.php']))); + $this->assertCount(\count($repository->getLog(self::LONGFILE_COMMIT)), $repository->getLog(self::LONGFILE_COMMIT, '.')); + } + private static function createRepositoryToCommitTo(): Repository { $repository = self::createEmptyRepository(false); diff --git a/tests/Gitonomy/Git/Tests/NulByteTest.php b/tests/Gitonomy/Git/Tests/NulByteTest.php new file mode 100644 index 00000000..a059d047 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/NulByteTest.php @@ -0,0 +1,68 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * libgit2 reads its arguments as C strings: "README.md\0.php" would name + * "README.md", where git refuses it. + */ +class NulByteTest extends AbstractTestCase +{ + #[DataProvider('provideCalls')] + public function testANulByteIsRefused(\Closure $call): void + { + $repository = self::createFoobarRepository(); + + try { + $call($repository); + $this->fail('A NUL byte is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('contains a NUL byte', $e->getMessage()); + } + + $this->assertTrue($repository->getReferences()->hasBranch('master'), 'Nothing is done'); + } + + /** + * @return iterable + */ + public static function provideCalls(): iterable + { + yield 'revision' => [static function (Repository $repository): void { $repository->getRevision("master\0junk")->getCommit(); }]; + yield 'blame' => [static function (Repository $repository): void { $repository->getBlame('master', "README.md\0.php")->getLines(); }]; + yield 'log' => [static function (Repository $repository): void { $repository->getLog('master', "README.md\0.php")->getCommits(); }]; + yield 'branch creation' => [static function (Repository $repository): void { $repository->getReferences()->createBranch("new\0master", 'master'); }]; + yield 'branch deletion' => [static function (Repository $repository): void { new Branch($repository, "refs/heads/master\0junk", self::LONGFILE_COMMIT)->delete(); }]; + } + + public function testADirectoryWithANulByteIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('contains a NUL byte'); + + new Repository(self::createTempDir()."\0junk", driver: self::getDriver()); + } + + public function testAUrlWithANulByteIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('contains a NUL byte'); + + Repository::isValidRepository(self::repositoryUrl()."\0junk", self::getDriver()); + } +} diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index c137b020..e3a0cf34 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -27,12 +27,15 @@ class PushReferenceTest extends AbstractTestCase public function testIsers(string $reference, string $before, string $after, int $mask): void { $reference = new PushReference(self::createFoobarRepository(), $reference, $before, $after); - $this->assertEquals($mask & self::CREATE, $reference->isCreate(), 'Create value is correct.'); - $this->assertEquals($mask & self::DELETE, $reference->isDelete(), 'Delete value is correct.'); - $this->assertEquals($mask & self::FORCE, $reference->isForce(), 'Force value is correct.'); - $this->assertEquals($mask & self::FAST_FORWARD, $reference->isFastForward(), 'FastForward value is correct.'); + $this->assertSame(0 !== ($mask & self::CREATE), $reference->isCreate(), 'Create value is correct.'); + $this->assertSame(0 !== ($mask & self::DELETE), $reference->isDelete(), 'Delete value is correct.'); + $this->assertSame(0 !== ($mask & self::FORCE), $reference->isForce(), 'Force value is correct.'); + $this->assertSame(0 !== ($mask & self::FAST_FORWARD), $reference->isFastForward(), 'FastForward value is correct.'); } + /** + * @return list + */ public static function provideIsers(): array { // mask: force fastforward create delete @@ -46,9 +49,11 @@ public static function provideIsers(): array public function testUnknownBeforeIsAnErrorNotAForcePush(): void { + $reference = new PushReference(self::createFoobarRepository(), 'foo', str_repeat('1', 40), self::LONGFILE_COMMIT); + $this->expectException(DriverException::class); - new PushReference(self::createFoobarRepository(), 'foo', str_repeat('1', 40), self::LONGFILE_COMMIT); + $reference->isForce(); } #[DataProvider('provideBare')] @@ -59,7 +64,7 @@ public function testLog(bool $bare): void $log = $ref->getLog()->getCommits(); $this->assertCount(7, $log, '7 commits in log'); - $this->assertEquals('add a long file', $log[0]->getShortMessage(), 'First commit is correct'); + $this->assertSame('add a long file', $log[0]->getSubjectMessage(), 'First commit is correct'); } /** @@ -72,7 +77,7 @@ public function testSignedLog(bool $bare): void $ref = new PushReference($repository, 'foo', self::INITIAL_COMMIT, self::SIGNED_COMMIT); $log = $ref->getLog()->getCommits(); $this->assertCount(16, $log, '16 commits in log'); - $this->assertEquals('signed commit', $log[0]->getShortMessage(), 'Last commit is correct'); + $this->assertSame('signed commit', $log[0]->getSubjectMessage(), 'Last commit is correct'); } #[DataProvider('provideBare')] @@ -83,6 +88,6 @@ public function testLogWithExclude(bool $bare): void $log = $ref->getLog([self::INITIAL_COMMIT])->getCommits(); $this->assertCount(7, $log, '7 commits in log'); - $this->assertEquals('add a long file', $log[0]->getShortMessage(), 'First commit is correct'); + $this->assertSame('add a long file', $log[0]->getSubjectMessage(), 'First commit is correct'); } } diff --git a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php index 0726afc0..fb50ec91 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php @@ -36,9 +36,9 @@ public function testGetFirstBranchWithNoBranches(): void $branch->delete(); } - $refs = $repository->getReferences(true); + $refs = $repository->getReferences(); $this->assertFalse($refs->hasBranches()); - $this->assertFalse($refs->getFirstBranch(), 'getFirstBranch() must not return a non-branch reference'); + $this->assertNull($refs->getFirstBranch(), 'getFirstBranch() must not return a non-branch reference'); } #[DataProvider('provideBare')] @@ -53,7 +53,6 @@ public function testUnknownReference(bool $bare): void self::git($repository, 'update-ref', ['refs/notes/gtm-data', $hash]); $refs = $repository->getReferences()->getAll(); - $this->assertIsArray($refs); // Check that at least it has the master ref $this->assertArrayHasKey('refs/heads/master', $refs); @@ -72,4 +71,18 @@ public function testEmptyRepo(bool $bare): void $refs = $repository->getReferences()->getAll(); $this->assertSame([], $refs); } + + public function testReferencesAreSortedByName(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'pack-refs', ['--all']); + self::git($repository, 'branch', ['aaa', 'master']); + self::git($repository, 'branch', ['zzz', 'master']); + + $names = array_keys($repository->getReferences()->getAll()); + $sorted = $names; + sort($sorted, \SORT_STRING); + + $this->assertSame($sorted, $names); + } } diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 27aa6f66..4251fdd2 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -12,10 +12,13 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Tag; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class ReferenceTest extends AbstractTestCase { @@ -24,7 +27,7 @@ public function testEmptyRepository(bool $bare): void { $repository = self::createEmptyRepository($bare); $this->assertCount(0, $repository->getReferences()); - $this->assertEquals([], $repository->getReferences()->getAll()); + $this->assertSame([], $repository->getReferences()->getAll()); } #[DataProvider('provideBare')] @@ -32,9 +35,7 @@ public function testGetBranch(bool $bare): void { $repository = self::createFoobarRepository($bare); $branch = $repository->getReferences()->getBranch('master'); - - $this->assertInstanceOf(Branch::class, $branch, 'Branch object is correct type'); - $this->assertEquals($branch->getCommitHash(), $branch->getCommit()->getHash(), 'Hash is correctly resolved'); + $this->assertSame($branch->getCommitHash(), $branch->getCommit()->getHash(), 'Hash is correctly resolved'); } #[DataProvider('provideBare')] @@ -67,12 +68,10 @@ public function testGetTag(bool $bare): void { $repository = self::createFoobarRepository($bare); $tag = $repository->getReferences()->getTag('0.1'); - - $this->assertInstanceOf(Tag::class, $tag, 'Tag object is correct type'); $this->assertFalse($tag->isAnnotated(), 'Tag is not annotated'); - $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommitHash(), 'Commit hash is correct'); - $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommit()->getHash(), 'Commit hash is correct'); + $this->assertSame(self::LONGFILE_COMMIT, $tag->getCommitHash(), 'Commit hash is correct'); + $this->assertSame(self::LONGFILE_COMMIT, $tag->getCommit()->getHash(), 'Commit hash is correct'); } #[DataProvider('provideBare')] @@ -80,24 +79,22 @@ public function testAnnotatedTag(bool $bare): void { $repository = self::createFoobarRepository($bare); $tag = $repository->getReferences()->getTag('annotated'); - - $this->assertInstanceOf(Tag::class, $tag, 'Tag object is correct type'); $this->assertTrue($tag->isAnnotated(), 'Tag is annotated'); $this->assertFalse($tag->isSigned(), 'Tag is not signed'); - $this->assertEquals('Graham Campbell', $tag->getTaggerName(), 'Tagger name is correct'); - $this->assertEquals('graham@alt-three.com', $tag->getTaggerEmail(), 'Tagger email is correct'); - $this->assertEquals(1471428000, $tag->getTaggerDate()->getTimestamp(), 'Tag date is correct'); + $this->assertSame('Graham Campbell', $tag->getTaggerName(), 'Tagger name is correct'); + $this->assertSame('graham@alt-three.com', $tag->getTaggerEmail(), 'Tagger email is correct'); + $this->assertSame(1471428000, $tag->getTaggerDate()?->getTimestamp(), 'Tag date is correct'); - $this->assertEquals('heading', $tag->getSubjectMessage(), 'Message heading is correct'); - $this->assertEquals("body\nbody", $tag->getBodyMessage(), 'Message body is correct'); + // Without an empty line after its first one, the message is one paragraph: its subject, like "git log --format=%s". + $this->assertSame('heading body body', $tag->getSubjectMessage(), 'Message heading is correct'); + $this->assertSame('', $tag->getBodyMessage(), 'Message body is correct'); - $closure = function () { - return parent::getCommit(); - }; - $parentCommit = $closure->bindTo($tag, Tag::class); - $this->assertNotEquals($parentCommit()->getHash(), $tag->getCommit()->getHash(), 'Tag commit is not the same as main commit'); - $this->assertEquals('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommit()->getHash(), 'Tag commit is correct'); + // The commit the tag object points to, not the tag object. + $this->assertSame('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommitHash()); + $this->assertSame('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommit()->getHash()); + $this->assertSame([$tag], $repository->getReferences()->resolveTags($tag->getCommit())); + $this->assertContains($tag, $tag->getCommit()->resolveReferences()); } #[DataProvider('provideBare')] @@ -112,8 +109,8 @@ public function testAnnotatedTagBodyMessageWithBlankLineSeparator(bool $bare): v $tag = $repository->getReferences()->getTag('blank-sep'); - $this->assertEquals('Subject line', $tag->getSubjectMessage(), 'Message subject is correct'); - $this->assertEquals("Body line 1\nBody line 2", $tag->getBodyMessage(), 'Message body has no leading blank line'); + $this->assertSame('Subject line', $tag->getSubjectMessage(), 'Message subject is correct'); + $this->assertSame("Body line 1\nBody line 2\n", $tag->getBodyMessage(), 'Message body has no leading blank line'); } #[DataProvider('provideBare')] @@ -133,7 +130,7 @@ public function testResolve(bool $bare): void $resolved = $repository->getReferences()->resolve($commit->getHash()); $this->assertCount(1, $resolved, '1 revision resolved'); - $this->assertInstanceOf(Tag::class, reset($resolved), 'Resolved object is a tag'); + $this->assertSame('refs/tags/0.1', $resolved[0]->getFullname()); } #[DataProvider('provideBare')] @@ -144,7 +141,7 @@ public function testResolveTags(bool $bare): void $resolved = $repository->getReferences()->resolveTags($commit->getHash()); $this->assertCount(1, $resolved, '1 revision resolved'); - $this->assertInstanceOf(Tag::class, reset($resolved), 'Resolved object is a tag'); + $this->assertSame('refs/tags/0.1', $resolved[0]->getFullname()); } #[DataProvider('provideBare')] @@ -161,7 +158,7 @@ public function testResolveBranches(bool $bare): void $this->assertCount(2, $resolved, '2 revision resolved'); } - $this->assertInstanceOf(Branch::class, reset($resolved), 'Resolved object is a branch'); + $this->assertNotEmpty($resolved, 'A branch is resolved'); } #[DataProvider('provideBare')] @@ -190,7 +187,7 @@ public function testCreateAndDeleteTag(bool $bare): void $tag = $references->createTag('0.0', self::INITIAL_COMMIT); $this->assertTrue($references->hasTag('0.0'), 'Tag 0.0 created'); - $this->assertEquals(self::INITIAL_COMMIT, $tag->getCommit()->getHash()); + $this->assertSame(self::INITIAL_COMMIT, $tag->getCommit()->getHash()); $this->assertSame($tag, $references->getTag('0.0')); $tag->delete(); @@ -205,7 +202,7 @@ public function testCreateAndDeleteBranch(bool $bare): void $branch = $references->createBranch('foobar', self::INITIAL_COMMIT); $this->assertTrue($references->hasBranch('foobar'), 'Branch foobar created'); - $this->assertEquals(self::INITIAL_COMMIT, $branch->getCommit()->getHash()); + $this->assertSame(self::INITIAL_COMMIT, $branch->getCommit()->getHash()); $this->assertSame($branch, $references->getBranch('foobar')); $branch->delete(); @@ -224,7 +221,8 @@ public function testIsBranchMergedToMaster(): void $branch = $references->createBranch('foobar-new', $master->getCommit()->getHash()); - $this->assertTrue($branch->isMergedTo('master')); + $this->assertTrue($branch->isMergedInto('master')); + $this->assertTrue($branch->isMergedInto($master)); $wc = $repository->getWorkingCopy(); $wc->checkout('foobar-new'); @@ -234,7 +232,116 @@ public function testIsBranchMergedToMaster(): void self::git($repository, 'add', [$file]); self::git($repository, 'commit', ['-m', 'foobar-test.txt updated']); - $this->assertFalse($branch->isMergedTo('master')); + // A branch is read once: read it again to see the new commit. + $this->assertFalse($references->getBranch('foobar-new')->isMergedInto('master')); + $this->assertTrue($master->isMergedInto('foobar-new')); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyIsMergedTo(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Reference\Branch::isMergedTo()" method is deprecated, use "Gitonomy\Git\Reference\Branch::isMergedInto()" instead.'); + + $repository = self::createFoobarRepository(false); + $references = $repository->getReferences(); + $branch = $references->createBranch('foobar-new', 'master'); + + $this->assertTrue($branch->isMergedTo()); + $this->assertFalse($branch->isMergedTo('master', true), 'A local branch is not remote'); + } + + #[DataProvider('provideBare')] + public function testCreateRefusesAnExistingReference(bool $bare): void + { + $references = self::createFoobarRepository($bare)->getReferences(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The reference "refs/heads/master" exists already.'); + + $references->createBranch('master', self::INITIAL_COMMIT); + } + + #[DataProvider('provideBare')] + public function testCreatedReferencesAreListed(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $references = $repository->getReferences(); + $names = static fn (array $tags): array => array_map(static fn (Tag $tag): string => $tag->getFullname(), $tags); + $before = $names($references->getTags()); + + $tag = $references->createTag('0.0', self::INITIAL_COMMIT); + + $this->assertSame(['refs/tags/0.0', ...$before], $names($references->getTags())); + $this->assertContains($tag, $repository->getReferences()->resolveTags(self::INITIAL_COMMIT)); + + $tag->delete(); + $this->assertSame($before, $names($references->getTags())); + } + + public function testResolveTagsOfARevision(): void + { + $references = self::createFoobarRepository()->getReferences(); + + $this->assertSame([$references->getTag('annotated')], $references->resolveTags('annotated')); + } + + public function testBranchMoveTo(): void + { + $repository = self::createFoobarRepository(); + $branch = $repository->getReferences()->createBranch('moving', self::INITIAL_COMMIT); + + $moved = $branch->moveTo($repository->getCommit(self::LONGFILE_COMMIT)); + + $this->assertSame(self::LONGFILE_COMMIT, $moved->getCommitHash()); + $this->assertSame($moved, $repository->getReferences()->getBranch('moving')); + $this->assertSame(self::INITIAL_COMMIT, $branch->getCommitHash(), 'A branch is read once'); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyUpdate(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\ReferenceBag::update()" method is deprecated, use "Gitonomy\Git\Reference\Branch::moveTo()" instead.'); + + $repository = self::createFoobarRepository(); + $references = $repository->getReferences(); + + $references->update(new Branch($repository, 'refs/heads/master', self::INITIAL_COMMIT)); + + $this->assertSame(self::INITIAL_COMMIT, $references->getBranch('master')->getCommitHash()); + } + + public function testBranchesEndingWithHead(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'branch', ['AHEAD', 'master']); + + $this->assertTrue($repository->getReferences()->hasBranch('AHEAD')); + } + + public function testDeletingADeletedReferenceDoesNothing(): void + { + $repository = self::createFoobarRepository(); + $branch = $repository->getReferences()->createBranch('deleted', 'master'); + self::git($repository, 'update-ref', ['-d', 'refs/heads/deleted']); + + // Like git update-ref -d. + $branch->delete(); + + $this->assertFalse($repository->getReferences()->hasBranch('deleted')); + } + + public function testTheHeadOfARemoteNamedWithASlashIsNoBranch(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['refs/remotes/foo/bar/main', self::LONGFILE_COMMIT]); + self::git($repository, 'symbolic-ref', ['refs/remotes/foo/bar/HEAD', 'refs/remotes/foo/bar/main']); + + $names = array_map(static fn (Branch $branch): string => $branch->getFullname(), $repository->getReferences()->getRemoteBranches()); + + $this->assertContains('refs/remotes/foo/bar/main', $names); + $this->assertNotContains('refs/remotes/foo/bar/HEAD', $names); } /** @@ -250,6 +357,10 @@ public function testAnnotatedTagWithoutTagger(): void $this->assertTrue($tag->isAnnotated()); $this->assertSame(self::LONGFILE_COMMIT, $tag->getCommit()->getHash()); + $this->assertNull($tag->getTaggerName()); + $this->assertNull($tag->getTaggerEmail()); + $this->assertNull($tag->getTaggerDate()); + $this->assertSame("No tagger.\n", $tag->getMessage()); } public function testSignedTag(): void @@ -261,7 +372,34 @@ public function testSignedTag(): void $tag = $repository->getReferences()->getTag('signed'); $this->assertTrue($tag->isSigned()); - $this->assertSame("\n\nsignature\n", $tag->getGPGSignature()); + $this->assertSame("\n\nsignature\n", $tag->getGpgSignature()); + $this->assertSame("Signed.\n", $tag->getMessage()); + } + + public function testSshSignedTag(): void + { + $repository = self::createFoobarRepository(false); + $hash = self::writeObject($repository, 'tag', \sprintf("object %s\ntype commit\ntag signed\ntagger Alice 1471428000 +0000\n\nSigned.\n-----BEGIN SSH SIGNATURE-----\nsignature\n-----END SSH SIGNATURE-----\n", self::LONGFILE_COMMIT)); + self::git($repository, 'update-ref', ['refs/tags/signed', $hash]); + + $tag = $repository->getReferences()->getTag('signed'); + + $this->assertTrue($tag->isSigned()); + $this->assertSame("\nsignature\n", $tag->getGpgSignature()); $this->assertSame("Signed.\n", $tag->getMessage()); } + + #[DataProvider('provideBare')] + public function testLightweightTag(bool $bare): void + { + $tag = self::createFoobarRepository($bare)->getReferences()->getTag('0.1'); + + $this->assertFalse($tag->isAnnotated()); + $this->assertFalse($tag->isSigned()); + $this->assertNull($tag->getTaggerName()); + $this->assertNull($tag->getMessage()); + $this->assertNull($tag->getSubjectMessage()); + $this->assertNull($tag->getBodyMessage()); + $this->assertNull($tag->getGpgSignature()); + } } diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 2c92d36b..40bad56b 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -14,12 +14,15 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\Commit; +use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Driver\Data\ReferenceData; use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use Gitonomy\Git\Driver\RepositoryDriverInterface; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ProcessTimedOutException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Exception\UnsupportedOperationException; @@ -29,6 +32,7 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Process; class RepositoryTest extends AbstractTestCase { @@ -39,7 +43,7 @@ public function testInitBare(): void $this->assertTrue($repository->isBare(), 'Repository is bare'); $this->assertDirectoryExists($dir.'/objects', 'objects/ folder is present'); - $this->assertEquals($dir, $repository->getGitDir(), 'The folder passed as argument is git dir'); + $this->assertSame($dir, $repository->getGitDir(), 'The folder passed as argument is git dir'); $this->assertNull($repository->getWorkingDir(), 'No working dir in bare repository'); } @@ -50,8 +54,274 @@ public function testInit(): void $this->assertFalse($repository->isBare(), 'Repository is not bare'); $this->assertDirectoryExists($dir.'/.git/objects', 'objects/ folder is present'); - $this->assertEquals($dir.'/.git', $repository->getGitDir(), 'git dir as subfolder of argument'); - $this->assertEquals($dir, $repository->getWorkingDir(), 'working dir present in bare repository'); + $this->assertSame($dir.'/.git', $repository->getGitDir(), 'git dir as subfolder of argument'); + $this->assertSame($dir, $repository->getWorkingDir(), 'working dir present in bare repository'); + } + + public function testOpensALinkedWorktree(): void + { + $repository = self::createFoobarRepository(false); + $dir = self::createTempDir().'/worktree'; + + try { + self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); + } catch (ProcessException) { + $this->markTestSkipped('git worktree requires git 2.5.'); + } + + // Its ".git" is a file pointing to a directory of the main repository. + $worktree = new Repository($dir, driver: self::getDriver()); + + $this->assertSame(realpath($dir), $worktree->getWorkingDir()); + $this->assertSame(realpath($repository->getGitDir().'/worktrees/worktree'), $worktree->getGitDir()); + $this->assertFalse($worktree->isBare()); + $head = $worktree->getHead(); + $this->assertInstanceOf(Branch::class, $head); + $this->assertSame('in-worktree', $head->getName()); + $this->assertSame($repository->getHeadCommit()->getHash(), $worktree->getHeadCommit()->getHash()); + } + + public function testOpeningADirectoryThatIsNotARepositoryThrows(): void + { + $dir = self::createTempDir(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Directory "%s" is not a git repository.', $dir)); + + new Repository($dir, driver: self::getDriver()); + } + + public function testReadsTheHeadOfAReftableRepository(): void + { + // libgit2 1.9 doesn't read reftable repositories. + $this->skipUnlessCliDriver(); + + $dir = self::createTempDir(); + $process = new Process([self::gitCommand(), 'init', '-q', '--ref-format=reftable', '--initial-branch=trunk', $dir]); + if (0 !== $process->run()) { + $this->markTestSkipped('Reftable repositories require git 2.45.'); + } + + $repository = new Repository($dir, driver: self::getDriver()); + $this->assertTrue($repository->isHeadAttached()); + + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + self::git($repository, 'commit', ['--allow-empty', '-m', 'First']); + + // The HEAD file of a reftable repository holds "ref: refs/heads/.invalid". + $this->assertSame('trunk', self::headBranch($repository)->getName()); + $this->assertSame('First', $repository->getHeadCommit()->getSubjectMessage()); + } + + public function testDetachedHead(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'checkout', ['-q', self::LONGFILE_COMMIT]); + + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); + } + + public function testObjectsAreTheSameWhileUsed(): void + { + $repository = self::createFoobarRepository(); + $commit = $repository->getCommit(self::INITIAL_COMMIT); + + $this->assertSame($commit, $repository->getCommit(self::INITIAL_COMMIT)); + $this->assertSame($repository->getBlob('412a0dad051163dcf9f9ce2fc04a141ffcb69479'), $repository->getBlob('412a0dad'), 'An abbreviated hash names the same blob'); + } + + public function testRefusesSha256Repositories(): void + { + $dir = self::createTempDir(); + $process = new Process([self::gitCommand(), 'init', '-q', '--object-format=sha256', $dir]); + if (0 !== $process->run()) { + $this->markTestSkipped('SHA-256 repositories require git 2.29.'); + } + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('uses SHA-256 hashes'); + + new Repository($dir, driver: self::getDriver()); + } + + public function testTimeout(): void + { + $repository = self::createFoobarRepository(false); + $repository->getHooks()->set('pre-commit', "#!/bin/sh\nsleep 10\n"); + $repository = new Repository($repository->getPath(), driver: self::getCliDriver(['GIT_AUTHOR_NAME' => 'A', 'GIT_AUTHOR_EMAIL' => 'a@example.com', 'GIT_COMMITTER_NAME' => 'A', 'GIT_COMMITTER_EMAIL' => 'a@example.com'], processTimeout: 0.5)); + + $this->expectException(ProcessTimedOutException::class); + $this->expectExceptionMessage('The git command "commit" ran longer than 0.5 seconds.'); + + $repository->run('commit', ['--allow-empty', '-m', 'Waits for its hook']); + } + + public function testIsValidRepositoryAndBranchMatchesTheNameExactly(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['refs/heads/feature/release', self::LONGFILE_COMMIT]); + $url = $repository->getGitDir(); + + $this->assertTrue(Repository::isValidRepositoryAndBranch($url, 'feature/release', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'release', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'rel*', self::getDriver())); + } + + public function testIsValidRepositoryReadsANameAsAPath(): void + { + // A clone, with an "origin" remote. + $repository = self::createFoobarRepository(false); + $cwd = getcwd(); + self::assertIsString($cwd); + + chdir($repository->getPath()); + + try { + $this->assertFalse(Repository::isValidRepository('origin', self::getDriver())); + $this->assertTrue(Repository::isValidRepository('.', self::getDriver())); + } finally { + chdir($cwd); + } + } + + public function testIsValidRepositoryThrowsWhenGitCantRun(): void + { + $this->skipUnlessCliDriver(); + + $this->expectException(ProcessException::class); + + Repository::isValidRepository(self::repositoryUrl(), new CliDriver('not-git')); + } + + public function testCloneATagAsBranchDetachesHead(): void + { + $repository = Repository::cloneFrom(self::repositoryUrl(), self::createTempDir().'/clone', 'annotated', self::getDriver()); + + // Like git, at the commit of the tag, without any local branch. + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame($repository->getReferences()->getTag('annotated')->getCommitHash(), $repository->getHeadCommit()->getHash()); + $this->assertSame([], $repository->getReferences()->getLocalBranches()); + $this->assertFileExists($repository->getWorkingDir().'/README.md'); + $this->assertSame([], $repository->getWorkingCopy()->getDiff()->getFiles(), 'The files of the tag are checked out'); + $this->assertFileDoesNotExist($repository->getGitDir().'/FETCH_HEAD'); + } + + public function testCloneBareATagAsBranchDetachesHead(): void + { + $repository = Repository::cloneBareFrom(self::repositoryUrl(), self::createTempDir(), '0.1', self::getDriver()); + + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); + $this->assertFileDoesNotExist($repository->getGitDir().'/FETCH_HEAD'); + } + + public function testCloneOfAMissingBranchThrows(): void + { + $this->expectException(DriverException::class); + + Repository::cloneFrom(self::repositoryUrl(), self::createTempDir().'/clone', 'missing', self::getDriver()); + } + + public function testClonesWriteNoFetchHead(): void + { + $dir = self::createTempDir(); + + $this->assertFileDoesNotExist(Repository::cloneFrom(self::repositoryUrl(), $dir.'/clone', driver: self::getDriver())->getGitDir().'/FETCH_HEAD'); + $this->assertFileDoesNotExist(Repository::cloneBareFrom(self::repositoryUrl(), $dir.'/bare', driver: self::getDriver())->getGitDir().'/FETCH_HEAD'); + $this->assertFileDoesNotExist(Repository::mirrorFrom(self::repositoryUrl(), $dir.'/mirror', self::getDriver())->getGitDir().'/FETCH_HEAD'); + } + + public function testReadingAMovedRepositoryThrows(): void + { + $dir = self::createTempDir(); + $repository = Repository::cloneBareFrom(self::repositoryUrl(), $dir.'/repository', driver: self::getDriver()); + rename($dir.'/repository', $dir.'/moved'); + + $this->expectException(DriverException::class); + + $repository->getReferences()->getBranches(); + } + + public function testAnObjectOfAnotherTypeThrows(): void + { + $repository = self::createFoobarRepository(); + $treeHash = $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->getHash(); + + foreach ([ + 'blob' => static fn () => $repository->getBlob(self::LONGFILE_COMMIT)->getContent(), + 'tree' => static fn () => $repository->getTree(self::LONGFILE_COMMIT)->getEntries(), + 'commit' => static fn () => $repository->getCommit($treeHash)->getMessage(), + ] as $type => $read) { + try { + $read(); + $this->fail(\sprintf('A %s of another type is refused.', $type)); + } catch (ReferenceNotFoundException $e) { + $this->assertMatchesRegularExpression(\sprintf('/^The object "[0-9a-f]{40}" is not a %s\.$/', $type), $e->getMessage()); + } + } + } + + public function testCloneRefusesADirectoryWithFiles(): void + { + $dir = self::createTempDir(); + touch($dir.'/file'); + + $this->expectException(DriverException::class); + + Repository::cloneBareFrom(self::repositoryUrl(), $dir, driver: self::getDriver()); + } + + public function testFailedCloneLeavesNothing(): void + { + $dir = self::createTempDir().'/clone'; + + try { + Repository::cloneBareFrom(self::repositoryUrl(), $dir, 'unknown', self::getDriver()); + $this->fail('The clone should have failed.'); + } catch (DriverException) { + } + + $this->assertDirectoryDoesNotExist($dir); + } + + public function testMirrorDoesNotFetchTagsTwice(): void + { + if (!self::getDriver() instanceof LibGit2Driver) { + $this->markTestSkipped('The configuration CliDriver writes depends on the version of git.'); + } + + $mirror = Repository::mirrorFrom(self::repositoryUrl(), self::createTempDir(), self::getDriver()); + + $this->assertSame('--no-tags', trim(self::git($mirror, 'config', ['remote.origin.tagopt']))); + } + + public function testGetMergeBase(): void + { + $repository = self::createFoobarRepository(); + + $this->assertSame(self::LONGFILE_COMMIT, $repository->getMergeBase(self::LONGFILE_COMMIT, 'master')?->getHash()); + } + + public function testAnAbbreviatedHashNamesAnObjectOfTheRightType(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(ReferenceNotFoundException::class); + + $repository->getBlob(substr(self::LONGFILE_COMMIT, 0, 8)); + } + + public function testAnAbbreviatedCommitHashNamesNoTree(): void + { + $repository = self::createFoobarRepository(); + $hash = substr(self::LONGFILE_COMMIT, 0, 8); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage(\sprintf('The object "%s" is not a tree.', $hash)); + + $repository->getTree($hash); } public function testInitOnAnExistingFileFails(): void @@ -64,13 +334,15 @@ public function testInitOnAnExistingFileFails(): void Repository::initBare($file, driver: self::getDriver()); } - public function testNamedConstructorsGiveTheLoggerToTheRepository(): void + public function testCliDriverLogsTheCommandsCreatingRepositories(): void { - $logger = $this->createStub(LoggerInterface::class); - - $repository = Repository::initBare(self::createTempDir(), $logger, self::getDriver()); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('info') + ->with($this->stringStartsWith('run command: init "-q --bare ')) + ; - $this->assertSame($logger, $repository->getLogger()); + Repository::initBare(self::createTempDir(), self::getCliDriver(logger: $logger)); } public function testCloneBareFrom(): void @@ -84,10 +356,10 @@ public function testCloneBareFrom(): void $this->assertContains('refs/heads/master', $newRefs); $this->assertContains('refs/tags/0.1', $newRefs); - $this->assertEquals($newDir, $new->getGitDir()); + $this->assertSame($newDir, $new->getGitDir()); $this->assertContains('refs/heads/new-feature', $newRefs); // Like `git clone --bare`: the remote has no fetch refspec. - $this->assertStringNotContainsString('fetch', file_get_contents($newDir.'/config')); + $this->assertStringNotContainsString('fetch', (string) file_get_contents($newDir.'/config')); } public function testCloneFrom(): void @@ -101,8 +373,8 @@ public function testCloneFrom(): void $this->assertContains('refs/heads/master', $newRefs); $this->assertContains('refs/tags/0.1', $newRefs); - $this->assertEquals($newDir.'/.git', $new->getGitDir()); - $this->assertEquals($newDir, $new->getWorkingDir()); + $this->assertSame($newDir.'/.git', $new->getGitDir()); + $this->assertSame($newDir, $new->getWorkingDir()); } public function testCloneBareFromABranch(): void @@ -116,7 +388,7 @@ public function testCloneBareFromABranch(): void $head = $new->getHead(); $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); - $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + $this->assertSame('new-feature', $head->getName(), 'HEAD is branch new-feature'); } public function testCloneFromABranch(): void @@ -130,7 +402,7 @@ public function testCloneFromABranch(): void $head = $new->getHead(); $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); - $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + $this->assertSame('new-feature', $head->getName(), 'HEAD is branch new-feature'); } public function testCloneFromAnUnknownBranchFails(): void @@ -151,7 +423,7 @@ public function testMirrorFrom(bool $bare): void $this->assertContains('refs/heads/master', $newRefs); $this->assertContains('refs/tags/0.1', $newRefs); - $this->assertEquals($newDir, $new->getGitDir()); + $this->assertSame($newDir, $new->getGitDir()); if ($repository->isBare()) { $this->assertContains('refs/heads/new-feature', $newRefs); @@ -291,9 +563,6 @@ public function testGetDescription(bool $bare): void #[DataProvider('provideBare')] public function testLoggerOk(bool $bare): void { - $repository = self::createFoobarRepository($bare); - $this->skipUnlessCliDriver(); - $logger = $this->createMock(LoggerInterface::class); $logger->expects($this->once()) ->method('info') @@ -304,7 +573,7 @@ public function testLoggerOk(bool $bare): void ->with($this->isString()) ; - $repository->setLogger($logger); + $repository = new Repository(self::createFoobarRepository($bare)->getPath(), driver: self::getCliDriver(logger: $logger)); $repository->run('remote'); } @@ -312,9 +581,6 @@ public function testLoggerOk(bool $bare): void #[DataProvider('provideBare')] public function testLoggerNOk(bool $bare): void { - $repository = self::createFoobarRepository($bare); - $this->skipUnlessCliDriver(); - $this->expectException(RuntimeException::class); $logger = $this->createMock(LoggerInterface::class); @@ -331,7 +597,7 @@ public function testLoggerNOk(bool $bare): void ->method('error') ; - $repository->setLogger($logger); + $repository = new Repository(self::createFoobarRepository($bare)->getPath(), driver: self::getCliDriver(logger: $logger)); $repository->run('not-work'); } @@ -383,52 +649,65 @@ public function testRunIsOnlySupportedByCliDriver(): void public function testDriverOpensTheRepository(): void { - $dir = realpath(self::createTempDir()); - $logger = $this->createStub(LoggerInterface::class); + $dir = self::createTempDir(); $repositoryDriver = $this->createStub(RepositoryDriverInterface::class); $repositoryDriver->method('getReferences')->willReturn([new ReferenceData('refs/heads/main', self::INITIAL_COMMIT)]); $driver = $this->createMock(DriverInterface::class); - $driver->expects($this->once())->method('open')->with($dir, null, $logger)->willReturn($repositoryDriver); + $driver->expects($this->once())->method('open')->with($dir, '/elsewhere')->willReturn($repositoryDriver); - $repository = new Repository($dir, ['logger' => $logger], $driver); + $repository = new Repository($dir, '/elsewhere', $driver); $this->assertSame(self::INITIAL_COMMIT, $repository->getReferences()->getBranch('main')->getCommitHash()); } - public function testSetLoggerReopensTheRepositoryWithIt(): void + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacySetLogger(): void { - $loggers = []; - $driver = $this->createStub(DriverInterface::class); - $driver->method('open')->willReturnCallback(function (string $gitDir, ?string $workingDir, ?LoggerInterface $logger) use (&$loggers): RepositoryDriverInterface { - $loggers[] = $logger; + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::setLogger()" method is deprecated, pass a "Gitonomy\Git\Driver\Cli\CliDriver" configured with the logger as driver argument instead.'); - return $this->createStub(RepositoryDriverInterface::class); - }); - $logger = $this->createStub(LoggerInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('info') + ->with('run command: remote "" ') + ; + + $repository = new Repository(self::createFoobarRepository()->getPath(), driver: self::getCliDriver()); + $repository->setLogger($logger); + $repository->run('remote'); + + $this->assertSame($logger, $repository->getLogger()); + } - new Repository(self::createTempDir(), driver: $driver)->setLogger($logger); + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacySetLoggerRequiresCliDriver(): void + { + $this->expectException(UnsupportedOperationException::class); - $this->assertSame([null, $logger], $loggers); + new Repository(self::createTempDir(), driver: $this->createStub(DriverInterface::class))->setLogger($this->createStub(LoggerInterface::class)); } + #[Group('legacy')] + #[IgnoreDeprecations] public function testUnknownOptionsAreRefused(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown options "foo", "bar": expected "logger" or "working_dir".'); + $this->expectExceptionMessage('Unknown options "foo", "bar".'); new Repository(self::createTempDir(), ['foo' => 1, 'bar' => 2]); } #[Group('legacy')] #[IgnoreDeprecations] - public function testLegacyGitOptionsConfigureACliDriver(): void + public function testLegacyOptionsConfigureACliDriver(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing "command", "process_timeout" in the options of "Gitonomy\Git\Repository::__construct()" is deprecated, pass a "Gitonomy\Git\Driver\Cli\CliDriver" as driver argument instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing an array of options to "Gitonomy\Git\Repository::__construct()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.'); $logger = $this->createStub(LoggerInterface::class); $repository = new Repository(self::createFoobarRepository()->getGitDir(), [ - 'command' => $_SERVER['GIT_COMMAND'] ?? 'git', + 'command' => self::gitCommand(), 'logger' => $logger, 'process_timeout' => 42, ]); @@ -437,10 +716,12 @@ public function testLegacyGitOptionsConfigureACliDriver(): void $this->assertSame(42.0, $repository->runProcess('rev-parse', ['HEAD'])->getTimeout()); } - public function testLegacyGitOptionsCantBeCombinedWithADriver(): void + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyOptionsCantBeCombinedWithADriver(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The driver argument can\'t be combined with "process_timeout" in the options: configure the driver instead.'); + $this->expectExceptionMessage('The driver argument can\'t be combined with the "logger" or git options: configure the driver instead.'); new Repository(self::createTempDir(), ['process_timeout' => 60], self::getDriver()); } @@ -449,13 +730,15 @@ public function testLegacyGitOptionsCantBeCombinedWithADriver(): void #[IgnoreDeprecations] public function testLegacyDebugOption(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing "debug" in the options of "Gitonomy\Git\Repository::__construct()" is deprecated, failing git operations always throw.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing an array of options to "Gitonomy\Git\Repository::__construct()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.'); $repository = new Repository(self::createFoobarRepository()->getGitDir(), ['debug' => true], self::getDriver()); $this->assertSame(self::LONGFILE_COMMIT, $repository->getCommit(self::LONGFILE_COMMIT)->getHash()); } + #[Group('legacy')] + #[IgnoreDeprecations] public function testDebugOptionSetToFalseIsRefused(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Gitonomy/Git/Tests/RevisionTest.php b/tests/Gitonomy/Git/Tests/RevisionTest.php index 14d60601..41377da6 100644 --- a/tests/Gitonomy/Git/Tests/RevisionTest.php +++ b/tests/Gitonomy/Git/Tests/RevisionTest.php @@ -12,10 +12,7 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Commit; use Gitonomy\Git\Exception\ReferenceNotFoundException; -use Gitonomy\Git\Log; -use Gitonomy\Git\Revision; use PHPUnit\Framework\Attributes\DataProvider; class RevisionTest extends AbstractTestCase @@ -26,13 +23,9 @@ public function testGetCommit(bool $bare): void $repository = self::createFoobarRepository($bare); $revision = $repository->getRevision(self::LONGFILE_COMMIT.'^'); - $this->assertInstanceOf(Revision::class, $revision, 'Revision object type'); - $commit = $revision->getCommit(); - $this->assertInstanceOf(Commit::class, $commit, 'getCommit returns a Commit'); - - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commit->getHash(), 'Resolution is correct'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $commit->getHash(), 'Resolution is correct'); } #[DataProvider('provideBare')] @@ -40,7 +33,7 @@ public function testGetFailingReference(bool $bare): void { $repository = self::createFoobarRepository($bare); $this->expectException(ReferenceNotFoundException::class); - $this->expectExceptionMessage('Can not find revision "non-existent-commit"'); + $this->expectExceptionMessage('The revision "non-existent-commit" does not exist, or names no commit.'); $repository->getRevision('non-existent-commit')->getCommit(); } @@ -52,10 +45,8 @@ public function testGetLog(bool $bare): void $revision = $repository->getRevision(self::LONGFILE_COMMIT); $log = $revision->getLog(null, 2, 3); - - $this->assertInstanceOf(Log::class, $log, 'Log type object'); - $this->assertEquals(2, $log->getOffset(), 'Log offset is passed'); - $this->assertEquals(3, $log->getLimit(), 'Log limit is passed'); - $this->assertEquals([$revision], $log->getRevisions()->getAll(), 'Revision is passed'); + $this->assertSame(2, $log->getOffset(), 'Log offset is passed'); + $this->assertSame(3, $log->getLimit(), 'Log limit is passed'); + $this->assertSame([$revision], $log->getRevisions()?->getAll(), 'Revision is passed'); } } diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index c56eac66..ca21b858 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -14,7 +14,12 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\CommitReference; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Submodule; +use Gitonomy\Git\Tree; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class TreeTest extends AbstractTestCase { @@ -28,11 +33,10 @@ public function testGetEntries(bool $bare): void $entries = $tree->getEntries(); - $this->assertNotEmpty($entries['long.php'], 'long.php is present'); - $this->assertTrue($entries['long.php'][1] instanceof Blob, 'long.php is a Blob'); - - $this->assertNotEmpty($entries['README.md'], 'README.md is present'); - $this->assertTrue($entries['README.md'][1] instanceof Blob, 'README.md is a Blob'); + $this->assertInstanceOf(Blob::class, $entries['long.php']->getObject()); + $this->assertSame('long.php', $entries['long.php']->getName()); + $this->assertSame('100644', $entries['long.php']->getMode()); + $this->assertInstanceOf(Blob::class, $entries['README.md']->getObject()); } #[DataProvider('provideBare')] @@ -49,25 +53,71 @@ public function testGetEntriesWithSpecialCharactersInNames(): void $repository = self::createFoobarRepository(false); self::git($repository, 'config', ['user.name', 'Unit Test']); self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); - file_put_contents($repository->getWorkingDir().'/tab\t"quote"', 'special'); + file_put_contents($repository->getWorkingDir().'/back\slash'."\ttab\"quote", 'special'); self::git($repository, 'add', ['--all']); self::git($repository, 'commit', ['-m', 'Add a file with special characters in its name']); $entries = $repository->getHeadCommit()->getTree()->getEntries(); - $this->assertArrayHasKey('tab\t"quote"', $entries); + $this->assertArrayHasKey('back\slash'."\ttab\"quote", $entries); } #[DataProvider('provideBare')] - public function testGetCommitReferenceEntries(bool $bare): void + public function testGetSubmoduleEntries(bool $bare): void { $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $commits = $tree->getCommitReferenceEntries(); + $submodules = $tree->getSubmoduleEntries(); + + $this->assertSame(['barbaz'], array_keys($submodules)); + $this->assertInstanceOf(Submodule::class, $submodules['barbaz']->getObject()); + $this->assertSame('160000', $submodules['barbaz']->getMode()); + } + + #[DataProvider('provideBare')] + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetCommitReferenceEntries(bool $bare): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Tree::getCommitReferenceEntries()" method is deprecated, use "getSubmoduleEntries()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Reading a "Gitonomy\Git\TreeEntry" as an array is deprecated, use its "getMode()" and "getObject()" methods instead.'); + + $tree = self::createFoobarRepository($bare)->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + [$mode, $object] = $tree->getCommitReferenceEntries()['barbaz']; + + $this->assertSame('160000', $mode); + // @phpstan-ignore method.impossibleType (PHPStan doesn't follow the alias of the deprecated class.) + $this->assertInstanceOf(CommitReference::class, $object); + } + + public function testResolvePathThroughASubmoduleThrows(): void + { + $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to resolve "barbaz/file": "barbaz" is a submodule, not a tree.'); + + $tree->resolvePath('barbaz/file'); + } + + public function testResolveAMissingPathThrows(): void + { + $tree = self::createFoobarRepository()->getCommit(self::PATH_RESOLVING_COMMIT)->getTree(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to resolve "test/a/missing/c": "test/a/missing" doesn\'t exist.'); + + $tree->resolvePath('test/a/missing/c'); + } + + public function testResolveTheRootPath(): void + { + $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $this->assertNotEmpty($commits['barbaz'], 'barbaz is present'); - $this->assertTrue($commits['barbaz'][1] instanceof CommitReference, 'barbaz is a Commit'); + $this->assertSame($tree, $tree->resolvePath('/')); + $this->assertSame($tree, $tree->resolvePath('')); } #[DataProvider('provideBare')] @@ -89,8 +139,7 @@ public function testGetBlobEntries(bool $bare): void $blobs = $tree->getBlobEntries(); - $this->assertNotEmpty($blobs['README.md'], 'README.md is present'); - $this->assertTrue($blobs['README.md'][1] instanceof Blob, 'README.md is a blob'); + $this->assertInstanceOf(Blob::class, $blobs['README.md']->getObject()); } #[DataProvider('provideBare')] @@ -101,8 +150,9 @@ public function testResolvePath(bool $bare): void $path = 'test/a/b/c'; $resolved = $tree->resolvePath($path); + $this->assertInstanceOf(Tree::class, $resolved); $entries = $resolved->getEntries(); - $this->assertNotEmpty($entries['d'], 'Successfully resolved source folder'); + $this->assertArrayHasKey('d', $entries, 'Successfully resolved source folder'); } } diff --git a/tests/Gitonomy/Git/Tests/WeakCacheTest.php b/tests/Gitonomy/Git/Tests/WeakCacheTest.php new file mode 100644 index 00000000..0347ece7 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/WeakCacheTest.php @@ -0,0 +1,48 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Util\WeakCache; +use PHPUnit\Framework\TestCase; + +class WeakCacheTest extends TestCase +{ + public function testKeepsAnObjectWhileItIsUsed(): void + { + /** @var WeakCache<\stdClass> $cache */ + $cache = new WeakCache(); + $object = new \stdClass(); + $cache->set('used', $object); + $cache->set('freed', new \stdClass()); + + $this->assertSame($object, $cache->get('used')); + $this->assertNull($cache->get('freed')); + $this->assertNull($cache->get('unknown')); + } + + public function testForgetsTheKeysOfTheFreedObjects(): void + { + /** @var WeakCache<\stdClass> $cache */ + $cache = new WeakCache(); + $object = new \stdClass(); + $cache->set('used', $object); + for ($i = 0; $i < 10_000; ++$i) { + $cache->set('freed'.$i, new \stdClass()); + } + + $references = new \ReflectionProperty(WeakCache::class, 'references')->getValue($cache); + $this->assertIsArray($references); + $this->assertLessThan(64, \count($references), 'Only the keys of the objects used, and the last ones'); + $this->assertSame($object, $cache->get('used')); + } +} diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index ac63c791..4867cdff 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -15,8 +15,11 @@ use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\LogicException; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\FastForward; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class WorkingCopyTest extends AbstractTestCase { @@ -34,11 +37,36 @@ public function testCheckout(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); - $wc->checkout('origin/new-feature', 'new-feature'); + $wc->checkoutNewBranch('new-feature', 'origin/new-feature'); $head = $repository->getHead(); $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); - $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + $this->assertSame('new-feature', $head->getName(), 'HEAD is branch new-feature'); + } + + public function testCheckoutOfABranchObjectAttachesHead(): void + { + $repository = self::createFoobarRepository(false); + $branch = $repository->getReferences()->createBranch('twin', 'master'); + $wc = $repository->getWorkingCopy(); + + $wc->checkout($repository->getRevision(self::INITIAL_COMMIT)); + $this->assertTrue($repository->isHeadDetached()); + + $wc->checkout($branch); + $this->assertSame('refs/heads/twin', self::headBranch($repository)->getFullname()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyCheckoutOfANewBranch(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing a branch to create to "Gitonomy\Git\WorkingCopy::checkout()" is deprecated, use "checkoutNewBranch()" instead.'); + + $repository = self::createFoobarRepository(false); + $repository->getWorkingCopy()->checkout('origin/new-feature', 'new-feature'); + + $this->assertSame('new-feature', self::headBranch($repository)->getName()); } public function testDiffStaged(): void @@ -84,7 +112,7 @@ public function testDiffPendingWithAbbreviatedHashesLikeOldGit(): void $files = $repository->getWorkingCopy()->getDiffPending()->getFiles(); $this->assertCount(1, $files); - $this->assertSame(40, \strlen($files[0]->getOldIndex())); + $this->assertSame(40, \strlen((string) $files[0]->getOldIndex())); } public function testCheckoutUnexisting(): void @@ -145,13 +173,41 @@ public function testMerge(): void public function testMergeConflict(): void { - $this->expectException(RuntimeException::class); + $repository = self::createFoobarRepository(false); + $master = $repository->getHeadCommit()->getHash(); + $wc = $repository->getWorkingCopy(); + + try { + $wc->merge('origin/new-feature'); + $this->fail('The merge should have been refused.'); + } catch (DriverException) { + } + + $this->assertSame($master, $repository->getHeadCommit()->getHash(), 'No merge commit is created'); + } + public function testMergeOnlyFastForwards(): void + { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); - $wc->checkout('master'); - $wc->merge('origin/new-feature'); + $this->expectException(DriverException::class); + + $wc->merge('origin/diff-features', FastForward::Only); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyMergeArguments(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing arguments of "git merge" to "Gitonomy\Git\WorkingCopy::merge()" is deprecated, pass a "Gitonomy\Git\FastForward" instead.'); + + $repository = self::createFoobarRepository(false); + $head = $repository->getHeadCommit(); + + $repository->getWorkingCopy()->merge($head->getHash(), ['--no-ff']); + + $this->assertSame($head->getHash(), $repository->getHeadCommit()->getHash()); } public function testMergeFastForwardsTheCurrentBranch(): void @@ -165,7 +221,7 @@ public function testMergeFastForwardsTheCurrentBranch(): void $wc->merge('master'); $repository = new Repository($repository->getPath(), driver: self::getDriver()); - $this->assertSame('refs/heads/behind', $repository->getHead()->getFullname(), 'HEAD stays on the branch'); + $this->assertSame('refs/heads/behind', self::headBranch($repository)->getFullname(), 'HEAD stays on the branch'); $this->assertSame($master->getHash(), $repository->getHeadCommit()->getHash()); $this->assertCount(0, $repository->getWorkingCopy()->getDiffPending()->getFiles()); $this->assertCount(0, $repository->getWorkingCopy()->getDiffStaged()->getFiles()); @@ -178,7 +234,7 @@ public function testMergeOfAnAncestorDoesNothing(): void $wc = $repository->getWorkingCopy(); $wc->merge($head->getParentHashes()[0]); - $wc->merge($head->getHash(), ['--no-ff']); + $wc->merge($head->getHash(), FastForward::Never); $repository = new Repository($repository->getPath(), driver: self::getDriver()); $this->assertSame($head->getHash(), $repository->getHeadCommit()->getHash()); @@ -203,7 +259,7 @@ public function testCheckoutRefusesToOverwriteLocalChanges(): void file_put_contents($file, 'local change'); try { - $repository->getWorkingCopy()->checkout('origin/pagination', 'pagination'); + $repository->getWorkingCopy()->checkoutNewBranch('pagination', 'origin/pagination'); $this->fail('The checkout should have been refused.'); } catch (DriverException) { } @@ -225,4 +281,62 @@ public function testUntrackedFilesInNewDirectories(): void $this->assertSame(['newdir/a.txt', 'newdir/sub/b.txt'], $files); } + + public function testCheckoutPrefersTheBranchToATagOfTheSameName(): void + { + $repository = self::createFoobarRepository(false); + $references = $repository->getReferences(); + $references->createTag('twin', self::INITIAL_COMMIT); + $references->createBranch('twin', 'master'); + + $repository->getWorkingCopy()->checkout('twin'); + + $this->assertSame('refs/heads/twin', self::headBranch($repository)->getFullname()); + $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); + } + + public function testCheckoutNewBranchWithAnInvalidNameChangesNothing(): void + { + $repository = self::createFoobarRepository(false); + $head = $repository->getHeadCommit()->getHash(); + + try { + $repository->getWorkingCopy()->checkoutNewBranch('bad..name', self::INITIAL_COMMIT); + $this->fail('The checkout should have been refused.'); + } catch (DriverException) { + } + + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + $this->assertSame($head, $repository->getHeadCommit()->getHash()); + $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); + } + + public function testMergeRefusesUnrelatedHistories(): void + { + $repository = self::createFoobarRepository(false); + $tree = $repository->getHeadCommit()->getTreeHash(); + $orphan = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\n\nOrphan\n"); + + $this->expectException(DriverException::class); + + $repository->getWorkingCopy()->merge($orphan); + } + + public function testMergeRefusesStagedChanges(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + file_put_contents($repository->getWorkingDir().'/staged', 'staged'); + self::git($repository, 'add', ['staged']); + $head = $repository->getHeadCommit()->getHash(); + + try { + $repository->getWorkingCopy()->merge('origin/diff-features'); + $this->fail('The merge should have been refused.'); + } catch (DriverException) { + } + + $this->assertSame($head, $repository->getHeadCommit()->getHash()); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 589c0ffc..8987aca0 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -13,7 +13,7 @@ require __DIR__.'/../vendor/autoload.php'; if (defined('PHP_WINDOWS_VERSION_BUILD')) { - $server = array_change_key_case($_SERVER, true); + $server = array_change_key_case($_SERVER, \CASE_UPPER); $_SERVER['GIT_ENVS'] = []; foreach (['PATH', 'SYSTEMROOT'] as $key) { if (isset($server[$key])) { From e659a882372fef15d32e802dc7d87bc5d7c2242b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 14:07:52 +0200 Subject: [PATCH 091/150] Test old git versions, and analyse the tests and the libgit2 driver CI builds git 1.8.4, the oldest supported version, and 2.25.1, and runs the suite with them. The workflow only reads the repository, checks the tarballs it builds, keys the libgit2 cache on its build flags, uses actions/cache 6, checks that the archive Composer installs only ships the code, the docs and the license (export-ignore), and Dependabot updates the actions and the tools. PHPStan analyses the tests, with phpstan-phpunit, and the libgit2 driver, ignoring only the calls to FFI it can't see. PHP-CS-Fixer applies php_unit_strict. PHPUnit fails on notices and on its own deprecations. psr/log, in the API, is required. The fixture's refs are pinned: they must never move. --- .gitattributes | 14 +++ .github/CONTRIBUTING.md | 13 +-- .github/dependabot.yml | 13 +++ .github/workflows/tests.yml | 71 ++++++++++++++- .php-cs-fixer.php | 1 + composer.json | 8 +- phpstan-baseline.neon | 150 -------------------------------- phpstan.neon | 26 +++--- phpunit.xml.dist | 2 + tests/fixtures/bundle-heads.txt | 8 ++ tests/fixtures/verify-bundle.sh | 13 ++- tools/phpstan/composer.json | 3 +- tools/phpstan/composer.lock | 111 ++++++++++++++++++++++- 13 files changed, 254 insertions(+), 179 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 tests/fixtures/bundle-heads.txt diff --git a/.gitattributes b/.gitattributes index 20a19621..f8886cc3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,16 @@ * text=auto /tests/fixtures/*.bundle binary + +# Left out of the archive Composer installs: only src/, the docs and the license ship. +/.gitattributes export-ignore +/.github export-ignore +/.gitignore export-ignore +/.php-cs-fixer.php export-ignore +/benchmark.php export-ignore +/castor.php export-ignore +/phpstan-baseline.neon export-ignore +/phpstan.neon export-ignore +/phpunit.xml.dist export-ignore +/test-git-versions.php export-ignore +/tests export-ignore +/tools export-ignore diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ed81c27d..827452f7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,7 +6,7 @@ We accept contributions via pull requests on GitHub. Please review these guideli ## Guidelines -* Please follow the [PSR-12 Coding Style Guide](https://www.php-fig.org/psr/psr-12/). +* Follow the coding standards of PHP-CS-Fixer, configured in `.php-cs-fixer.php` (the Symfony and PhpCsFixer rule sets): `castor cs` applies them. * Ensure that the current tests pass, and if you've added something new, add the tests where relevant. * Send a coherent commit history, making sure each commit in your pull request is meaningful. * You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts. @@ -62,10 +62,13 @@ locally, ready to receive new commits. `build` rebuilds `tests/fixtures/foobar.b from that directory, restricted to the refs listed in `tests/fixtures/bundle-refs.txt` — add your new branch or tag there first if you introduced one. -Then update the commit SHA constants in `AbstractTestCase` to match, and run -`tests/fixtures/verify-bundle.sh`. It checks the bundle's integrity, its ref list against -`bundle-refs.txt`, and its size, since GitHub renders any change to this binary file as an -opaque diff. If your change intentionally adds a ref or grows the file, update +Existing refs must not move, since the tests rely on the commits they point to: add your +commits on new branches or tags, and constants in `AbstractTestCase` for the ones your +tests use. Then add the new refs to `tests/fixtures/bundle-heads.txt` (the output of +`git bundle list-heads tests/fixtures/foobar.bundle`), and run +`tests/fixtures/verify-bundle.sh`. It checks the bundle's integrity, its refs against +`bundle-refs.txt` and `bundle-heads.txt`, and its size, since GitHub renders any change to +this binary file as an opaque diff. If your change intentionally adds a ref or grows the file, update `tests/fixtures/bundle-refs.txt` or `MAX_SIZE_KB` in that script as part of the same pull request, so the reason for the change is explicit and reviewable rather than a silent binary diff. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..27976684 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + + - package-ecosystem: composer + directories: + - /tools/php-cs-fixer + - /tools/phpstan + schedule: + interval: monthly diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 59c7d55f..23a1fb82 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,9 @@ on: branches: [ main ] pull_request: +permissions: + contents: read + jobs: verify-fixture: name: Verify Test Fixture Bundle @@ -34,6 +37,16 @@ jobs: - name: Run CS check run: castor cs --dry-run + check-archive: + name: Check the Archive + runs-on: ubuntu-24.04 + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Ship only the code, the docs and the license + run: diff <(git archive HEAD | tar -t | cut -d/ -f1 | sort -u) <(printf '%s\n' CHANGELOG.md LICENSE README.md UPGRADE-2.0.md composer.json doc src | sort) + phpstan: name: Static Analysis runs-on: ubuntu-24.04 @@ -91,6 +104,52 @@ jobs: - name: Execute PHPUnit run: vendor/bin/phpunit + tests-git: + name: Test git ${{ matrix.git }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + # The oldest supported git, and the one of Ubuntu 20.04: the other jobs test the latest one. + include: + - git: '1.8.4' + sha256: '5e0efe7cd30e258bc3be9ae023790743974e76eb1b24ecac24de6fc2af2b2786' + - git: '2.25.1' + sha256: 'aaef0cd609a7730cfbae530d7c91214a941d7e26054d2314cc99794880156b33' + + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Cache git + id: cache-git + uses: actions/cache@v6 + with: + path: ~/git + key: git-${{ matrix.git }}-ubuntu-24.04 + + - name: Build git + if: steps.cache-git.outputs.cache-hit != 'true' + run: | + curl -fsSL "https://github.com/git/git/archive/refs/tags/v${{ matrix.git }}.tar.gz" -o git.tar.gz + echo "${{ matrix.sha256 }} git.tar.gz" | sha256sum --check + tar xzf git.tar.gz + make -C "git-${{ matrix.git }}" prefix="$HOME/git" CFLAGS='-std=gnu17 -O2' NO_OPENSSL=1 NO_CURL=1 NO_EXPAT=1 NO_GETTEXT=1 NO_PERL=1 NO_PYTHON=1 NO_TCLTK=1 --jobs="$(nproc)" install + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + + - name: Setup Problem Matchers + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install Composer dependencies + run: composer update --prefer-dist --no-interaction + + - name: Execute PHPUnit + run: GIT_COMMAND="$HOME/git/bin/git" vendor/bin/phpunit + tests-ffi: name: Test PHP ${{ matrix.php }} (LibGit2Driver) runs-on: ubuntu-24.04 @@ -101,6 +160,8 @@ jobs: env: # LibGit2Driver only supports libgit2 1.9, which Ubuntu 24.04 doesn't ship. LIBGIT2_VERSION: '1.9.1' + LIBGIT2_SHA256: '14cab3014b2b7ad75970ff4548e83615f74d719afe00aa479b4a889c1e13fc00' + LIBGIT2_CMAKE_FLAGS: '-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=OFF -DBUILD_CLI=OFF -DUSE_HTTPS=OFF -DUSE_SSH=OFF' steps: - name: Checkout Code @@ -108,16 +169,18 @@ jobs: - name: Cache libgit2 id: cache-libgit2 - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/libgit2 - key: libgit2-${{ env.LIBGIT2_VERSION }}-ubuntu-24.04 + key: libgit2-${{ env.LIBGIT2_VERSION }}-${{ env.LIBGIT2_CMAKE_FLAGS }}-ubuntu-24.04 - name: Build libgit2 if: steps.cache-libgit2.outputs.cache-hit != 'true' run: | - curl -fsSL "https://github.com/libgit2/libgit2/archive/refs/tags/v${LIBGIT2_VERSION}.tar.gz" | tar xz - cmake -S "libgit2-${LIBGIT2_VERSION}" -B libgit2-build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$HOME/libgit2" -DCMAKE_INSTALL_LIBDIR=lib -DBUILD_TESTS=OFF -DBUILD_CLI=OFF -DUSE_HTTPS=OFF -DUSE_SSH=OFF + curl -fsSL "https://github.com/libgit2/libgit2/archive/refs/tags/v${LIBGIT2_VERSION}.tar.gz" -o libgit2.tar.gz + echo "${LIBGIT2_SHA256} libgit2.tar.gz" | sha256sum --check + tar xzf libgit2.tar.gz + cmake -S "libgit2-${LIBGIT2_VERSION}" -B libgit2-build -DCMAKE_INSTALL_PREFIX="$HOME/libgit2" -DCMAKE_INSTALL_LIBDIR=lib $LIBGIT2_CMAKE_FLAGS cmake --build libgit2-build --parallel cmake --install libgit2-build diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index f2669280..84e2387c 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -39,6 +39,7 @@ '@Symfony' => true, '@Symfony:risky' => true, 'php_unit_internal_class' => false, // From @PhpCsFixer but we don't want it + 'php_unit_strict' => true, // assertSame() instead of assertEquals() 'php_unit_test_class_requires_covers' => false, // From @PhpCsFixer but we don't want it 'phpdoc_add_missing_param_annotation' => false, // From @PhpCsFixer but we don't want it 'header_comment' => ['header' => $fileHeaderComment], diff --git a/composer.json b/composer.json index 6f8fd78f..7db091c7 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "require": { "php": "^8.4", "ext-pcre": "*", + "psr/log": "^1.1 || ^2.0 || ^3.0", "symfony/deprecation-contracts": "^2.5 || ^3", "symfony/process": "^6.4 || ^7.4 || ^8.1", "symfony/string": "^7.4.6 || ^8.0.6" @@ -45,15 +46,14 @@ "ext-fileinfo": "*", "ext-zlib": "*", "phpunit/phpunit": "^12.0", - "psr/log": "^3.0", "symfony/console": "^8.1" }, "suggest": { - "ext-ffi": "To use the experimental LibGit2Driver (in-process libgit2) instead of the default CliDriver — also requires libgit2 1.9 to be installed on the system." + "ext-ffi": "To use the experimental LibGit2Driver (in-process libgit2) instead of the default CliDriver — also requires libgit2 1.9 to be installed on the system.", + "ext-fileinfo": "To read the mimetype of a blob with Blob::getMimetype()." }, "config": { "preferred-install": "dist", "sort-packages": true - }, - "prefer-stable": true + } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 00a93fc2..60e89c10 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,149 +1,11 @@ parameters: ignoreErrors: - - - message: '#^Method Gitonomy\\Git\\Blame\:\:getGroupedLines\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Blame.php - - - - message: '#^Property Gitonomy\\Git\\Blame\\Line\:\:\$blockLine is never read, only written\.$#' - identifier: property.onlyWritten - count: 1 - path: src/Gitonomy/Git/Blame/Line.php - - - - message: '#^Property Gitonomy\\Git\\Blame\\Line\:\:\$targetLine is never read, only written\.$#' - identifier: property.onlyWritten - count: 1 - path: src/Gitonomy/Git/Blame/Line.php - - - - message: '#^Method Gitonomy\\Git\\Diff\\Diff\:\:fromArray\(\) has parameter \$array with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Diff/Diff.php - - - - message: '#^Method Gitonomy\\Git\\Diff\\Diff\:\:toArray\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Diff/Diff.php - - - - message: '#^Method Gitonomy\\Git\\Diff\\File\:\:fromArray\(\) has parameter \$array with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Diff/File.php - - - - message: '#^Method Gitonomy\\Git\\Diff\\File\:\:toArray\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Diff/File.php - - - - message: '#^Parameter \#1 \$hash of method Gitonomy\\Git\\Repository\:\:getBlob\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: src/Gitonomy/Git/Diff/File.php - - - - message: '#^Method Gitonomy\\Git\\Diff\\FileChange\:\:fromArray\(\) has parameter \$array with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Diff/FileChange.php - - - - message: '#^Method Gitonomy\\Git\\Diff\\FileChange\:\:toArray\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Diff/FileChange.php - - - - message: '#^Method Gitonomy\\Git\\Hooks\:\:get\(\) should return string but returns string\|false\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Hooks.php - - - - message: '#^Class Gitonomy\\Git\\Log implements generic interface IteratorAggregate but does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Method Gitonomy\\Git\\Log\:\:__construct\(\) has parameter \$paths with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Method Gitonomy\\Git\\Log\:\:__construct\(\) has parameter \$revisions with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Method Gitonomy\\Git\\Log\:\:count\(\) should return int\<0, max\> but returns int\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Method Gitonomy\\Git\\Log\:\:getIterator\(\) return type with generic class ArrayIterator does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Method Gitonomy\\Git\\Log\:\:getPaths\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Property Gitonomy\\Git\\Log\:\:\$paths type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Log.php - - message: '#^Method Gitonomy\\Git\\Parser\\ParserBase\:\:consumeRegexp\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Gitonomy/Git/Parser/ParserBase.php - - - message: '#^Method Gitonomy\\Git\\Reference\\Branch\:\:isLocal\(\) should return bool but returns bool\|null\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Branch.php - - - - message: '#^Class Gitonomy\\Git\\ReferenceBag implements generic interface IteratorAggregate but does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/Gitonomy/Git/ReferenceBag.php - - - - message: '#^Method Gitonomy\\Git\\ReferenceBag\:\:getIterator\(\) return type with generic class ArrayIterator does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/Gitonomy/Git/ReferenceBag.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:getDescription\(\) should return string but returns string\|false\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:getDiff\(\) has parameter \$revisions with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - message: '#^Method Gitonomy\\Git\\Repository\:\:getLog\(\) has parameter \$paths with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -174,12 +36,6 @@ parameters: count: 1 path: src/Gitonomy/Git/Repository.php - - - message: '#^Parameter \#1 \$string of function trim expects string, string\|false given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Repository.php - - message: '#^Method Gitonomy\\Git\\Revision\:\:getLog\(\) has parameter \$paths with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -204,9 +60,3 @@ parameters: count: 1 path: src/Gitonomy/Git/RevisionList.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Tree.php - diff --git a/phpstan.neon b/phpstan.neon index 6b86729c..7a65f6c4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,20 +1,24 @@ includes: - phpstan-baseline.neon + - tools/phpstan/vendor/phpstan/phpstan-phpunit/extension.neon + - tools/phpstan/vendor/phpstan/phpstan-phpunit/rules.neon parameters: level: 9 paths: - src + - tests/Gitonomy + - tests/bootstrap.php tmpDir: 'var/phpstan/tmp' inferPrivatePropertyTypeFromConstructor: true - excludePaths: - analyse: - # PHP FFI is dynamic by design: `$ffi->some_c_function()` and - # `$cdata->some_field` are resolved at runtime against the cdef - # loaded into that specific FFI/CData instance, not against any - # PHP class PHPStan can see. There is no stub format for this, so - # every call/property access here reads as "undefined" to static - # analysis even though it's correct — verified by actually - # exercising this driver against a live libgit2 and the full test - # suite (`GITLIB_DRIVER=ffi vendor/bin/phpunit`), not by type-checking it. - - src/Gitonomy/Git/Driver/Ffi/* + ignoreErrors: + # PHP FFI is dynamic by design: `$ffi->some_c_function()` and + # `$cdata->some_field` are resolved at runtime against the cdef loaded + # into that FFI instance, which PHPStan can't see. The tests run with + # GITLIB_DRIVER=ffi check them instead. + - + identifier: method.notFound + path: src/Gitonomy/Git/Driver/Ffi/* + - + identifier: property.notFound + path: src/Gitonomy/Git/Driver/Ffi/* diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a08183b3..be4cabfb 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,6 +5,8 @@ colors="true" displayDetailsOnTestsThatTriggerDeprecations="true" failOnDeprecation="true" + failOnNotice="true" + failOnPhpunitDeprecation="true" failOnRisky="true" failOnWarning="true" beStrictAboutOutputDuringTests="true" diff --git a/tests/fixtures/bundle-heads.txt b/tests/fixtures/bundle-heads.txt new file mode 100644 index 00000000..e7b18c46 --- /dev/null +++ b/tests/fixtures/bundle-heads.txt @@ -0,0 +1,8 @@ +7e9ba14939b0ce67ab95d83e03bd64710db91ef4 HEAD +7e9ba14939b0ce67ab95d83e03bd64710db91ef4 refs/heads/master +543dc7c3983b6a99b43e274d4823e4f8a9bda7a9 refs/heads/new-feature +519d5693c72c925cd59205d9f11e9fa1d550028b refs/heads/diff-features +10b5d7125a5da15eb350430b6f0504fccff26d71 refs/heads/pagination +cc06ac171d884282202dff88c1ded499a1f89420 refs/heads/path-resolving +4f17752acc9b7c54ba679291bf24cb7d354f0f4f refs/tags/0.1 +b61d8c2cac78a443729ca16cad12b96e3d98365d refs/tags/annotated diff --git a/tests/fixtures/verify-bundle.sh b/tests/fixtures/verify-bundle.sh index aaa52d63..a1b0d3ee 100755 --- a/tests/fixtures/verify-bundle.sh +++ b/tests/fixtures/verify-bundle.sh @@ -4,7 +4,8 @@ # request touching it should be reviewed by hand (see CONTRIBUTING.md), but # this catches the obvious cases automatically: # - a corrupted or incomplete bundle -# - refs that were not in the original fixture +# - refs that were not in the original fixture, or that moved: the tests +# rely on the commits they point to (bundle-heads.txt pins them) # - a file that has silently grown well past its expected size # # Run it locally with: tests/fixtures/verify-bundle.sh @@ -22,7 +23,7 @@ echo "== Verifying $BUNDLE ==" VERIFY_OUTPUT="$(git bundle verify "$BUNDLE")" echo "$VERIFY_OUTPUT" -SIZE_KB=$(( $(stat -c%s "$BUNDLE") / 1024 )) +SIZE_KB=$(( $(wc -c < "$BUNDLE") / 1024 )) echo "Size: ${SIZE_KB}KB (limit: ${MAX_SIZE_KB}KB)" if [ "$SIZE_KB" -gt "$MAX_SIZE_KB" ]; then echo "ERROR: $BUNDLE is ${SIZE_KB}KB, which exceeds the ${MAX_SIZE_KB}KB limit." >&2 @@ -40,4 +41,10 @@ if [ -n "$UNEXPECTED_REFS" ]; then exit 1 fi -echo "OK: bundle structure and size are within expected bounds." +if ! diff -u bundle-heads.txt <(git bundle list-heads "$BUNDLE"); then + echo "ERROR: the refs of $BUNDLE differ from tests/fixtures/bundle-heads.txt." >&2 + echo "Existing refs must not move: add commits on new branches or tags, then add their lines to bundle-heads.txt." >&2 + exit 1 +fi + +echo "OK: bundle structure, refs and size are within expected bounds." diff --git a/tools/phpstan/composer.json b/tools/phpstan/composer.json index 33cce923..e66b3802 100644 --- a/tools/phpstan/composer.json +++ b/tools/phpstan/composer.json @@ -1,6 +1,7 @@ { "require": { - "phpstan/phpstan": "^2.2.12" + "phpstan/phpstan": "^2.2.12", + "phpstan/phpstan-phpunit": "^2.0" }, "config": { "bump-after-update": true, diff --git a/tools/phpstan/composer.lock b/tools/phpstan/composer.lock index 9c925845..99a5babc 100644 --- a/tools/phpstan/composer.lock +++ b/tools/phpstan/composer.lock @@ -4,8 +4,59 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ab3e41a1c8f6553bf1007f6afad3d08e", + "content-hash": "c5d318150410709bc4ee6eeb07693fc4", "packages": [ + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, { "name": "phpstan/phpstan", "version": "2.2.12", @@ -69,6 +120,64 @@ } ], "time": "2026-08-31T19:09:43+00:00" + }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.0.21", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "8dbdc3a3e2d7c5dfea655d20ecca5bc9b1b1e441" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/8dbdc3a3e2d7c5dfea655d20ecca5bc9b1b1e441", + "reference": "8dbdc3a3e2d7c5dfea655d20ecca5bc9b1b1e441", + "shasum": "" + }, + "require": { + "phar-io/version": "^3.2", + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.2.6" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.21" + }, + "time": "2026-10-01T11:59:29+00:00" } ], "packages-dev": [], From 07597be741bcf71f4dd6590a3765a819930c0d0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 14:07:52 +0200 Subject: [PATCH 092/150] Document the 2.0 API Every page of doc/ describes the API of 2.0, its errors, and what differs between the drivers, with examples run against both. The deprecated forms are mentioned once, pointing to UPGRADE-2.0.md, which now lists every change, with a before/after for each deprecation. The CHANGELOG splits breaking changes, features, deprecations and fixes. The README installs 2.0 and shows how to choose the driver. --- CHANGELOG.md | 161 ++++++++++-------- README.md | 20 ++- UPGRADE-2.0.md | 404 +++++++++++++++++++++++++++++++++++---------- doc/admin.md | 52 +++++- doc/blame.md | 86 +++++++--- doc/blob.md | 42 +++-- doc/branch.md | 63 ++++++- doc/commit.md | 209 ++++++++++++----------- doc/diff.md | 222 ++++++++++++++++++------- doc/hooks.md | 26 ++- doc/log.md | 129 ++++++++++++--- doc/references.md | 128 ++++++++++---- doc/repository.md | 402 ++++++++++++++++++++++++++++++++------------ doc/revision.md | 43 +++-- doc/tree.md | 104 +++++++++--- doc/workingcopy.md | 117 +++++++++++-- 16 files changed, 1633 insertions(+), 575 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b9320d..081545f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,31 @@ All notable changes to this project are documented in this file. ## [Unreleased] Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). To upgrade, see -[UPGRADE-2.0.md](UPGRADE-2.0.md). +[UPGRADE-2.0.md](UPGRADE-2.0.md): most of the API of 1.x that 2.0 replaces keeps working, +deprecated until 3.0. + +### Breaking changes + +- Run every git operation through a driver (`Gitonomy\Git\Driver`): the domain objects call a + `Driver\RepositoryDriverInterface`, typed (hashes, enums, value objects of `Driver\Data`), + instead of building processes and parsing their output. A failing operation always throws, a + missing revision or object a `ReferenceNotFoundException`, a timeout a + `ProcessTimedOutException`; dates are `\DateTimeImmutable`; names in diffs and trees are + UTF-8; references are read again after every write gitlib makes, and peel annotated tags; + the diff of a merge commit is against its first parent; blame lines, tree entries, diff + lines and logs are typed objects; the subject of a message is its first paragraph. See + [UPGRADE-2.0.md](UPGRADE-2.0.md) for every change. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Require PHP 8.4 or later, Symfony 6.4/7.4/8.1+: most classes are `final` (some `readonly`), + and every method has native types. ([#238](https://github.com/gitonomy/gitlib/pull/238)) +- Remove `Gitonomy\Git\Util\StringHelper`, replaced by `symfony/string`, and its configurable + encoding: gitlib reads everything in UTF-8. ([#252](https://github.com/gitonomy/gitlib/pull/252)) +- Require git 1.8.4 or later, the oldest version the test suite passes with: it simplifies the + log of a range filtered by path like the current versions, and `Log::count()` needs git + 1.7.2. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- `Diff::parse()` throws for a diff without its raw information, deprecated since 1.6.0 + ([#229](https://github.com/gitonomy/gitlib/pull/229)): parse the output of + `git diff --raw --patch`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) ### Features @@ -16,17 +40,27 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). differences with `CliDriver`, and when it is faster. `benchmark.php` compares both drivers on a repository. With php-fpm, `LibGit2Driver::preload()` keeps libgit2 loaded from `opcache.preload`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- `Repository` takes the driver running git as its last argument: a - `Driver\Cli\CliDriver` by default, configured through its constructor, or any - `Driver\DriverInterface`, to replace it in tests; see - ["Using your own driver"](doc/repository.md#using-your-own-driver). ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- `Repository` takes the working directory and the driver as arguments: a `Driver\Cli\CliDriver` + by default, configured through its constructor (git binary, environment, timeout and logger, + which gets every command, clones included), or any `Driver\DriverInterface`, to replace it in + tests; see ["Using your own driver"](doc/repository.md#using-your-own-driver). The driver + finds the repository: linked worktrees, submodules and reftable repositories open, and a + directory that isn't a repository throws right away. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Add static methods to `Repository` creating repositories, `init()`, `initBare()`, `cloneFrom()`, `cloneBareFrom()` and `mirrorFrom()`, and checking a repository URL, `isValidRepository()` and `isValidRepositoryAndBranch()`; see ["Create and access git repositories"](doc/admin.md). -- Add `WorkingCopy::merge()` to support `git merge`: accepts a `Commit`, `Reference` or - string revision, plus optional extra CLI args (e.g. `['--no-ff']`), mirroring the existing - `checkout()` method. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Add `Repository::getDiff($from, $to)`, `Repository::getMergeBase()` and `WorkingCopy::getDiff()`, + `WorkingCopy::checkoutNewBranch()`, `Branch::moveTo()` and `Branch::isMergedInto()`, + `Log::withOffset()` and `withLimit()`, `Blame::getHunks()` and the line numbers of + `Blame\Line`, `TreeEntry` and `Submodule`, `Diff\DiffLine` and `Diff\DiffLineType`, + `File::isModeChange()`, `FileChange::getAdditions()` and `getDeletions()`, + `PushReference::getFullname()` and `getRange()`, and the SSH and X.509 signatures of tags. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Add `WorkingCopy::merge()` to support `git merge`, fast-forwarding or not, with a + `FastForward` enum. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) - Add `Repository::runProcess()` to expose stderr from successful commands: `run()` only ever returned stdout, even on success, but some git commands (e.g. `push`) write meaningful output to stderr even when they succeed. `run()` now delegates to it and keeps @@ -44,22 +78,60 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). ### Deprecations -- Deprecate `Admin`, use the static methods of `Repository` instead, and - `Repository::cloneTo()`, use `Repository::cloneFrom()` or `Repository::cloneBareFrom()` - instead; see [UPGRADE-2.0.md](UPGRADE-2.0.md#creating-repositories). -- Deprecate the `command`, `environment_variables`, `inherit_environment_variables` and - `process_timeout` options of `Repository` and `Admin`, pass a `Driver\Cli\CliDriver` - configured through its arguments as the driver of `Repository` instead, and the `debug` - option, as a failing operation always throws; see - [UPGRADE-2.0.md](UPGRADE-2.0.md#configuring-git). +All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). +([#253](https://github.com/gitonomy/gitlib/pull/253)) + +- `Admin`, use the static methods of `Repository`, and `Repository::cloneTo()`. +- The array of options of `Repository` and `Admin` (`working_dir`, `logger`, `command`, + `environment_variables`, `inherit_environment_variables`, `process_timeout`, `debug`): pass + the working directory and a `CliDriver` instead; `inherit_environment_variables` has no + counterpart, pass the variables git needs instead. `Repository::setLogger()` and `getLogger()`. +- Passing `$reload` to `Repository::getReferences()`, `ReferenceBag::update()`, + `Branch::isMergedTo()`, and arguments to `Commit::getIncludingBranches()`. +- Calling `Repository::getDiff()` with one argument, `Log::getDiff()`, `Diff::setRepository()`, + `File::setRepository()`, `addChange()`, `isChangeMode()`, `isDelete()` and `getAnchor()`, + `FileChange::getCount()` and its `LINE_*` constants, reading diff lines as arrays. +- `Log::setOffset()` and `setLimit()`. +- A range of lines as a string in `Repository::getBlame()`, `Blame::getGroupedLines()` and + `Blame\Line::getLine()`. +- `CommitReference`, `Tree::getCommitReferenceEntries()`, reading tree entries as arrays. +- The branch to create of `WorkingCopy::checkout()`, the arguments of `git merge` of + `WorkingCopy::merge()`. +- `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, + `PushReference::getReference()` and `getRevision()`. ### Bug fixes -- Fix `Commit::getDiff()` returning an empty diff for a root commit: it now lists the files the - commit adds. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `Commit::getDiff()` returning an empty diff for a root commit, and throwing for a merge + commit (`CliDriver`). ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix `Repository::getDiff()`, `WorkingCopy::getDiffPending()` and `WorkingCopy::getDiffStaged()` - failing to parse the output of git before 2.16, which writes "..." after abbreviated hashes. + failing to parse the output of git before 2.16, which writes "..." after abbreviated hashes, + and throwing during a conflict. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix the names of files with spaces in diffs, split at the wrong place, the hashes of the files + following a type change, shifted, and hunks of one line, counted as empty. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix annotated tags never resolving to their commit (`ReferenceBag::resolveTags()`, + `Commit::resolveReferences()`), tags created or deleted missing from `getTags()`, and + `getHead()` throwing after a checkout of a new branch: references are read again after + every write gitlib makes. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `createBranch()` and `createTag()` moving an existing reference instead of refusing it, + a branch named "remotes/x" taken for a remote one, and branches whose name ends with "HEAD" + missing. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `isValidRepositoryAndBranch()` matching the end of the name of a branch ("release" for + "feature/release"), and `isValidRepository()` reading a relative path as the name of a remote + of PHP's working directory, or returning `false` when git can't run. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `getTree()` reading the tree of a commit, and arguments holding a NUL byte throwing a + `ValueError`: they throw a `ReferenceNotFoundException` and an `InvalidArgumentException`. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix the log of a merge of an unsigned or SSH-signed tag throwing, or swallowing commits up to + the next PGP signature. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `Commit::getBodyMessage()` losing the second line of a message without an empty line + after its subject, tags without tagger throwing, and SSH-signed tags not seen as signed. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix blame lines numbered from 1 for a range of lines. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `Hooks` failing silently, in a repository without hooks directory, and writing the hooks + of a linked worktree where git doesn't run them. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix blame lines starting with a TAB character (`Blame\Line::getContent()`): code removing it must stop doing so. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix reading a commit whose `encoding` header isn't UTF-8, which threw: its message, author and @@ -82,9 +154,9 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). branch existed. - `Tag::getBodyMessage()` dropped or truncated a line for annotated tags using the standard `subject\n\nbody` convention. - - `Repository::run()` can return `null` on failure when `debug=false`; several call sites - (`Tag::isAnnotated()`, `PushReference::isForce()`, `Blob::getContent()`, ...) did not - handle that case and produced wrong data or crashed. All call sites now check for `null`. + - With the `debug` option set to `false`, several call sites (`Tag::isAnnotated()`, + `PushReference::isForce()`, `Blob::getContent()`, ...) produced wrong data or crashed + when git failed. - `Repository::shell()` interpolated the repository path into the shell command without escaping it; it is now passed through `escapeshellarg()`. - `Hooks::set()` created hook scripts world-writable (`0777`); they are now created @@ -93,50 +165,9 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). ### Minor -- Parse only diffs with their raw information, the output of `git diff --raw --patch`. - **Breaking:** `Diff::parse()` throws an `InvalidArgumentException` for a diff without it, - deprecated since 1.6.0 ([#229](https://github.com/gitonomy/gitlib/pull/229)). - ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Require git 1.8.4 or later, the oldest version the test suite passes with: it simplifies the - log of a range filtered by path like the current versions, and `Log::count()` needs git - 1.7.2. **Breaking:** git 1.7 and 1.8 before 1.8.4 are no longer supported. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Replace `Gitonomy\Git\Util\StringHelper` with `symfony/string`'s `CodePointString`, which - covers the same UTF-8-aware `strlen`/`substr`/`strpos`/`strrpos` operations. - **Breaking:** `StringHelper` is removed, along with its configurable encoding - (`getEncoding()`/`setEncoding()`); the library now consistently targets `UTF-8`. - ([#252](https://github.com/gitonomy/gitlib/pull/252)) -- Introduce a driver abstraction (`Gitonomy\Git\Driver`) between the domain objects and git: - every operation goes through a `Driver\DriverInterface` returning value objects - (`Driver\Data\CommitData`, `TreeEntryData`, `ReferenceData`, `TagData`, `BlameLineData`), - instead of building processes and parsing their output across the domain classes. - `CliDriver`, the default, keeps running the `git` binary. - **Breaking** (see [UPGRADE-2.0.md](UPGRADE-2.0.md) for the complete list): - - `Repository` throws on unknown options. - - The `debug` option set to `false` is not supported anymore: a failing operation always - throws, an `Exception\DriverException` (`ProcessException` with `CliDriver`) or a more - specific exception, and never returns empty data. `Repository::run()` returns a `string`, and - `Repository::getHead()` and `Repository::getHeadCommit()` never return `null`. - `Commit::getIncludingBranches()`, `Tag::isAnnotated()` and `ReferenceBag` throw where they - returned `false` or an empty result. Failures are no longer logged at the `error` level. - - `Commit::getAuthorDate()`, `Commit::getCommitterDate()` and `Tag::getTaggerDate()` return a - `\DateTimeImmutable`. - - File names in diffs and trees are returned in UTF-8, instead of git's quoted form (which - depended on `core.quotePath` for non-ASCII names in diffs). - - `Commit::setData()` and `Commit`'s constructor third argument take a - `Driver\Data\CommitData`. The parsers of `Gitonomy\Git\Parser` other than `DiffParser` - and `ParserBase` moved to `Driver\Cli\Parser`, and `CommitParser` is merged into - `LogParser`. - - `PushReference::isForce()` returns `true` for histories without a common ancestor, where - the constructor threw. - +- CI tests git 1.8.4 and 2.25.1, analyses the tests and the libgit2 driver with PHPStan, + checks the archive Composer installs, and the suite deletes what it creates, in any order. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Modernize for PHP 8.4+, Symfony 6.4/7.4/8.1+ and PHPUnit 12: full property/parameter/return - type coverage, `readonly`/`final` where applicable, PHPUnit 12 attributes, and new - php-cs-fixer/PHPStan/Castor tooling. - **Breaking:** the minimum supported PHP version is now 8.4, most classes are `final` (some - `readonly`), and every method has native types: see [UPGRADE-2.0.md](UPGRADE-2.0.md). - ([#238](https://github.com/gitonomy/gitlib/pull/238)) - Add a regression test for reading a blob's content in a tree that also contains a submodule. ([#243](https://github.com/gitonomy/gitlib/pull/243)) - Replace the network-dependent test fixture (cloned from GitHub on every run) with a local, diff --git a/README.md b/README.md index 790ce5f8..7005d8b1 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,13 @@ or edit your `composer.json` file by hand: ```json { "require": { - "gitonomy/gitlib": "^1.3" + "gitonomy/gitlib": "^2.0" } } ``` +Upgrading from 1.x? Read [UPGRADE-2.0.md](UPGRADE-2.0.md). + Example Usage ------------- @@ -43,9 +45,23 @@ foreach ($repository->getReferences()->getBranches() as $branch) { echo '- '.$branch->getName().PHP_EOL; } +echo $repository->getHeadCommit()->getSubjectMessage().PHP_EOL; + +// Any git command, with the default driver only $repository->run('fetch', ['--all']); ``` +The last argument of `Repository` is the driver running git. It is `CliDriver`, which runs the +`git` binary, by default. To run libgit2 instead, pass a `LibGit2Driver`: everything above works +the same, except `run()`, which needs the `git` binary. + +```php +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository', driver: new LibGit2Driver()); +``` + API Documentation ----------------- @@ -58,7 +74,7 @@ API Documentation + [Hooks](doc/hooks.md) + [Log](doc/log.md) + [References](doc/references.md) -+ [Repository](doc/repository.md) ++ [Repository](doc/repository.md): opening a repository, drivers, errors + [Revision](doc/revision.md) + [Tree](doc/tree.md) + [Working Copy](doc/workingcopy.md) diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 9dda0e4c..5a611d5f 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -4,10 +4,13 @@ UPGRADE FROM 1.x to 2.0 gitlib 2.0 runs every git operation through a driver: `CliDriver`, the default, runs the `git` binary like 1.x did, and the experimental `LibGit2Driver` runs libgit2 in-process (see -["Choosing a driver"](doc/repository.md#choosing-a-driver)). Code reading -repositories through `Repository`, `Commit`, `Log`... mostly keeps working: -check the points below, starting with the configuration of git, errors, -dates and classes you extend. +["Choosing a driver"](doc/repository.md#choosing-a-driver)). + +Most of the API of 1.x that 2.0 replaces keeps working, deprecated: each +call triggers a deprecation (`E_USER_DEPRECATED`, through +`symfony/deprecation-contracts`) naming its replacement, and 3.0 removes +them. Log the deprecations of your application to find them. The other +changes, listed below too, break code that relies on them. Requirements ------------ @@ -17,23 +20,33 @@ Requirements git simplifies the log of a range filtered by path differently, and it can't count commits (`Log::count()`) before 1.7.2. * `symfony/process` must be `^6.4`, `^7.4` or `^8.1`. - * `symfony/string` `^7.4.6` or `^8.0.6`, and `symfony/deprecation-contracts` - `^2.5` or `^3`, are new dependencies. - -Configuring git ---------------- - - * The `command`, `environment_variables`, `inherit_environment_variables` - and `process_timeout` options of `Repository` and `Admin` are deprecated, - and will be removed in 3.0: pass a `Gitonomy\Git\Driver\Cli\CliDriver` - configured through its arguments as the last argument of `Repository` - instead. They can't be combined with this argument. + * `symfony/string` `^7.4.6` or `^8.0.6`, `symfony/deprecation-contracts` + `^2.5` or `^3`, and `psr/log` `^1.1`, `^2.0` or `^3.0` are new + dependencies. + * Repositories using SHA-256 hashes are refused, with an + `UnsupportedOperationException`, by both drivers. + +Opening a repository +-------------------- + + * The second argument of `Repository` is the working directory, when the + git directory is kept apart from it. An array of options there is + deprecated: its `working_dir` option is this argument, and its `logger`, + `command`, `environment_variables` and `process_timeout` options configure + a `Gitonomy\Git\Driver\Cli\CliDriver`, which `Repository` takes as its last + argument. They can't be combined with this argument. + * The `inherit_environment_variables` option has no counterpart in + `CliDriver`: git inherits the environment variables of PHP anyway, and + `$_SERVER` holds, under php-fpm, the headers and the credentials of the + request. Pass the variables git needs to the `environmentVariables` + argument instead. Before: ```php $repository = new Repository($path, [ 'command' => '/usr/bin/git', + 'logger' => $logger, 'process_timeout' => 60, ]); ``` @@ -43,45 +56,56 @@ Configuring git ```php use Gitonomy\Git\Driver\Cli\CliDriver; - $repository = new Repository($path, driver: new CliDriver(command: '/usr/bin/git', processTimeout: 60)); + $repository = new Repository($path, driver: new CliDriver(command: '/usr/bin/git', processTimeout: 60, logger: $logger)); ``` - * `Repository` throws an `InvalidArgumentException` for an unknown option, - instead of ignoring it. + * The logger is an argument of `CliDriver`, which logs every git command it + runs, cloning and initializing included: `Repository::setLogger()` and + `Repository::getLogger()` are deprecated. `LibGit2Driver` runs no command, + and logs nothing. + * An unknown option throws an `InvalidArgumentException`, instead of being + ignored. + * The `debug` option is deprecated, and setting it to `false` throws an + `InvalidArgumentException`: see [Errors](#errors). + * `$path` may be a linked worktree or a submodule, whose `.git` is a file. + * A directory that isn't a repository throws an `InvalidArgumentException` + right away, instead of a `ProcessException` at the first operation. Creating repositories --------------------- - * `Admin` is deprecated, and will be removed in 3.0: use the static methods - of `Repository`. They take the URL before the path, and have a method for - each kind of repository instead of the `$bare` argument, whose default - was a bare repository: - - | 1.x | 2.0 | - |------------------------------------------------------|--------------------------------------------------------------| - | `Admin::init($path)` | `Repository::initBare($path)` | - | `Admin::init($path, false)` | `Repository::init($path)` | - | `Admin::cloneTo($path, $url)` | `Repository::cloneBareFrom($url, $path)` | - | `Admin::cloneTo($path, $url, false)` | `Repository::cloneFrom($url, $path)` | - | `Admin::cloneBranchTo($path, $url, $branch)` | `Repository::cloneBareFrom($url, $path, $branch)` | - | `Admin::cloneBranchTo($path, $url, $branch, false)` | `Repository::cloneFrom($url, $path, $branch)` | - | `Admin::mirrorTo($path, $url)` | `Repository::mirrorFrom($url, $path)` | - | `Admin::isValidRepository($url)` | `Repository::isValidRepository($url)` | - | `Admin::isValidRepositoryAndBranch($url, $branch)` | `Repository::isValidRepositoryAndBranch($url, $branch)` | - | `$repository->cloneTo($path)` | `Repository::cloneBareFrom($repository->getGitDir(), $path)` | - | `$repository->cloneTo($path, false)` | `Repository::cloneFrom($repository->getGitDir(), $path)` | - - Until then, `Admin` keeps working like in 1.x, with `CliDriver`. - * The new methods take the logger of the repository instead of options, - and the driver as their last argument: + * `Admin` is deprecated: use the static methods of `Repository`. They take + the URL before the path, have a method for each kind of repository + instead of the `$bare` argument, whose default was a bare repository, and + take the driver as last argument: + + | 1.x | 2.0 | + |-----------------------------------------------------|--------------------------------------------------------------| + | `Admin::init($path)` | `Repository::initBare($path)` | + | `Admin::init($path, false)` | `Repository::init($path)` | + | `Admin::cloneTo($path, $url)` | `Repository::cloneBareFrom($url, $path)` | + | `Admin::cloneTo($path, $url, false)` | `Repository::cloneFrom($url, $path)` | + | `Admin::cloneBranchTo($path, $url, $branch)` | `Repository::cloneBareFrom($url, $path, $branch)` | + | `Admin::cloneBranchTo($path, $url, $branch, false)` | `Repository::cloneFrom($url, $path, $branch)` | + | `Admin::mirrorTo($path, $url)` | `Repository::mirrorFrom($url, $path)` | + | `Admin::isValidRepository($url)` | `Repository::isValidRepository($url)` | + | `Admin::isValidRepositoryAndBranch($url, $branch)` | `Repository::isValidRepositoryAndBranch($url, $branch)` | + | `$repository->cloneTo($path)` | `Repository::cloneBareFrom($repository->getGitDir(), $path)` | + | `$repository->cloneTo($path, false)` | `Repository::cloneFrom($repository->getGitDir(), $path)` | + + Until 3.0, `Admin` keeps working like in 1.x, with `CliDriver` and the + options of 1.x. + * `isValidRepositoryAndBranch()` checks the exact name of the branch: + `release` no longer matches `feature/release`, nor `rel*` `release`. + * `isValidRepository()` and `isValidRepositoryAndBranch()` read a relative + path as a path, never as the name of a remote of PHP's working directory, + and throw a `ProcessException` when git can't run, instead of returning + `false`. Before: ```php - $repository = Admin::cloneTo($path, $url, false, [ - 'logger' => $logger, - 'process_timeout' => 60, - ]); + $repository = Admin::cloneTo($path, $url, false, ['process_timeout' => 60]); ``` After: @@ -89,25 +113,233 @@ Creating repositories ```php use Gitonomy\Git\Driver\Cli\CliDriver; - $repository = Repository::cloneFrom($url, $path, logger: $logger, driver: new CliDriver(processTimeout: 60)); + $repository = Repository::cloneFrom($url, $path, driver: new CliDriver(processTimeout: 60)); ``` - * `Repository::cloneTo()` used the driver of the repository: pass it to - `Repository::cloneFrom()` or `Repository::cloneBareFrom()` as well. + * `Repository::cloneTo()` cloned with the driver of the repository: pass it + to `cloneFrom()` or `cloneBareFrom()` as well. * `Admin::cloneRepository()` is deprecated, with no replacement for the arguments of `git clone` other than `--bare`, `--branch` and `--mirror`: run `git clone` yourself, then open the repository with `new Repository($path)`. +References +---------- + + * `Repository::getReferences()` reads the references again after every + write gitlib makes (creating or deleting a reference, a checkout, a merge, + `run()`...): passing `true` to read them again is deprecated. Open a new + `Repository` to see the writes of other processes. A `Reference` object + keeps the commit it pointed to when it was read. + * `Reference::getCommitHash()` returns the commit an annotated tag points + to, instead of the hash of the tag object: `ReferenceBag::resolve()`, + `resolveTags()` and `Commit::resolveReferences()` find annotated tags. + * `ReferenceBag::createBranch()` and `createTag()` throw an + `InvalidArgumentException` when the reference exists, instead of moving + it, and take a `Revision` or a revision name as second argument, instead + of a commit hash only. + * `ReferenceBag::update()` is deprecated: use `Branch::moveTo()`. + + Before: + + ```php + $repository->getReferences()->update(new Branch($repository, 'refs/heads/main', $hash)); + ``` + + After: + + ```php + $repository->getReferences()->getBranch('main')->moveTo($hash); + ``` + + * `Branch::isMergedTo()` is deprecated: use `isMergedInto()`, which takes + the destination as a `Revision` or a revision name, without a default + (`'master'` before) nor `$compareOnlyWithRemote`. + * Passing arguments to `Commit::getIncludingBranches()` is deprecated: it + returns the local and remote branches, which `Branch::isLocal()` and + `isRemote()` filter. + + Before: + + ```php + $branches = $commit->getIncludingBranches(true, false); + ``` + + After: + + ```php + $branches = array_filter($commit->getIncludingBranches(), static fn (Branch $branch): bool => $branch->isLocal()); + ``` + + * `ReferenceBag::getFirstBranch()` returns `null` instead of `false` when + there is no branch, and only branches. + * The getters of `Reference\Tag` (`getTaggerName()`, `getTaggerEmail()`, + `getTaggerDate()`, `getMessage()`, `getSubjectMessage()`, + `getBodyMessage()`, `getGpgSignature()`) return `null` instead of `false` + for a lightweight tag. The tagger is `null` for an annotated tag without + one too, where gitlib threw. `getGpgSignature()` returns `null` for an + unsigned tag instead of throwing, and finds X.509 and SSH signatures too. + * A branch whose name ends with `HEAD` is listed: only the `HEAD` of a + remote is left out. + +History +------- + + * `Log::setOffset()` and `setLimit()` are deprecated: `withOffset()` and + `withLimit()` return another `Log`. + * `count($log)` returns the number of commits the log iterates, after its + offset and up to its limit; `countCommits()` still counts them all. + * The revisions of a log are names, ranges (`a..b`, `a...b`), names to + exclude (`^a`) or `--all`: any other option of `git log` throws an + `InvalidArgumentException`, instead of being passed to git. An unknown + revision throws a `ReferenceNotFoundException` naming it. + * `Log::getDiff()` is deprecated: it ignored the paths of the log. Use + `Repository::getDiff()`. + +Diffs +----- + + * `Repository::getDiff()` takes the commits to compare: `$from` and `$to`. + Calling it with one argument is deprecated. + + | 1.x | 2.0 | + |------------------------------------|------------------------------------------------------------------| + | `$repository->getDiff('a..b')` | `$repository->getDiff('a', 'b')` | + | `$repository->getDiff(['a', 'b'])` | `$repository->getDiff('a', 'b')` | + | `$repository->getDiff('a...b')` | `$repository->getDiff($repository->getMergeBase('a', 'b'), 'b')` | + | `$repository->getDiff('a')` | `$repository->getWorkingCopy()->getDiff('a')` | + + * The diff of a merge commit, `Commit::getDiff()`, is against its first + parent, like GitHub shows it, with both drivers. + * `Diff`, `Diff\File` and `Diff\FileChange` get their repository and their + changes when constructed: `Diff::setRepository()`, + `File::setRepository()` and `File::addChange()` are deprecated. + `Diff::parse()` and `fromArray()` take the repository to read blobs from. + * The lines of a hunk, `FileChange::getLines()`, are `Diff\DiffLine` + objects, with `getType()`, a `Diff\DiffLineType`, and `getContent()`: + reading them as arrays is deprecated, as the `FileChange::LINE_*` + constants and `FileChange::getCount()`, replaced by `getAdditions()` and + `getDeletions()`. + + Before: + + ```php + foreach ($change->getLines() as [$type, $content]) { + if (FileChange::LINE_ADD === $type) { + // ... + } + } + ``` + + After: + + ```php + use Gitonomy\Git\Diff\DiffLineType; + + foreach ($change->getLines() as $line) { + if (DiffLineType::Addition === $line->getType()) { + // ... $line->getContent() + } + } + ``` + + * `File::isChangeMode()`, `isDelete()` and `getAnchor()` are deprecated: + use `isModeChange()`, `isDeletion()`, and + `substr($file->getNewIndex(), 0, 12)`. + * `File::getOldIndex()` and `getNewIndex()` return `null` for a missing + hash, never an empty string; `getName()` returns a `string`. + * `File::getOldBlob()` and `getNewBlob()` throw a `LogicException` without + repository, and a `RuntimeException` without hash. + * `Diff::parse()` throws an `InvalidArgumentException` for a diff without + its raw information, deprecated since 1.6.0: parse the output of + `git diff --raw --patch`, with `-z` or not. It reads the names of the + files from the raw information: names with spaces are no longer split at + the wrong place. + * During a merge conflict, `WorkingCopy::getDiffPending()` and + `getDiffStaged()` leave the unmerged files out, where they threw, and + `WorkingCopy::getDiff()` compares them, conflict markers included, to the + revision, like `git diff HEAD`. + +Blame +----- + + * `Repository::getBlame()` takes the first and the last lines to blame: + a range as a string (`'3,5'`, `'3,+3'`) is deprecated, and a regular + expression is no longer supported. + * `Blame::getLines()` and `getLine()` number the lines like the file, from + the first blamed line, instead of from 1. + * `Blame\Line::getNumber()` and `getOriginalNumber()` return the number of + the line in the file and in the commit that changed it, as integers: + `Line::getLine()` is deprecated. + * `Blame::getGroupedLines()` is deprecated: `getHunks()` returns + `Blame\Hunk` objects. + +Trees +----- + + * `Tree::getEntries()`, `getTreeEntries()`, `getBlobEntries()` and + `getSubmoduleEntries()` return `TreeEntry` objects, with `getName()`, + `getMode()` and `getObject()`: reading them as arrays + (`[$mode, $object]`) is deprecated. + * `CommitReference` is deprecated: it is `Submodule`. So is + `Tree::getCommitReferenceEntries()`: use `getSubmoduleEntries()`. + * `Tree::resolvePath()` throws an `InvalidArgumentException` explaining + why a path doesn't resolve, and resolves `/` to the tree. + +Working copy +------------ + + * `WorkingCopy::checkout()` takes a `Revision` or a revision name: a local + `Branch` object attaches `HEAD` to the branch, instead of detaching it. + Its second argument, a branch to create, is deprecated: use + `checkoutNewBranch($branch, $startPoint)`. + * `WorkingCopy::merge()` takes a `FastForward` instead of arguments of + `git merge`, deprecated. + + Before: + + ```php + $workingCopy->merge('feature', ['--no-ff']); + ``` + + After: + + ```php + use Gitonomy\Git\FastForward; + + $workingCopy->merge('feature', FastForward::Never); + ``` + +Commits, trees and blobs +------------------------ + + * `Repository::getCommit()` takes a full hash. `getTree()` and `getBlob()` + take a hash, abbreviated or not, instead of any revision: use + `getRevision($name)->getCommit()` for a name. + * Reading an object of another type throws a `ReferenceNotFoundException` + ("The object ... is not a tree."): `getTree()` read the tree of a commit, + and `getBlob()` threw a `ProcessException`. + * The subject of a commit or a tag is its first paragraph, on one line, and + its body what follows, like `git log --format=%s` and `%b`: a message + without an empty line after its first line has no body, where its second + line was lost. + * `Commit::getShortMessage()` and `getFixedShortHash()` are deprecated: use + `symfony/string` (`u($commit->getSubjectMessage())->truncate(50, '...')`) + and `substr()`. + * `Blob::getMimetype()` returns the mimetype alone (`text/plain`, instead + of `text/plain; charset=us-ascii`), and throws instead of returning + `false`. + * `Repository` keeps the objects it returns only while your application + uses them: memory no longer grows with every commit read. + Errors ------ - * The `debug` option of `Repository` is deprecated, and setting it to - `false` throws an `InvalidArgumentException`: a failing git operation - always throws, a `Gitonomy\Git\Exception\ProcessException` with - `CliDriver`. Nothing returns `null` or empty data instead anymore: - `Repository::run()` returns a `string`, and `Repository::getHead()` and - `Repository::getHeadCommit()` never return `null`. + * A failing git operation always throws, a + `Gitonomy\Git\Exception\ProcessException` with `CliDriver`. Nothing + returns `null` or empty data instead anymore: `Repository::run()` returns + a `string`, and `Repository::getHead()` and `Repository::getHeadCommit()` + never return `null`. Before: @@ -134,22 +366,32 @@ Errors * Failures are no longer logged at the `error` level: the exception reports them, and the logger gets their error output at the `debug` level. Alerts based on `error` logs must rely on exceptions instead. - * `ProcessException` now extends the new + * A missing revision, reference or object throws a + `ReferenceNotFoundException`, with both drivers: it extends + `Gitonomy\Git\Exception\InvalidArgumentException`, still an + `\InvalidArgumentException`, and takes a whole message instead of a name. + * A timeout throws a `Gitonomy\Git\Exception\ProcessTimedOutException`, + and the other failures of Symfony Process a `DriverException`: no + exception of Symfony or of PHP leaks anymore. + * `ProcessException` extends the new `Gitonomy\Git\Exception\DriverException`, itself a `RuntimeException`: catching `RuntimeException` or `GitExceptionInterface` still works. * These methods throw instead of returning a fallback value: * `Commit::getIncludingBranches()` threw nothing and returned `[]` when git failed; - * `Reference\Tag::isAnnotated()`, and the getters of a tag, return `false` - for a lightweight tag but throw for a tag that doesn't exist; * `ReferenceBag` (`Repository::getReferences()`) only returns an empty bag - for a repository without references, and throws for any other failure, - like a directory that isn't a repository. - * `Commit::getShortHash()` throws a `ReferenceNotFoundException` (an - `\InvalidArgumentException`) for an unknown commit, instead of a - `ProcessException`. + for a repository without references, and throws for any other failure. + * `Commit::getShortHash()` throws a `ReferenceNotFoundException` for an + unknown commit, instead of a `ProcessException`. + * `Hooks` throws a `RuntimeException` when a hook exists already, doesn't + exist, or can't be written, instead of a `LogicException` or a silent + failure, and creates the hooks directory. * The `Admin` methods throw a `ProcessException`, still a `RuntimeException`, with git's error output as message. + * An argument holding a NUL byte throws an `InvalidArgumentException`, + where PHP threw a `ValueError`: git and libgit2 read their arguments as C + strings, which end at the first NUL byte. + * Every message is a sentence ending with a dot. Dates ----- @@ -163,24 +405,23 @@ Extending gitlib ---------------- * These classes are `final`: `Admin`, `Blame`, `Blob`, `Commit`, - `Diff\File`, `Log`, `Parser\DiffParser`, `Reference\Branch`, - `Reference\Stash`, `Reference\Tag`, `ReferenceBag`, `Repository`, `Tree`, - and the exceptions `InvalidArgumentException`, `LogicException`, + `Diff\File`, `Log`, `Parser\DiffParser`, `PushReference`, + `Reference\Branch`, `Reference\Stash`, `Reference\Tag`, `ReferenceBag`, + `Repository`, `Tree`, and the exceptions `LogicException`, `ProcessException`, `ReferenceNotFoundException` and `UnexpectedValueException`. - * These classes are `final readonly`: `Blame\Line`, `CommitReference`, - `Diff\Diff`, `Diff\FileChange`, `Hooks`, `PushReference`, `RevisionList`, - `WorkingCopy`. + * These classes are `final readonly`: `Blame\Line`, `Diff\Diff`, + `Diff\FileChange`, `Hooks`, `RevisionList`, `WorkingCopy`. * Every method has native parameter and return types. A class extending `Revision`, `Reference` or `Parser\ParserBase`, which remain extendable, must declare the same types, and must not redeclare their properties. + The constructor of `Reference` requires the commit hash. * To change how gitlib runs git, for example in tests, implement `Gitonomy\Git\Driver\DriverInterface` and pass it as the driver of `Repository`, instead of extending `Repository`: see ["Using your own driver"](doc/repository.md#using-your-own-driver). - * `Commit::__construct()` and `Commit::setData()` take a - `Gitonomy\Git\Driver\Data\CommitData` instead of an array, and - `setData()` replaces all the data instead of merging it. + * `Commit::__construct()` takes a `Gitonomy\Git\Driver\Data\CommitData` + instead of an array, and `Commit::setData()` is internal. * `Gitonomy\Git\Util\StringHelper` is removed, with its configurable encoding: use `symfony/string`. Logs are always read in UTF-8. * The parsers of the git output moved to `Gitonomy\Git\Driver\Cli\Parser` @@ -189,7 +430,8 @@ Extending gitlib of `Gitonomy\Git\Driver\Data`: `LogParser::$commits` (instead of `$log`), `TagParser::$data`, `ReferenceParser::$references`, `TreeParser::$entries` and `BlameParser::$lines`. `TreeParser` parses `git ls-tree -z`, and - `BlameParser` no longer takes a `Repository`. + `BlameParser` no longer takes a `Repository`. `ParserBase::parse()` + takes a `string`. * Objects serialized with 1.x (a `Commit` or a `Diff` in a cache...) can't be unserialized: clear such caches. `Diff::toArray()` and `Diff::fromArray()` remain compatible. @@ -208,17 +450,11 @@ Data returned lines around it, nor whitespace at the end of its lines. It also comes re-encoded to UTF-8 when the commit has an `encoding` header, instead of throwing. - * `Commit::getShortMessage()` throws a - `Symfony\Component\String\Exception\InvalidArgumentException` when the - subject isn't valid UTF-8. - * `Reference\Tag::isSigned()` returns `false` for a lightweight tag, - `Reference\Tag::getBodyMessage()` no longer starts with a line break, and - `Reference\Tag::getGPGSignature()` throws a - `Gitonomy\Git\Exception\InvalidArgumentException` for an unsigned tag. - * `ReferenceBag::getFirstBranch()` returns the first branch, instead of the - first reference of any kind. + * `Reference\Tag::isSigned()` returns `false` for a lightweight tag. * `PushReference::isForce()` returns `true` for histories without a common - ancestor, where the constructor threw. + ancestor, where the constructor threw. `PushReference` runs git when + `isForce()` needs it, not when constructed. `getReference()` and + `getRevision()` are deprecated: use `getFullname()` and `getRange()`. * `Blob::getContent()` only reads blobs, where `git cat-file -p` also printed trees and commits. * `WorkingCopy::getUntrackedFiles()` and `RevisionList::getAll()` return @@ -228,12 +464,10 @@ Data returned Other changes ------------- - * `Diff::parse()` throws an `InvalidArgumentException` for a diff without - its raw information, deprecated since 1.6.0: parse the output of - `git diff --raw --patch`, which gives the full hashes of the files, even - empty ones. * Relative paths, in the arguments of `Repository::run()` and in the paths of a log, are relative to the repository instead of the current directory of PHP. * `Hooks::set()` makes hooks executable by their owner only (`0700`, instead of `0777`): change their permissions if another user runs them. + * `Repository::shell()` is deprecated: it ignored the driver. Run + `git shell` with Symfony Process instead. diff --git a/doc/admin.md b/doc/admin.md index be38124f..3aff0a53 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -5,6 +5,9 @@ The static methods of `Repository` create new repositories, and return them. They replace `Admin`, deprecated since 2.0: see [UPGRADE-2.0.md](../UPGRADE-2.0.md#creating-repositories). +To open a repository that exists already, use `new Repository($path)`: see +[Repository](repository.md). + Create a repository ------------------- @@ -38,6 +41,12 @@ working directory, this branch will be checked out: $repository = Repository::cloneFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib', 'a-branch'); ``` +Like `git clone --branch`, the branch may also be a tag: the clone checks +its commit out, `HEAD` detached. + +Like `git clone`, the methods refuse a directory with files in it. When a +clone fails, they remove what they created. + To clone a `Repository` object, use its git directory as URL: ```php @@ -78,20 +87,47 @@ Repository::isValidRepositoryAndBranch('https://github.com/gitonomy/gitlib.git', ``` They return `false` as well for a repository that can't be reached, or that -refuses access. +refuses access. The branch must have this name exactly: `release` doesn't +name `feature/release`. A relative path is the path of a repository, never +the name of a remote. + +Errors +------ -Logger and driver +When git fails (an unknown branch, a directory with files, a network +error...), the methods creating repositories throw a +`Gitonomy\Git\Exception\DriverException`: a `ProcessException`, with git's +error output, when `CliDriver` runs git. With `CliDriver`, every method, +`isValidRepository()` included, throws a `ProcessException` when git can't +run, like a missing binary. See [Errors](repository.md#errors). + +Driver and logger ----------------- -The methods returning a `Repository` take its [logger](repository.md#logger), -and all of them the driver running git as their last argument — see -["Choosing a driver"](repository.md#choosing-a-driver): +All these methods take the driver running git as their last argument, see +["Choosing a driver"](repository.md#choosing-a-driver). The repositories they +return use it too: ```php -$repository = Repository::initBare('/path/to/repository', driver: new Gitonomy\Git\Driver\Ffi\LibGit2Driver()); +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Repository; + +$repository = Repository::initBare('/path/to/repository', driver: new LibGit2Driver()); +``` + +They take no logger: the logger is an argument of `CliDriver`, which logs the +commands initializing and cloning repositories too. See +[Logger](repository.md#logger): + +```php +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; + +$repository = Repository::cloneFrom($url, '/tmp/gitlib', driver: new CliDriver(logger: $logger)); ``` ### References -- -- +- +- +- diff --git a/doc/blame.md b/doc/blame.md index 80816c9b..37556af0 100644 --- a/doc/blame.md +++ b/doc/blame.md @@ -1,58 +1,90 @@ Blaming files ============= +`Repository::getBlame()` returns, for each line of a file, the commit that +last changed it, like `git blame`. It takes a revision (a name or a +`Revision` object) and the path of the file, relative to the root of the +repository: + +```php +$blame = $repository->getBlame('master', 'README.md'); +``` + +To blame some lines only, pass the first and the last ones, numbered from +1: + +```php +// Lines 2 to 3 +$blame = $repository->getBlame('master', 'README.md', 2, 3); + +// From line 2 to the end of the file +$blame = $repository->getBlame('master', 'README.md', 2); +``` + +Like git, the blame stops at the end of the file, and a first line after it +throws a `DriverException`. A last line before the first one blames the lines +in order, like `git blame -L 5,3`. + +Passing a range as a string (`'2,3'` or `'2,+2'`, like `git blame -L`) is +deprecated since 2.0: pass the first and the last lines instead. + Line-per-line iteration ----------------------- -To iterate on lines of a blame: +`getLines()` returns the lines of the blame, `Blame\Line` objects, keyed by +their number in the file: ```php -$blame = $repository->getBlame('master', 'README.md'); - -foreach ($blame->getLines() as $lineNumber => $line) { - $commit = $line->getCommit(); - echo $lineNumber.': '.$line->getContent().' - '.$commit->getAuthorName().PHP_EOL; +foreach ($blame->getLines() as $number => $line) { + echo $number.': '.$line->getContent().' - '.$line->getCommit()->getAuthorName().PHP_EOL; } ``` -The *getLines* method returns an array indexed starting from 1. +With a range, the keys start at its first line, not at 1. -As you can see, you can access the commit object related to the line you -are iterating on. - -If you want to access directly a line: +To get a line by its number in the file: ```php -$line = $blame->getLine(32); +$line = $blame->getLine(2); ``` +It throws an `InvalidArgumentException` when the blame doesn't have this +line. `count($blame)` returns the number of lines blamed. + The Line object --------------- -LineObject represents an item of the blame file. It is composed of those -informations: +A `Blame\Line` is a line of the blamed file: ```php -$line->getCommit(); // returns a Commit -$line->getContent(); // returns text +$line->getNumber(); // Its number in the blamed file +$line->getOriginalNumber(); // Its number in the file of the commit that last changed it +$line->getContent(); // Its text, without its line break +$line->getCommit(); // The commit that last changed it -// you can access author from commmit: +// The author of the commit $author = $line->getCommit()->getAuthorName(); ``` +`Line::getLine()` is deprecated since 2.0: use `getOriginalNumber()`, which +returns an integer. + Group reading by commit ----------------------- -If you plan to display it, you'll probably need a version where lines -from same commit are grouped. - -To do so, use the *getGroupedLines* method that will return an array -like this: +To display a blame, you'll probably group the consecutive lines last +changed by the same commit. `getHunks()` returns these groups, `Blame\Hunk` +objects, with their commit and their lines, keyed by their number in the +file: ```php -$blame = array( - array(Commit, array(1 => Line, 2 => Line, 3 => Line)), - array(Commit, array(4 => Line)), - array(Commit, array(5 => Line, 6 => Line)) -) +foreach ($blame->getHunks() as $hunk) { + echo $hunk->getCommit()->getShortHash().' '.$hunk->getCommit()->getSubjectMessage().PHP_EOL; + + foreach ($hunk->getLines() as $number => $line) { + echo ' '.$number.': '.$line->getContent().PHP_EOL; + } +} ``` + +`getGroupedLines()` is deprecated since 2.0: use `getHunks()`. diff --git a/doc/blob.md b/doc/blob.md index 2ed3bd41..52e7815c 100644 --- a/doc/blob.md +++ b/doc/blob.md @@ -1,24 +1,35 @@ Blob ==== -In git, a blob represents a file content. You can't access the file name -directly from the *Blob* object; the filename information is stored -within the tree, not in the blob. +In git, a blob is the content of a file. A `Blob` object doesn't know the +name of its file: names are stored in trees, not in blobs. Two files with +different names but the same content have the same blob. -It means that for git, two files with different names but same content -will have the same hash. +To get the blob of a file, resolve its path in the tree of a commit (see +[tree.md](tree.md)): -To access a repository *Blob*, you need the hash identifier: +```php +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository'); +$blob = $repository->getRevision('master')->getCommit()->getTree()->resolvePath('README.md'); +``` + +`Repository::getBlob()` takes the hash of a blob, abbreviated or not: ```php -$repository = new Gitonomy\Git\Repository('/path/to/repository'); -$blob = $repository->getBlob('a7c8d2b4'); +$blob = $repository->getBlob('07eeea9'); + +echo $blob->getHash().PHP_EOL; // "07eeea94584fb6f1d8fe55e5b78adc4668c02508", always the full hash ``` +It throws a `ReferenceNotFoundException` for anything else than a hash, of +4 characters at least, and for an abbreviated hash naming nothing. + Get content ----------- -To get content from a *Blob* object: +To get the content of a `Blob` object: ```php echo $blob->getContent(); @@ -27,18 +38,21 @@ echo $blob->getContent(); File informations ----------------- -To get mimetype of a *Blob* object using finfo extension: +`getMimetype()` returns the mimetype of the content, alone, like +`text/plain` or `image/jpeg`. It needs the `fileinfo` extension of PHP, and +throws a `RuntimeException` when it can't read the mimetype: ```php -echo $blob->getMimetype(); +echo $blob->getMimetype().PHP_EOL; ``` -You can also test if *Blob* is a text of a binary file: +`isText()` and `isBinary()` tell if the blob is a text or a binary file. +They check its first 8000 bytes for a NUL character, like git does: ```php if ($blob->isText()) { - echo $blob->getContent(), PHP_EOL; + echo $blob->getContent().PHP_EOL; } elseif ($blob->isBinary()) { - echo 'File is binary', PHP_EOL; + echo 'File is binary'.PHP_EOL; } ``` diff --git a/doc/branch.md b/doc/branch.md index d6e0872c..7752e2c8 100644 --- a/doc/branch.md +++ b/doc/branch.md @@ -1,16 +1,69 @@ Branch ====== -To access a *Branch*, starting from a repository object: +To access a `Branch`, starting from a repository object: ```php -$repository = new Gitonomy\Git\Repository('/path/to/repository'); +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository'); $branch = $repository->getReferences()->getBranch('master'); +$remoteBranch = $repository->getReferences()->getRemoteBranch('origin/master'); +``` + +See [references.md](references.md) to list, create and delete branches. + +You can check if the branch is a local or a remote one: + +```php +$branch->isLocal(); // A branch of refs/heads/ +$branch->isRemote(); // A branch of refs/remotes/, like origin/master + +$branch->getName(); // "master", or "origin/master" for a remote branch +``` + +Like every reference, a branch gives its commit: + +```php +$commit = $branch->getCommit(); +$hash = $branch->getCommitHash(); ``` -You can check is the branch is a local or remote one: +Moving a branch +--------------- + +`moveTo()` points the branch to a revision: a name, a hash, or a `Revision` +object. It returns the branch, read again: the object you called it on +keeps the commit it pointed to. ```php -$branch->isLocal(); -$branch->isRemote(); +$branch = $branch->moveTo('master~2'); ``` + +Like `git update-ref`, it doesn't touch the working tree, even for the +branch checked out. `ReferenceBag::update()` is deprecated since 2.0: use +`moveTo()`. + +Merged branches +--------------- + +`isMergedInto()` tells if the history of a revision (a name, a hash, or a +`Revision` object) contains the branch: + +```php +if ($branch->isMergedInto('master')) { + echo $branch->getName().' is merged into master'.PHP_EOL; +} +``` + +`isMergedTo()` is deprecated since 2.0: use `isMergedInto()`, which has no +default destination (`master`) nor flag to compare remote branches only. + +Branches containing a commit +---------------------------- + +To find the branches whose history contains a commit, use +`Commit::getIncludingBranches()`: see +[commit.md](commit.md#branches-containing-a-commit). + +To check a branch out, see [workingcopy.md](workingcopy.md). diff --git a/doc/commit.md b/doc/commit.md index 7a6d53fd..f75b119f 100644 --- a/doc/commit.md +++ b/doc/commit.md @@ -1,171 +1,194 @@ Commit ====== -To access a *Commit*, starting from a repository object: +To get a `Commit`, resolve a revision: a branch, a tag, `HEAD`, `master~2`, +an abbreviated hash... ```php -$repository = new Gitonomy\Git\Repository('/path/to/repository'); -$commit = $repository->getCommit('a7c8d2b4'); +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository'); +$commit = $repository->getRevision('master')->getCommit(); +``` + +`Repository::getCommit()` takes the full hash of a commit, its 40 +characters. It throws a `ReferenceNotFoundException` for anything else, an +abbreviated hash included: + +```php +$commit = $repository->getCommit('7e9ba14939b0ce67ab95d83e03bd64710db91ef4'); ``` +The commit is read when you first need it: a hash naming no commit throws a +`ReferenceNotFoundException` then. + +Hashes +------ + +```php +$commit->getHash(); // "7e9ba14939b0ce67ab95d83e03bd64710db91ef4" +$commit->getShortHash(); // "7e9ba14", abbreviated and unique in the repository +``` + +`getFixedShortHash()` is deprecated since 2.0: use +`substr($commit->getHash(), 0, 6)`. + Browsing parents ---------------- -A *Commit* can have a natural number of parents: +A commit has: -- **no parent**: it's an initial commit, the root of a tree -- **one parent**: it means it's not a merge, just a regular commit -- **many parents**: it's a merge-commit - -You have 2 methods available for accessing parents: +- **no parent**: it's a root commit, the first one of a history; +- **one parent**: it's a regular commit; +- **several parents**: it's a merge commit. Its first parent is the + commit the other ones were merged into. ```php -// Access parent hashes +// The hashes of the parents $hashes = $commit->getParentHashes(); -// Access parent commit objects -$commits = $commit->getParents(); +// The parents, Commit objects +$parents = $commit->getParents(); ``` -For example, if you want to display all parents, starting from a commit: +For example, to display the first parents of a commit, like +`git log --first-parent`: ```php -function displayLog(Gitonomy\Git\Commit $commit) { - echo '- '.$commit->getShortMessage().PHP_EOL; - foreach ($commit->getParents() as $parent) { - displayLog($parent); - } +for ($current = $commit; null !== $current; $current = $current->getParents()[0] ?? null) { + echo $current->getShortHash().' '.$current->getSubjectMessage().PHP_EOL; } ``` -Notice that this function will first display all commits from first -merged branch and then display all commits from next branch, and so on. - -Accessing tree --------------- +To list a history in order, with every parent, use a log: see +[log.md](log.md). -The tree object contains the reference to the files associated to a -given commit. Every commit has one and only one tree, referencing all -files and folders of a given state for a project. For more informations -about the tree, see the chapter dedicated to it. +Accessing the tree +------------------ -To access a tree starting from a commit: +The tree of a commit holds its files and folders, as the commit recorded +them. Every commit has exactly one tree. See [tree.md](tree.md) to browse +it. ```php -// Returns the tree hash -$tree = $commit->getTreeHash(); +// The hash of the tree +$hash = $commit->getTreeHash(); -// Returns the tree object +// The Tree object $tree = $commit->getTree(); ``` -Author & Committer informations -------------------------------- +Author and committer +-------------------- -Each commit has two authoring informations: an author and a committer. -The author is the creator of the modification, authoring a modification -in the repository. The committer is responsible of introducing this -modification to the repository. - -You can access informations from author and committer using those -methods: +A commit has an author and a committer. The author wrote the change. The +committer recorded it in the repository: they can differ when a commit is +rebased, cherry-picked or applied from a patch. ```php // Author $commit->getAuthorName(); $commit->getAuthorEmail(); -$commit->getAuthorDate(); // returns a DateTimeImmutable object +$commit->getAuthorDate(); // A DateTimeImmutable // Committer $commit->getCommitterName(); $commit->getCommitterEmail(); -$commit->getCommitterDate(); // returns a DateTimeImmutable object +$commit->getCommitterDate(); // A DateTimeImmutable ``` -Commit message and short message --------------------------------- - -Each commit also has a message, associated to the modification. This -message can be multilined. +Message +------- -To access the message, you can use the *getMessage* method: +The message of a commit can have several lines. Like git, gitlib splits it +in a subject and a body: ```php +// The whole message $commit->getMessage(); -``` - -For your convenience, this library provides a shortcut method to keep -only the first line or first 50 characters if the first line is too -long: - -```php -$commit->getShortMessage(); -``` -You can customize it like this: +// The first paragraph, on one line, like "git log --format=%s" +$commit->getSubjectMessage(); -```php -$commit->getShortMessage(45, true, '.'); +// What follows the first paragraph and its empty lines, like "git log --format=%b" +$commit->getBodyMessage(); ``` -- The first parameter is the max length of the message. -- The second parameter determine if the last word should be cut or - preserved -- The third parameter is the separator +For a message `"Fix the parser\nof dates\n\nIt failed on leap years.\n"`, +the subject is `"Fix the parser of dates"`, and the body +`"It failed on leap years.\n"`. A message without an empty line has no +body. -There are also two other methods for your convenience: +`getShortMessage()` is deprecated since 2.0: truncate the subject with +`symfony/string`, a dependency of gitlib, instead: ```php -// The first line -$commit->getSubjectMessage(); +use function Symfony\Component\String\u; -// The body (rest of the message) -$commit->getBodyMessage(); +echo u($commit->getSubjectMessage())->truncate(50, '...').PHP_EOL; ``` Diff of a commit ---------------- -You can check the modifications introduced by a commit using the -*getDiff* method. When you request a diff for a commit, depending of the -number of parents, the strategy will be different: +`getDiff()` returns the changes a commit made: -- If you have *no parent*, the diff will be the content of the tree -- If you only have *one parent*, the diff will be between the commit - and his parent -- If you have *multiple parents*, the diff will be the difference - between the commit and the first common ancestor of all parents - -For more informations about the diff API, read the related chapter. - -To access the *Diff* object of a commit, use the method *getDiff*: +- against its first parent, for a regular commit or a merge commit. For a + merge, these are the changes it brought into the branch it was merged + into, like GitHub shows a merge commit; +- against nothing, for a root commit: every file is a creation. ```php $diff = $commit->getDiff(); ``` +To compare any two commits, use `Repository::getDiff($from, $to)`. See +[diff.md](diff.md) for the diff API. + Last modification of a file --------------------------- -To know the last modification of a file, you can use the -*getLastModification* method on a commit. - -Here is a very straightforward example: +`getLastModification()` returns the last commit, in the history of this +one, that changed a path. It throws a `ReferenceNotFoundException` when no +commit changed it. The path is relative to the root of the repository. ```php -$last = $commit->getLastModification('README'); +$last = $commit->getLastModification('README.md'); -echo 'Last README modification'.PHP_EOL; +echo 'Last README.md modification'.PHP_EOL; echo ' Author: '.$last->getAuthorName().PHP_EOL; echo ' Date: '.$last->getAuthorDate()->format('d/m/Y').PHP_EOL; -echo ' Message: '.$last->getMessage(); +echo ' Message: '.$last->getSubjectMessage().PHP_EOL; +``` + +References pointing to a commit +------------------------------- + +`resolveReferences()` returns the branches and tags pointing to the commit, +annotated tags included: + +```php +foreach ($commit->resolveReferences() as $reference) { + echo $reference->getFullname().PHP_EOL; // "refs/heads/master", "refs/tags/1.0"... +} ``` -Find every branches containing a commit ---------------------------------------- +See [references.md](references.md#resolution-from-a-commit). + +Branches containing a commit +---------------------------- + +`getIncludingBranches()` returns the local and remote branches whose +history contains the commit, as `Branch` objects. Filter them with +`isLocal()` or `isRemote()`: ```php -$branches = $commit->getIncludingBranches($includeLocalBranches, $includeRemoteBranches); -$localBranches = $commit->getIncludingBranches(true, false); -$remoteBranches = $commit->getIncludingBranches(false, true); +use Gitonomy\Git\Reference\Branch; + +$branches = $commit->getIncludingBranches(); +$localBranches = array_filter($branches, static fn (Branch $branch): bool => $branch->isLocal()); +$remoteBranches = array_filter($branches, static fn (Branch $branch): bool => $branch->isRemote()); ``` + +Passing arguments to `getIncludingBranches()`, to keep the local or the +remote branches only, is deprecated since 2.0. diff --git a/doc/diff.md b/doc/diff.md index 2ce7870a..415816a9 100644 --- a/doc/diff.md +++ b/doc/diff.md @@ -1,104 +1,210 @@ -Computing diff -============== +Computing diffs +=============== -Even if git is a diff-less storage engine, it's possible to compute -them. +Git stores snapshots of files, not diffs: gitlib computes them, like +`git diff`. -To compute a diff in git, you need to specify a *revision*. This -revision can be a commit (*2bc7a8*) or a range (*2bc7a8..ff4c21b*). - -For more informations about git revisions: *man gitrevisions*. +Comparing two commits +--------------------- -When you have decided the revision you want and have your *Repository* -object, you can call the *getDiff* method on the repository: +`Repository::getDiff($from, $to)` returns the changes from the commit +`$from` to the commit `$to`. Each one is a revision name (a branch, a tag, a +hash, `master~2`...) or a `Revision` object (a `Commit`, a `Branch`...): ```php -$diff = $repository->getDiff('master@{2 days ago}..master'); +$diff = $repository->getDiff('0.1', 'master'); ``` -You can also access it from a *Log* object: +Like `git diff 0.1 master`, it compares the files of both commits. To compare +a branch with the commit it forked from, like `git diff master...feature`, +start from their best common ancestor: ```php -$log = $repository->getLog('master@{2 days ago}..master'); -$diff = $log->getDiff(); +$diff = $repository->getDiff($repository->getMergeBase('master', 'feature'), 'feature'); ``` +Calling `getDiff()` with one argument (a range like `a..b` or `a...b`, two +revisions in an array, or one revision to compare with the working tree) +is deprecated since 2.0: see [UPGRADE-2.0.md](../UPGRADE-2.0.md). +`Log::getDiff()` is deprecated too: it ignored the paths of the log. + +Other diffs +----------- + +- `Commit::getDiff()` returns the changes of a commit, against its first + parent: see [commit.md](commit.md#diff-of-a-commit). +- `WorkingCopy::getDiff($revision)` returns the changes of the working + tree, staged or not, since a revision (`HEAD` by default). +- `WorkingCopy::getDiffPending()` returns the changes of the working tree + not staged yet. +- `WorkingCopy::getDiffStaged()` returns the changes staged in the index. + +See [workingcopy.md](workingcopy.md) for the working copy. + +Every diff detects renames, like `git diff -M`. `LibGit2Driver` computes +the similarity of files like libgit2 does: it can find a rename where git +finds a deletion and a creation (see +[Choosing a driver](repository.md#choosing-a-driver)). During a merge +conflict, `WorkingCopy::getDiffStaged()` and `getDiffPending()` leave the +unmerged files out, and `WorkingCopy::getDiff()` compares them, conflict +markers included, to the revision, like `git diff HEAD`. + Iterating a diff ---------------- -When you have a *Diff* object, you can iterate over files using method -*getFiles()*. This method returns a list of *File* objects, who -represents the modifications for a single file. +`getFiles()` returns the files of a diff, `File` objects: ```php $files = $diff->getFiles(); -echo sprintf('%s files modified%s', count($files), PHP_EOL); +echo count($files).' files changed'.PHP_EOL; -foreach ($files as $fileDiff) { - echo sprintf('Old name: (%s) %s%s', $fileDiff->getOldMode(), $fileDiff->getOldName(), PHP_EOL); - echo sprintf('New name: (%s) %s%s', $fileDiff->getNewMode(), $fileDiff->getNewName(), PHP_EOL); +foreach ($files as $file) { + echo $file->getName().': +'.$file->getAdditions().' -'.$file->getDeletions().PHP_EOL; } ``` The File object --------------- -Here is an exhaustive list of the *File* class methods: +A `File` tells what happened to a file: ```php -$file->getOldName(); -$file->getNewName(); -$file->getOldDiff(); -$file->getNewDiff(); +$file->isCreation(); // There is no old file +$file->isDeletion(); // There is no new file +$file->isModification(); // There are both: a rename, a copy or a mode change included +$file->isRename(); +$file->isCopy(); // Only for a diff detecting copies, like "git diff -C" +$file->isModeChange(); // From "100644" to "100755", for example +$file->isBinary(); // A binary file has no changes +``` -$file->isCreation(); -$file->isDeletion(); -$file->isModification(); +A type change, like a file replaced by a symbolic link, is a deletion +followed by a creation, like git shows it. -$file->isRename(); -$file->isChangeMode(); +Its names, modes and blob hashes: + +```php +$file->getName(); // The new name, or the old one for a deletion +$file->getOldName(); // null for a creation +$file->getNewName(); // null for a deletion + +$file->getOldMode(); // "100644", "100755", "120000" (a symbolic link)... null for a creation +$file->getNewMode(); // null for a deletion + +$file->getOldIndex(); // The hash of the old blob, null for a creation +$file->getNewIndex(); // The hash of the new blob, null for a deletion +``` + +`getOldBlob()` and `getNewBlob()` return the `Blob` objects, to read the +content of the file before and after (see [blob.md](blob.md)): + +```php +if (!$file->isCreation() && !$file->isBinary()) { + $before = $file->getOldBlob()->getContent(); +} +``` + +They throw a `LogicException` when there is no such file (`getOldBlob()` +for a creation, `getNewBlob()` for a deletion), or when the diff has no +repository. The diffs gitlib returns have one: see +[Parsing a diff](#parsing-a-diff) for the others. They throw a +`RuntimeException` when the diff doesn't know the hash of the blob. -$file->getAdditions(); // Number of added lines -$file->getDeletions(); // Number of deleted lines +Two cases have no blob in the repository, and reading the content of their +`Blob` throws a `ReferenceNotFoundException`: -$file->isBinary(); // Binary files have no "lines" +- a submodule, of mode `160000`: its hashes are commits of the submodule; +- a file of the working tree not staged yet: read it from the working + tree instead. -$file->getChanges(); // See next chapter +The number of lines added and deleted, and the changes themselves: + +```php +$file->getAdditions(); +$file->getDeletions(); + +$file->getChanges(); // FileChange objects: see below ``` +`isChangeMode()`, `isDelete()` and `getAnchor()` are deprecated since 2.0: +use `isModeChange()`, `isDeletion()` and `substr($file->getNewIndex(), 0, 12)`. + The FileChange object --------------------- -> **note** -> -> This part of API is not very clean, very consistent. If you have any -> idea or suggestion on how to enhance this, your comment would be -> appreciated. +The changes of a file are its hunks, the blocks starting with `@@` in a +patch. Each one is a `FileChange` object, with the lines of the hunk, +`DiffLine` objects: -A *File* object is composed of many changes. For each of those changes, -a *FileChange* object is associated. +```php +use Gitonomy\Git\Diff\DiffLineType; -To access changes from a file, use the *getChanges* method: +foreach ($file->getChanges() as $change) { + echo sprintf('@@ -%d,%d +%d,%d @@', $change->getRangeOldStart(), $change->getRangeOldCount(), $change->getRangeNewStart(), $change->getRangeNewCount()).PHP_EOL; -```php -$changes = $file->getChanges(); -foreach ($changes as $change) { - foreach ($lines as $data) { - list ($type, $line) = $data; - if ($type === FileChange::LINE_CONTEXT) { - echo ' '.$line.PHP_EOL; - } elseif ($type === FileChange::LINE_ADD) { - echo '+'.$line.PHP_EOL; - } else { - echo '-'.$line.PHP_EOL; - } + foreach ($change->getLines() as $line) { + echo match ($line->getType()) { + DiffLineType::Addition => '+', + DiffLineType::Context => ' ', + DiffLineType::Deletion => '-', + }.$line->getContent().PHP_EOL; } } ``` -To get line numbers, use the range methods: +- `getRangeOldStart()` and `getRangeOldCount()` are the first line of the + hunk in the old file, and its number of lines there; +- `getRangeNewStart()` and `getRangeNewCount()` are the same in the new + file; +- `DiffLine::getType()` returns a `DiffLineType`: `Addition`, `Context` or + `Deletion`; +- `DiffLine::getContent()` returns the line, without its prefix (`+`, + `-` or a space) nor its line break. + +`getAdditions()` and `getDeletions()` count the lines the hunk adds and +deletes. + +Reading a line as an array (`[$type, $content]`), the `FileChange::LINE_ADD`, +`LINE_CONTEXT` and `LINE_REMOVE` constants, and `FileChange::getCount()` are +deprecated since 2.0: use `getType()` and `getContent()`, `DiffLineType`, +and `getAdditions()` or `getDeletions()`. + +Parsing a diff +-------------- + +`Diff::parse()` reads the output of `git diff --raw --patch`, with `-z` or +not. The raw information gives the names, modes, hashes and status of the +files: a patch without it throws an `InvalidArgumentException`. Pass the +repository to read the blobs of the files. Here, the output comes from +`Repository::run()`, which needs `CliDriver`: ```php -echo sprintf('Previously from line %s to %s%s', $change->getOldRangeStart(), $change->getOldRangeEnd(), PHP_EOL); -echo sprintf('Now from line %s to %s%s', $change->getNewRangeStart(), $change->getNewRangeEnd(), PHP_EOL); +use Gitonomy\Git\Diff\Diff; + +$output = $repository->run('diff', ['--raw', '--patch', '0.1', 'master']); +$diff = Diff::parse($output, $repository); ``` + +`getRawDiff()` returns the output a diff was parsed from. It is empty for +the diffs of `LibGit2Driver`, which runs no git command. + +Storing a diff +-------------- + +`toArray()` exports a diff as an array of scalars, to store it in a cache +or as JSON. `Diff::fromArray()` reads it back, with the repository to read +the blobs from: + +```php +use Gitonomy\Git\Diff\Diff; + +$array = $diff->toArray(); + +$diff = Diff::fromArray($array, $repository); +``` + +The arrays of 1.x are read too. + +`Diff::setRepository()`, `File::setRepository()` and `File::addChange()` are +deprecated since 2.0: pass the repository to `Diff::parse()` or +`Diff::fromArray()`, and the changes to the constructor of `File`. diff --git a/doc/hooks.md b/doc/hooks.md index 28227a33..8bc11895 100644 --- a/doc/hooks.md +++ b/doc/hooks.md @@ -2,12 +2,19 @@ Hooks ===== It's possible to define custom hooks on any repository with git. Those -hooks are located in the *.git/hooks* folder. +hooks are located in the *hooks* folder of the git directory: *.git/hooks*, +or *hooks* in a bare repository. A linked worktree (`git worktree add`) runs +the hooks of the repository it belongs to. gitlib ignores the `core.hooksPath` +setting of git. Those files need to be executable: gitlib makes them executable by their owner only (*0700*). If another user runs them (a git daemon, a web server...), change their permissions yourself. +git runs them, when it receives a push for instance, or when `CliDriver` +checks out or merges. `LibGit2Driver` doesn't run them (see +["Choosing a driver"](repository.md#choosing-a-driver)). + With *gitlib*, you can manage hooks over a repository using the *Hooks* object. @@ -27,8 +34,9 @@ To read the content of a hook, use the *get* method like this: $content = $hooks->get('pre-receive'); // returns a string ``` -If the hook does not exist, an exception will be thrown -(*InvalidArgumentException*). +If the hook does not exist, a `Gitonomy\Git\Exception\InvalidArgumentException` +is thrown, and a `Gitonomy\Git\Exception\RuntimeException` if it can't be +read. You can test if a hook is present using the method *has*: @@ -48,10 +56,6 @@ To create the hook using a symlink: $hooks->setSymlink('pre-receive', '/path/to/file-to-link'); ``` -If the hook already exist, a *LogicException* will be thrown. If an -error occured during symlink creation, a *RuntimeException* will be -thrown. - If you want to directly create a new file in hooks directory, use the method *set*. This method will create a new file, put content in it and make it executable: @@ -69,7 +73,10 @@ HOOK; $hooks->set('pre-receive', $content); ``` -If the hook already exists, a *LogicException* will be thrown. +Both methods create the hooks directory when the repository has none, like +one initialized with an empty template. They throw a +`Gitonomy\Git\Exception\RuntimeException` if the hook already exists, or if +it can't be created. Removing hooks -------------- @@ -79,3 +86,6 @@ To remove a hook from a repository, use the function *remove*: ```php $hooks->remove('pre-receive'); ``` + +It throws a `Gitonomy\Git\Exception\RuntimeException` if the hook does not +exist, or if it can't be removed. diff --git a/doc/log.md b/doc/log.md index 4dc5a465..29bdea25 100644 --- a/doc/log.md +++ b/doc/log.md @@ -1,55 +1,138 @@ Getting log history =================== -Crawling manually commits and parents to browse history is surely a good -solution. But when it comes to ordering them or aggregate them from -multiple branches, we tend to use `git log`. +Walking the parents of commits browses a history. To order the commits, or +to gather the history of several branches, use a log, like `git log`. -To get a *Log* object from a repository: +To get a `Log` object from a repository: ```php $log = $repository->getLog(); ``` -You can pass four arguments to *getLog* method: +`getLog()` takes four arguments, all optional: the revisions, the paths, +the offset and the limit. ```php -// Global log for repository +// The history of every reference $log = $repository->getLog(); -// Log for master branch +// The history of the master branch $log = $repository->getLog('master'); -// Returns last 10 commits on README file -$log = $repository->getLog('master', 'README', 0, 10); +// The last 10 commits of master changing the README.md file +$log = $repository->getLog('master', 'README.md', 0, 10); -// Returns last 10 commits on README or UPGRADE files -$log = $repository->getLog('master', ['README', 'UPGRADE'], 0, 10); +// The last 10 commits of master changing README or README.md +$log = $repository->getLog('master', ['README', 'README.md'], 0, 10); +``` + +A `Revision` object, like a `Commit` or a `Branch`, has a shortcut, taking +the paths, the offset and the limit: + +```php +$log = $repository->getRevision('master')->getLog('README.md', 0, 10); +``` + +Revisions +--------- + +The revisions are a string, a `Revision` object, or an array of them. Each +one can be: + +- a name: a branch, a tag, a hash, `HEAD~3`..., for its history; +- a range, `a..b`: the history of `b` without the history of `a`; +- a symmetric range, `a...b`: the commits in the history of `a` or `b`, + but not in both; +- a name to exclude, `^a`: the history of the other revisions, without the + history of `a`; +- `--all`: every reference and `HEAD`, like no revision at all. + +An empty end of a range is `HEAD`: `..master` is `HEAD..master`. + +```php +// The commits of master since the tag 0.1 +$log = $repository->getLog('0.1..master'); + +// The same +$log = $repository->getLog(['master', '^0.1']); + +// The commits of feature and master since they diverged +$log = $repository->getLog('feature...master'); +``` + +A revision naming no commit throws a `ReferenceNotFoundException` naming +it. Other options of `git log` are not supported: they throw an +`InvalidArgumentException`. + +Paths +----- + +The paths filter the log: it lists the commits changing them. They are +relative to the root of the repository, and support the wildcards of git: + +```php +// The commits changing a PHP file, in any folder +$log = $repository->getLog('master', '*.php'); + +// The commits changing a file in the src folder +$log = $repository->getLog('master', 'src'); +``` + +Iterating +--------- + +A log is iterable: + +```php +foreach ($log as $commit) { + echo $commit->getShortHash().' '.$commit->getSubjectMessage().PHP_EOL; +} +``` + +`getCommits()` returns them as an array. `getSingleCommit()` returns the +first commit of the log, and throws a `ReferenceNotFoundException` when the +log is empty: + +```php +$commits = $log->getCommits(); + +$lastChange = $repository->getLog('master', 'README.md')->getSingleCommit(); ``` Counting -------- -If you want to count overall commits, without offset or limit, use the -*countCommits* method: +`count($log)` returns the number of commits the log iterates, after its +offset and up to its limit. To count every commit of the log, without offset +nor limit, use `countCommits()`: ```php -echo sprintf('This log contains %s commits%s', $log->countCommits(), PHP_EOL); - -// Countable interface -echo sprintf('This log contains %s commits%s', count($log), PHP_EOL); +echo sprintf('This page has %d commits%s', count($log), PHP_EOL); +echo sprintf('This log has %d commits%s', $log->countCommits(), PHP_EOL); ``` Offset and limit ---------------- -Use those methods: +A log is immutable: `withOffset()` and `withLimit()` return another log. ```php -$log->setOffset(32); -$log->setLimit(40); +$log = $repository->getLog('master'); -// or read it: -$log->getOffset(); -$log->getLimit(); +$firstPage = $log->withLimit(10); +$secondPage = $firstPage->withOffset(10); + +// To read them +$secondPage->getOffset(); // 10 +$secondPage->getLimit(); // 10 ``` + +`setOffset()` and `setLimit()`, which changed the log, are deprecated since +2.0: use `withOffset()` and `withLimit()`. + +Diff of a log +------------- + +`Log::getDiff()` is deprecated since 2.0: it ignored the paths of the log. +Use `Repository::getDiff($from, $to)`, see [diff.md](diff.md). diff --git a/doc/references.md b/doc/references.md index 64430929..ec658d6e 100644 --- a/doc/references.md +++ b/doc/references.md @@ -4,89 +4,147 @@ Tags and branches Accessing tags and branches --------------------------- -With *gitlib*, you can access them via the *ReferenceBag* object. To get -this object from a *Repository*, use the *getReferences* method: +Branches and tags are references. To get them, call `getReferences()` on a +`Repository`. It returns a `ReferenceBag` object: ```php $references = $repository->getReferences(); ``` -First, you can test existence of tags and branches like this: +The bag reads the references again after every write gitlib makes: creating +or deleting a reference, a checkout, a merge, `Repository::run()`... To see +the writes of other processes, open a new `Repository`. Passing an argument +to `getReferences()`, to read them again, is deprecated since 2.0. + +First, you can test the existence of tags and branches like this: ```php if ($references->hasBranch('master') && $references->hasTag('0.1')) { echo 'Good start!'.PHP_EOL; } + +$references->hasRemoteBranch('origin/master'); +$references->hasBranches(); // A branch at least, local or remote +$references->has('refs/heads/master'); // A reference, by its full name ``` -If you want to access all branches or all tags: +To get all branches or all tags, sorted by full name (local branches come +before remote ones): ```php -$branches = $references->getBranches(); +$branches = $references->getBranches(); // Branch objects, local and remote $localBranches = $references->getLocalBranches(); $remoteBranches = $references->getRemoteBranches(); -$tags = $references->getTags(); -$all = $references->getAll(); +$tags = $references->getTags(); // Tag objects +$all = $references->getAll(); // Every reference, keyed by its full name ``` -To get a given branch or tag, call *getBranch* or *getTag* on the -*ReferenceBag*. Those methods return *Branch* and *Tag* objects: +The bag is also iterable and countable, like `getAll()`. The default branch +of a remote (`refs/remotes/origin/HEAD`) is left out, and the stash is a +`Reference\Stash` object. + +To get a given branch or tag, call `getBranch()`, `getRemoteBranch()` or +`getTag()` on the `ReferenceBag`. They return `Branch` and `Tag` objects: ```php $master = $references->getBranch('master'); -$feat123 = $references->getLocalBranch('feat123'); -$feat456 = $references->getRemoteBranch('origin/feat456'); +$feature = $references->getRemoteBranch('origin/feature'); $v0_1 = $references->getTag('0.1'); + +// A reference by its full name +$reference = $references->get('refs/heads/master'); ``` -If the reference cannot be resolved, a *ReferenceNotFoundException* will -be thrown. +If the reference doesn't exist, they throw a `ReferenceNotFoundException`. +`getFirstBranch()` returns the first branch of `getBranches()`, or `null` +when there is none. -On each of those objects, you can access those informations: +Every reference is a `Revision`. On each one, you can access: ```php -// Get the associated commit -$commit = $master->getCommit(); +// The name: "master", "origin/feature" for a remote branch, "0.1" for a tag +$name = $master->getName(); -// Get the commit hash +// The full name: "refs/heads/master", "refs/remotes/origin/feature", "refs/tags/0.1" +$fullname = $master->getFullname(); + +// The commit it points to +$commit = $master->getCommit(); $hash = $master->getCommitHash(); -// Get the last modification -$lastModification = $master->getLastModification(); +// The last commit changing a path, in its history +$lastModification = $master->getLastModification('README.md'); ``` -Create and delete reference ---------------------------- +`getCommitHash()` is always a commit: for an annotated tag, the commit the +tag object points to. A reference object keeps the commit it pointed to when +it was read: get it again from the bag after a change. -You can create new tags and branches on repository, using helper methods -on ReferenceBag object: +Create and delete references +---------------------------- + +You can create new tags and branches, using methods of the `ReferenceBag`. +They take the revision to point to: a name, a hash, or a `Revision` object +like a `Commit` or a `Branch`. ```php -// create a branch -$references = $repository->getReferences(); -$branch = $references->createBranch('foobar', 'a8b7e4...'); // commit to reference +// Create a branch +$branch = $references->createBranch('foobar', 'master'); -// create a tag -$references = $repository->getReferences(); -$tag = $references->createTag('0.3', 'a8b7e4...'); // commit to reference +// Create a lightweight tag +$tag = $references->createTag('0.3', $repository->getHeadCommit()); -// delete a branch or a tag +// Delete a branch or a tag $branch->delete(); +$tag->delete(); +``` + +`createBranch()` and `createTag()` throw an `InvalidArgumentException` when +the reference exists: they never move it. To move a branch, use +`Branch::moveTo()`: see [branch.md](branch.md). `ReferenceBag::update()` is +deprecated since 2.0: use `Branch::moveTo()`. + +Tags +---- + +A tag is lightweight, a name pointing to a commit, or annotated: a tag +object, with a tagger, a message, and maybe a signature. + +```php +$tag = $references->getTag('1.0'); + +if ($tag->isAnnotated()) { + echo $tag->getTaggerName().' <'.$tag->getTaggerEmail().'>'.PHP_EOL; + echo $tag->getTaggerDate()?->format('Y-m-d').PHP_EOL; + echo $tag->getSubjectMessage().PHP_EOL; +} ``` +- `getTaggerName()`, `getTaggerEmail()` and `getTaggerDate()` (a + `DateTimeImmutable`) return `null` for a lightweight tag, and for an + annotated tag without tagger; +- `getMessage()` returns the message, without the signature, `null` for a + lightweight tag; +- `getSubjectMessage()` and `getBodyMessage()` split the message like + for a commit (see [commit.md](commit.md#message)), `null` for a + lightweight tag; +- `isSigned()` tells if the tag is signed, with GPG, X.509 or SSH; +- `getGpgSignature()` returns the signature, without its armor lines, + `null` for an unsigned or lightweight tag. + Resolution from a commit ------------------------ -To resolve a branch or a commit from a commit, you can use the -*resolveTags* and *resolveBranches* methods on it: +To find the branches or the tags pointing to a commit, use the +`resolveBranches()` and `resolveTags()` methods: ```php $branches = $references->resolveBranches($commit); -$tags = $references->resolveTags($commit); +$tags = $references->resolveTags($commit); // Annotated tags included // Resolve branches and tags $all = $references->resolve($commit); ``` -You can pass a *Commit* object or a hash to the method, gitlib will -handle it. +You can pass a `Commit` object, a hash or any revision name. +`Commit::resolveReferences()` is a shortcut for `resolve()`. diff --git a/doc/repository.md b/doc/repository.md index 78788173..d70f5231 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -1,88 +1,239 @@ Repository methods ================== -Creating a *Repository* object is possible, providing a *path* argument -to the constructor: +A `Repository` object opens a repository on the disk. It is the entry point +to everything else: commits, references, working copy... + +To create a repository, or to clone one, see +[Creating repositories](admin.md). + +Opening a repository +-------------------- + +Give the path of the repository to the constructor: ```php +use Gitonomy\Git\Repository; + $repository = new Repository('/path/to/repo'); ``` -Repository options ------------------- +The path may be: -The constructor of Repository takes an additional parameter: `$options`. -This parameter can be used used to tune behavior of library. +- a bare repository; +- a working tree, with its `.git` directory; +- a working tree whose `.git` is a file pointing to its git directory: a + linked worktree (`git worktree add`) or a submodule. -Available options are: +The constructor takes two more arguments: -- **logger**: (default: none) Logger to use for reporting of execution - (a `Psr\Log\LoggerInterface`) -- **working\_dir**: If you are using multiple working directories, - this option is for you +```php +public function __construct(string $dir, ?string $workingDir = null, DriverInterface $driver = new CliDriver()) +``` -An example: +- `$workingDir`: the working tree, when `$dir` is a git directory kept apart + from it, like `git --git-dir=$dir --work-tree=$workingDir`; +- `$driver`: the backend running git. See + ["Choosing a driver"](#choosing-a-driver), + ["Configuring `CliDriver`"](#configuring-clidriver) and + ["Logger"](#logger). ```php -$repository = new Repository('/path/to/repo', [ - 'logger' => new Monolog\Logger(), -]); +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/srv/project.git', '/srv/project', new CliDriver(processTimeout: 60)); ``` -Its last parameter, `$driver`, is the backend running git operations: see -"Choosing a driver" below. The `command`, `environment_variables`, -`inherit_environment_variables` and `process_timeout` options of 1.x are -deprecated in favor of the arguments of `CliDriver`: see -["Configuring `CliDriver`"](#configuring-clidriver). +An array of options as second argument (`working_dir`, `logger`, `command`, +`process_timeout`...) is deprecated since 2.0: pass the working directory, +and configure a `CliDriver` instead. See +[UPGRADE-2.0.md](../UPGRADE-2.0.md#opening-a-repository). -Test if a repository is bare ----------------------------- +A directory that doesn't exist, or that isn't a repository, throws an +`InvalidArgumentException`. -On a *Repository* object, you can call method *isBare* to test if your -repository is bare or not: +gitlib doesn't support the repositories using SHA-256 hashes yet: opening one +throws an `UnsupportedOperationException`. + +Paths +----- ```php -$repository->isBare(); +$repository->isBare(); // true for a bare repository +$repository->getGitDir(); // the git directory, "/path/to/repo/.git" +$repository->getWorkingDir(); // the working tree, "/path/to/repo", or null for a bare repository +$repository->getPath(); // the working tree, or the git directory of a bare repository ``` -Compute size of a repository ----------------------------- +Access HEAD +----------- -To know how much size a repository is using on your drive, you can use -`getSize` method on a *Repository* object. +`HEAD` represents in git the version you are working on (in working +tree). Your `HEAD` can be attached (pointing to a branch) or detached +(pointing to a commit). -> **warning** -> -> This command was only tested with linux. +```php +$head = $repository->getHead(); // the Branch HEAD points to, or the Commit when it is detached +$commit = $repository->getHeadCommit(); // the Commit, in both cases -The returned size is in kilobytes: +if ($repository->isHeadAttached()) { + echo 'On branch '.$head->getName().PHP_EOL; +} + +if ($repository->isHeadDetached()) { + echo 'HEAD detached at '.$commit->getShortHash().PHP_EOL; +} +``` + +In a repository without commits yet, `HEAD` points to a branch that doesn't +exist yet: `getHead()` and `getHeadCommit()` throw a +`ReferenceNotFoundException`, and `isHeadAttached()` returns `true`. + +Commits, trees and blobs +------------------------ ```php -$size = $repository->getSize(); +// A revision: a branch, a tag, a hash, "HEAD~2"... +$commit = $repository->getRevision('master')->getCommit(); + +// A full hash only +$commit = $repository->getCommit('7e9ba14939b0ce67ab95d83e03bd64710db91ef4'); -echo 'Your repository size is '.$size.'KB'; +// A hash, abbreviated or not +$tree = $repository->getTree('56953e3'); +$blob = $repository->getBlob('412a0dad'); ``` -Access HEAD +`getCommit()` only takes a full hash: anything else throws a +`ReferenceNotFoundException`. Use `getRevision($name)->getCommit()` for a +name or an abbreviated hash, see [Revision](revision.md). `getTree()` and +`getBlob()` take a hash, abbreviated or not, and throw for anything else: to +read a file of a commit, go through its tree, see [Tree](tree.md). + +An object that doesn't exist throws a `ReferenceNotFoundException`, at the +latest when its data is read. + +The repository returns the same object for a hash, as long as your +application uses it: + +```php +$repository->getCommit($hash) === $repository->getCommit($hash); // true +``` + +It keeps no reference to the objects your application doesn't use anymore: +reading a long history doesn't fill the memory. + +`getMergeBase()` returns the best common ancestor of two revisions, the +commit `git diff a...b` compares with, or `null` when they have no common +history: + +```php +$base = $repository->getMergeBase('master', 'feature'); // Commit|null +``` + +References +---------- + +`getReferences()` returns the branches, tags... of the repository: see +[References](references.md). + +```php +$references = $repository->getReferences(); +``` + +gitlib reads them again after every write it makes: creating or deleting a +reference, a checkout, a merge, `run()`... It doesn't see the writes of other +processes (a push to the repository, another worker...): open a new +`Repository` to see them. The `$reload` argument of `getReferences()` is +deprecated since 2.0. + +Description ----------- -`HEAD` represents in git the version you are working on (in working -tree). Your `HEAD` can be attached (using a reference) or detached -(using a commit). +```php +$repository->getDescription(); // the content of the "description" file of the git directory +$repository->hasDescription(); // false while it is git's default one +$repository->setDescription('My project'); +``` + +Compute size of a repository +---------------------------- + +`getSize()` returns the size of the files of the git directory, working tree +excluded, in kilobytes (1,000 bytes): + +```php +$size = $repository->getSize(); + +echo 'Your repository size is '.$size.' KB'; +``` + +Running git commands +-------------------- + +With `CliDriver`, `run()` runs any git command on the repository, and returns +its standard output: + +```php +$output = $repository->run('log', ['--oneline', '-n', '5']); +``` + +Some commands, like `push`, write their meaningful output to the error +output, even when they succeed. `runProcess()` returns the Symfony `Process`, +already run: ```php -$head = $repository->getHead(); // Commit or Reference -$head = $repository->getHeadCommit(); // Commit +$process = $repository->runProcess('push', ['origin', 'main']); -if ($repository->isHeadDetached()) { - echo 'Sorry man'.PHP_EOL; -} +echo $process->getErrorOutput(); ``` -Options for repository ----------------------- +Both throw a `ProcessException` when git fails, and an +`UnsupportedOperationException` with another driver than `CliDriver`. The +relative paths of the arguments are relative to the repository. gitlib reads +the references again after the command, which may have written them. + +`shell()` is deprecated since 2.0: run `git shell` with Symfony Process +instead. + +Errors +------ + +Gitlib throws an exception when an operation fails. It never returns `null` +or empty data instead. Every exception of gitlib implements +`Gitonomy\Git\Exception\GitExceptionInterface`, and lives in the +`Gitonomy\Git\Exception` namespace: + +- `ReferenceNotFoundException`, an `InvalidArgumentException`: a revision, + a reference or an object doesn't exist (`getRevision('unknown')->getCommit()`, + `getReferences()->getBranch('unknown')`...). +- `DriverException`, a `RuntimeException`: git reports a failure, like a + merge conflict or a clone of an unknown repository. With `CliDriver`, it + is mostly a `ProcessException`, whose `getErrorOutput()` and + `getOutput()` return the output of git, or a `ProcessTimedOutException`, + when git runs longer than [the timeout](#configuring-clidriver). +- `UnsupportedOperationException`, a `RuntimeException`: the driver can't + perform the operation at all, where another driver may: `run()` with + `LibGit2Driver`, libgit2 that isn't installed... +- `InvalidArgumentException`: an argument gitlib doesn't support, like an + option of `git log` in the revisions of a log, or a string holding a NUL + byte, which git doesn't accept. +- `LogicException`: a call that makes no sense, like the working copy of a + bare repository. -### Choosing a driver +```php +use Gitonomy\Git\Exception\ReferenceNotFoundException; + +try { + $commit = $repository->getRevision($name)->getCommit(); +} catch (ReferenceNotFoundException) { + echo 'No commit named '.$name.PHP_EOL; +} +``` + +Choosing a driver +----------------- By default, gitlib spawns a `git` process for every operation, with `CliDriver`, which requires git 1.8.4 or later. You can instead run @@ -91,6 +242,7 @@ by passing a `LibGit2Driver`: ```php use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Repository; $repository = new Repository('/path/to/repo', driver: new LibGit2Driver()); ``` @@ -107,14 +259,16 @@ every `Repository` you open or create, as a service of your container for instance. `LibGit2Driver` avoids spawning a process for every operation. It requires -the `ext-ffi` PHP extension and libgit2 1.9: libgit2 changes its binary -interface with every minor version, so any other version is refused. If +the `ext-ffi` PHP extension and libgit2 1.9, built for SHA-1 only: libgit2 +changes its binary interface with every minor version, and its build +supporting SHA-256 changes its structures, so any other build is refused. If either is missing, constructing the `LibGit2Driver` throws an `UnsupportedOperationException` right away — there is no silent fallback to `CliDriver`. You can check availability upfront: ```php use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; $driver = LibGit2Driver::isSupported() ? new LibGit2Driver() : new CliDriver(); ``` @@ -131,16 +285,43 @@ Differences with `CliDriver`: - `Repository::run()` and `Repository::runProcess()` throw an `UnsupportedOperationException`: there is no `git` process to run. -- Nothing is logged, and there is no timeout on network operations. -- `WorkingCopy::merge()` only supports fast-forward and conflict-free - merges, with `--no-ff` as the only extra argument: a conflict throws - instead of leaving conflict markers in the working copy. +- Nothing is logged, and nothing times out, not even network operations. +- SSH and git's credential helpers aren't supported: clone a private + repository through HTTPS, with the credentials in the URL + (`https://user:token@example.com/repo.git`). +- Bundles (`.bundle` files) can't be cloned. +- Repositories storing their references in the reftable format can't be + opened. +- Neither hooks nor external filters (Git LFS...) are run: a checkout or a + merge writes the files without them. +- `Diff::getRawDiff()` returns an empty string. +- Short hashes (`Commit::getShortHash()`) have 7 characters, more when 7 + are ambiguous: git adapts their length to the size of the repository. - `WorkingCopy::checkout()` doesn't create a tracking branch for a name that only exists on a remote, like `git checkout` does: it throws. +- `WorkingCopy::merge()` only supports fast-forward and conflict-free + merges: a conflict throws instead of leaving conflict markers in the + working copy. A merge commit is never signed, whatever + `commit.gpgSign`, and its message never lists the merged commits, + whatever `merge.log`. +- Blame is much slower on a long history: see + ["Performance"](#performance). - Rename detection in diffs may pair files differently. -- Blame line ranges are limited to `,` and `,+`. +- Network operations go through the proxy of `https_proxy`, or of + `http.proxy`, for HTTPS only: libgit2 1.9 uses no proxy for a plain + `http://` URL. +- With php-fpm, libgit2 must be preloaded: see + ["With php-fpm"](#with-php-fpm). + +These limits apply to both drivers, so that they behave the same: -#### With php-fpm +- The revisions of a log are names, ranges (`a..b`, `a...b`), names to + exclude (`^a`) or `--all`: any other option of `git log` throws an + `InvalidArgumentException`. +- A blame takes the numbers of its first and last lines, none of the other + forms of `git blame -L`. + +### With php-fpm Call `LibGit2Driver::preload()` from the [`opcache.preload`](https://www.php.net/manual/en/opcache.preloading.php) @@ -156,9 +337,12 @@ Gitonomy\Git\Driver\Ffi\LibGit2Driver::preload(); libgit2 then stays loaded and initialized for the lifetime of php-fpm, instead of every request loading it again (about 10 ms, see below), and the driver works with the default `ffi.enable` setting, `preload`: outside the -CLI, only preloaded code can use FFI. Without preloading, `LibGit2Driver` -throws an `UnsupportedOperationException` unless `ffi.enable` is `true`, -which lets any code of the application call C functions. +CLI, only preloaded code can use FFI. + +Without preloading, `LibGit2Driver` throws an `UnsupportedOperationException` +unless `ffi.enable` is `true`, which lets any code of the application call C +functions. Even then, don't: every request initializes libgit2 again, and +leaks its global state, about 700 KB per request. ### Performance @@ -212,59 +396,49 @@ in a long-running process, and check with your own repository: php benchmark.php /path/to/repository path/to/a/file ``` -### Configuring `CliDriver` +Configuring `CliDriver` +----------------------- + +The arguments of `CliDriver` are: -The arguments of `CliDriver` set the git binary to run (`command`), the -environment variables to pass to it (`environmentVariables`, where `false` -removes an inherited one), whether git inherits the environment variables of -PHP (`inheritEnvironmentVariables`), and the timeout of every git process, -in seconds (`processTimeout`, `null` for none): +- `command`: the git binary to run, `git` by default; +- `environmentVariables`: the environment variables to set on every git + process, where `false` removes an inherited one: git inherits the + environment variables of PHP; +- `processTimeout`: the timeout of every git process, in seconds, for its + whole duration and without output, `3600` by default, `null` for none. A + git process running longer throws a `ProcessTimedOutException`; +- `logger`: see ["Logger"](#logger). ```php use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; -$repository = new Gitonomy\Git\Repository('/tmp/foo', driver: new CliDriver( +$repository = new Repository('/tmp/foo', driver: new CliDriver( command: '/home/alice/bin/git', environmentVariables: ['GIT_SSH_COMMAND' => 'ssh -i /home/alice/.ssh/deploy'], processTimeout: 60, )); ``` -### Using your own driver - -`$driver` accepts any `Gitonomy\Git\Driver\DriverInterface` implementation: -its `open()` method returns the `Gitonomy\Git\Driver\RepositoryDriverInterface` -the repository runs its operations with. In tests, a stub of both lets you -exercise code built on gitlib without any git repository: - -```php -$repositoryDriver = $this->createStub(RepositoryDriverInterface::class); -$repositoryDriver->method('getReferences')->willReturn([new ReferenceData('refs/heads/main', $hash)]); - -$driver = $this->createStub(DriverInterface::class); -$driver->method('open')->willReturn($repositoryDriver); - -$repository = new Repository('/path/to/any/directory', driver: $driver); -``` - -### Logger +The `command`, `environment_variables` and `process_timeout` options of +`Repository` and `Admin`, from 1.x, are deprecated since 2.0 in favor of these +arguments. So is `inherit_environment_variables`, which passed the entries of +`$_SERVER` to git, the headers and credentials of an HTTP request included: +pass the variables git needs to `environmentVariables`. -If you are developing, you may appreciate to have a logger inside -repository, telling you every executed command (`CliDriver` only). +Logger +------ -You call method `setLogger` as an option on repository creation: +`CliDriver` takes a PSR-3 logger, and logs every git command it runs, +initializing and cloning repositories included: ```php -$repository->setLogger(new Monolog\Logger('repository')); - -$repository->run('fetch', ['--all']); -``` +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; -You can also specify as an option on repository creation: +$repository = new Repository('/path/foo', driver: new CliDriver(logger: new Monolog\Logger('git'))); -```php -$logger = new MonologLogger('repository'); -$repository = new Repository('/path/foo', ['logger' => $logger]); $repository->run('fetch', ['--all']); ``` @@ -278,12 +452,38 @@ debug last command (fetch) output: Fetching origin ``` A failing command also logs its error output, at the `debug` level: the -exception it throws reports the failure. +exception it throws reports the failure. `LibGit2Driver` logs nothing. -### Errors +`Repository::setLogger()` and `Repository::getLogger()` are deprecated since +2.0: give the logger to `CliDriver`. + +Using your own driver +--------------------- + +`$driver` accepts any `Gitonomy\Git\Driver\DriverInterface` implementation. +Its `open($dir, $workingDir)` method returns the +`Gitonomy\Git\Driver\RepositoryDriverInterface` the repository runs its +operations with: `getGitDir()`, `getWorkingDir()`, `getHeadReference()`, +`getReferences()`, `getCommitData()`... In tests, a stub of both lets you +exercise code built on gitlib without any git repository: + +```php +use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Repository; + +$repositoryDriver = $this->createStub(RepositoryDriverInterface::class); +$repositoryDriver->method('getGitDir')->willReturn('/path/to/repo.git'); +$repositoryDriver->method('getReferences')->willReturn([new ReferenceData('refs/heads/main', $hash)]); + +$driver = $this->createStub(DriverInterface::class); +$driver->method('open')->willReturn($repositoryDriver); + +$repository = new Repository('/path/to/repo.git', driver: $driver); +$repository->getReferences()->getBranch('main')->getCommitHash(); // $hash +``` -Gitlib throws an exception when git reports a failure: a -`Gitonomy\Git\Exception\DriverException` (a `ProcessException` with -`CliDriver`), or a more specific one where it makes sense, like a -`ReferenceNotFoundException` for an unknown revision. Operations never return -empty data instead. +The other methods of `DriverInterface` create repositories: `init()`, +`initBare()`, `cloneFrom()`, `cloneBareFrom()`, `mirrorFrom()`, +`isValidRepository()` and `isValidRepositoryAndBranch()`. diff --git a/doc/revision.md b/doc/revision.md index 81370c80..39848cf9 100644 --- a/doc/revision.md +++ b/doc/revision.md @@ -1,22 +1,17 @@ Revision ======== +A revision is a name git resolves to a commit: a branch, a tag, a hash, +abbreviated or not, `HEAD~2`, `master@{2 days ago}`... + To get a revision from a *Repository* object: ```php $revision = $repository->getRevision('master@{2 days ago}'); ``` -Getting the log ---------------- - -You can access a *Log* object starting from a revision using the -*getLog* method. This method takes two parameters: *offset* and *limit*: - -```php -// Returns 100 lasts commits -$log = $revision->getLog(null, 100); -``` +`Commit` and `Reference` (a branch, a tag) are revisions too: you can pass +them wherever gitlib takes a revision. Resolve a revision ------------------ @@ -26,3 +21,31 @@ To resolve a revision to a commit: ```php $commit = $revision->getCommit(); ``` + +It throws a `Gitonomy\Git\Exception\ReferenceNotFoundException` when the +revision names no commit. A tag, annotated or not, resolves to the commit it +points to. + +`Repository::getCommit()` only takes a full hash: use +`getRevision($name)->getCommit()` for anything else. + +Getting the log +--------------- + +You can access a *Log* object starting from a revision using the +*getLog* method. This method takes three parameters: *paths*, *offset* and +*limit*: + +```php +// Returns the 100 last commits +$log = $revision->getLog(limit: 100); + +// Returns the 100 last commits changing README.md +$log = $revision->getLog('README.md', limit: 100); + +// Skips the 100 last commits +$log = $revision->getLog(null, 100); +``` + +The first argument filters on paths: `getLog(null, 100)` is an offset, not a +limit. See [Log](log.md). diff --git a/doc/tree.md b/doc/tree.md index 0c5c8ef7..47abe374 100644 --- a/doc/tree.md +++ b/doc/tree.md @@ -1,35 +1,67 @@ Tree and files ============== -To organize folders, git uses trees. In gitlib, those trees are -represented via *Tree* object. +Git stores folders as trees. In gitlib, a tree is a `Tree` object. -To get the root tree associated to a commit, use the *getTree* method on -the commit object: +The root tree of a commit is the entry point of all its files: ```php $tree = $commit->getTree(); ``` -This tree is the entry point of all of your files. +`Repository::getTree()` takes the hash of a tree, abbreviated or not. -The main method for a tree is the *getEntries* method. This method will -return an array, indexed by name. Each of those elements will be the -entry mode and the entry object. +Entries of a tree +----------------- -Let's understand how it works with a concrete example: +`getEntries()` returns the entries of a tree, keyed by their name. Each one +is a `TreeEntry` object, with the name of the entry, its mode, and its +object: a `Blob` for a file (see [blob.md](blob.md)), a `Tree` for a folder, +or a `Submodule`. ```php -function displayTree(Tree $tree, $indent = 0) +foreach ($tree->getEntries() as $name => $entry) { + $entry->getName(); // The name of the file or the folder, the same as $name + $entry->getMode(); // The mode git stores: "100644", "100755" (executable), "120000" (symbolic link), "040000" (tree), "160000" (submodule) + $entry->getObject(); // A Blob, a Tree or a Submodule +} +``` + +To keep one kind of entries only, also keyed by their name: + +```php +$folders = $tree->getTreeEntries(); +$files = $tree->getBlobEntries(); +$submodules = $tree->getSubmoduleEntries(); +``` + +To get the object of an entry by its name: + +```php +$readme = $tree->getEntry('README.md'); // A Blob +``` + +It throws an `InvalidArgumentException` when the tree has no such entry. + +Here is a function displaying a tree and its folders, recursively: + +```php +use Gitonomy\Git\Submodule; +use Gitonomy\Git\Tree; + +function displayTree(Tree $tree, int $depth = 0): void { - $indent = str_repeat(' ', $indent); - foreach ($tree->getEntries() as $name => $data) { - list($mode, $entry) = $data; - if ($entry instanceof Tree) { - echo $indent.$name.'/'.PHP_EOL; - displayTree($tree, $indent + 1); - } else { - echo $indent.$name.PHP_EOL; + foreach ($tree->getEntries() as $name => $entry) { + $object = $entry->getObject(); + + echo str_repeat(' ', $depth).match (true) { + $object instanceof Tree => $name.'/', + $object instanceof Submodule => $name.' @ '.$object->getHash(), + default => $name, + }.PHP_EOL; + + if ($object instanceof Tree) { + displayTree($object, $depth + 1); } } } @@ -37,18 +69,38 @@ function displayTree(Tree $tree, $indent = 0) displayTree($commit->getTree()); ``` -This method will recursively display all entries of a tree. +Reading an entry as an array (`[$mode, $object]`) is deprecated since 2.0: +use `getMode()` and `getObject()`. + +Submodules +---------- + +A submodule is a commit of another repository. Its `Submodule` object only +holds the hash of this commit: gitlib doesn't read the submodule. Open it as +a repository, from its folder in the working tree, to read it. + +```php +foreach ($tree->getSubmoduleEntries() as $name => $entry) { + echo $name.' is at '.$entry->getObject()->getHash().PHP_EOL; +} +``` + +`CommitReference` is deprecated since 2.0: it is `Submodule`. So is +`Tree::getCommitReferenceEntries()`: use `getSubmoduleEntries()`. Resolve a path -------------- -To access directly a sub-file, the easier is probably to use the -*resolvePath* method. - -An example: +To access a file or a folder from its path, use `resolvePath()`. It returns +a `Blob`, a `Tree` or a `Submodule`: ```php -$source = $tree->resolvePath('src/Gitonomy/Git'); - -$source instanceof Tree; +$folder = $tree->resolvePath('src/Gitonomy/Git'); // A Tree +$file = $tree->resolvePath('src/Gitonomy/Git/Repository.php'); // A Blob ``` + +- The path is relative to the tree. +- `""` and `"/"` resolve to the tree itself. Leading, trailing and + repeated slashes are ignored. +- A path that doesn't exist throws an `InvalidArgumentException`, as a + path going through a file or a submodule (`README.md/foo`). diff --git a/doc/workingcopy.md b/doc/workingcopy.md index 7b3c92df..c2b34471 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -6,50 +6,120 @@ you can access this object using the *getWorkingCopy* on a *Repository* object: ```php +use Gitonomy\Git\Repository; + $repo = new Repository('/path/to/working-dir'); $wc = $repo->getWorkingCopy(); ``` +A bare repository has no working copy: `getWorkingCopy()` throws a +`LogicException`. + Checkout a revision ------------------- -You can checkout any revision using *checkout* method. You can also pass -a second argument, which will be passed as argument with `-b`: +You can checkout any revision using *checkout* method. Like `git checkout`, +it attaches `HEAD` to a local branch, and detaches it on anything else: a +tag, a commit, a remote branch... ```php // git checkout master $wc->checkout('master'); -// git checkout origin/master -b master -$wc->checkout('origin/master', 'master'); +// git checkout v1.0 (HEAD is detached) +$wc->checkout('v1.0'); +``` + +You can also pass a *Revision*: a *Commit*, a *Reference*... A local *Branch* +object attaches `HEAD` to the branch. + +```php +$wc->checkout($repo->getReferences()->getBranch('master')); +``` + +A name that only exists as a remote branch, like `feature` for +`origin/feature`, creates the local tracking branch with `CliDriver`, like +`git checkout` does. `LibGit2Driver` throws instead. + +To create a branch and check it out, use *checkoutNewBranch*. Its second +argument is the revision to start the branch from, `HEAD` by default: + +```php +// git checkout -b feature +$wc->checkoutNewBranch('feature'); + +// git checkout -b develop origin/develop +$wc->checkoutNewBranch('develop', 'origin/develop'); ``` -You can also pass a *Reference* or a *Commit*. +Like `git checkout`, both methods keep the local changes, and refuse to +overwrite the ones the checkout would change: they throw a +`DriverException`. + +Passing the branch to create as second argument of *checkout* is deprecated +since 2.0: use *checkoutNewBranch*. Merge a revision ---------------- You can merge any revision into the current branch using the *merge* method. -A second argument can be used to pass extra options to the underlying -`git merge` command: +A second argument, a `FastForward`, tells whether the merge fast-forwards +the branch when its history allows it: ```php +use Gitonomy\Git\FastForward; + // git merge origin/develop $wc->merge('origin/develop'); // git merge --no-ff origin/develop -$wc->merge('origin/develop', ['--no-ff']); +$wc->merge('origin/develop', FastForward::Never); + +// git merge --ff-only origin/develop +$wc->merge('origin/develop', FastForward::Only); ``` -You can also pass a *Reference* or a *Commit*. A `RuntimeException` is thrown -if the merge fails (for instance because of a conflict). +`FastForward::Allow`, the default, fast-forwards when possible and creates a +merge commit otherwise. `FastForward::Never` always creates a merge commit. +`FastForward::Only` throws instead of creating one. + +You can also pass a *Revision*. A `DriverException` is thrown if the merge +fails: a conflict, staged changes, a revision whose history is unrelated to +the branch... + +With `CliDriver`, a conflict leaves the working copy merging, with conflict +markers in the files, like `git merge`. To cancel the merge: + +```php +$repo->run('merge', ['--abort']); +``` > **note** > > With `LibGit2Driver` (see [Choosing a driver](repository.md#choosing-a-driver)), -> `merge()` only supports fast-forward and conflict-free merges, with `--no-ff` -> as the only extra argument: a conflict, or any other extra argument, throws -> instead of leaving conflict markers in the working copy. +> `merge()` only supports fast-forward and conflict-free merges: a conflict +> throws without changing anything, instead of leaving conflict markers in +> the working copy. + +Passing arguments of `git merge` as an array (`[]`, `['--no-ff']` or +`['--ff-only']`) is deprecated since 2.0: pass a `FastForward`. + +Modifications since a revision +------------------------------ + +You can get the modifications of the working copy, staged or not, since a +revision, `HEAD` by default: + +```php +// git diff HEAD +$diff = $wc->getDiff(); + +// git diff master +$diff = $wc->getDiff('master'); +``` + +Calling `Repository::getDiff()` with one revision, to compare it with the +working copy, is deprecated since 2.0: use `WorkingCopy::getDiff()`. Staged modifications -------------------- @@ -64,9 +134,26 @@ $diff = $wc->getDiffStaged(); Pending modifications --------------------- -You can get pending modifications on tracked files by calling method -`getDiffPending()`: +You can get pending modifications on tracked files, not staged yet, by +calling method `getDiffPending()`: ```php $diff = $wc->getDiffPending(); ``` + +During a merge conflict, `getDiffStaged()` and `getDiffPending()` leave the +unmerged files out, and `getDiff()` compares them, conflict markers included, +to the revision, like `git diff HEAD`. + +See [Diff](diff.md) to read these diffs. + +Untracked files +--------------- + +To list the files git doesn't track, ignored files excluded, call +`getUntrackedFiles()`. It returns their paths, relative to the root of the +working copy: + +```php +$files = $wc->getUntrackedFiles(); // ['new-file.txt', 'new-directory/other-file.txt'] +``` From 4b7ee077e82f2db207976e929a63e9be107c5d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 16:44:34 +0200 Subject: [PATCH 093/150] Refuse arguments that git would read as options git reads an argument starting with a dash as an option, and a value from a user can be one: getRevision('--output=/path') or a checkout, a merge, a reference name of that kind could write a file, and isValidRepository('--upload-pack=...') ran the command in a repository with a remote. git 1.8.4 has no --end-of-options, and "--" starts the paths of most commands, ls-remote taking it only since git 2.8. Both drivers refuse a revision, a reference, a hash or a URL starting with a dash with an InvalidArgumentException (Util\LeadingDash), for the same parameters. A path may start with one: init and clone end the options of git with "--". run() and runProcess() pass their arguments as they are, which the doc says. --- CHANGELOG.md | 7 ++ UPGRADE-2.0.md | 5 + doc/admin.md | 9 ++ doc/repository.md | 12 +- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 17 ++- .../Git/Driver/Cli/CliRepositoryDriver.php | 33 +++++ src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 7 ++ .../Driver/Ffi/LibGit2RepositoryDriver.php | 17 +++ src/Gitonomy/Git/Util/LeadingDash.php | 41 ++++++ tests/Gitonomy/Git/Tests/LeadingDashTest.php | 117 ++++++++++++++++++ 10 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 src/Gitonomy/Git/Util/LeadingDash.php create mode 100644 tests/Gitonomy/Git/Tests/LeadingDashTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 081545f9..112de6e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,13 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, `PushReference::getReference()` and `getRevision()`. +### Security + +- Refuse a revision, a reference, a hash or a URL starting with a dash, which git read as an + option: `getRevision('--output=/path')`, `WorkingCopy::checkout()` or `merge()` could write + a file, and `Repository::isValidRepository('--upload-pack=...')` run a command. A path may + start with a dash: `init()` and the clones end the options of git with `--`. + ### Bug fixes - Fix `Commit::getDiff()` returning an empty diff for a root commit, and throwing for a merge diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 5a611d5f..ee97fe8c 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -391,6 +391,11 @@ Errors * An argument holding a NUL byte throws an `InvalidArgumentException`, where PHP threw a `ValueError`: git and libgit2 read their arguments as C strings, which end at the first NUL byte. + * A revision, a reference, a hash or a URL starting with a dash throws an + `InvalidArgumentException`, with both drivers: git read it as an option, + `getRevision('--output=/path')` writing a file, or + `isValidRepository('--upload-pack=...')` running a command. A path may + start with a dash. * Every message is a sentence ending with a dot. Dates diff --git a/doc/admin.md b/doc/admin.md index 3aff0a53..b65353bc 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -101,6 +101,15 @@ error output, when `CliDriver` runs git. With `CliDriver`, every method, `isValidRepository()` included, throws a `ProcessException` when git can't run, like a missing binary. See [Errors](repository.md#errors). +A URL starting with a dash throws an `InvalidArgumentException`, with both +drivers: git would read it as an option, like `--upload-pack=...`, which +runs a command. A path may start with one. + +gitlib doesn't check the URLs any further. Old git versions run the command +of an `ext::` URL, which recent ones refuse by default (see the +`protocol.allow` setting of git): with URLs from your users, use a recent +git, or check them yourself. + Driver and logger ----------------- diff --git a/doc/repository.md b/doc/repository.md index d70f5231..8348d4dd 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -191,8 +191,10 @@ echo $process->getErrorOutput(); Both throw a `ProcessException` when git fails, and an `UnsupportedOperationException` with another driver than `CliDriver`. The -relative paths of the arguments are relative to the repository. gitlib reads -the references again after the command, which may have written them. +relative paths of the arguments are relative to the repository. The +arguments go to git as they are: a value from your users starting with a +dash is an option for git, unless `--` comes before it. gitlib reads the +references again after the command, which may have written them. `shell()` is deprecated since 2.0: run `git shell` with Symfony Process instead. @@ -217,8 +219,10 @@ or empty data instead. Every exception of gitlib implements perform the operation at all, where another driver may: `run()` with `LibGit2Driver`, libgit2 that isn't installed... - `InvalidArgumentException`: an argument gitlib doesn't support, like an - option of `git log` in the revisions of a log, or a string holding a NUL - byte, which git doesn't accept. + option of `git log` in the revisions of a log, a string holding a NUL + byte, which git doesn't accept, or a revision, a reference, a hash or a + URL starting with a dash, which git would read as an option + (`getRevision('--output=/path')`). - `LogicException`: a call that makes no sense, like the working copy of a bare repository. diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index 773555f9..1d6d8736 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -15,6 +15,7 @@ use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Util\LeadingDash; use Psr\Log\LoggerInterface; /** @@ -49,12 +50,13 @@ public function open(string $dir, ?string $workingDir): CliRepositoryDriver public function init(string $path): void { - $this->mustRun('init', ['-q', $path]); + // --: a path may start with a dash. + $this->mustRun('init', ['-q', '--', $path]); } public function initBare(string $path): void { - $this->mustRun('init', ['-q', '--bare', $path]); + $this->mustRun('init', ['-q', '--bare', '--', $path]); } public function cloneFrom(string $url, string $path, ?string $branch): void @@ -74,11 +76,16 @@ public function mirrorFrom(string $url, string $path): void public function isValidRepository(string $url): bool { + // ls-remote only takes "--" since git 2.8. + LeadingDash::assertNone($url); + return $this->runner()->run('ls-remote', [self::absoluteUrl($url)])->isSuccessful(); } public function isValidRepositoryAndBranch(string $url, string $branch): bool { + LeadingDash::assertNone($url); + // git lists the branches whose name ends with the pattern: "release" matches "feature/release". $process = $this->runner()->run('ls-remote', ['--heads', self::absoluteUrl($url), 'refs/heads/'.$branch]); if (!$process->isSuccessful()) { @@ -103,7 +110,11 @@ public function isValidRepositoryAndBranch(string $url, string $branch): bool */ public function cloneRepository(string $path, string $url, array $args): void { - $this->mustRun('clone', ['-q', ...$args, $url, $path]); + // Like isValidRepository(), whose ls-remote can't take "--". + LeadingDash::assertNone($url); + + // --: a path may start with a dash. + $this->mustRun('clone', ['-q', ...$args, '--', $url, $path]); } /** diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index 2d0a98e7..eb54336d 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -26,6 +26,7 @@ use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\FastForward; use Gitonomy\Git\Parser\DiffParser; +use Gitonomy\Git\Util\LeadingDash; use Symfony\Component\Process\Process; /** @@ -81,6 +82,8 @@ public function getHeadReference(): ?string public function resolveRevision(string $revision): string { + LeadingDash::assertNone($revision); + $process = $this->execute('rev-parse', ['--verify', '--quiet', $revision]); // --quiet: git exits with 1, without output, when nothing has this name. @@ -97,11 +100,15 @@ public function resolveRevision(string $revision): string public function getShortHash(string $hash): string { + LeadingDash::assertNone($hash); + return trim($this->runOnObject($hash, null, 'log', ['--abbrev-commit', '--format=%h', '-n', '1', $hash])); } public function getCommitData(string $hash): CommitData { + LeadingDash::assertNone($hash); + $parser = new LogParser(); $parser->parse($this->runOnObject($hash, ObjectType::Commit, 'log', ['--encoding=UTF-8', '--format=raw', '-n', '1', $hash])); @@ -116,6 +123,8 @@ public function getCommitData(string $hash): CommitData public function getCommitLog(?array $includes, array $excludes, array $paths, int $offset, ?int $limit): array { + LeadingDash::assertNone(...$includes ?? [], ...$excludes); + if ([] === $includes) { return []; } @@ -136,6 +145,8 @@ public function getCommitLog(?array $includes, array $excludes, array $paths, in public function countCommits(?array $includes, array $excludes, array $paths): int { + LeadingDash::assertNone(...$includes ?? [], ...$excludes); + if ([] === $includes) { return 0; } @@ -145,6 +156,8 @@ public function countCommits(?array $includes, array $excludes, array $paths): i public function getMergeBases(string $hash1, string $hash2): array { + LeadingDash::assertNone($hash1, $hash2); + $process = $this->execute('merge-base', ['--all', $hash1, $hash2]); // git exits with 1 and no output when there is no common ancestor; any @@ -203,22 +216,30 @@ public function getReferences(): array public function createReference(string $fullname, string $hash): void { + LeadingDash::assertNone($fullname, $hash); + // Zeros as the old value: git refuses to update a reference that exists. $this->run('update-ref', [$fullname, $hash, str_repeat('0', 40)]); } public function updateReference(string $fullname, string $hash): void { + LeadingDash::assertNone($fullname, $hash); + $this->run('update-ref', [$fullname, $hash]); } public function deleteReference(string $fullname): void { + LeadingDash::assertNone($fullname); + $this->run('update-ref', ['-d', $fullname]); } public function getIncludingBranches(string $hash): array { + LeadingDash::assertNone($hash); + // for-each-ref only accepts --contains since git 2.7. return [ ...$this->listBranches(['--contains', $hash], 'refs/heads/'), @@ -228,11 +249,15 @@ public function getIncludingBranches(string $hash): array public function getDiff(?string $from, string $to): DiffData { + LeadingDash::assertNone($from ?? '', $to); + return $this->diff('diff-tree', [$from ?? self::EMPTY_TREE, $to]); } public function getWorkingTreeDiff(string $hash): DiffData { + LeadingDash::assertNone($hash); + return $this->diff('diff', [$hash]); } @@ -248,6 +273,8 @@ public function getDiffStaged(): DiffData public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array { + LeadingDash::assertNone($hash); + $args = ['-p']; if (null !== $firstLine || null !== $lastLine) { $args[] = '-L'; @@ -274,16 +301,22 @@ public function getUntrackedFiles(): array public function checkout(string $revision): void { + LeadingDash::assertNone($revision); + $this->run('checkout', [$revision]); } public function checkoutNewBranch(string $branch, string $startHash): void { + LeadingDash::assertNone($branch, $startHash); + $this->run('checkout', ['-b', $branch, $startHash]); } public function merge(string $revision, FastForward $fastForward): void { + LeadingDash::assertNone($revision); + $option = match ($fastForward) { FastForward::Allow => '--ff', FastForward::Never => '--no-ff', diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index ef69d86e..19ec327e 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -17,6 +17,7 @@ use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\NulByte; /** @@ -101,6 +102,8 @@ public function initBare(string $path): void public function cloneFrom(string $url, string $path, ?string $branch): void { NulByte::assertNone($url, $path, $branch ?? ''); + // Like CliDriver, where git would read it as an option. + LeadingDash::assertNone($url); // git_clone() only checks a branch out, where git also checks a tag out, HEAD detached. $tag = null !== $branch && $this->isTagOnly($url, $branch) ? $branch : null; @@ -144,6 +147,7 @@ public function cloneFrom(string $url, string $path, ?string $branch): void public function cloneBareFrom(string $url, string $path, ?string $branch): void { NulByte::assertNone($url, $path, $branch ?? ''); + LeadingDash::assertNone($url); // Like git, only the branches (and the tags). $this->fetchInto($path, $url, '+refs/heads/*:refs/heads/*', $branch, ['remote.origin.url' => $url]); @@ -152,6 +156,7 @@ public function cloneBareFrom(string $url, string $path, ?string $branch): void public function mirrorFrom(string $url, string $path): void { NulByte::assertNone($url, $path); + LeadingDash::assertNone($url); $this->fetchInto($path, $url, '+refs/*:refs/*', null, [ 'remote.origin.url' => $url, @@ -164,6 +169,7 @@ public function mirrorFrom(string $url, string $path): void public function isValidRepository(string $url): bool { NulByte::assertNone($url); + LeadingDash::assertNone($url); return null !== $this->listRemoteReferences($url); } @@ -171,6 +177,7 @@ public function isValidRepository(string $url): bool public function isValidRepositoryAndBranch(string $url, string $branch): bool { NulByte::assertNone($url, $branch); + LeadingDash::assertNone($url); return \in_array('refs/heads/'.$branch, $this->listRemoteReferences($url) ?? [], true); } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index f6dfcea7..62ebd7cd 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -28,6 +28,7 @@ use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\FastForward; +use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\NulByte; use Symfony\Component\String\ByteString; use Symfony\Component\String\Exception\InvalidArgumentException; @@ -133,6 +134,7 @@ public function getHeadReference(): ?string public function resolveRevision(string $revision): string { NulByte::assertNone($revision); + LeadingDash::assertNone($revision); $object = $this->ffi->new('git_object*'); $rc = $this->ffi->git_revparse_single(\FFI::addr($object), $this->repo, $revision); @@ -151,6 +153,7 @@ public function resolveRevision(string $revision): string public function getCommitData(string $hash): CommitData { NulByte::assertNone($hash); + LeadingDash::assertNone($hash); $commit = $this->lookupCommit($hash); @@ -164,6 +167,7 @@ public function getCommitData(string $hash): CommitData public function getCommitLog(?array $includes, array $excludes, array $paths, int $offset, ?int $limit): array { NulByte::assertNone(...$includes ?? [], ...$excludes, ...$paths); + LeadingDash::assertNone(...$includes ?? [], ...$excludes); $result = []; if (0 === $limit) { @@ -192,6 +196,7 @@ public function getCommitLog(?array $includes, array $excludes, array $paths, in public function countCommits(?array $includes, array $excludes, array $paths): int { NulByte::assertNone(...$includes ?? [], ...$excludes, ...$paths); + LeadingDash::assertNone(...$includes ?? [], ...$excludes); return iterator_count($this->walk($includes, $excludes, $paths)); } @@ -199,6 +204,7 @@ public function countCommits(?array $includes, array $excludes, array $paths): i public function getMergeBases(string $hash1, string $hash2): array { NulByte::assertNone($hash1, $hash2); + LeadingDash::assertNone($hash1, $hash2); $oneOid = $this->hexToOid($hash1); $twoOid = $this->hexToOid($hash2); @@ -224,6 +230,7 @@ public function getMergeBases(string $hash1, string $hash2): array public function getShortHash(string $hash): string { NulByte::assertNone($hash); + LeadingDash::assertNone($hash); $object = $this->lookupObject($hash); @@ -340,6 +347,7 @@ public function getReferences(): array public function createReference(string $fullname, string $hash): void { NulByte::assertNone($fullname, $hash); + LeadingDash::assertNone($fullname, $hash); $oid = $this->hexToOid($hash); $ref = $this->ffi->new('git_reference*'); @@ -350,6 +358,7 @@ public function createReference(string $fullname, string $hash): void public function updateReference(string $fullname, string $hash): void { NulByte::assertNone($fullname, $hash); + LeadingDash::assertNone($fullname, $hash); $oid = $this->hexToOid($hash); $ref = $this->ffi->new('git_reference*'); @@ -360,6 +369,7 @@ public function updateReference(string $fullname, string $hash): void public function deleteReference(string $fullname): void { NulByte::assertNone($fullname); + LeadingDash::assertNone($fullname); $ref = $this->ffi->new('git_reference*'); $rc = $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname); @@ -379,6 +389,7 @@ public function deleteReference(string $fullname): void public function getIncludingBranches(string $hash): array { NulByte::assertNone($hash); + LeadingDash::assertNone($hash); $tips = []; foreach ($this->iterateBranches() as [$fullname, $ref]) { @@ -408,6 +419,7 @@ public function getIncludingBranches(string $hash): array public function getDiff(?string $from, string $to): DiffData { NulByte::assertNone($from ?? '', $to); + LeadingDash::assertNone($from ?? '', $to); $oldTree = null === $from ? null : $this->treeForRevision($from); @@ -429,6 +441,7 @@ public function getDiff(?string $from, string $to): DiffData public function getWorkingTreeDiff(string $hash): DiffData { NulByte::assertNone($hash); + LeadingDash::assertNone($hash); $tree = $this->treeForRevision($hash); @@ -488,6 +501,7 @@ public function getDiffStaged(): DiffData public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array { NulByte::assertNone($hash, $path); + LeadingDash::assertNone($hash); $range = null !== $firstLine || null !== $lastLine; $firstLine ??= 1; @@ -563,6 +577,7 @@ public function getUntrackedFiles(): array public function checkout(string $revision): void { NulByte::assertNone($revision); + LeadingDash::assertNone($revision); // `git checkout ` attaches HEAD when names a // local branch, before a tag of the same name, and detaches it for @@ -584,6 +599,7 @@ public function checkout(string $revision): void public function checkoutNewBranch(string $branch, string $startHash): void { NulByte::assertNone($branch, $startHash); + LeadingDash::assertNone($branch, $startHash); if ($this->referenceExists('refs/heads/'.$branch)) { throw new DriverException(\sprintf('A branch named "%s" already exists.', $branch)); @@ -613,6 +629,7 @@ public function checkoutNewBranch(string $branch, string $startHash): void public function merge(string $revision, FastForward $fastForward): void { NulByte::assertNone($revision); + LeadingDash::assertNone($revision); $branch = $this->getHeadReference(); $headHash = $this->resolveCommit('HEAD'); diff --git a/src/Gitonomy/Git/Util/LeadingDash.php b/src/Gitonomy/Git/Util/LeadingDash.php new file mode 100644 index 00000000..538908d0 --- /dev/null +++ b/src/Gitonomy/Git/Util/LeadingDash.php @@ -0,0 +1,41 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * git reads an argument starting with a dash as an option: the revision + * "--output=/path" of a log writes a file, the URL "--upload-pack=..." of + * ls-remote runs a command. git 1.8.4 has no "--end-of-options", and what + * follows "--" is a path for most commands: such a revision, reference, hash + * or URL is refused, by both drivers. + * + * @author Grégoire Pineau + * + * @internal + */ +final class LeadingDash +{ + /** + * @throws InvalidArgumentException One of the values starts with a dash + */ + public static function assertNone(string ...$values): void + { + foreach ($values as $value) { + if (str_starts_with($value, '-')) { + throw new InvalidArgumentException(\sprintf('"%s" starts with a dash, which git would read as an option.', $value)); + } + } + } +} diff --git a/tests/Gitonomy/Git/Tests/LeadingDashTest.php b/tests/Gitonomy/Git/Tests/LeadingDashTest.php new file mode 100644 index 00000000..1a6d7914 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/LeadingDashTest.php @@ -0,0 +1,117 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * git reads an argument starting with a dash as an option: a revision from + * the URL of a web application, like "--output=/path", would write a file. + */ +class LeadingDashTest extends AbstractTestCase +{ + #[DataProvider('provideCalls')] + public function testARevisionStartingWithADashIsRefused(\Closure $call): void + { + $repository = self::createFoobarRepository(false); + $file = self::createTempDir().'/written'; + + try { + $call($repository, '--output='.$file); + $this->fail('A leading dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('starts with a dash', $e->getMessage()); + } + + $this->assertFileDoesNotExist($file); + $this->assertTrue($repository->getReferences()->hasBranch('master'), 'Nothing is done'); + } + + /** + * @return iterable + */ + public static function provideCalls(): iterable + { + yield 'revision' => [static function (Repository $repository, string $option): void { $repository->getRevision($option)->getCommit(); }]; + yield 'range of a log' => [static function (Repository $repository, string $option): void { $repository->getLog($option.'..master')->getCommits(); }]; + yield 'blame' => [static function (Repository $repository, string $option): void { $repository->getBlame($option, 'README.md')->getLines(); }]; + yield 'diff' => [static function (Repository $repository, string $option): void { $repository->getDiff($option, 'master'); }]; + yield 'checkout' => [static function (Repository $repository, string $option): void { $repository->getWorkingCopy()->checkout($option); }]; + yield 'new branch' => [static function (Repository $repository, string $option): void { $repository->getWorkingCopy()->checkoutNewBranch($option, 'master'); }]; + yield 'merge' => [static function (Repository $repository, string $option): void { $repository->getWorkingCopy()->merge($option); }]; + yield 'reference' => [static function (Repository $repository, string $option): void { new Branch($repository, $option, self::LONGFILE_COMMIT)->delete(); }]; + } + + /** + * In a repository with a remote, "git ls-remote --upload-pack=..." runs + * the command to list the references of the remote. + */ + #[DataProvider('provideUrlCalls')] + public function testAUrlStartingWithADashIsRefused(\Closure $call): void + { + $repository = self::createFoobarRepository(false); + $previousCwd = getcwd(); + $this->assertIsString($previousCwd); + chdir($repository->getPath()); + + try { + // A colon before any slash: a URL, never a local path. + $call('--upload-pack=touch pwned;:;git-upload-pack', self::createTempDir().'/clone'); + $this->fail('A leading dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('starts with a dash', $e->getMessage()); + } finally { + chdir($previousCwd); + } + + $this->assertFileDoesNotExist($repository->getPath().'/pwned'); + } + + /** + * @return iterable + */ + public static function provideUrlCalls(): iterable + { + yield 'valid repository' => [static function (string $url): void { Repository::isValidRepository($url, self::getDriver()); }]; + yield 'valid branch' => [static function (string $url): void { Repository::isValidRepositoryAndBranch($url, 'master', self::getDriver()); }]; + yield 'clone' => [static function (string $url, string $path): void { Repository::cloneFrom($url, $path, driver: self::getDriver()); }]; + yield 'bare clone' => [static function (string $url, string $path): void { Repository::cloneBareFrom($url, $path, driver: self::getDriver()); }]; + yield 'mirror' => [static function (string $url, string $path): void { Repository::mirrorFrom($url, $path, self::getDriver()); }]; + } + + /** + * Unlike a URL, a path may start with a dash: git reads it after "--". + */ + public function testAPathMayStartWithADash(): void + { + $dir = self::createTempDir(); + $previousCwd = getcwd(); + $this->assertIsString($previousCwd); + chdir($dir); + + try { + Repository::init('-repository', self::getDriver()); + Repository::initBare('-bare', self::getDriver()); + Repository::cloneFrom(self::repositoryUrl(), '-clone', driver: self::getDriver()); + } finally { + chdir($previousCwd); + } + + $this->assertDirectoryExists($dir.'/-repository/.git'); + $this->assertFileExists($dir.'/-bare/HEAD'); + $this->assertFileExists($dir.'/-clone/README.md'); + } +} From 755484bb3acde43765bce709a2c421a8e078adea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 16:45:18 +0200 Subject: [PATCH 094/150] Hide the credentials of URLs in exceptions and logs A URL like https://user:token@host/repository.git ended up, token included, in the message of the ProcessException of a failed clone, through its command line, and in the logs of CliDriver, through the command and its output (git remote -v...). The messages of LibGit2Driver named the URL too. Util\Credentials::redact() replaces the user information of the URLs by "***" in the messages of ProcessException and of libgit2's errors, and in the logs. getOutput() and getErrorOutput() of ProcessException still return the output of git as it is. --- CHANGELOG.md | 2 + UPGRADE-2.0.md | 3 + doc/admin.md | 3 +- doc/repository.md | 7 +- src/Gitonomy/Git/Driver/Cli/GitRunner.php | 8 +- src/Gitonomy/Git/Driver/Ffi/LibGit2.php | 4 +- src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 5 +- .../Git/Exception/ProcessException.php | 8 +- src/Gitonomy/Git/Util/Credentials.php | 35 ++++++++ tests/Gitonomy/Git/Tests/CredentialsTest.php | 83 +++++++++++++++++++ 10 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 src/Gitonomy/Git/Util/Credentials.php create mode 100644 tests/Gitonomy/Git/Tests/CredentialsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 112de6e9..a8098be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). option: `getRevision('--output=/path')`, `WorkingCopy::checkout()` or `merge()` could write a file, and `Repository::isValidRepository('--upload-pack=...')` run a command. A path may start with a dash: `init()` and the clones end the options of git with `--`. +- Hide the credentials of URLs (`https://user:token@host`) in the messages of exceptions and + in the logs. ### Bug fixes diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index ee97fe8c..1a9d25fa 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -396,6 +396,9 @@ Errors `getRevision('--output=/path')` writing a file, or `isValidRepository('--upload-pack=...')` running a command. A path may start with a dash. + * The messages of exceptions and the logs hide the credentials of URLs + (`https://***@example.com/repository.git`). `getErrorOutput()` and + `getOutput()` of `ProcessException` don't. * Every message is a sentence ending with a dot. Dates diff --git a/doc/admin.md b/doc/admin.md index b65353bc..322b1814 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -103,7 +103,8 @@ run, like a missing binary. See [Errors](repository.md#errors). A URL starting with a dash throws an `InvalidArgumentException`, with both drivers: git would read it as an option, like `--upload-pack=...`, which -runs a command. A path may start with one. +runs a command. A path may start with one. The message of an exception +hides the credentials of the URL (`https://***@example.com/repository.git`). gitlib doesn't check the URLs any further. Old git versions run the command of an `ext::` URL, which recent ones refuse by default (see the diff --git a/doc/repository.md b/doc/repository.md index 8348d4dd..69c394a5 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -236,6 +236,10 @@ try { } ``` +The message of an exception hides the credentials of the URLs it holds +(`https://***@example.com/repository.git`): `getErrorOutput()` and +`getOutput()` of a `ProcessException` return the output of git as it is. + Choosing a driver ----------------- @@ -456,7 +460,8 @@ debug last command (fetch) output: Fetching origin ``` A failing command also logs its error output, at the `debug` level: the -exception it throws reports the failure. `LibGit2Driver` logs nothing. +exception it throws reports the failure. The logs hide the credentials of +URLs, like exceptions. `LibGit2Driver` logs nothing. `Repository::setLogger()` and `Repository::getLogger()` are deprecated since 2.0: give the logger to `CliDriver`. diff --git a/src/Gitonomy/Git/Driver/Cli/GitRunner.php b/src/Gitonomy/Git/Driver/Cli/GitRunner.php index 9637221f..e1a59708 100644 --- a/src/Gitonomy/Git/Driver/Cli/GitRunner.php +++ b/src/Gitonomy/Git/Driver/Cli/GitRunner.php @@ -16,6 +16,7 @@ use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ProcessTimedOutException; +use Gitonomy\Git\Util\Credentials; use Gitonomy\Git\Util\NulByte; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException; @@ -63,7 +64,8 @@ public function run(string $command, array $args, array $globalOptions = [], ?st $process->setTimeout($this->timeout); $process->setIdleTimeout($this->timeout); - $this->logger?->info(\sprintf('run command: %s "%s" ', $command, implode(' ', $args))); + // The logs hide the credentials of the URLs, like the exceptions. + $this->logger?->info(Credentials::redact(\sprintf('run command: %s "%s" ', $command, implode(' ', $args)))); $before = microtime(true); try { @@ -83,9 +85,9 @@ public function run(string $command, array $args, array $globalOptions = [], ?st if (null !== $this->logger) { $this->logger->debug(\sprintf('last command (%s) duration: %.2fms', $command, (microtime(true) - $before) * 1000)); $this->logger->debug(\sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); - $this->logger->debug(\sprintf('last command (%s) output: %s', $command, $process->getOutput())); + $this->logger->debug(Credentials::redact(\sprintf('last command (%s) output: %s', $command, $process->getOutput()))); if (!$process->isSuccessful()) { - $this->logger->debug(\sprintf('last command (%s) error output: %s', $command, $process->getErrorOutput())); + $this->logger->debug(Credentials::redact(\sprintf('last command (%s) error output: %s', $command, $process->getErrorOutput()))); } } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php index ad8a5704..df82a63f 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php @@ -15,6 +15,7 @@ use FFI\CData; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\Credentials; /** * Loads libgit2, and helps calling it. @@ -100,7 +101,8 @@ public static function preload(?string $libraryPath): void public static function check(\FFI $ffi, int $rc, string $context): void { if ($rc < 0) { - throw new DriverException(\sprintf('%s: %s', $context, self::lastErrorMessage($ffi))); + // $context and libgit2 may name a URL holding credentials. + throw new DriverException(Credentials::redact(\sprintf('%s: %s', $context, self::lastErrorMessage($ffi)))); } } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index 19ec327e..c2b552f1 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -17,6 +17,7 @@ use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\Credentials; use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\NulByte; @@ -201,7 +202,7 @@ private function fetchInto(string $path, string $url, string $refspec, ?string $ // Like git, which refuses to clone into a directory with files. $existing = is_dir($path); if ($existing && [] !== array_diff(scandir($path) ?: [], ['.', '..'])) { - throw new DriverException(\sprintf('Unable to clone "%s": "%s" exists and is not an empty directory.', $url, $path)); + throw new DriverException(\sprintf('Unable to clone "%s": "%s" exists and is not an empty directory.', Credentials::redact($url), $path)); } $repository = $this->initRepository($path, true); @@ -368,7 +369,7 @@ private function detachHeadAtTag(CData $repository, string $tag, string $url): v $commit = $this->ffi->new('git_object*'); $rc = $this->ffi->git_revparse_single(\FFI::addr($commit), $repository, 'refs/tags/'.$tag.'^{commit}'); if (LibGit2::GIT_ENOTFOUND === $rc) { - throw new DriverException(\sprintf('Remote branch "%s" not found in "%s".', $tag, $url)); + throw new DriverException(\sprintf('Remote branch "%s" not found in "%s".', $tag, Credentials::redact($url))); } LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve the tag "%s"', $tag)); diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index 0a008c92..c747e5bd 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -12,20 +12,24 @@ namespace Gitonomy\Git\Exception; +use Gitonomy\Git\Util\Credentials; use Symfony\Component\Process\Process; final class ProcessException extends DriverException implements GitExceptionInterface { + /** + * The message hides the credentials of the URLs, unlike getErrorOutput() and getOutput(). + */ public function __construct(private readonly Process $process) { - parent::__construct( + parent::__construct(Credentials::redact( "Error while running git command:\n". $process->getCommandLine()."\n". "\n". $process->getErrorOutput()."\n". "\n". $process->getOutput() - ); + )); } public function getErrorOutput(): string diff --git a/src/Gitonomy/Git/Util/Credentials.php b/src/Gitonomy/Git/Util/Credentials.php new file mode 100644 index 00000000..4d19dee0 --- /dev/null +++ b/src/Gitonomy/Git/Util/Credentials.php @@ -0,0 +1,35 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * A URL like "https://user:token@host/repository.git" carries credentials: + * the messages of exceptions and of the logger hide them. + * + * @author Grégoire Pineau + * + * @internal + */ +final class Credentials +{ + /** + * Replaces the user information of the URLs of $text, a token or a user + * and a password, by "***". + */ + public static function redact(string $text): string + { + // Up to the last "@" before the host, as a password may hold one. + // @phpstan-ignore return.type (possessive quantifiers never reach the backtracking limit, and without "u" there is no UTF-8 to fail on) + return preg_replace('#\b([a-z][a-z0-9+.-]*+://)(?:[^/?\#\s@]*+@)++#i', '$1***@', $text); + } +} diff --git a/tests/Gitonomy/Git/Tests/CredentialsTest.php b/tests/Gitonomy/Git/Tests/CredentialsTest.php new file mode 100644 index 00000000..f97112ad --- /dev/null +++ b/tests/Gitonomy/Git/Tests/CredentialsTest.php @@ -0,0 +1,83 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\Credentials; +use PHPUnit\Framework\Attributes\DataProvider; +use Psr\Log\LoggerInterface; + +/** + * The credentials of a URL must not end up in the logs or in an error tracker. + */ +class CredentialsTest extends AbstractTestCase +{ + /** + * Nothing listens on port 1: the clone fails. + */ + private const string URL = 'http://user:secret@127.0.0.1:1/repository.git'; + + public function testAFailedCloneHidesTheCredentials(): void + { + try { + Repository::cloneFrom(self::URL, self::createTempDir().'/clone', driver: self::getDriver()); + $this->fail('The clone fails.'); + } catch (DriverException $e) { + $this->assertStringNotContainsString('secret', $e->getMessage()); + $this->assertStringContainsString('http://***@127.0.0.1:1/repository.git', $e->getMessage()); + } + } + + public function testTheLogsHideTheCredentials(): void + { + $this->skipUnlessCliDriver(); + + $messages = []; + $log = static function (string $message) use (&$messages): void { + $messages[] = $message; + }; + $logger = $this->createStub(LoggerInterface::class); + $logger->method('info')->willReturnCallback($log); + $logger->method('debug')->willReturnCallback($log); + + $repository = new Repository(self::createFoobarRepository()->getPath(), driver: self::getCliDriver(logger: $logger)); + // The URL is in the command, then in its output. + $repository->run('remote', ['add', 'private', self::URL]); + $repository->run('remote', ['-v']); + + $this->assertStringContainsString("private\thttp://***@127.0.0.1:1/repository.git (fetch)\n", implode("\n", $messages)); + foreach ($messages as $message) { + $this->assertStringNotContainsString('secret', $message); + } + } + + #[DataProvider('provideTexts')] + public function testRedact(string $expected, string $text): void + { + $this->assertSame($expected, Credentials::redact($text)); + } + + /** + * @return iterable + */ + public static function provideTexts(): iterable + { + yield 'user and password' => ["clone 'https://***@example.com/a.git'", "clone 'https://user:password@example.com/a.git'"]; + yield 'token' => ['https://***@example.com', 'https://ghp_token@example.com']; + yield 'password holding an "@"' => ['https://***@example.com/a.git', 'https://user:p@ss@example.com/a.git']; + yield 'several URLs' => ['ssh://***@a.com/x and https://***@b.com/y', 'ssh://git@a.com/x and https://user:password@b.com/y']; + yield 'no user information' => ['https://example.com/a@b.git', 'https://example.com/a@b.git']; + yield 'no scheme' => ['git@example.com:a.git', 'git@example.com:a.git']; + } +} From e8c7a563ac0cedebcd64e6d9c7364e99ba68eb1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 16:45:54 +0200 Subject: [PATCH 095/150] Refuse hook names outside the hooks directory Hooks joined the name to the hooks directory as it was: set('../evil', ...) wrote .git/evil, and remove('../config') deleted the configuration of the repository. A hook name is made of letters, digits, "-", "_" and ".", and doesn't start with a dot, as git for Windows also runs "pre-commit.exe": every method of Hooks throws an InvalidArgumentException for any other name. --- CHANGELOG.md | 3 ++ UPGRADE-2.0.md | 2 ++ doc/hooks.md | 5 +++ src/Gitonomy/Git/Hooks.php | 22 +++++++++--- tests/Gitonomy/Git/Tests/HooksTest.php | 46 ++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8098be8..6cb2ad68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,9 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). start with a dash: `init()` and the clones end the options of git with `--`. - Hide the credentials of URLs (`https://user:token@host`) in the messages of exceptions and in the logs. +- Refuse a hook name that isn't the one of a file of the hooks directory: + `Hooks::set('../config', ...)` and `remove('../config')` wrote and deleted the + configuration of the repository. ### Bug fixes diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 1a9d25fa..57ef0d5f 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -396,6 +396,8 @@ Errors `getRevision('--output=/path')` writing a file, or `isValidRepository('--upload-pack=...')` running a command. A path may start with a dash. + * `Hooks` throws an `InvalidArgumentException` for a name that isn't the one + of a file of the hooks directory, like `../config` or `.hidden`. * The messages of exceptions and the logs hide the credentials of URLs (`https://***@example.com/repository.git`). `getErrorOutput()` and `getOutput()` of `ProcessException` don't. diff --git a/doc/hooks.md b/doc/hooks.md index 8bc11895..663a5064 100644 --- a/doc/hooks.md +++ b/doc/hooks.md @@ -11,6 +11,11 @@ Those files need to be executable: gitlib makes them executable by their owner only (*0700*). If another user runs them (a git daemon, a web server...), change their permissions yourself. +A hook is a file of the hooks directory: its name, like `pre-receive`, is +made of letters, digits, `-`, `_` and `.`, and doesn't start with a dot. Any +other name, like `../config`, throws a +`Gitonomy\Git\Exception\InvalidArgumentException`. + git runs them, when it receives a push for instance, or when `CliDriver` checks out or merges. `LibGit2Driver` doesn't run them (see ["Choosing a driver"](repository.md#choosing-a-driver)). diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index c673f919..a8847ce3 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -31,6 +31,8 @@ public function __construct( * Tests if repository has a given hook. * * @param string $name Name of the hook + * + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory */ public function has(string $name): bool { @@ -44,7 +46,7 @@ public function has(string $name): bool * * @return string Content of the hook * - * @throws InvalidArgumentException The hook does not exist + * @throws InvalidArgumentException The hook does not exist, or the name isn't valid * @throws RuntimeException The hook can't be read */ public function get(string $name): string @@ -67,7 +69,8 @@ public function get(string $name): string * @param string $name Name of the hook to insert * @param string $file Target of symlink * - * @throws RuntimeException The hook exists already, or can't be created + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory + * @throws RuntimeException The hook exists already, or can't be created */ public function setSymlink(string $name, string $file): void { @@ -84,7 +87,8 @@ public function setSymlink(string $name, string $file): void * @param string $name Name of the hook * @param string $content Content of the hook * - * @throws RuntimeException The hook exists already, or can't be created + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory + * @throws RuntimeException The hook exists already, or can't be created */ public function set(string $name, string $content): void { @@ -100,7 +104,8 @@ public function set(string $name, string $content): void * * @param string $name Name of the hook * - * @throws RuntimeException The hook doesn't exist, or can't be removed + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory + * @throws RuntimeException The hook doesn't exist, or can't be removed */ public function remove(string $name): void { @@ -131,8 +136,17 @@ private function createPath(string $name): string return $this->getPath($name); } + /** + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory + */ private function getPath(string $name): string { + // "../config" would be the configuration of the repository. A name may + // hold a dot, but not start with one: git for Windows runs "pre-commit.exe". + if (1 !== preg_match('/^[a-zA-Z0-9_-][a-zA-Z0-9._-]*$/D', $name)) { + throw new InvalidArgumentException(\sprintf('"%s" is not a valid hook name: it must be the name of a file of the hooks directory, like "pre-commit".', addcslashes($name, "\0"))); + } + return $this->getDirectory().'/'.$name; } diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 75a5f68a..2646fa3b 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -69,6 +69,52 @@ public function assertNoHook(Repository $repository, string $hook): void $this->assertFileDoesNotExist($file, "Hook {$hook} is not present"); } + /** + * A hook is a file of the hooks directory: "../config" would be the + * configuration of the repository. + */ + #[DataProvider('provideInvalidNames')] + public function testAnInvalidNameIsRefused(string $name): void + { + $repository = self::createFoobarRepository(); + $hooks = $repository->getHooks(); + $calls = [ + 'has' => static fn () => $hooks->has($name), + 'get' => static fn () => $hooks->get($name), + 'set' => static fn () => $hooks->set($name, 'content'), + 'setSymlink' => static fn () => $hooks->setSymlink($name, __FILE__), + 'remove' => static fn () => $hooks->remove($name), + ]; + + foreach ($calls as $method => $call) { + try { + $call(); + $this->fail(\sprintf('%s() refuses "%s".', $method, $name)); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('is not a valid hook name', $e->getMessage()); + } + } + + $this->assertFileExists($repository->getGitDir().'/config'); + $this->assertFileDoesNotExist($repository->getGitDir().'/evil'); + } + + /** + * @return iterable + */ + public static function provideInvalidNames(): iterable + { + yield 'parent directory' => ['../config']; + yield 'new file in the parent directory' => ['../evil']; + yield 'Windows separator' => ['..\config']; + yield 'sub-directory' => ['sub/pre-commit']; + yield 'dot' => ['.']; + yield 'dot dot' => ['..']; + yield 'empty' => ['']; + yield 'hidden file' => ['.pre-commit']; + yield 'NUL byte' => ["pre-commit\0.sample"]; + } + #[DataProvider('provideBare')] public function testHas(bool $bare): void { From 2bc07d3b749eb6095e6a9db6b7ce1464a349d70b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 16:46:36 +0200 Subject: [PATCH 096/150] Read git whatever the configuration of the user git reads the configuration of the user running PHP, which changed what CliDriver parsed: color.ui=always, log.showSignature, diff.noprefix, diff.external, diff.suppressBlankEmpty, textconv filters or column.ui made reads throw or return wrong data, a short hash prefixed with the verification of a signature for instance. diff.algorithm, diff.context, diff.orderFile... changed the diff of the working tree, unlike the one of a commit, which diff-tree reads without them, and log.follow and branch.sort the logs and the branches of a commit. CliRepositoryDriver sets them back to git's defaults: with the options of its commands (--no-color, --no-ext-diff, --no-textconv, the prefixes, the diff algorithm, the context...), and with -c on every command, run() included, where git 1.8.4 has none. Identity, credentials, URL rewrites, core.abbrev or blame.ignoreRevsFile still apply, like with git. The tests no longer depend on the configuration of the developer: the bootstrap points HOME to tests/fixtures/home, whose .gitconfig sets everything gitlib sets back, so every run checks it. test-git-versions needs no HOME of its own anymore, and leaves nothing in the temporary directory. --- CHANGELOG.md | 8 ++ UPGRADE-2.0.md | 11 +++ doc/repository.md | 25 ++++++ .../Git/Driver/Cli/CliRepositoryDriver.php | 39 +++++++-- test-git-versions.php | 11 +-- tests/Gitonomy/Git/Tests/AbstractTestCase.php | 21 +++-- tests/Gitonomy/Git/Tests/AdminTest.php | 6 +- tests/Gitonomy/Git/Tests/CommitTest.php | 11 +++ tests/Gitonomy/Git/Tests/DiffTest.php | 83 +++++++++++++++++++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 21 ++--- tests/bootstrap.php | 22 +++++ tests/fixtures/home/.gitconfig | 70 ++++++++++++++++ tests/fixtures/home/attributes | 1 + tests/fixtures/home/orderfile | 2 + 14 files changed, 296 insertions(+), 35 deletions(-) create mode 100644 tests/fixtures/home/.gitconfig create mode 100644 tests/fixtures/home/attributes create mode 100644 tests/fixtures/home/orderfile diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb2ad68..4170038d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,12 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). ### Bug fixes +- Fix the configuration of the user changing what `CliDriver` reads: `color.ui=always`, + `log.showSignature`, `diff.noprefix`, `diff.external`, textconv filters, `column.ui`... + made reads throw or return wrong data, and `diff.algorithm`, `diff.context`, + `diff.orderFile`, `log.follow` or `branch.sort` changed diffs, logs and the branches of a + commit. gitlib sets them back to git's defaults; see + ["Git configuration"](doc/repository.md#git-configuration). - Fix `Commit::getDiff()` returning an empty diff for a root commit, and throwing for a merge commit (`CliDriver`). ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix `Repository::getDiff()`, `WorkingCopy::getDiffPending()` and `WorkingCopy::getDiffStaged()` @@ -180,6 +186,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - CI tests git 1.8.4 and 2.25.1, analyses the tests and the libgit2 driver with PHPStan, checks the archive Composer installs, and the suite deletes what it creates, in any order. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- The tests run with a git configuration of their own, whatever the developer's + (`tests/fixtures/home`), which sets what gitlib sets back to git's defaults. - Add a regression test for reading a blob's content in a tree that also contains a submodule. ([#243](https://github.com/gitonomy/gitlib/pull/243)) - Replace the network-dependent test fixture (cloned from GitHub on every run) with a local, diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 57ef0d5f..cb43da66 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -403,6 +403,17 @@ Errors `getOutput()` of `ProcessException` don't. * Every message is a sentence ending with a dot. +Git configuration +----------------- + + * `CliDriver` sets back to git's defaults the configuration of the user that + changes what git prints, or what a read returns: colors, columns, + `branch.sort`, `log.follow`, `log.showSignature`, and for diffs + `diff.algorithm`, `diff.context`, `diff.external`, `diff.orderFile`, the + prefixes, the textconv filters... Before, they made reads throw or return + other data: a log of a path following its renames with `log.follow`, for + instance. See ["Git configuration"](doc/repository.md#git-configuration). + Dates ----- diff --git a/doc/repository.md b/doc/repository.md index 69c394a5..2d1232a3 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -435,6 +435,31 @@ arguments. So is `inherit_environment_variables`, which passed the entries of `$_SERVER` to git, the headers and credentials of an HTTP request included: pass the variables git needs to `environmentVariables`. +Git configuration +----------------- + +git reads the configuration of the user running PHP (`~/.gitconfig`, +`/etc/gitconfig`...) and of the repository. Where it changes what git +prints, which gitlib reads, or what a read returns, `CliDriver` sets it back +to git's defaults: + +- colors and columns; +- `branch.sort`, `log.follow` and `log.showSignature`; +- for diffs, `diff.algorithm`, `diff.context`, `diff.external`, + `diff.indentHeuristic`, `diff.interHunkContext`, `diff.orderFile`, + `diff.suppressBlankEmpty`, the prefixes (`diff.noprefix`, + `diff.mnemonicPrefix`...), and the textconv filters, of blames too. + +The rest of the configuration applies, like with git: the identity, the +credentials and the rewrites of URLs, `safe.directory`, line endings, hooks, +`merge.log`, `blame.ignoreRevsFile`, or `core.abbrev`, the length of short +hashes, with `LibGit2Driver` too. + +`run()` and `runProcess()` get `branch.sort`, `log.follow`, +`log.showSignature`, `diff.indentHeuristic` and `diff.suppressBlankEmpty` set +back too, but not the rest, which gitlib sets with the options of the +commands it runs: pass `--no-color` or `--no-ext-diff` yourself. + Logger ------ diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index eb54336d..d5a26b78 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -38,9 +38,31 @@ */ final readonly class CliRepositoryDriver implements RepositoryDriverInterface { - // -z: the raw information gives the names as they are, without quoting. - // --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". - private const array DIFF_ARGS = ['-r', '-p', '--raw', '-z', '-M', '--full-index', '--no-abbrev']; + /** + * Sets back to git's defaults the configuration of the user that changes + * what git prints or reads, where git 1.8.4 has no option for it. A git + * version that doesn't know a key ignores it. + * + * diff.indentHeuristic is the default since git 2.14, and LibGit2Driver's. + */ + private const array CONFIG = [ + '-c', 'branch.sort=refname', + '-c', 'diff.indentHeuristic=true', + '-c', 'diff.suppressBlankEmpty=false', + '-c', 'log.follow=false', + '-c', 'log.showSignature=false', + ]; + + /** + * -z: the raw information gives the names as they are, without quoting. + * --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". + * The others set back to git's defaults the configuration "git diff" reads, + * unlike diff-tree: diff.algorithm, diff.context, diff.external, diff.noprefix... + */ + private const array DIFF_ARGS = [ + '-r', '-p', '--raw', '-z', '-M', '--full-index', '--no-abbrev', + '--diff-algorithm=myers', '--dst-prefix=b/', '--inter-hunk-context=0', '--no-color', '--no-ext-diff', '--no-textconv', '-O/dev/null', '--src-prefix=a/', '-U3', + ]; /** * The tree without any file: git knows it, even when no commit has it. @@ -110,7 +132,7 @@ public function getCommitData(string $hash): CommitData LeadingDash::assertNone($hash); $parser = new LogParser(); - $parser->parse($this->runOnObject($hash, ObjectType::Commit, 'log', ['--encoding=UTF-8', '--format=raw', '-n', '1', $hash])); + $parser->parse($this->runOnObject($hash, ObjectType::Commit, 'log', ['--encoding=UTF-8', '--format=raw', '--no-color', '-n', '1', $hash])); $commit = $parser->commits[0] ?? null; // git log peels a tag to its commit. @@ -130,7 +152,7 @@ public function getCommitLog(?array $includes, array $excludes, array $paths, in } // Re-encodes the commits whose "encoding" header isn't UTF-8. - $args = ['--encoding=UTF-8', '--format=raw', '--skip='.$offset]; + $args = ['--encoding=UTF-8', '--format=raw', '--no-color', '--skip='.$offset]; if (null !== $limit) { $args[] = '-n'; @@ -275,7 +297,8 @@ public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int { LeadingDash::assertNone($hash); - $args = ['-p']; + // --no-textconv: blame reads the files through their textconv filter by default. + $args = ['-p', '--no-textconv']; if (null !== $firstLine || null !== $lastLine) { $args[] = '-L'; $args[] = ($firstLine ?? 1).','.$lastLine; @@ -437,7 +460,7 @@ private static function revisions(?array $includes, array $excludes): array private function listBranches(array $args, string $prefix): array { $branches = []; - foreach (explode("\n", rtrim($this->run('branch', ['--no-color', ...$args]), "\n")) as $line) { + foreach (explode("\n", rtrim($this->run('branch', ['--no-color', '--no-column', ...$args]), "\n")) as $line) { // "* " flags the current branch, "(HEAD detached at ...)" a detached // HEAD, and "origin/HEAD -> origin/main" a symbolic reference. $name = substr($line, 2); @@ -456,7 +479,7 @@ private function listBranches(array $args, string $prefix): array */ private function execute(string $command, array $args, ?string $input = null): Process { - $globalOptions = ['--git-dir', $this->gitDir]; + $globalOptions = [...self::CONFIG, '--git-dir', $this->gitDir]; if (null !== $this->workingDir) { $globalOptions = [...$globalOptions, '--work-tree', $this->workingDir]; } diff --git a/test-git-versions.php b/test-git-versions.php index 82cdd48b..787dd32e 100755 --- a/test-git-versions.php +++ b/test-git-versions.php @@ -166,13 +166,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); $versions = $input->getArgument('versions'); - // Old git binaries choke on config values introduced after their release (e.g. - // "merge.conflictstyle = zdiff3", added in 2.35, is a fatal "unknown style" error - // on anything older) if they pick up the invoking user's own ~/.gitconfig. Give - // each git binary an empty, isolated HOME so only gitlib's own fixtures apply. - $isolatedHome = sys_get_temp_dir().'/gitlib-test-git-versions-home-'.bin2hex(random_bytes(4)); - mkdir($isolatedHome); - $results = []; foreach ($versions as $version) { @@ -188,8 +181,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->section(sprintf('Testing against %s', $version)); $io->writeln(sprintf('Command: %s', $binary)); - $env = ['GIT_COMMAND' => $binary, 'HOME' => $isolatedHome, 'XDG_CONFIG_HOME' => $isolatedHome]; - $process = new Process(['vendor/bin/phpunit'], __DIR__, $env, timeout: null); + // The bootstrap of the tests gives git a configuration of its own. + $process = new Process(['vendor/bin/phpunit'], __DIR__, ['GIT_COMMAND' => $binary], timeout: null); $process->run(static function (string $type, string $data) use ($output): void { $output->write($data); }); diff --git a/tests/Gitonomy/Git/Tests/AbstractTestCase.php b/tests/Gitonomy/Git/Tests/AbstractTestCase.php index d68dc773..a67912e5 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTestCase.php +++ b/tests/Gitonomy/Git/Tests/AbstractTestCase.php @@ -257,11 +257,7 @@ protected static function getDriver(): DriverInterface */ protected static function getCliDriver(array $environmentVariables = [], ?float $processTimeout = 60, ?LoggerInterface $logger = null): CliDriver { - // git 2.29 and later run their maintenance after a commit, in the - // background since 2.46: it would write in the repositories the tests delete. - $envs = [...self::gitEnvs(), 'GIT_CONFIG_COUNT' => '1', 'GIT_CONFIG_KEY_0' => 'maintenance.auto', 'GIT_CONFIG_VALUE_0' => 'false']; - - return new CliDriver(self::gitCommand(), [...$envs, ...$environmentVariables], processTimeout: $processTimeout, logger: $logger); + return new CliDriver(self::gitCommand(), [...self::gitEnvs(), ...$environmentVariables], processTimeout: $processTimeout, logger: $logger); } /** @@ -277,6 +273,21 @@ protected static function gitCommand(): string return $command; } + /** + * @return string The version of the git binary of the tests, like "2.25.1" + */ + protected static function gitVersion(): string + { + $process = new Process([self::gitCommand(), 'version']); + $process->mustRun(); + + if (1 !== preg_match('/^git version (\d+\.\d+\.\d+)/', $process->getOutput(), $matches)) { + throw new \UnexpectedValueException(\sprintf('Unable to read the version of git in "%s".', $process->getOutput())); + } + + return $matches[1]; + } + /** * @return array The environment variables of git: GIT_ENVS, which the bootstrap sets on Windows */ diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index b383fc75..86a3cf28 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -147,9 +147,9 @@ public function testInheritEnvironmentVariablesOnlyAppliesToTheRepository(): voi $this->markTestSkipped('The fake git binary is a shell script.'); } - // A git binary logging its first argument, and whether it gets SECRET. + // A git binary logging whether it gets SECRET. $dir = self::createTempDir(); - file_put_contents($dir.'/git', \sprintf("#!/bin/sh\necho \"\$1 \${GITLIB_TEST_SECRET:-none}\" >> %s\nexec %s \"\$@\"\n", escapeshellarg($dir.'/log'), escapeshellarg(self::gitCommand()))); + file_put_contents($dir.'/git', \sprintf("#!/bin/sh\necho \"\${GITLIB_TEST_SECRET:-none}\" >> %s\nexec %s \"\$@\"\n", escapeshellarg($dir.'/log'), escapeshellarg(self::gitCommand()))); chmod($dir.'/git', 0o755); // Like the credentials of an HTTP request, in $_SERVER only. $_SERVER['GITLIB_TEST_SECRET'] = 'secret'; @@ -162,7 +162,7 @@ public function testInheritEnvironmentVariablesOnlyAppliesToTheRepository(): voi } // Like in 1.x, git init doesn't get $_SERVER, the git processes of the repository do. - $this->assertSame("init none\n--git-dir secret\n", file_get_contents($dir.'/log')); + $this->assertSame("none\nsecret\n", file_get_contents($dir.'/log'), 'git init, then git config'); } public function testRemovedOptionsAreRefused(): void diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 67c813ec..8992cbbf 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -74,6 +74,17 @@ public function testGetShortHash(bool $bare): void $this->assertSame('4f17752', $commit->getShortHash(), 'Short hash'); } + /** + * With log.showSignature, which the configuration of the tests sets, git + * prints the verification of the signature before the hash. + */ + public function testGetShortHashOfASignedCommit(): void + { + $repository = self::createFoobarRepository(); + + $this->assertSame('e1a83f1', $repository->getCommit(self::SIGNED_COMMIT)->getShortHash()); + } + #[DataProvider('provideBare')] public function testGetParentHashesWithNoParent(bool $bare): void { diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 7c5e9afe..f22434bc 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -543,6 +543,75 @@ public function testDirtySubmodule(): void $this->assertSame('sub', $firstFile->getNewName()); } + /** + * Unlike diff-tree, "git diff" reads diff.algorithm, diff.context, + * diff.orderFile... which the configuration of the tests changes (see + * fixtures/home/.gitconfig): the diff of the working tree is the one of + * its commit all the same. + */ + public function testTheDiffOfTheWorkingTreeIsTheOneOfItsCommit(): void + { + $repository = self::createWorkingCopy(); + // Ten lines between two changes: two hunks. + $lines = array_map(static fn (int $number): string => 'line '.$number, range(1, 20)); + self::commitFile($repository, 'notes.txt', implode("\n", $lines)."\n"); + // The diff algorithm of git keeps "{", histogram keeps "}". + self::commitFile($repository, 'code.c', "c\n}\n{\n"); + + $lines[2] = 'line 3, changed'; + $lines[13] = 'line 14, changed'; + file_put_contents($repository->getWorkingDir().'/notes.txt', implode("\n", $lines)."\n"); + file_put_contents($repository->getWorkingDir().'/code.c', "c\n{\nc\n}\n"); + + $workingTreeDiff = $repository->getWorkingCopy()->getDiff(); + self::git($repository, 'commit', ['-q', '-a', '-m', 'Changes']); + $commitDiff = $repository->getHeadCommit()->getDiff(); + + $this->assertSame(['code.c', 'notes.txt'], array_map(static fn (File $file): string => $file->getName(), $commitDiff->getFiles())); + $this->assertSame(self::changes($commitDiff), self::changes($workingTreeDiff)); + } + + /** + * The indent heuristic of git, which diff.indentHeuristic turns off, + * places an added block after the blank line that ends it. + */ + public function testTheIndentHeuristicIsGits(): void + { + if (version_compare(self::gitVersion(), '2.11.0', '<')) { + $this->markTestSkipped('git has an indent heuristic since 2.11.'); + } + + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'code.c', "\n\nfoo();\n"); + self::commitFile($repository, 'code.c', "\n\nfoo();\n\nfoo();\n"); + + $this->assertSame([['code.c', [[1, 3, 1, 5, [ + ['Context', ''], + ['Context', ''], + ['Addition', 'foo();'], + ['Addition', ''], + ['Context', 'foo();'], + ]]]]], self::changes($repository->getHeadCommit()->getDiff())); + } + + /** + * "git diff" reads diff.noprefix, diff.mnemonicPrefix, diff.srcPrefix and + * diff.dstPrefix too: a patch to apply has the prefixes of git. + */ + public function testTheRawDiffOfTheWorkingTreeHasThePrefixesOfGit(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'code.c', "a\n"); + file_put_contents($repository->getWorkingDir().'/code.c', "b\n"); + + $rawDiff = $repository->getWorkingCopy()->getDiff()->getRawDiff(); + + $this->assertStringContainsString("diff --git a/code.c b/code.c\n", $rawDiff); + $this->assertStringContainsString("--- a/code.c\n+++ b/code.c\n", $rawDiff); + } + protected function verifyCreateCommitDiff(Diff $diff): void { $files = $diff->getFiles(); @@ -560,6 +629,20 @@ protected function verifyCreateCommitDiff(Diff $diff): void $this->assertSame(0, $files[0]->getDeletions(), '0 lines deleted'); } + /** + * @return array}>}> The name of each file, with its hunks + */ + private static function changes(Diff $diff): array + { + return array_map(static fn (File $file): array => [$file->getName(), array_map(static fn (FileChange $change): array => [ + $change->getRangeOldStart(), + $change->getRangeOldCount(), + $change->getRangeNewStart(), + $change->getRangeNewCount(), + array_map(static fn (DiffLine $line): array => [$line->getType()->name, $line->getContent()], $change->getLines()), + ], $file->getChanges())], $diff->getFiles()); + } + private static function createWorkingCopy(): Repository { $repository = self::createEmptyRepository(false); diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 40bad56b..44d07e6c 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -611,22 +611,23 @@ public function testRunResolvesRelativePathsAgainstTheRepositoryRegardlessOfCwd( $repository = self::createFoobarRepository(false); - $file = $repository->getWorkingDir().'/README.md'; - $original = file_get_contents($file); - file_put_contents($file, $original."Applied line.\n"); - - $patch = $repository->run('diff', ['--', 'README.md']); - file_put_contents($file, $original); - $patchFile = tempnam(sys_get_temp_dir(), 'gitlib_patch_'); - file_put_contents($patchFile, $patch); + file_put_contents($patchFile, <<<'PATCH' + diff --git a/applied.txt b/applied.txt + new file mode 100644 + --- /dev/null + +++ b/applied.txt + @@ -0,0 +1 @@ + +Applied line. + + PATCH); $previousCwd = getcwd(); $this->assertIsString($previousCwd); chdir(sys_get_temp_dir()); try { - // "README.md" is relative to the repository work-tree, not to the + // "applied.txt" is relative to the repository work-tree, not to the // process cwd (which is an unrelated directory here). This only // works if the git process is run with its cwd set to the repository. $repository->run('apply', [$patchFile]); @@ -635,7 +636,7 @@ public function testRunResolvesRelativePathsAgainstTheRepositoryRegardlessOfCwd( unlink($patchFile); } - $this->assertSame($original."Applied line.\n", file_get_contents($file)); + $this->assertSame("Applied line.\n", file_get_contents($repository->getWorkingDir().'/applied.txt')); } public function testRunIsOnlySupportedByCliDriver(): void diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8987aca0..42937796 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,6 +12,28 @@ require __DIR__.'/../vendor/autoload.php'; +// git and libgit2 read the configuration of fixtures/home only, whatever the +// developer's: see fixtures/home/.gitconfig. Symfony Process passes on the +// variables of getenv() that $_SERVER has too, $_ENV taking precedence. +foreach ([ + 'GIT_CONFIG' => null, + 'GIT_CONFIG_COUNT' => null, + 'GIT_CONFIG_GLOBAL' => null, + 'GIT_CONFIG_NOSYSTEM' => '1', + 'GIT_CONFIG_PARAMETERS' => null, + 'GIT_CONFIG_SYSTEM' => null, + 'HOME' => __DIR__.'/fixtures/home', + 'XDG_CONFIG_HOME' => null, +] as $name => $value) { + if (null === $value) { + putenv($name); + unset($_ENV[$name], $_SERVER[$name]); + } else { + putenv($name.'='.$value); + $_ENV[$name] = $_SERVER[$name] = $value; + } +} + if (defined('PHP_WINDOWS_VERSION_BUILD')) { $server = array_change_key_case($_SERVER, \CASE_UPPER); $_SERVER['GIT_ENVS'] = []; diff --git a/tests/fixtures/home/.gitconfig b/tests/fixtures/home/.gitconfig new file mode 100644 index 00000000..8dc6cdb2 --- /dev/null +++ b/tests/fixtures/home/.gitconfig @@ -0,0 +1,70 @@ +# The git configuration of the tests, whatever the developer's: the bootstrap +# points HOME to this directory. It changes what git prints or reads wherever +# gitlib must set it back to git's defaults, so that every test checks it. +# +# gitlib follows the rest of the configuration of the user, which isn't here: +# core.abbrev, blame.ignoreRevsFile, init.defaultBranch... + +[blame] + blankBoundary = true + coloring = repeatedLines + date = relative + markIgnoredLines = true + markUnblamableLines = true + showEmail = true + showRoot = true +[branch] + sort = -committerdate +[color] + branch = always + diff = always + status = always + ui = always +[column] + ui = always +[core] + attributesFile = ~/attributes + quotePath = true +[diff] + algorithm = histogram + colorMoved = zebra + context = 1 + dstPrefix = DST/ + external = false + indentHeuristic = false + interHunkContext = 10 + mnemonicPrefix = true + noprefix = true + orderFile = ~/orderfile + relative = true + renames = copies + srcPrefix = SRC/ + submodule = log + suppressBlankEmpty = true + wsErrorHighlight = all +[diff "hostile"] + textconv = false +[format] + pretty = oneline +[i18n] + logOutputEncoding = ISO-8859-1 +[log] + abbrevCommit = true + date = relative + decorate = full + follow = true + showRoot = false + showSignature = true +[maintenance] + # Not hostile: git 2.29 and later run their maintenance after a commit, in + # the background since 2.46, which would write in the repositories the tests delete. + auto = false +[merge] + ff = false + log = true +[status] + showUntrackedFiles = no +[submodule] + recurse = true +[tag] + sort = -version:refname diff --git a/tests/fixtures/home/attributes b/tests/fixtures/home/attributes new file mode 100644 index 00000000..bf534795 --- /dev/null +++ b/tests/fixtures/home/attributes @@ -0,0 +1 @@ +* diff=hostile diff --git a/tests/fixtures/home/orderfile b/tests/fixtures/home/orderfile new file mode 100644 index 00000000..ae7364c5 --- /dev/null +++ b/tests/fixtures/home/orderfile @@ -0,0 +1,2 @@ +*.txt +* From b26e6b8e2bf26117e0e811f8ae13a159396df724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 17:02:19 +0200 Subject: [PATCH 097/150] Refuse ext:: URLs git runs the command an "ext::" URL names, to talk to it: an application checking or cloning a URL from its users, isValidRepository('ext::sh -c ...') for instance, ran the command with git before 2.12, which made the protocol.allow setting forbid ext by default. git 1.8.4, still supported, runs it. Both drivers refuse such a URL with an InvalidArgumentException, like a URL starting with a dash: Util\RemoteUrl checks both. The tests of the URLs move to RemoteUrlTest. --- CHANGELOG.md | 2 + UPGRADE-2.0.md | 2 + doc/admin.md | 20 ++--- doc/repository.md | 7 +- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 8 +- src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 14 ++-- src/Gitonomy/Git/Util/RemoteUrl.php | 40 +++++++++ tests/Gitonomy/Git/Tests/LeadingDashTest.php | 37 --------- tests/Gitonomy/Git/Tests/RemoteUrlTest.php | 81 +++++++++++++++++++ 9 files changed, 151 insertions(+), 60 deletions(-) create mode 100644 src/Gitonomy/Git/Util/RemoteUrl.php create mode 100644 tests/Gitonomy/Git/Tests/RemoteUrlTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4170038d..4392740e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). option: `getRevision('--output=/path')`, `WorkingCopy::checkout()` or `merge()` could write a file, and `Repository::isValidRepository('--upload-pack=...')` run a command. A path may start with a dash: `init()` and the clones end the options of git with `--`. +- Refuse an `ext::` URL, like `ext::sh -c ...`, whose command git runs, by default before git + 2.12. - Hide the credentials of URLs (`https://user:token@host`) in the messages of exceptions and in the logs. - Refuse a hook name that isn't the one of a file of the hooks directory: diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index cb43da66..8f8537a7 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -396,6 +396,8 @@ Errors `getRevision('--output=/path')` writing a file, or `isValidRepository('--upload-pack=...')` running a command. A path may start with a dash. + * An `ext::` URL throws an `InvalidArgumentException`, with both drivers: + git runs its command, by default before git 2.12. * `Hooks` throws an `InvalidArgumentException` for a name that isn't the one of a file of the hooks directory, like `../config` or `.hidden`. * The messages of exceptions and the logs hide the credentials of URLs diff --git a/doc/admin.md b/doc/admin.md index 322b1814..6b502739 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -101,15 +101,17 @@ error output, when `CliDriver` runs git. With `CliDriver`, every method, `isValidRepository()` included, throws a `ProcessException` when git can't run, like a missing binary. See [Errors](repository.md#errors). -A URL starting with a dash throws an `InvalidArgumentException`, with both -drivers: git would read it as an option, like `--upload-pack=...`, which -runs a command. A path may start with one. The message of an exception -hides the credentials of the URL (`https://***@example.com/repository.git`). - -gitlib doesn't check the URLs any further. Old git versions run the command -of an `ext::` URL, which recent ones refuse by default (see the -`protocol.allow` setting of git): with URLs from your users, use a recent -git, or check them yourself. +A URL that makes git run a command throws an `InvalidArgumentException`, +with both drivers: + +- a URL starting with a dash, which git would read as an option, like + `--upload-pack=...`; a path may start with one; +- an `ext::` URL, like `ext::sh -c ...`, whose command git runs, unless its + `protocol.allow` setting forbids it, as it does by default since git 2.12. + +The message of an exception hides the credentials of the URL +(`https://***@example.com/repository.git`). gitlib doesn't check the URLs +any further: a path, for instance, names a repository of the server. Driver and logger ----------------- diff --git a/doc/repository.md b/doc/repository.md index 2d1232a3..ddc00530 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -220,9 +220,10 @@ or empty data instead. Every exception of gitlib implements `LibGit2Driver`, libgit2 that isn't installed... - `InvalidArgumentException`: an argument gitlib doesn't support, like an option of `git log` in the revisions of a log, a string holding a NUL - byte, which git doesn't accept, or a revision, a reference, a hash or a - URL starting with a dash, which git would read as an option - (`getRevision('--output=/path')`). + byte, which git doesn't accept, a revision, a reference, a hash or a URL + starting with a dash, which git would read as an option + (`getRevision('--output=/path')`), or an `ext::` URL, whose command git + would run. - `LogicException`: a call that makes no sense, like the working copy of a bare repository. diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index 1d6d8736..8c777041 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -15,7 +15,7 @@ use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\ProcessException; -use Gitonomy\Git\Util\LeadingDash; +use Gitonomy\Git\Util\RemoteUrl; use Psr\Log\LoggerInterface; /** @@ -77,14 +77,14 @@ public function mirrorFrom(string $url, string $path): void public function isValidRepository(string $url): bool { // ls-remote only takes "--" since git 2.8. - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); return $this->runner()->run('ls-remote', [self::absoluteUrl($url)])->isSuccessful(); } public function isValidRepositoryAndBranch(string $url, string $branch): bool { - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); // git lists the branches whose name ends with the pattern: "release" matches "feature/release". $process = $this->runner()->run('ls-remote', ['--heads', self::absoluteUrl($url), 'refs/heads/'.$branch]); @@ -111,7 +111,7 @@ public function isValidRepositoryAndBranch(string $url, string $branch): bool public function cloneRepository(string $path, string $url, array $args): void { // Like isValidRepository(), whose ls-remote can't take "--". - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); // --: a path may start with a dash. $this->mustRun('clone', ['-q', ...$args, '--', $url, $path]); diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index c2b552f1..d2a00d80 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -18,8 +18,8 @@ use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\Util\Credentials; -use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\NulByte; +use Gitonomy\Git\Util\RemoteUrl; /** * Runs libgit2 1.9 in-process, through PHP's FFI extension. @@ -103,8 +103,8 @@ public function initBare(string $path): void public function cloneFrom(string $url, string $path, ?string $branch): void { NulByte::assertNone($url, $path, $branch ?? ''); - // Like CliDriver, where git would read it as an option. - LeadingDash::assertNone($url); + // libgit2 runs no command for a URL, unlike git: refused like by CliDriver. + RemoteUrl::assertSafe($url); // git_clone() only checks a branch out, where git also checks a tag out, HEAD detached. $tag = null !== $branch && $this->isTagOnly($url, $branch) ? $branch : null; @@ -148,7 +148,7 @@ public function cloneFrom(string $url, string $path, ?string $branch): void public function cloneBareFrom(string $url, string $path, ?string $branch): void { NulByte::assertNone($url, $path, $branch ?? ''); - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); // Like git, only the branches (and the tags). $this->fetchInto($path, $url, '+refs/heads/*:refs/heads/*', $branch, ['remote.origin.url' => $url]); @@ -157,7 +157,7 @@ public function cloneBareFrom(string $url, string $path, ?string $branch): void public function mirrorFrom(string $url, string $path): void { NulByte::assertNone($url, $path); - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); $this->fetchInto($path, $url, '+refs/*:refs/*', null, [ 'remote.origin.url' => $url, @@ -170,7 +170,7 @@ public function mirrorFrom(string $url, string $path): void public function isValidRepository(string $url): bool { NulByte::assertNone($url); - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); return null !== $this->listRemoteReferences($url); } @@ -178,7 +178,7 @@ public function isValidRepository(string $url): bool public function isValidRepositoryAndBranch(string $url, string $branch): bool { NulByte::assertNone($url, $branch); - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); return \in_array('refs/heads/'.$branch, $this->listRemoteReferences($url) ?? [], true); } diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php new file mode 100644 index 00000000..c67197e7 --- /dev/null +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -0,0 +1,40 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * Refuses the URLs of a repository that make git run a command, as an + * application may get them from its users. + * + * @author Grégoire Pineau + * + * @internal + */ +final class RemoteUrl +{ + /** + * @throws InvalidArgumentException $url starts with a dash, or uses the "ext::" transport + */ + public static function assertSafe(string $url): void + { + LeadingDash::assertNone($url); + + // "ext::sh -c ..." runs the command, to talk to it: git refuses it by + // default since 2.12 only, through its protocol.allow setting. + if (str_starts_with($url, 'ext::')) { + throw new InvalidArgumentException(\sprintf('"%s" uses the "ext::" transport, which runs a command.', Credentials::redact($url))); + } + } +} diff --git a/tests/Gitonomy/Git/Tests/LeadingDashTest.php b/tests/Gitonomy/Git/Tests/LeadingDashTest.php index 1a6d7914..8eda91fe 100644 --- a/tests/Gitonomy/Git/Tests/LeadingDashTest.php +++ b/tests/Gitonomy/Git/Tests/LeadingDashTest.php @@ -55,43 +55,6 @@ public static function provideCalls(): iterable yield 'reference' => [static function (Repository $repository, string $option): void { new Branch($repository, $option, self::LONGFILE_COMMIT)->delete(); }]; } - /** - * In a repository with a remote, "git ls-remote --upload-pack=..." runs - * the command to list the references of the remote. - */ - #[DataProvider('provideUrlCalls')] - public function testAUrlStartingWithADashIsRefused(\Closure $call): void - { - $repository = self::createFoobarRepository(false); - $previousCwd = getcwd(); - $this->assertIsString($previousCwd); - chdir($repository->getPath()); - - try { - // A colon before any slash: a URL, never a local path. - $call('--upload-pack=touch pwned;:;git-upload-pack', self::createTempDir().'/clone'); - $this->fail('A leading dash is refused.'); - } catch (InvalidArgumentException $e) { - $this->assertStringContainsString('starts with a dash', $e->getMessage()); - } finally { - chdir($previousCwd); - } - - $this->assertFileDoesNotExist($repository->getPath().'/pwned'); - } - - /** - * @return iterable - */ - public static function provideUrlCalls(): iterable - { - yield 'valid repository' => [static function (string $url): void { Repository::isValidRepository($url, self::getDriver()); }]; - yield 'valid branch' => [static function (string $url): void { Repository::isValidRepositoryAndBranch($url, 'master', self::getDriver()); }]; - yield 'clone' => [static function (string $url, string $path): void { Repository::cloneFrom($url, $path, driver: self::getDriver()); }]; - yield 'bare clone' => [static function (string $url, string $path): void { Repository::cloneBareFrom($url, $path, driver: self::getDriver()); }]; - yield 'mirror' => [static function (string $url, string $path): void { Repository::mirrorFrom($url, $path, self::getDriver()); }]; - } - /** * Unlike a URL, a path may start with a dash: git reads it after "--". */ diff --git a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php new file mode 100644 index 00000000..9167bd81 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php @@ -0,0 +1,81 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * An application may check or clone a URL from its users: git must run no + * command for it. + */ +class RemoteUrlTest extends AbstractTestCase +{ + /** + * In a repository with a remote, "git ls-remote --upload-pack=..." runs + * the command to list the references of the remote. + */ + #[DataProvider('provideCalls')] + public function testAUrlStartingWithADashIsRefused(\Closure $call): void + { + $repository = self::createFoobarRepository(false); + $previousCwd = getcwd(); + $this->assertIsString($previousCwd); + chdir($repository->getPath()); + + try { + // A colon before any slash: a URL, never a local path. + $call('--upload-pack=touch pwned;:;git-upload-pack', self::createTempDir().'/clone'); + $this->fail('A leading dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('starts with a dash', $e->getMessage()); + } finally { + chdir($previousCwd); + } + + $this->assertFileDoesNotExist($repository->getPath().'/pwned'); + } + + /** + * git runs the command of an "ext::" URL, unless its protocol.allow + * setting forbids it, as it does by default since git 2.12. + */ + #[DataProvider('provideCalls')] + public function testAnExtUrlIsRefused(\Closure $call): void + { + $file = self::createTempDir().'/pwned'; + + try { + // "% " is a space in the command of an "ext::" URL. + $call('ext::sh -c touch% '.$file, self::createTempDir().'/clone'); + $this->fail('An "ext::" URL is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('uses the "ext::" transport', $e->getMessage()); + } + + $this->assertFileDoesNotExist($file); + } + + /** + * @return iterable + */ + public static function provideCalls(): iterable + { + yield 'valid repository' => [static function (string $url): void { Repository::isValidRepository($url, self::getDriver()); }]; + yield 'valid branch' => [static function (string $url): void { Repository::isValidRepositoryAndBranch($url, 'master', self::getDriver()); }]; + yield 'clone' => [static function (string $url, string $path): void { Repository::cloneFrom($url, $path, driver: self::getDriver()); }]; + yield 'bare clone' => [static function (string $url, string $path): void { Repository::cloneBareFrom($url, $path, driver: self::getDriver()); }]; + yield 'mirror' => [static function (string $url, string $path): void { Repository::mirrorFrom($url, $path, self::getDriver()); }]; + } +} From 8caefe0c745c2ed2b2dae0d9fb070970791768ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 098/150] Refuse ssh hosts starting with a dash, and references outside refs/ Before 2.14.1 (CVE-2017-1000117), git passes the "user@host" of an ssh URL to ssh, which reads "ssh://-oProxyCommand=.../repository" as an option and runs the command. git passes the host of a git:// URL to the command of core.gitProxy the same way. Util\RemoteUrl refuses, with an InvalidArgumentException, an ssh, scp-like or git URL whose host or user starts with a dash, even encoded ("%2D", or "%5B" for a bracket), as git decodes the URL before it reads its host. "git update-ref" writes any name: ReferenceBag::delete('HEAD') deleted .git/HEAD with CliDriver, which left no repository, and creating the short name "master" wrote it next to HEAD. Util\ReferenceName makes both drivers refuse, with an InvalidArgumentException, to create, update or delete a reference outside refs/. --- CHANGELOG.md | 6 ++ UPGRADE-2.0.md | 9 ++ doc/admin.md | 6 +- doc/references.md | 5 ++ doc/repository.md | 6 +- .../Git/Driver/Cli/CliRepositoryDriver.php | 4 + .../Driver/Ffi/LibGit2RepositoryDriver.php | 4 + .../Git/Driver/RepositoryDriverInterface.php | 14 +++- src/Gitonomy/Git/ReferenceBag.php | 7 ++ src/Gitonomy/Git/Util/ReferenceName.php | 38 +++++++++ src/Gitonomy/Git/Util/RemoteUrl.php | 32 ++++++- tests/Gitonomy/Git/Tests/ReferenceTest.php | 57 +++++++++++++ tests/Gitonomy/Git/Tests/RemoteUrlTest.php | 84 +++++++++++++++++++ 13 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 src/Gitonomy/Git/Util/ReferenceName.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4392740e..3e89dbcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,11 +108,17 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). start with a dash: `init()` and the clones end the options of git with `--`. - Refuse an `ext::` URL, like `ext::sh -c ...`, whose command git runs, by default before git 2.12. +- Refuse an ssh URL whose host or user starts with a dash, even encoded (`%2D`), like + `ssh://-oProxyCommand=.../repository`: git before 2.14.1 passes it to ssh, which reads it as an + option and runs the command (CVE-2017-1000117). A `git://` URL whose host starts with a dash + is refused too: git passes it to the command of `core.gitProxy`. - Hide the credentials of URLs (`https://user:token@host`) in the messages of exceptions and in the logs. - Refuse a hook name that isn't the one of a file of the hooks directory: `Hooks::set('../config', ...)` and `remove('../config')` wrote and deleted the configuration of the repository. +- Fix `ReferenceBag::delete('HEAD')` deleting `HEAD`, which left no repository: a name outside + `refs/` throws an `InvalidArgumentException`. ### Bug fixes diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 8f8537a7..c7110eaf 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -181,6 +181,9 @@ References unsigned tag instead of throwing, and finds X.509 and SSH signatures too. * A branch whose name ends with `HEAD` is listed: only the `HEAD` of a remote is left out. + * `ReferenceBag::delete()` throws an `InvalidArgumentException` for a name + outside `refs/`: `HEAD`, which `CliDriver` deleted, leaving no + repository, or a short name, like `master`, which did nothing. History ------- @@ -398,6 +401,12 @@ Errors start with a dash. * An `ext::` URL throws an `InvalidArgumentException`, with both drivers: git runs its command, by default before git 2.12. + * An ssh URL whose host or user starts with a dash, even encoded (`%2D`), + like `ssh://-oProxyCommand=.../repository` or + `ssh://-oProxyCommand=...@host/path`, or a `git://` URL whose host does, + throws an `InvalidArgumentException`, with both drivers: git before 2.14.1 + passes it to ssh, or to the command of `core.gitProxy`, which reads it as + an option. * `Hooks` throws an `InvalidArgumentException` for a name that isn't the one of a file of the hooks directory, like `../config` or `.hidden`. * The messages of exceptions and the logs hide the credentials of URLs diff --git a/doc/admin.md b/doc/admin.md index 6b502739..0b95e4ac 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -107,7 +107,11 @@ with both drivers: - a URL starting with a dash, which git would read as an option, like `--upload-pack=...`; a path may start with one; - an `ext::` URL, like `ext::sh -c ...`, whose command git runs, unless its - `protocol.allow` setting forbids it, as it does by default since git 2.12. + `protocol.allow` setting forbids it, as it does by default since git 2.12; +- an ssh URL whose host or user starts with a dash, even encoded (`%2D`), + like `ssh://-oProxyCommand=.../repository` or + `ssh://-oProxyCommand=...@host/repository`: before 2.14.1, git passes it + to ssh, which reads it as an option and runs the command. The message of an exception hides the credentials of the URL (`https://***@example.com/repository.git`). gitlib doesn't check the URLs diff --git a/doc/references.md b/doc/references.md index ec658d6e..a9c0ed1a 100644 --- a/doc/references.md +++ b/doc/references.md @@ -99,6 +99,11 @@ $branch->delete(); $tag->delete(); ``` +`ReferenceBag::delete()` takes the full name of a reference under `refs/`, +like `refs/heads/foobar`, and does nothing when it doesn't exist, like +`git update-ref -d`. Another name, like `HEAD` or `foobar`, throws an +`InvalidArgumentException`. + `createBranch()` and `createTag()` throw an `InvalidArgumentException` when the reference exists: they never move it. To move a branch, use `Branch::moveTo()`: see [branch.md](branch.md). `ReferenceBag::update()` is diff --git a/doc/repository.md b/doc/repository.md index ddc00530..83754135 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -222,8 +222,10 @@ or empty data instead. Every exception of gitlib implements option of `git log` in the revisions of a log, a string holding a NUL byte, which git doesn't accept, a revision, a reference, a hash or a URL starting with a dash, which git would read as an option - (`getRevision('--output=/path')`), or an `ext::` URL, whose command git - would run. + (`getRevision('--output=/path')`), an `ext::` URL, whose command git + would run, a URL whose ssh host or user starts with a dash, which ssh + would read as an option, or a reference to delete outside `refs/`, like + `HEAD`. - `LogicException`: a call that makes no sense, like the working copy of a bare repository. diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index d5a26b78..aa94283e 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -27,6 +27,7 @@ use Gitonomy\Git\FastForward; use Gitonomy\Git\Parser\DiffParser; use Gitonomy\Git\Util\LeadingDash; +use Gitonomy\Git\Util\ReferenceName; use Symfony\Component\Process\Process; /** @@ -239,6 +240,7 @@ public function getReferences(): array public function createReference(string $fullname, string $hash): void { LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); // Zeros as the old value: git refuses to update a reference that exists. $this->run('update-ref', [$fullname, $hash, str_repeat('0', 40)]); @@ -247,6 +249,7 @@ public function createReference(string $fullname, string $hash): void public function updateReference(string $fullname, string $hash): void { LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); $this->run('update-ref', [$fullname, $hash]); } @@ -254,6 +257,7 @@ public function updateReference(string $fullname, string $hash): void public function deleteReference(string $fullname): void { LeadingDash::assertNone($fullname); + ReferenceName::assertUnderRefs($fullname); $this->run('update-ref', ['-d', $fullname]); } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index 62ebd7cd..72c83b89 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -30,6 +30,7 @@ use Gitonomy\Git\FastForward; use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\NulByte; +use Gitonomy\Git\Util\ReferenceName; use Symfony\Component\String\ByteString; use Symfony\Component\String\Exception\InvalidArgumentException; @@ -348,6 +349,7 @@ public function createReference(string $fullname, string $hash): void { NulByte::assertNone($fullname, $hash); LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); $oid = $this->hexToOid($hash); $ref = $this->ffi->new('git_reference*'); @@ -359,6 +361,7 @@ public function updateReference(string $fullname, string $hash): void { NulByte::assertNone($fullname, $hash); LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); $oid = $this->hexToOid($hash); $ref = $this->ffi->new('git_reference*'); @@ -370,6 +373,7 @@ public function deleteReference(string $fullname): void { NulByte::assertNone($fullname); LeadingDash::assertNone($fullname); + ReferenceName::assertUnderRefs($fullname); $ref = $this->ffi->new('git_reference*'); $rc = $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname); diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index 2d60586f..6ed6bf1e 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -120,14 +120,26 @@ public function getTagData(string $hash): TagData; public function getReferences(): array; /** - * @throws DriverException $fullname exists already + * @param string $fullname The full name of a reference under ``refs/``, like those getReferences() lists + * + * @throws DriverException $fullname exists already + * @throws InvalidArgumentException $fullname isn't under ``refs/``, like "HEAD" or "master" */ public function createReference(string $fullname, string $hash): void; + /** + * @param string $fullname The full name of a reference under ``refs/`` + * + * @throws InvalidArgumentException $fullname isn't under ``refs/``, like "HEAD" or "master" + */ public function updateReference(string $fullname, string $hash): void; /** * Like ``git update-ref -d``, deleting a missing reference does nothing. + * + * @param string $fullname The full name of a reference under ``refs/`` + * + * @throws InvalidArgumentException $fullname isn't under ``refs/``, like "HEAD" or "master" */ public function deleteReference(string $fullname): void; diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 9cfa972b..85395b51 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -96,6 +96,13 @@ public function createTag(string $name, Revision|string $revision): Tag return $this->getTag($name); } + /** + * Like `git update-ref -d`, deleting a missing reference does nothing. + * + * @param string $fullname fullname of the reference (refs/heads/master, for example) + * + * @throws InvalidArgumentException $fullname isn't the full name of a reference under "refs/", like "HEAD" or "master" + */ public function delete(string $fullname): void { $this->repository->getRepositoryDriver()->deleteReference($fullname); diff --git a/src/Gitonomy/Git/Util/ReferenceName.php b/src/Gitonomy/Git/Util/ReferenceName.php new file mode 100644 index 00000000..525a297a --- /dev/null +++ b/src/Gitonomy/Git/Util/ReferenceName.php @@ -0,0 +1,38 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * gitlib writes the references under "refs/" only, the ones it lists. "git + * update-ref" writes any other name: it deletes HEAD, which leaves no + * repository, and writes the short name "master" next to HEAD. Both drivers + * refuse such a name. + * + * @author Grégoire Pineau + * + * @internal + */ +final class ReferenceName +{ + /** + * @throws InvalidArgumentException $fullname isn't under "refs/" + */ + public static function assertUnderRefs(string $fullname): void + { + if (!str_starts_with($fullname, 'refs/')) { + throw new InvalidArgumentException(\sprintf('"%s" is not the full name of a reference under "refs/", like "refs/heads/master".', $fullname)); + } + } +} diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php index c67197e7..b8dcb306 100644 --- a/src/Gitonomy/Git/Util/RemoteUrl.php +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -25,7 +25,7 @@ final class RemoteUrl { /** - * @throws InvalidArgumentException $url starts with a dash, or uses the "ext::" transport + * @throws InvalidArgumentException $url starts with a dash, uses the "ext::" transport, or has a host or a user starting with a dash, encoded or not */ public static function assertSafe(string $url): void { @@ -36,5 +36,35 @@ public static function assertSafe(string $url): void if (str_starts_with($url, 'ext::')) { throw new InvalidArgumentException(\sprintf('"%s" uses the "ext::" transport, which runs a command.', Credentials::redact($url))); } + + // Before 2.14.1 (CVE-2017-1000117), git passes the "user@host" of an ssh + // URL to ssh, without its brackets: ssh reads "ssh://-oProxyCommand=..." + // as an option, and runs the command. The command of core.gitProxy gets + // the host of a git URL the same way. A valid host never starts with a dash. + $authority = self::authority($url); + if (null !== $authority && 1 === preg_match('/(?:^|@)\[?-/', $authority)) { + throw new InvalidArgumentException(\sprintf('"%s" has a host or a user starting with a dash, which ssh would read as an option.', Credentials::redact($url))); + } + } + + /** + * @return string|null The "user@host:port" of an ssh or git URL, decoded, + * or of an address like "user@host:path", which git + * reaches through ssh; null for any other URL, or a + * local path + */ + private static function authority(string $url): ?string + { + // git decodes a URL before it reads its host: "%2D" is a dash, "%5B" a bracket. + if (1 === preg_match('#^(?:git|ssh|git\+ssh|ssh\+git)://([^/]*)#', $url, $matches)) { + return rawurldecode($matches[1]); + } + + // Like git: a colon before any slash, but not the one of "scheme://". + if (1 === preg_match('#^([^/:]*):(?!//)#', $url, $matches)) { + return $matches[1]; + } + + return null; } } diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 4251fdd2..46e071dc 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -16,6 +16,7 @@ use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Tag; +use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; @@ -332,6 +333,62 @@ public function testDeletingADeletedReferenceDoesNothing(): void $this->assertFalse($repository->getReferences()->hasBranch('deleted')); } + /** + * "git update-ref -d HEAD" deletes HEAD: git no longer sees a repository. + */ + #[DataProvider('provideBare')] + public function testDeletingHeadIsRefused(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + + try { + $repository->getReferences()->delete('HEAD'); + $this->fail('HEAD is no reference under "refs/".'); + } catch (InvalidArgumentException $e) { + $this->assertSame('"HEAD" is not the full name of a reference under "refs/", like "refs/heads/master".', $e->getMessage()); + } + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + } + + /** + * "git update-ref" writes any name: HEAD, or a short name next to it. + */ + #[DataProvider('provideWritesOfANameOutsideRefs')] + public function testTheDriverWritesNoReferenceOutsideRefs(\Closure $write, string $fullname): void + { + $repository = self::createFoobarRepository(); + + try { + $write($repository, $fullname); + $this->fail('Only a reference under "refs/" is written.'); + } catch (InvalidArgumentException $e) { + $this->assertSame(\sprintf('"%s" is not the full name of a reference under "refs/", like "refs/heads/master".', $fullname), $e->getMessage()); + } + + $this->assertSame('ref: refs/heads/master', trim((string) file_get_contents($repository->getGitDir().'/HEAD'))); + $this->assertFileDoesNotExist($repository->getGitDir().'/master'); + } + + /** + * @return iterable + */ + public static function provideWritesOfANameOutsideRefs(): iterable + { + $writes = [ + 'create' => static function (Repository $repository, string $fullname): void { $repository->getRepositoryDriver()->createReference($fullname, self::INITIAL_COMMIT); }, + 'update' => static function (Repository $repository, string $fullname): void { $repository->getRepositoryDriver()->updateReference($fullname, self::INITIAL_COMMIT); }, + 'delete' => static function (Repository $repository, string $fullname): void { $repository->getRepositoryDriver()->deleteReference($fullname); }, + ]; + + foreach ($writes as $writeName => $write) { + foreach (['HEAD', 'master'] as $fullname) { + yield $writeName.' '.$fullname => [$write, $fullname]; + } + } + } + public function testTheHeadOfARemoteNamedWithASlashIsNoBranch(): void { $repository = self::createFoobarRepository(); diff --git a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php index 9167bd81..cdb56a2b 100644 --- a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php +++ b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php @@ -14,6 +14,8 @@ use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\Credentials; +use Gitonomy\Git\Util\RemoteUrl; use PHPUnit\Framework\Attributes\DataProvider; /** @@ -78,4 +80,86 @@ public static function provideCalls(): iterable yield 'bare clone' => [static function (string $url, string $path): void { Repository::cloneBareFrom($url, $path, driver: self::getDriver()); }]; yield 'mirror' => [static function (string $url, string $path): void { Repository::mirrorFrom($url, $path, self::getDriver()); }]; } + + /** + * Before 2.14.1 (CVE-2017-1000117), git passes a host starting with a dash + * to ssh, which reads it as an option: "-oProxyCommand=..." runs the + * command. Recent versions of OpenSSH refuse such a host. + */ + #[DataProvider('provideHostsStartingWithADash')] + public function testAHostStartingWithADashIsRefused(\Closure $call, string $url): void + { + $dir = self::createTempDir(); + $previousCwd = getcwd(); + $this->assertIsString($previousCwd); + chdir($dir); + + try { + $call($url, $dir.'/clone'); + $this->fail('A host starting with a dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertSame(\sprintf('"%s" has a host or a user starting with a dash, which ssh would read as an option.', Credentials::redact($url)), $e->getMessage()); + } finally { + chdir($previousCwd); + } + + $this->assertFileDoesNotExist($dir.'/pwned'); + } + + /** + * @return iterable + */ + public static function provideHostsStartingWithADash(): iterable + { + // "${IFS}": the space of a command in a host. + $command = '-oProxyCommand=touch${IFS}pwned'; + $urls = [ + 'ssh' => 'ssh://'.$command.'/repository', + 'git+ssh' => 'git+ssh://'.$command.'/repository', + 'ssh+git' => 'ssh+git://'.$command.'/repository', + 'host between brackets' => 'ssh://['.$command.']/repository', + 'user' => 'ssh://'.$command.'@example.com/repository', + 'host after a user' => 'ssh://git@'.$command.'/repository', + 'address' => 'git@'.$command.':repository', + 'address between brackets' => '['.$command.']:repository', + // Passed to the command of the core.gitProxy setting. + 'git' => 'git://'.$command.'/repository', + // git decodes a URL before it reads its host. + 'encoded dash' => 'ssh://%2D'.substr($command, 1).'/repository', + 'encoded dash, lowercase' => 'git+ssh://%2d'.substr($command, 1).'/repository', + 'encoded dash of a user' => 'ssh://%2D'.substr($command, 1).'@example.com/repository', + 'encoded brackets' => 'ssh://%5B'.$command.'%5D/repository', + ]; + + foreach (self::provideCalls() as $callName => [$call]) { + foreach ($urls as $urlName => $url) { + yield $callName.', '.$urlName => [$call, $url]; + } + } + } + + #[DataProvider('provideUrlsGitReachesWithoutRunningACommand')] + public function testAUrlGitReachesWithoutRunningACommandIsAccepted(string $url): void + { + RemoteUrl::assertSafe($url); + + $this->addToAssertionCount(1); + } + + /** + * @return iterable + */ + public static function provideUrlsGitReachesWithoutRunningACommand(): iterable + { + yield 'ssh' => ['ssh://git@example.com:2222/repository.git']; + yield 'host between brackets' => ['ssh://git@[::1]/repository.git']; + yield 'address' => ['git@example.com:repository.git']; + yield 'dash in the path of an address' => ['git@example.com:-repository.git']; + yield 'dash in the host' => ['ssh://git@my-host.example.com/repository.git']; + yield 'encoded character in the user' => ['ssh://first%2Dlast@example.com/repository.git']; + // Not reached through ssh: a token may start with a dash. + yield 'https' => ['https://-token@example.com/repository.git']; + yield 'local path' => ['/path/to/repository.git']; + yield 'local path with a colon' => ['./a:b']; + } } From a9eb1766ecbc7acef338c3f66bf615e6f339fa4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 099/150] Hide the credentials of URLs in traces and chained exceptions The credentials of a URL still leaked: - Credentials::redact() stopped at a "#" or a "?" of the password, which git and libgit2 accept unencoded there; - the exceptions of Symfony Process, the previous exception of a timeout or of a process that couldn't start, hold the command line; - the InvalidArgumentException of a URL holding a NUL byte or starting with a dash named it as it was; - with zend.exception_ignore_args off, the default of php.ini-development, the traces of the exceptions hold the arguments of the calls. redact() reads the user information up to the last "@" before the host, the exceptions of Symfony are no longer chained, NulByte and LeadingDash redact their messages, and every parameter taking a URL, or arguments of git, is #[\SensitiveParameter]. --- CHANGELOG.md | 6 +- UPGRADE-2.0.md | 6 +- src/Gitonomy/Git/Admin.php | 12 +- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 16 +-- .../Git/Driver/Cli/CliRepositoryDriver.php | 4 +- src/Gitonomy/Git/Driver/Cli/GitRunner.php | 10 +- src/Gitonomy/Git/Driver/Ffi/LibGit2.php | 2 +- src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 22 ++-- src/Gitonomy/Git/Repository.php | 14 +- src/Gitonomy/Git/Util/Credentials.php | 7 +- src/Gitonomy/Git/Util/LeadingDash.php | 5 +- src/Gitonomy/Git/Util/NulByte.php | 5 +- src/Gitonomy/Git/Util/RemoteUrl.php | 4 +- tests/Gitonomy/Git/Tests/CredentialsTest.php | 123 +++++++++++++++++- 14 files changed, 183 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e89dbcb..697da8a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,8 +112,10 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). `ssh://-oProxyCommand=.../repository`: git before 2.14.1 passes it to ssh, which reads it as an option and runs the command (CVE-2017-1000117). A `git://` URL whose host starts with a dash is refused too: git passes it to the command of `core.gitProxy`. -- Hide the credentials of URLs (`https://user:token@host`) in the messages of exceptions and - in the logs. +- Hide the credentials of URLs (`https://user:token@host`), a password holding a `#` or a `?` + included, in the messages of exceptions, in their traces (`#[\SensitiveParameter]`), and in + the logs. The exceptions of Symfony Process, which hold the command line, no longer leak, not + even as the previous exception. - Refuse a hook name that isn't the one of a file of the hooks directory: `Hooks::set('../config', ...)` and `remove('../config')` wrote and deleted the configuration of the repository. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index c7110eaf..ef621034 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -375,7 +375,8 @@ Errors `\InvalidArgumentException`, and takes a whole message instead of a name. * A timeout throws a `Gitonomy\Git\Exception\ProcessTimedOutException`, and the other failures of Symfony Process a `DriverException`: no - exception of Symfony or of PHP leaks anymore. + exception of Symfony or of PHP leaks anymore, not even as the previous + exception, as they hold the command line. * `ProcessException` extends the new `Gitonomy\Git\Exception\DriverException`, itself a `RuntimeException`: catching `RuntimeException` or `GitExceptionInterface` still works. @@ -409,7 +410,8 @@ Errors an option. * `Hooks` throws an `InvalidArgumentException` for a name that isn't the one of a file of the hooks directory, like `../config` or `.hidden`. - * The messages of exceptions and the logs hide the credentials of URLs + * The messages of exceptions, their traces (`#[\SensitiveParameter]`) and + the logs hide the credentials of URLs (`https://***@example.com/repository.git`). `getErrorOutput()` and `getOutput()` of `ProcessException` don't. * Every message is a sentence ending with a dot. diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 3799838c..83e2d7b5 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -71,7 +71,7 @@ public static function init(string $path, bool $bare = true, array $options = [] * * @deprecated since gitonomy/gitlib 2.0, use Repository::isValidRepository() instead */ - public static function isValidRepository(string $url, array $options = []): bool + public static function isValidRepository(#[\SensitiveParameter] string $url, array $options = []): bool { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class); @@ -94,7 +94,7 @@ public static function isValidRepository(string $url, array $options = []): bool * * @deprecated since gitonomy/gitlib 2.0, use Repository::isValidRepositoryAndBranch() instead */ - public static function isValidRepositoryAndBranch(string $url, string $branchName, array $options = []): bool + public static function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branchName, array $options = []): bool { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class); @@ -111,7 +111,7 @@ public static function isValidRepositoryAndBranch(string $url, string $branchNam * * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() instead */ - public static function cloneTo(string $path, string $url, bool $bare = true, array $options = []): Repository + public static function cloneTo(string $path, #[\SensitiveParameter] string $url, bool $bare = true, array $options = []): Repository { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); @@ -137,7 +137,7 @@ public static function cloneTo(string $path, string $url, bool $bare = true, arr * * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() with their "branch" argument instead */ - public static function cloneBranchTo(string $path, string $url, string $branch, bool $bare = true, array $options = []): Repository + public static function cloneBranchTo(string $path, #[\SensitiveParameter] string $url, string $branch, bool $bare = true, array $options = []): Repository { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); @@ -161,7 +161,7 @@ public static function cloneBranchTo(string $path, string $url, string $branch, * * @deprecated since gitonomy/gitlib 2.0, use Repository::mirrorFrom() instead */ - public static function mirrorTo(string $path, string $url, array $options = []): Repository + public static function mirrorTo(string $path, #[\SensitiveParameter] string $url, array $options = []): Repository { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class); @@ -182,7 +182,7 @@ public static function mirrorTo(string $path, string $url, array $options = []): * * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneFrom(), Repository::cloneBareFrom() or Repository::mirrorFrom() instead */ - public static function cloneRepository(string $path, string $url, array $args = [], array $options = []): Repository + public static function cloneRepository(string $path, #[\SensitiveParameter] string $url, array $args = [], array $options = []): Repository { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class); diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index 8c777041..a798580a 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -59,22 +59,22 @@ public function initBare(string $path): void $this->mustRun('init', ['-q', '--bare', '--', $path]); } - public function cloneFrom(string $url, string $path, ?string $branch): void + public function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void { $this->cloneRepository($path, $url, null === $branch ? [] : ['--branch', $branch]); } - public function cloneBareFrom(string $url, string $path, ?string $branch): void + public function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void { $this->cloneRepository($path, $url, null === $branch ? ['--bare'] : ['--branch', $branch, '--bare']); } - public function mirrorFrom(string $url, string $path): void + public function mirrorFrom(#[\SensitiveParameter] string $url, string $path): void { $this->cloneRepository($path, $url, ['--mirror']); } - public function isValidRepository(string $url): bool + public function isValidRepository(#[\SensitiveParameter] string $url): bool { // ls-remote only takes "--" since git 2.8. RemoteUrl::assertSafe($url); @@ -82,7 +82,7 @@ public function isValidRepository(string $url): bool return $this->runner()->run('ls-remote', [self::absoluteUrl($url)])->isSuccessful(); } - public function isValidRepositoryAndBranch(string $url, string $branch): bool + public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch): bool { RemoteUrl::assertSafe($url); @@ -108,7 +108,7 @@ public function isValidRepositoryAndBranch(string $url, string $branch): bool * * @throws ProcessException */ - public function cloneRepository(string $path, string $url, array $args): void + public function cloneRepository(string $path, #[\SensitiveParameter] string $url, array $args): void { // Like isValidRepository(), whose ls-remote can't take "--". RemoteUrl::assertSafe($url); @@ -141,7 +141,7 @@ private function runner(): GitRunner /** * @param string[] $args */ - private function mustRun(string $command, array $args): void + private function mustRun(string $command, #[\SensitiveParameter] array $args): void { $process = $this->runner()->run($command, $args); @@ -155,7 +155,7 @@ private function mustRun(string $command, array $args): void * remote first: "origin" would be the URL of the remote of PHP's working * directory. */ - private static function absoluteUrl(string $url): string + private static function absoluteUrl(#[\SensitiveParameter] string $url): string { if (str_starts_with($url, '/') || 1 === preg_match('#^[a-zA-Z]:[\\\/]#', $url)) { return $url; diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index aa94283e..256b10a6 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -361,7 +361,7 @@ public function merge(string $revision, FastForward $fastForward): void * * @throws ProcessException The command failed */ - public function runProcess(string $command, array $args = [], ?string $input = null): Process + public function runProcess(string $command, #[\SensitiveParameter] array $args = [], ?string $input = null): Process { $process = $this->execute($command, array_map(strval(...), $args), $input); @@ -481,7 +481,7 @@ private function listBranches(array $args, string $prefix): array /** * @param string[] $args */ - private function execute(string $command, array $args, ?string $input = null): Process + private function execute(string $command, #[\SensitiveParameter] array $args, ?string $input = null): Process { $globalOptions = [...self::CONFIG, '--git-dir', $this->gitDir]; if (null !== $this->workingDir) { diff --git a/src/Gitonomy/Git/Driver/Cli/GitRunner.php b/src/Gitonomy/Git/Driver/Cli/GitRunner.php index e1a59708..8a861192 100644 --- a/src/Gitonomy/Git/Driver/Cli/GitRunner.php +++ b/src/Gitonomy/Git/Driver/Cli/GitRunner.php @@ -53,7 +53,7 @@ public function __construct( * @throws InvalidArgumentException An argument contains a NUL byte * @throws ProcessException The git binary can't be run */ - public function run(string $command, array $args, array $globalOptions = [], ?string $workingDirectory = null, ?string $input = null): Process + public function run(string $command, #[\SensitiveParameter] array $args, array $globalOptions = [], ?string $workingDirectory = null, ?string $input = null): Process { NulByte::assertNone(...$globalOptions, ...$args); @@ -68,13 +68,15 @@ public function run(string $command, array $args, array $globalOptions = [], ?st $this->logger?->info(Credentials::redact(\sprintf('run command: %s "%s" ', $command, implode(' ', $args)))); $before = microtime(true); + // The exceptions of Symfony aren't chained: their messages may hold the + // command line, with the credentials of its URLs. try { $process->run(); - } catch (SymfonyProcessTimedOutException $e) { - throw new ProcessTimedOutException(\sprintf('The git command "%s" ran longer than %s seconds.', $command, $this->timeout), 0, $e); + } catch (SymfonyProcessTimedOutException) { + throw new ProcessTimedOutException(\sprintf('The git command "%s" ran longer than %s seconds.', $command, $this->timeout)); } catch (SymfonyRuntimeException $e) { // A working directory removed since the repository was opened... - throw new DriverException(\sprintf('Unable to run the git command "%s": %s', $command, $e->getMessage()), 0, $e); + throw new DriverException(Credentials::redact(\sprintf('Unable to run the git command "%s": %s', $command, $e->getMessage()))); } // Symfony Process runs git through a shell, which exits with them when it can't find or run git. diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php index df82a63f..6615a06a 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php @@ -98,7 +98,7 @@ public static function preload(?string $libraryPath): void /** * @throws DriverException When $rc is a libgit2 error code */ - public static function check(\FFI $ffi, int $rc, string $context): void + public static function check(\FFI $ffi, int $rc, #[\SensitiveParameter] string $context): void { if ($rc < 0) { // $context and libgit2 may name a URL holding credentials. diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index d2a00d80..44cc003b 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -100,7 +100,7 @@ public function initBare(string $path): void $this->ffi->git_repository_free($this->initRepository($path, true)); } - public function cloneFrom(string $url, string $path, ?string $branch): void + public function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void { NulByte::assertNone($url, $path, $branch ?? ''); // libgit2 runs no command for a URL, unlike git: refused like by CliDriver. @@ -145,7 +145,7 @@ public function cloneFrom(string $url, string $path, ?string $branch): void self::removeFetchHead($path.'/.git'); } - public function cloneBareFrom(string $url, string $path, ?string $branch): void + public function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void { NulByte::assertNone($url, $path, $branch ?? ''); RemoteUrl::assertSafe($url); @@ -154,7 +154,7 @@ public function cloneBareFrom(string $url, string $path, ?string $branch): void $this->fetchInto($path, $url, '+refs/heads/*:refs/heads/*', $branch, ['remote.origin.url' => $url]); } - public function mirrorFrom(string $url, string $path): void + public function mirrorFrom(#[\SensitiveParameter] string $url, string $path): void { NulByte::assertNone($url, $path); RemoteUrl::assertSafe($url); @@ -167,7 +167,7 @@ public function mirrorFrom(string $url, string $path): void ]); } - public function isValidRepository(string $url): bool + public function isValidRepository(#[\SensitiveParameter] string $url): bool { NulByte::assertNone($url); RemoteUrl::assertSafe($url); @@ -175,7 +175,7 @@ public function isValidRepository(string $url): bool return null !== $this->listRemoteReferences($url); } - public function isValidRepositoryAndBranch(string $url, string $branch): bool + public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch): bool { NulByte::assertNone($url, $branch); RemoteUrl::assertSafe($url); @@ -197,7 +197,7 @@ private function initRepository(string $path, bool $bare): CData * * @param array $config */ - private function fetchInto(string $path, string $url, string $refspec, ?string $branch, array $config): void + private function fetchInto(string $path, #[\SensitiveParameter] string $url, string $refspec, ?string $branch, #[\SensitiveParameter] array $config): void { // Like git, which refuses to clone into a directory with files. $existing = is_dir($path); @@ -237,7 +237,7 @@ private static function removeFetchHead(string $gitDir): void /** * @return bool Whether $url has a tag named $name, and no branch of this name */ - private function isTagOnly(string $url, string $name): bool + private function isTagOnly(#[\SensitiveParameter] string $url, string $name): bool { $references = $this->listRemoteReferences($url) ?? []; @@ -316,7 +316,7 @@ private static function remove(string $path, bool $keep): void * Fetches $refspec from $url, and points HEAD to $branch, or to the * default branch of $url. */ - private function fetch(CData $repository, string $url, string $refspec, ?string $branch): void + private function fetch(CData $repository, #[\SensitiveParameter] string $url, string $refspec, ?string $branch): void { $remote = $this->ffi->new('git_remote*'); LibGit2::check($this->ffi, $this->ffi->git_remote_create_anonymous(\FFI::addr($remote), $repository, $url), \sprintf('Unable to create a remote for "%s"', $url)); @@ -364,7 +364,7 @@ private function fetch(CData $repository, string $url, string $refspec, ?string /** * @throws DriverException $repository has no tag named $tag */ - private function detachHeadAtTag(CData $repository, string $tag, string $url): void + private function detachHeadAtTag(CData $repository, string $tag, #[\SensitiveParameter] string $url): void { $commit = $this->ffi->new('git_object*'); $rc = $this->ffi->git_revparse_single(\FFI::addr($commit), $repository, 'refs/tags/'.$tag.'^{commit}'); @@ -402,7 +402,7 @@ private function defaultBranch(CData $remote): ?string /** * @param array $config */ - private function setConfig(CData $repository, array $config): void + private function setConfig(CData $repository, #[\SensitiveParameter] array $config): void { $handle = $this->ffi->new('git_config*'); LibGit2::check($this->ffi, $this->ffi->git_repository_config(\FFI::addr($handle), $repository), 'Unable to open the repository configuration'); @@ -419,7 +419,7 @@ private function setConfig(CData $repository, array $config): void /** * @return string[]|null The full names of the references of $url, or null when it can't be reached */ - private function listRemoteReferences(string $url): ?array + private function listRemoteReferences(#[\SensitiveParameter] string $url): ?array { $remote = $this->ffi->new('git_remote*'); if ($this->ffi->git_remote_create_detached(\FFI::addr($remote), $url) < 0) { diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index cc599e7e..424b5f7b 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -163,7 +163,7 @@ public static function initBare(string $path, DriverInterface $driver = new CliD * * @throws DriverException */ - public static function cloneFrom(string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self + public static function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self { $driver->cloneFrom($url, $path, $branch); @@ -177,7 +177,7 @@ public static function cloneFrom(string $url, string $path, ?string $branch = nu * * @throws DriverException */ - public static function cloneBareFrom(string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self + public static function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self { $driver->cloneBareFrom($url, $path, $branch); @@ -190,7 +190,7 @@ public static function cloneBareFrom(string $url, string $path, ?string $branch * * @throws DriverException */ - public static function mirrorFrom(string $url, string $path, DriverInterface $driver = new CliDriver()): self + public static function mirrorFrom(#[\SensitiveParameter] string $url, string $path, DriverInterface $driver = new CliDriver()): self { $driver->mirrorFrom($url, $path); @@ -201,7 +201,7 @@ public static function mirrorFrom(string $url, string $path, DriverInterface $dr * Checks, without cloning it, that $url is a repository the driver can * read: false when it doesn't exist, can't be reached, or refuses access. */ - public static function isValidRepository(string $url, DriverInterface $driver = new CliDriver()): bool + public static function isValidRepository(#[\SensitiveParameter] string $url, DriverInterface $driver = new CliDriver()): bool { return $driver->isValidRepository($url); } @@ -210,7 +210,7 @@ public static function isValidRepository(string $url, DriverInterface $driver = * Checks, without cloning it, that $url is a repository the driver can * read, and that it has the $branch branch. */ - public static function isValidRepositoryAndBranch(string $url, string $branch, DriverInterface $driver = new CliDriver()): bool + public static function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch, DriverInterface $driver = new CliDriver()): bool { return $driver->isValidRepositoryAndBranch($url, $branch); } @@ -621,7 +621,7 @@ public function setDescription(string $description): static * @throws ProcessException Error while executing git command * @throws UnsupportedOperationException The driver isn't a CliDriver */ - public function run(string $command, array $args = []): string + public function run(string $command, #[\SensitiveParameter] array $args = []): string { return $this->runProcess($command, $args)->getOutput(); } @@ -640,7 +640,7 @@ public function run(string $command, array $args = []): string * @throws ProcessException Error while executing git command * @throws UnsupportedOperationException The driver isn't a CliDriver */ - public function runProcess(string $command, array $args = []): Process + public function runProcess(string $command, #[\SensitiveParameter] array $args = []): Process { if (!$this->repositoryDriver instanceof CliRepositoryDriver) { throw new UnsupportedOperationException('Only CliDriver can run git commands.'); diff --git a/src/Gitonomy/Git/Util/Credentials.php b/src/Gitonomy/Git/Util/Credentials.php index 4d19dee0..fbc87192 100644 --- a/src/Gitonomy/Git/Util/Credentials.php +++ b/src/Gitonomy/Git/Util/Credentials.php @@ -26,10 +26,11 @@ final class Credentials * Replaces the user information of the URLs of $text, a token or a user * and a password, by "***". */ - public static function redact(string $text): string + public static function redact(#[\SensitiveParameter] string $text): string { - // Up to the last "@" before the host, as a password may hold one. + // Up to the last "@" before the host, as a password may hold one, or a + // "#" or a "?", which git and libgit2 accept unencoded there. // @phpstan-ignore return.type (possessive quantifiers never reach the backtracking limit, and without "u" there is no UTF-8 to fail on) - return preg_replace('#\b([a-z][a-z0-9+.-]*+://)(?:[^/?\#\s@]*+@)++#i', '$1***@', $text); + return preg_replace('#\b([a-z][a-z0-9+.-]*+://)(?:[^/\s@]*+@)++#i', '$1***@', $text); } } diff --git a/src/Gitonomy/Git/Util/LeadingDash.php b/src/Gitonomy/Git/Util/LeadingDash.php index 538908d0..84ccab62 100644 --- a/src/Gitonomy/Git/Util/LeadingDash.php +++ b/src/Gitonomy/Git/Util/LeadingDash.php @@ -30,11 +30,12 @@ final class LeadingDash /** * @throws InvalidArgumentException One of the values starts with a dash */ - public static function assertNone(string ...$values): void + public static function assertNone(#[\SensitiveParameter] string ...$values): void { foreach ($values as $value) { if (str_starts_with($value, '-')) { - throw new InvalidArgumentException(\sprintf('"%s" starts with a dash, which git would read as an option.', $value)); + // The value may be a URL. + throw new InvalidArgumentException(Credentials::redact(\sprintf('"%s" starts with a dash, which git would read as an option.', $value))); } } } diff --git a/src/Gitonomy/Git/Util/NulByte.php b/src/Gitonomy/Git/Util/NulByte.php index c6b7a8c7..5f3d74e0 100644 --- a/src/Gitonomy/Git/Util/NulByte.php +++ b/src/Gitonomy/Git/Util/NulByte.php @@ -27,11 +27,12 @@ final class NulByte /** * @throws InvalidArgumentException One of the values contains a NUL byte */ - public static function assertNone(string ...$values): void + public static function assertNone(#[\SensitiveParameter] string ...$values): void { foreach ($values as $value) { if (str_contains($value, "\0")) { - throw new InvalidArgumentException(\sprintf('"%s" contains a NUL byte, which git doesn\'t accept.', addcslashes($value, "\0"))); + // The value may be a URL. + throw new InvalidArgumentException(Credentials::redact(\sprintf('"%s" contains a NUL byte, which git doesn\'t accept.', addcslashes($value, "\0")))); } } } diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php index b8dcb306..2923b3a4 100644 --- a/src/Gitonomy/Git/Util/RemoteUrl.php +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -27,7 +27,7 @@ final class RemoteUrl /** * @throws InvalidArgumentException $url starts with a dash, uses the "ext::" transport, or has a host or a user starting with a dash, encoded or not */ - public static function assertSafe(string $url): void + public static function assertSafe(#[\SensitiveParameter] string $url): void { LeadingDash::assertNone($url); @@ -53,7 +53,7 @@ public static function assertSafe(string $url): void * reaches through ssh; null for any other URL, or a * local path */ - private static function authority(string $url): ?string + private static function authority(#[\SensitiveParameter] string $url): ?string { // git decodes a URL before it reads its host: "%2D" is a dash, "%5B" a bracket. if (1 === preg_match('#^(?:git|ssh|git\+ssh|ssh\+git)://([^/]*)#', $url, $matches)) { diff --git a/tests/Gitonomy/Git/Tests/CredentialsTest.php b/tests/Gitonomy/Git/Tests/CredentialsTest.php index f97112ad..b52bdea6 100644 --- a/tests/Gitonomy/Git/Tests/CredentialsTest.php +++ b/tests/Gitonomy/Git/Tests/CredentialsTest.php @@ -12,7 +12,12 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\GitExceptionInterface; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessTimedOutException; use Gitonomy\Git\Repository; use Gitonomy\Git\Util\Credentials; use PHPUnit\Framework\Attributes\DataProvider; @@ -34,11 +39,111 @@ public function testAFailedCloneHidesTheCredentials(): void Repository::cloneFrom(self::URL, self::createTempDir().'/clone', driver: self::getDriver()); $this->fail('The clone fails.'); } catch (DriverException $e) { - $this->assertStringNotContainsString('secret', $e->getMessage()); + // What loggers print: the message, the trace, and the chained exceptions. + $this->assertStringNotContainsString('secret', (string) $e); $this->assertStringContainsString('http://***@127.0.0.1:1/repository.git', $e->getMessage()); } } + /** + * The exception of Symfony Process holds the command line. + */ + public function testATimeoutHidesTheCredentials(): void + { + $this->skipUnlessCliDriver(); + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('The slow git is a shell script.'); + } + + $slowGit = self::createTempDir().'/git'; + file_put_contents($slowGit, "#!/bin/sh\nexec sleep 10\n"); + chmod($slowGit, 0o700); + + try { + Repository::cloneFrom(self::URL, self::createTempDir().'/clone', driver: new CliDriver($slowGit, self::gitEnvs(), processTimeout: 0.2)); + $this->fail('The clone times out.'); + } catch (ProcessTimedOutException $e) { + $this->assertSame('The git command "clone" ran longer than 0.2 seconds.', $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + } + } + + /** + * The exception of Symfony Process holds the command line: here, longer + * than what the system accepts. + */ + public function testAProcessThatCannotStartHidesTheCredentials(): void + { + $this->skipUnlessCliDriver(); + + try { + Repository::isValidRepository(self::URL.'/'.str_repeat('a', 4 * 1024 * 1024), self::getDriver()); + $this->fail('The command line is too long.'); + } catch (DriverException $e) { + $this->assertStringNotContainsString('secret', (string) $e); + } + } + + /** + * The exceptions of the arguments gitlib refuses hide the credentials too. + */ + #[DataProvider('provideRefusedUrls')] + public function testARefusedUrlHidesTheCredentials(string $url, string $message): void + { + try { + Repository::isValidRepository($url, self::getDriver()); + $this->fail('The URL is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertSame($message, $e->getMessage()); + } + } + + /** + * @return iterable + */ + public static function provideRefusedUrls(): iterable + { + yield 'NUL byte' => [self::URL."\0", '"http://***@127.0.0.1:1/repository.git\000" contains a NUL byte, which git doesn\'t accept.']; + yield 'leading dash' => ['--upload-pack='.self::URL, '"--upload-pack=http://***@127.0.0.1:1/repository.git" starts with a dash, which git would read as an option.']; + } + + /** + * With "zend.exception_ignore_args" off, the default of php.ini-development, + * the traces of exceptions hold the arguments of the calls. + */ + #[DataProvider('provideFailingCalls')] + public function testTheTracesHideTheCredentials(\Closure $call): void + { + $ignoreArgs = ini_set('zend.exception_ignore_args', '0'); + + try { + $call(self::getDriver()); + $this->fail('The call fails.'); + } catch (GitExceptionInterface $e) { + for ($exception = $e; null !== $exception; $exception = $exception->getPrevious()) { + foreach ($exception->getTrace() as $frame) { + $this->assertNoSecretIn($frame['args'] ?? []); + } + } + } finally { + ini_set('zend.exception_ignore_args', (string) $ignoreArgs); + } + } + + /** + * @return iterable + */ + public static function provideFailingCalls(): iterable + { + yield 'clone' => [static function (DriverInterface $driver): void { Repository::cloneFrom(self::URL, self::createTempDir().'/clone', driver: $driver); }]; + yield 'bare clone' => [static function (DriverInterface $driver): void { Repository::cloneBareFrom(self::URL, self::createTempDir().'/clone', driver: $driver); }]; + yield 'mirror' => [static function (DriverInterface $driver): void { Repository::mirrorFrom(self::URL, self::createTempDir().'/clone', $driver); }]; + yield 'NUL byte' => [static function (DriverInterface $driver): void { Repository::isValidRepository(self::URL."\0", $driver); }]; + yield 'NUL byte in a branch' => [static function (DriverInterface $driver): void { Repository::isValidRepositoryAndBranch(self::URL, "master\0", $driver); }]; + yield 'ext' => [static function (DriverInterface $driver): void { Repository::cloneFrom('ext::'.self::URL, self::createTempDir().'/clone', driver: $driver); }]; + yield 'run' => [static function (DriverInterface $driver): void { new Repository(self::createFoobarRepository()->getPath(), driver: $driver)->run('remote', ['add', 'private', self::URL."\0"]); }]; + } + public function testTheLogsHideTheCredentials(): void { $this->skipUnlessCliDriver(); @@ -76,8 +181,24 @@ public static function provideTexts(): iterable yield 'user and password' => ["clone 'https://***@example.com/a.git'", "clone 'https://user:password@example.com/a.git'"]; yield 'token' => ['https://***@example.com', 'https://ghp_token@example.com']; yield 'password holding an "@"' => ['https://***@example.com/a.git', 'https://user:p@ss@example.com/a.git']; + yield 'password holding a "#"' => ['https://***@example.com/a.git', 'https://user:p#ss@example.com/a.git']; + yield 'password holding a "?"' => ['https://***@example.com/a.git', 'https://user:p?ss@example.com/a.git']; yield 'several URLs' => ['ssh://***@a.com/x and https://***@b.com/y', 'ssh://git@a.com/x and https://user:password@b.com/y']; yield 'no user information' => ['https://example.com/a@b.git', 'https://example.com/a@b.git']; yield 'no scheme' => ['git@example.com:a.git', 'git@example.com:a.git']; } + + /** + * @param array $values + */ + private function assertNoSecretIn(array $values): void + { + foreach ($values as $value) { + if (\is_array($value)) { + $this->assertNoSecretIn($value); + } elseif (\is_string($value)) { + $this->assertStringNotContainsString('secret', $value); + } + } + } } From d3abe748773e2dddb705aa814e3d188c94191aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 100/150] Read replaced objects and symbolic references as they are git reads the replacements of objects (git replace, refs/replace/): whoever could push such a reference to a repository changed the message of a commit, or the content of a blob, CliDriver read for a hash, where libgit2 ignores them. CliDriver passes --no-replace-objects to every command, run() included. "git update-ref" writes through a symbolic reference: deleting refs/remotes/origin/HEAD deleted the branch it points to, origin/main, and moving it moved that branch. CliDriver passes --no-deref, which gives the behavior LibGit2Driver already had. --- CHANGELOG.md | 5 ++ UPGRADE-2.0.md | 5 ++ doc/repository.md | 8 +++- .../Git/Driver/Cli/CliRepositoryDriver.php | 12 +++-- .../Git/Driver/RepositoryDriverInterface.php | 3 ++ tests/Gitonomy/Git/Tests/CommitTest.php | 23 +++++++++ tests/Gitonomy/Git/Tests/ReferenceTest.php | 48 +++++++++++++++++++ 7 files changed, 99 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 697da8a9..307f43ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,11 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - Refuse a hook name that isn't the one of a file of the hooks directory: `Hooks::set('../config', ...)` and `remove('../config')` wrote and deleted the configuration of the repository. +- Ignore the replacements of objects (`git replace`, `refs/replace/`) with `CliDriver`, like + `LibGit2Driver`: whoever could push such a reference to a repository changed the message of a + commit, or the content of a blob, gitlib read for a hash. +- Fix moving or deleting a symbolic reference, which changed the branch it points to instead: + `ReferenceBag::delete('refs/remotes/origin/HEAD')` deleted `origin/main`. - Fix `ReferenceBag::delete('HEAD')` deleting `HEAD`, which left no repository: a name outside `refs/` throws an `InvalidArgumentException`. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index ef621034..7f65df3c 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -181,6 +181,8 @@ References unsigned tag instead of throwing, and finds X.509 and SSH signatures too. * A branch whose name ends with `HEAD` is listed: only the `HEAD` of a remote is left out. + * Moving or deleting a symbolic reference, like `refs/remotes/origin/HEAD`, + changes it, instead of the branch it points to. * `ReferenceBag::delete()` throws an `InvalidArgumentException` for a name outside `refs/`: `HEAD`, which `CliDriver` deleted, leaving no repository, or a short name, like `master`, which did nothing. @@ -426,6 +428,9 @@ Git configuration prefixes, the textconv filters... Before, they made reads throw or return other data: a log of a path following its renames with `log.follow`, for instance. See ["Git configuration"](doc/repository.md#git-configuration). + * `CliDriver` ignores the replacements of objects (`git replace`, + `refs/replace/`), like `LibGit2Driver`: `run()` and `runProcess()` too. + Run git yourself to read them. Dates ----- diff --git a/doc/repository.md b/doc/repository.md index 83754135..bc18b354 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -453,6 +453,11 @@ to git's defaults: `diff.suppressBlankEmpty`, the prefixes (`diff.noprefix`, `diff.mnemonicPrefix`...), and the textconv filters, of blames too. +Like libgit2, `CliDriver` reads the objects as they are, ignoring their +replacements (`git replace`, `refs/replace/`): whoever can push such a +reference to a repository would change what your application shows for a +hash. + The rest of the configuration applies, like with git: the identity, the credentials and the rewrites of URLs, `safe.directory`, line endings, hooks, `merge.log`, `blame.ignoreRevsFile`, or `core.abbrev`, the length of short @@ -460,7 +465,8 @@ hashes, with `LibGit2Driver` too. `run()` and `runProcess()` get `branch.sort`, `log.follow`, `log.showSignature`, `diff.indentHeuristic` and `diff.suppressBlankEmpty` set -back too, but not the rest, which gitlib sets with the options of the +back too, and ignore the replacements, but not the rest, which gitlib sets +with the options of the commands it runs: pass `--no-color` or `--no-ext-diff` yourself. Logger diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index 256b10a6..fe131505 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -243,7 +243,7 @@ public function createReference(string $fullname, string $hash): void ReferenceName::assertUnderRefs($fullname); // Zeros as the old value: git refuses to update a reference that exists. - $this->run('update-ref', [$fullname, $hash, str_repeat('0', 40)]); + $this->run('update-ref', ['--no-deref', $fullname, $hash, str_repeat('0', 40)]); } public function updateReference(string $fullname, string $hash): void @@ -251,7 +251,9 @@ public function updateReference(string $fullname, string $hash): void LeadingDash::assertNone($fullname, $hash); ReferenceName::assertUnderRefs($fullname); - $this->run('update-ref', [$fullname, $hash]); + // --no-deref: a symbolic reference, like refs/remotes/origin/HEAD, is + // written itself, instead of the reference it points to. + $this->run('update-ref', ['--no-deref', $fullname, $hash]); } public function deleteReference(string $fullname): void @@ -259,7 +261,7 @@ public function deleteReference(string $fullname): void LeadingDash::assertNone($fullname); ReferenceName::assertUnderRefs($fullname); - $this->run('update-ref', ['-d', $fullname]); + $this->run('update-ref', ['--no-deref', '-d', $fullname]); } public function getIncludingBranches(string $hash): array @@ -483,7 +485,9 @@ private function listBranches(array $args, string $prefix): array */ private function execute(string $command, #[\SensitiveParameter] array $args, ?string $input = null): Process { - $globalOptions = [...self::CONFIG, '--git-dir', $this->gitDir]; + // --no-replace-objects: like libgit2, gitlib reads the objects as they + // are, whatever "git replace" made of them in refs/replace/. + $globalOptions = [...self::CONFIG, '--git-dir', $this->gitDir, '--no-replace-objects']; if (null !== $this->workingDir) { $globalOptions = [...$globalOptions, '--work-tree', $this->workingDir]; } diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index 6ed6bf1e..f921f736 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -128,6 +128,8 @@ public function getReferences(): array; public function createReference(string $fullname, string $hash): void; /** + * A symbolic reference is written itself, never the reference it points to. + * * @param string $fullname The full name of a reference under ``refs/`` * * @throws InvalidArgumentException $fullname isn't under ``refs/``, like "HEAD" or "master" @@ -136,6 +138,7 @@ public function updateReference(string $fullname, string $hash): void; /** * Like ``git update-ref -d``, deleting a missing reference does nothing. + * A symbolic reference is deleted itself, never the reference it points to. * * @param string $fullname The full name of a reference under ``refs/`` * diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 8992cbbf..e9bdf793 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Blob; use Gitonomy\Git\Commit; use Gitonomy\Git\Diff\File; use Gitonomy\Git\Exception\InvalidArgumentException; @@ -381,4 +382,26 @@ public function testMessageIsReadLikeGitLogPrintsIt(): void $this->assertSame("Subject\n\nBody\n indented\n", $repository->getCommit($hash)->getMessage()); $this->assertSame("Subject\n\nBody\n indented\n", new Repository($repository->getPath(), driver: self::getDriver())->getLog([$hash])->getSingleCommit()->getMessage()); } + + /** + * Whoever can push refs/replace/* to a repository would change what an + * application shows for a hash: like libgit2, gitlib ignores them. + */ + public function testReplacedObjectsAreReadAsTheyAre(): void + { + $repository = self::createFoobarRepository(); + $commit = $repository->getCommit(self::LONGFILE_COMMIT); + $message = $commit->getMessage(); + $blob = $commit->getTree()->resolvePath('README.md'); + $this->assertInstanceOf(Blob::class, $blob); + $content = $blob->getContent(); + self::git($repository, 'replace', [self::LONGFILE_COMMIT, self::writeObject($repository, 'commit', "tree {$commit->getTreeHash()}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\n\nFake\n")]); + self::git($repository, 'replace', [$blob->getHash(), self::writeObject($repository, 'blob', "Fake\n")]); + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + + $this->assertSame($message, $repository->getCommit(self::LONGFILE_COMMIT)->getMessage()); + $this->assertSame($message, $repository->getLog(self::LONGFILE_COMMIT, null, null, 1)->getSingleCommit()->getMessage()); + $this->assertSame($content, $repository->getBlob($blob->getHash())->getContent()); + } } diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 46e071dc..b941c93f 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; @@ -333,6 +334,36 @@ public function testDeletingADeletedReferenceDoesNothing(): void $this->assertFalse($repository->getReferences()->hasBranch('deleted')); } + /** + * The symbolic reference is deleted, not the branch it points to. + */ + public function testDeletingASymbolicReference(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['refs/remotes/origin/main', self::LONGFILE_COMMIT]); + self::git($repository, 'symbolic-ref', ['refs/remotes/origin/HEAD', 'refs/remotes/origin/main']); + + $repository->getReferences()->delete('refs/remotes/origin/HEAD'); + + $this->assertSame(\sprintf("%s refs/remotes/origin/main\n", self::LONGFILE_COMMIT), self::git($repository, 'for-each-ref', ['--format=%(objectname) %(refname)', 'refs/remotes/origin/'])); + } + + /** + * The symbolic reference moves, not the branch it points to. + * ReferenceBag leaves it out: through the driver, which Branch::moveTo() + * calls. + */ + public function testUpdatingASymbolicReference(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['refs/remotes/origin/main', self::LONGFILE_COMMIT]); + self::git($repository, 'symbolic-ref', ['refs/remotes/origin/HEAD', 'refs/remotes/origin/main']); + + $repository->getRepositoryDriver()->updateReference('refs/remotes/origin/HEAD', self::INITIAL_COMMIT); + + $this->assertSame(\sprintf("%s refs/remotes/origin/HEAD\n%s refs/remotes/origin/main\n", self::INITIAL_COMMIT, self::LONGFILE_COMMIT), self::git($repository, 'for-each-ref', ['--format=%(objectname) %(refname)', 'refs/remotes/origin/'])); + } + /** * "git update-ref -d HEAD" deletes HEAD: git no longer sees a repository. */ @@ -389,6 +420,23 @@ public static function provideWritesOfANameOutsideRefs(): iterable } } + /** + * Nothing is created through a symbolic reference to a missing branch: + * git 2.x and libgit2 refuse it, git 1.x replaces it. + */ + public function testCreatingABranchNamedLikeADanglingSymbolicReference(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'symbolic-ref', ['refs/heads/dangling', 'refs/heads/missing']); + + try { + $repository->getReferences()->createBranch('dangling', self::LONGFILE_COMMIT); + } catch (DriverException) { + } + + $this->assertFalse($repository->getReferences()->hasBranch('missing')); + } + public function testTheHeadOfARemoteNamedWithASlashIsNoBranch(): void { $repository = self::createFoobarRepository(); From 72b118f3d313a7c825bf96bf8de27775f6a35b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 101/150] Write no hook through a dangling symbolic link Hooks::has() used file_exists(), false for a symbolic link to a file not deployed yet: set() then wrote through the link, out of the hooks directory. A link is a hook, whatever its target: set() and setSymlink() refuse it, as a hook that exists already, get() throws a RuntimeException, as it can't read it, and remove() removes the link. --- CHANGELOG.md | 2 ++ UPGRADE-2.0.md | 4 +++ doc/hooks.md | 3 +++ src/Gitonomy/Git/Hooks.php | 8 ++++-- tests/Gitonomy/Git/Tests/HooksTest.php | 36 ++++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 307f43ea..e8d066c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - Refuse a hook name that isn't the one of a file of the hooks directory: `Hooks::set('../config', ...)` and `remove('../config')` wrote and deleted the configuration of the repository. +- Fix `Hooks` writing through a symbolic link to a missing file, out of the hooks directory: + `has()` returned `false` for it, and `set()` followed it. - Ignore the replacements of objects (`git replace`, `refs/replace/`) with `CliDriver`, like `LibGit2Driver`: whoever could push such a reference to a repository changed the message of a commit, or the content of a blob, gitlib read for a hash. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 7f65df3c..234deeb0 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -510,3 +510,7 @@ Other changes instead of `0777`): change their permissions if another user runs them. * `Repository::shell()` is deprecated: it ignored the driver. Run `git shell` with Symfony Process instead. + * `Hooks::has()` returns `true` for a symbolic link to a missing file: + `get()` throws a `RuntimeException`, as it can't read it, `set()` and + `setSymlink()` refuse it, as a hook that exists already, and `remove()` + removes the link. diff --git a/doc/hooks.md b/doc/hooks.md index 663a5064..21ca84df 100644 --- a/doc/hooks.md +++ b/doc/hooks.md @@ -49,6 +49,9 @@ You can test if a hook is present using the method *has*: $hooks->has('pre-receive'); // a boolean indicating presence ``` +A symbolic link is a hook, even when the file it points to doesn't exist +yet: *get* can't read it, and *set* doesn't write through it. + Inserting hooks --------------- diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index a8847ce3..8f5d4abc 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -36,7 +36,11 @@ public function __construct( */ public function has(string $name): bool { - return file_exists($this->getPath($name)); + $path = $this->getPath($name); + + // A symbolic link to a file not deployed yet is a hook too: set() + // would write through it, out of the hooks directory. + return is_link($path) || file_exists($path); } /** @@ -55,7 +59,7 @@ public function get(string $name): string throw new InvalidArgumentException(\sprintf('The hook "%s" does not exist.', $name)); } - $content = file_get_contents($this->getPath($name)); + $content = @file_get_contents($this->getPath($name)); if (false === $content) { throw new RuntimeException(\sprintf('Unable to read the hook "%s".', $name)); } diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 2646fa3b..03db4213 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -222,6 +222,42 @@ public function testRemoveNotExistingThrows(bool $bare): void $repository->getHooks()->remove('foo'); } + /** + * A symbolic link to a file deployed later: set() would write through it, + * out of the hooks directory. + */ + public function testASymlinkToAMissingFileIsAHook(): void + { + $this->markAsSkippedIfSymlinkIsMissing(); + + $repository = self::createFoobarRepository(); + $target = self::createTempDir().'/pre-receive'; + $hooks = $repository->getHooks(); + $hooks->setSymlink('pre-receive', $target); + + $this->assertTrue($hooks->has('pre-receive')); + + try { + $hooks->get('pre-receive'); + $this->fail('A symbolic link to a missing file can\'t be read.'); + } catch (RuntimeException $e) { + $this->assertSame('Unable to read the hook "pre-receive".', $e->getMessage()); + } + + try { + $hooks->set('pre-receive', "#!/bin/sh\nexit 1\n"); + $this->fail('The hook exists already.'); + } catch (RuntimeException $e) { + $this->assertSame('The hook "pre-receive" exists already.', $e->getMessage()); + } + $this->assertFileDoesNotExist($target, 'Nothing is written through the link'); + + $hooks->remove('pre-receive'); + + $this->assertFalse(is_link($this->hookPath($repository, 'pre-receive'))); + $this->assertFalse($hooks->has('pre-receive')); + } + public function testSetCreatesTheHooksDirectory(): void { $repository = self::createEmptyRepository(); From c987534a25be8e02c7b5c150d19e06ff2f0b3ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 102/150] Never read a revision as a path When no revision had the name, "git checkout " restored the file of this name, discarding its changes: WorkingCopy::checkout('README.md') did. git also read a revision named like a file of the working tree as ambiguous, which made getShortHash(), the data of a commit and the diffs fail. CliDriver ends the revisions with "--". WorkingCopy::checkout() and merge() resolve the revision first, and throw a ReferenceNotFoundException for a name that no revision has, with both drivers. checkout('feature') created the local branch of origin/feature with CliDriver, where LibGit2Driver threw: gitlib doesn't guess anymore, checkoutNewBranch() creates it. A range, which "rev-parse --verify" prints before exiting with 1, throws a ReferenceNotFoundException too, and ":/text" is resolved before "^{commit}", which would be part of its text. checkoutNewBranch() passes the name of its start point to the driver, instead of its hash: the branch tracks it when it is a remote branch, like with "git checkout -b" and branch.autoSetupMerge=true, git's default, whatever the configuration. CliDriver sets branch.autoSetupMerge and branch.autoSetupRebase back, and LibGit2Driver finds the remote and its branch through the refspecs of the remotes. Like git, both refuse an ambiguous start point, and create no branch when the checkout fails. The configuration of the tests sets both to "always", so every run checks it. --- CHANGELOG.md | 7 + UPGRADE-2.0.md | 31 ++- doc/repository.md | 17 +- doc/workingcopy.md | 26 ++- .../Git/Driver/Cli/CliRepositoryDriver.php | 46 +++- .../Driver/Ffi/LibGit2RepositoryDriver.php | 183 ++++++++++++++- src/Gitonomy/Git/Driver/Ffi/libgit2.h | 12 + .../Git/Driver/RepositoryDriverInterface.php | 22 +- src/Gitonomy/Git/Revision.php | 7 +- src/Gitonomy/Git/WorkingCopy.php | 34 ++- tests/Gitonomy/Git/Tests/CommitTest.php | 21 ++ tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 210 +++++++++++++++++- tests/fixtures/home/.gitconfig | 2 + 13 files changed, 568 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d066c7..808660fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ deprecated until 3.0. - `Diff::parse()` throws for a diff without its raw information, deprecated since 1.6.0 ([#229](https://github.com/gitonomy/gitlib/pull/229)): parse the output of `git diff --raw --patch`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a name that no revision has, + with both drivers: the name of a remote branch alone, like `feature` for `origin/feature`, + created the local branch. Use `checkoutNewBranch()`. ### Features @@ -124,6 +127,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - Ignore the replacements of objects (`git replace`, `refs/replace/`) with `CliDriver`, like `LibGit2Driver`: whoever could push such a reference to a repository changed the message of a commit, or the content of a blob, gitlib read for a hash. +- Fix `WorkingCopy::checkout()` of a path, like `checkout('README.md')`, discarding the changes + of the file: a path is no revision, and throws a `ReferenceNotFoundException`. - Fix moving or deleting a symbolic reference, which changed the branch it points to instead: `ReferenceBag::delete('refs/remotes/origin/HEAD')` deleted `origin/main`. - Fix `ReferenceBag::delete('HEAD')` deleting `HEAD`, which left no repository: a name outside @@ -137,6 +142,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). `diff.orderFile`, `log.follow` or `branch.sort` changed diffs, logs and the branches of a commit. gitlib sets them back to git's defaults; see ["Git configuration"](doc/repository.md#git-configuration). +- Fix `Commit::getShortHash()`, `Commit::getDiff()` and `Repository::getDiff()` failing when a + file of the working tree has the name of the revision they read, which git found ambiguous. - Fix `Commit::getDiff()` returning an empty diff for a root commit, and throwing for a merge commit (`CliDriver`). ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix `Repository::getDiff()`, `WorkingCopy::getDiffPending()` and `WorkingCopy::getDiffStaged()` diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 234deeb0..21bbd422 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -298,6 +298,23 @@ Working copy `Branch` object attaches `HEAD` to the branch, instead of detaching it. Its second argument, a branch to create, is deprecated: use `checkoutNewBranch($branch, $startPoint)`. + * `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a + name that no revision has, with both drivers: the name of a remote + branch alone, like `feature` for `origin/feature`, created the local + branch, and a path, like `README.md`, discarded its changes. + + Before: + + ```php + $workingCopy->checkout('feature'); + ``` + + After: + + ```php + $workingCopy->checkoutNewBranch('feature', 'origin/feature'); + ``` + * `WorkingCopy::merge()` takes a `FastForward` instead of arguments of `git merge`, deprecated. @@ -422,12 +439,14 @@ Git configuration ----------------- * `CliDriver` sets back to git's defaults the configuration of the user that - changes what git prints, or what a read returns: colors, columns, - `branch.sort`, `log.follow`, `log.showSignature`, and for diffs - `diff.algorithm`, `diff.context`, `diff.external`, `diff.orderFile`, the - prefixes, the textconv filters... Before, they made reads throw or return - other data: a log of a path following its renames with `log.follow`, for - instance. See ["Git configuration"](doc/repository.md#git-configuration). + changes what git prints, what a read returns, or the upstream of a new + branch: colors, columns, `branch.autoSetupMerge`, + `branch.autoSetupRebase`, `branch.sort`, `log.follow`, + `log.showSignature`, and for diffs `diff.algorithm`, `diff.context`, + `diff.external`, `diff.orderFile`, the prefixes, the textconv + filters... Before, they made reads throw or return other data: a log of + a path following its renames with `log.follow`, for instance. See + ["Git configuration"](doc/repository.md#git-configuration). * `CliDriver` ignores the replacements of objects (`git replace`, `refs/replace/`), like `LibGit2Driver`: `run()` and `runProcess()` too. Run git yourself to read them. diff --git a/doc/repository.md b/doc/repository.md index bc18b354..69579ae6 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -308,8 +308,6 @@ Differences with `CliDriver`: - `Diff::getRawDiff()` returns an empty string. - Short hashes (`Commit::getShortHash()`) have 7 characters, more when 7 are ambiguous: git adapts their length to the size of the repository. -- `WorkingCopy::checkout()` doesn't create a tracking branch for a name - that only exists on a remote, like `git checkout` does: it throws. - `WorkingCopy::merge()` only supports fast-forward and conflict-free merges: a conflict throws instead of leaving conflict markers in the working copy. A merge commit is never signed, whatever @@ -443,10 +441,13 @@ Git configuration git reads the configuration of the user running PHP (`~/.gitconfig`, `/etc/gitconfig`...) and of the repository. Where it changes what git -prints, which gitlib reads, or what a read returns, `CliDriver` sets it back -to git's defaults: +prints, which gitlib reads, what a read returns, or the branches gitlib +creates, `CliDriver` sets it back to git's defaults: - colors and columns; +- `branch.autoSetupMerge` and `branch.autoSetupRebase`: like with + `LibGit2Driver`, a branch `WorkingCopy::checkoutNewBranch()` creates from + a remote branch tracks it, without rebasing; - `branch.sort`, `log.follow` and `log.showSignature`; - for diffs, `diff.algorithm`, `diff.context`, `diff.external`, `diff.indentHeuristic`, `diff.interHunkContext`, `diff.orderFile`, @@ -463,10 +464,10 @@ credentials and the rewrites of URLs, `safe.directory`, line endings, hooks, `merge.log`, `blame.ignoreRevsFile`, or `core.abbrev`, the length of short hashes, with `LibGit2Driver` too. -`run()` and `runProcess()` get `branch.sort`, `log.follow`, -`log.showSignature`, `diff.indentHeuristic` and `diff.suppressBlankEmpty` set -back too, and ignore the replacements, but not the rest, which gitlib sets -with the options of the +`run()` and `runProcess()` get `branch.autoSetupMerge`, +`branch.autoSetupRebase`, `branch.sort`, `log.follow`, `log.showSignature`, +`diff.indentHeuristic` and `diff.suppressBlankEmpty` set back too, and ignore +the replacements, but not the rest, which gitlib sets with the options of the commands it runs: pass `--no-color` or `--no-ext-diff` yourself. Logger diff --git a/doc/workingcopy.md b/doc/workingcopy.md index c2b34471..d4670154 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -37,9 +37,11 @@ object attaches `HEAD` to the branch. $wc->checkout($repo->getReferences()->getBranch('master')); ``` -A name that only exists as a remote branch, like `feature` for -`origin/feature`, creates the local tracking branch with `CliDriver`, like -`git checkout` does. `LibGit2Driver` throws instead. +A name that no revision has throws a `ReferenceNotFoundException`, with both +drivers, unlike `git checkout`: a path, like `README.md`, whose changes git +would discard, or the name of a remote branch alone, like `feature` for +`origin/feature`, whose local branch git would create. Create it with +*checkoutNewBranch*. To create a branch and check it out, use *checkoutNewBranch*. Its second argument is the revision to start the branch from, `HEAD` by default: @@ -52,9 +54,18 @@ $wc->checkoutNewBranch('feature'); $wc->checkoutNewBranch('develop', 'origin/develop'); ``` +Like with `git checkout -b`, a branch created from a remote branch, like +`origin/develop`, tracks it: `git pull` and `git push` know its upstream +branch. One created from a local branch, a tag or a commit tracks none. +gitlib does so whatever the configuration (`branch.autoSetupMerge`), with +both drivers. Like git, *checkoutNewBranch* refuses a start point several +references have the name of, like `origin/develop` when a local branch has +this name too, with a `DriverException`: use its full name, +`refs/remotes/origin/develop`. + Like `git checkout`, both methods keep the local changes, and refuse to overwrite the ones the checkout would change: they throw a -`DriverException`. +`DriverException`. *checkoutNewBranch* then creates no branch. Passing the branch to create as second argument of *checkout* is deprecated since 2.0: use *checkoutNewBranch*. @@ -83,9 +94,10 @@ $wc->merge('origin/develop', FastForward::Only); merge commit otherwise. `FastForward::Never` always creates a merge commit. `FastForward::Only` throws instead of creating one. -You can also pass a *Revision*. A `DriverException` is thrown if the merge -fails: a conflict, staged changes, a revision whose history is unrelated to -the branch... +You can also pass a *Revision*. Like with *checkout*, a name that no +revision has throws a `ReferenceNotFoundException`. A `DriverException` is +thrown if the merge fails: a conflict, staged changes, a revision whose +history is unrelated to the branch... With `CliDriver`, a conflict leaves the working copy merging, with conflict markers in the files, like `git merge`. To cancel the merge: diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index fe131505..85a4f31f 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -22,6 +22,7 @@ use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Driver\Data\TagData; use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\FastForward; @@ -41,12 +42,16 @@ { /** * Sets back to git's defaults the configuration of the user that changes - * what git prints or reads, where git 1.8.4 has no option for it. A git - * version that doesn't know a key ignores it. + * what git prints, reads or writes, where git 1.8.4 has no option for it. + * A git version that doesn't know a key ignores it. * + * branch.autoSetupMerge and branch.autoSetupRebase: a branch created from + * a remote branch tracks it, without rebasing, like with LibGit2Driver. * diff.indentHeuristic is the default since git 2.14, and LibGit2Driver's. */ private const array CONFIG = [ + '-c', 'branch.autoSetupMerge=true', + '-c', 'branch.autoSetupRebase=never', '-c', 'branch.sort=refname', '-c', 'diff.indentHeuristic=true', '-c', 'diff.suppressBlankEmpty=false', @@ -109,8 +114,9 @@ public function resolveRevision(string $revision): string $process = $this->execute('rev-parse', ['--verify', '--quiet', $revision]); - // --quiet: git exits with 1, without output, when nothing has this name. - if (1 === $process->getExitCode() && '' === $process->getOutput()) { + // --quiet: git exits with 1 when nothing has this name, or when it + // names several objects, like the range "a...b", which it prints. + if (1 === $process->getExitCode()) { throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist.', $revision)); } @@ -125,7 +131,8 @@ public function getShortHash(string $hash): string { LeadingDash::assertNone($hash); - return trim($this->runOnObject($hash, null, 'log', ['--abbrev-commit', '--format=%h', '-n', '1', $hash])); + // --: a file of the working tree may have the name of the hash. + return trim($this->runOnObject($hash, null, 'log', ['--abbrev-commit', '--format=%h', '-n', '1', $hash, '--'])); } public function getCommitData(string $hash): CommitData @@ -133,7 +140,7 @@ public function getCommitData(string $hash): CommitData LeadingDash::assertNone($hash); $parser = new LogParser(); - $parser->parse($this->runOnObject($hash, ObjectType::Commit, 'log', ['--encoding=UTF-8', '--format=raw', '--no-color', '-n', '1', $hash])); + $parser->parse($this->runOnObject($hash, ObjectType::Commit, 'log', ['--encoding=UTF-8', '--format=raw', '--no-color', '-n', '1', $hash, '--'])); $commit = $parser->commits[0] ?? null; // git log peels a tag to its commit. @@ -332,14 +339,30 @@ public function checkout(string $revision): void { LeadingDash::assertNone($revision); - $this->run('checkout', [$revision]); + // --: without it, git restores the file $revision names, when no + // revision has this name, discarding its changes. + $this->run('checkout', [$revision, '--']); } - public function checkoutNewBranch(string $branch, string $startHash): void + public function checkoutNewBranch(string $branch, string $startPoint): void { - LeadingDash::assertNone($branch, $startHash); + LeadingDash::assertNone($branch, $startPoint); + + // The branch, then its files: "checkout -b" checks the files of an + // ambiguous $startPoint out before refusing it, and leaves them. + // CONFIG sets the upstream branch, like git's default: --track or + // --no-track would need to know whether $startPoint is a remote branch. + $this->run('branch', [$branch, $startPoint]); + + try { + $this->checkout($branch); + } catch (DriverException $e) { + // Like "checkout -b", which creates nothing when the checkout + // fails. The configuration of the branch goes with it. + $this->run('branch', ['-D', $branch]); - $this->run('checkout', ['-b', $branch, $startHash]); + throw $e; + } } public function merge(string $revision, FastForward $fastForward): void @@ -389,7 +412,8 @@ private function run(string $command, array $args): string */ private function diff(string $command, array $args): DiffData { - $output = $this->run($command, [...self::DIFF_ARGS, ...$args]); + // --: a file of the working tree may have the name of a revision. + $output = $this->run($command, [...self::DIFF_ARGS, ...$args, '--']); $parser = new DiffParser(); $parser->parse($output); diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index 72c83b89..67f1cee3 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -62,6 +62,7 @@ private const int GIT_DIFF_FLAG_BINARY = 1 << 0; private const int GIT_DIFF_INDENT_HEURISTIC = 1 << 18; private const int GIT_DIFF_FIND_RENAMES = 1 << 0; + private const int GIT_DIRECTION_FETCH = 0; private const int GIT_STATUS_OPT_INCLUDE_UNTRACKED = 1 << 0; private const int GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS = 1 << 4; private const int GIT_STATUS_WT_NEW = 1 << 7; @@ -600,10 +601,10 @@ public function checkout(string $revision): void } } - public function checkoutNewBranch(string $branch, string $startHash): void + public function checkoutNewBranch(string $branch, string $startPoint): void { - NulByte::assertNone($branch, $startHash); - LeadingDash::assertNone($branch, $startHash); + NulByte::assertNone($branch, $startPoint); + LeadingDash::assertNone($branch, $startPoint); if ($this->referenceExists('refs/heads/'.$branch)) { throw new DriverException(\sprintf('A branch named "%s" already exists.', $branch)); @@ -615,18 +616,34 @@ public function checkoutNewBranch(string $branch, string $startHash): void if (0 === $valid->cdata) { throw new DriverException(\sprintf('"%s" is not a valid branch name.', $branch)); } - $commit = $this->lookupCommit($this->resolveCommit($startHash)); + $startHash = $this->resolveCommit($startPoint); + $upstream = $this->upstreamOf($startPoint); + $commit = $this->lookupCommit($startHash); - try { - $this->checkoutRevision($startHash); + $reference = $this->ffi->new('git_reference*'); - $reference = $this->ffi->new('git_reference*'); + try { LibGit2::check($this->ffi, $this->ffi->git_branch_create(\FFI::addr($reference), $this->repo, $branch, $commit, 0), \sprintf('Unable to create branch "%s"', $branch)); - $this->ffi->git_reference_free($reference); } finally { $this->ffi->git_commit_free($commit); } + // The branch and its upstream before the files, which may conflict + // with local changes: then, like git, nothing is left behind. + try { + if (null !== $upstream) { + $this->setUpstream($branch, ...$upstream); + } + $this->checkoutRevision($startHash); + } catch (DriverException $e) { + // Its configuration with it. + LibGit2::check($this->ffi, $this->ffi->git_branch_delete($reference), \sprintf('Unable to delete the branch "%s" after a failed checkout', $branch)); + + throw $e; + } finally { + $this->ffi->git_reference_free($reference); + } + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, 'refs/heads/'.$branch), 'Unable to update HEAD'); } @@ -1227,6 +1244,142 @@ private function mergeMessage(string $revision, ?string $branch): string // -- references ---------------------------------------------------------- + /** + * Like git's dwim_ref(), which counts the references a name may be the + * short name of, unlike git_reference_dwim(). + * + * @return list The full names of the references $name names, after the symbolic ones: several when $name is ambiguous + */ + private function dwimReferences(string $name): array + { + $references = []; + // The rules of git's ref_rev_parse_rules. + foreach (['%s', 'refs/%s', 'refs/tags/%s', 'refs/heads/%s', 'refs/remotes/%s', 'refs/remotes/%s/HEAD'] as $rule) { + $fullname = \sprintf($rule, $name); + $ref = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname); + if (LibGit2::GIT_ENOTFOUND === $rc || self::GIT_EINVALIDSPEC === $rc) { + continue; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up the reference "%s"', $fullname)); + + try { + $resolved = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_resolve(\FFI::addr($resolved), $ref); + // Like git, which ignores a symbolic reference to nothing. + if (LibGit2::GIT_ENOTFOUND === $rc) { + continue; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve the reference "%s"', $fullname)); + + try { + $references[] = $this->ffi->git_reference_name($resolved); + } finally { + $this->ffi->git_reference_free($resolved); + } + } finally { + $this->ffi->git_reference_free($ref); + } + } + + return $references; + } + + /** + * Like git with branch.autoSetupMerge=true, its default, a branch created + * from a remote branch tracks it. + * + * @return array{string, string}|null The remote, and its branch, that $startPoint names, like ["origin", "refs/heads/feature"] for "origin/feature"; null when $startPoint names another revision, or a branch no remote fetches + * + * @throws DriverException $startPoint names several references, or a branch several remotes fetch + */ + private function upstreamOf(string $startPoint): ?array + { + $references = $this->dwimReferences($startPoint); + // Like git, which refuses to create a branch from an ambiguous name. + if (\count($references) > 1) { + throw new DriverException(\sprintf('The name "%s" is ambiguous: "%s".', $startPoint, implode('", "', $references))); + } + $reference = $references[0] ?? null; + if (null === $reference || !str_starts_with($reference, 'refs/remotes/')) { + return null; + } + + // Like git, which only tracks the branch of a remote it knows. + $remotes = $this->ffi->new('git_strarray'); + LibGit2::check($this->ffi, $this->ffi->git_remote_list(\FFI::addr($remotes), $this->repo), 'Unable to list the remotes'); + + try { + $upstream = null; + for ($i = 0; $i < $remotes->count; ++$i) { + $remote = \FFI::string($remotes->strings[$i]); + $branch = $this->fetchedBranch($remote, $reference); + if (null === $branch) { + continue; + } + if (null !== $upstream) { + throw new DriverException(\sprintf('Unable to tell the upstream of "%s": the remotes "%s" and "%s" fetch it.', $startPoint, $upstream[0], $remote)); + } + $upstream = [$remote, $branch]; + } + + return $upstream; + } finally { + $this->ffi->git_strarray_dispose(\FFI::addr($remotes)); + } + } + + /** + * @return string|null The branch of $remote a fetch writes to $reference, like "refs/heads/feature" for "refs/remotes/origin/feature", null when no fetch of $remote writes it + */ + private function fetchedBranch(string $remote, string $reference): ?string + { + $handle = $this->ffi->new('git_remote*'); + LibGit2::check($this->ffi, $this->ffi->git_remote_lookup(\FFI::addr($handle), $this->repo, $remote), \sprintf('Unable to look up the remote "%s"', $remote)); + + try { + for ($i = 0, $count = $this->ffi->git_remote_refspec_count($handle); $i < $count; ++$i) { + $refspec = $this->ffi->git_remote_get_refspec($handle, $i); + if (self::GIT_DIRECTION_FETCH !== $this->ffi->git_refspec_direction($refspec) || 0 === $this->ffi->git_refspec_dst_matches($refspec, $reference)) { + continue; + } + + $branch = $this->ffi->new('git_buf'); + LibGit2::check($this->ffi, $this->ffi->git_refspec_rtransform(\FFI::addr($branch), $refspec, $reference), \sprintf('Unable to find the branch of the remote "%s" fetched to "%s"', $remote, $reference)); + + try { + return \FFI::string($branch->ptr, $branch->size); + } finally { + $this->ffi->git_buf_dispose(\FFI::addr($branch)); + } + } + + return null; + } finally { + $this->ffi->git_remote_free($handle); + } + } + + /** + * Writes the configuration git_branch_set_upstream() would, which looks + * for a local branch of the name of the remote one first. + * + * @param string $merge The branch on the remote, like "refs/heads/feature" + */ + private function setUpstream(string $branch, string $remote, string $merge): void + { + $config = $this->ffi->new('git_config*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_config(\FFI::addr($config), $this->repo), 'Unable to open the repository configuration'); + + try { + foreach (['remote' => $remote, 'merge' => $merge] as $key => $value) { + LibGit2::check($this->ffi, $this->ffi->git_config_set_string($config, 'branch.'.$branch.'.'.$key, $value), \sprintf('Unable to set the upstream of "%s"', $branch)); + } + } finally { + $this->ffi->git_config_free($config); + } + } + private function referenceExists(string $fullname): bool { $ref = $this->ffi->new('git_reference*'); @@ -1363,17 +1516,25 @@ private function revparse(string $spec): CData } /** - * Resolves a revision to a commit hash, peeling annotated tags. + * Resolves a revision to a commit hash, peeling annotated tags: the + * object it names, as "^{commit}" would be part of the text of ":/text". */ private function resolveCommit(string $revision): string { - $object = $this->revparse($revision.'^{commit}'); + $object = $this->revparse($revision); try { - return $this->oidToHex($this->ffi->git_object_id($object)); + $commit = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_object_peel(\FFI::addr($commit), $object, $this->ffi->GIT_OBJECT_COMMIT), \sprintf('Unable to resolve "%s" to a commit', $revision)); } finally { $this->ffi->git_object_free($object); } + + try { + return $this->oidToHex($this->ffi->git_object_id($commit)); + } finally { + $this->ffi->git_object_free($commit); + } } private function lookupCommit(string $hash): CData diff --git a/src/Gitonomy/Git/Driver/Ffi/libgit2.h b/src/Gitonomy/Git/Driver/Ffi/libgit2.h index 309cc566..6b0eca74 100644 --- a/src/Gitonomy/Git/Driver/Ffi/libgit2.h +++ b/src/Gitonomy/Git/Driver/Ffi/libgit2.h @@ -74,6 +74,7 @@ typedef struct git_blame git_blame; typedef struct git_status_list git_status_list; typedef struct git_patch git_patch; typedef struct git_remote git_remote; +typedef struct git_refspec git_refspec; typedef struct git_config git_config; /* -- global -- */ @@ -88,6 +89,7 @@ void git_buf_dispose(git_buf *buffer); int git_signature_default_from_env(git_signature **author_out, git_signature **committer_out, git_repository *repo); void git_signature_free(git_signature *sig); void git_oidarray_dispose(git_oidarray *array); +void git_strarray_dispose(git_strarray *array); /* -- errors -- */ @@ -489,3 +491,13 @@ int git_remote_connect(git_remote *remote, int direction, const git_remote_callb int git_remote_ls(const git_remote_head ***out, size_t *size, git_remote *remote); int git_remote_disconnect(git_remote *remote); void git_remote_free(git_remote *remote); +int git_remote_list(git_strarray *out, git_repository *repo); +int git_remote_lookup(git_remote **out, git_repository *repo, const char *name); +size_t git_remote_refspec_count(const git_remote *remote); +const git_refspec *git_remote_get_refspec(const git_remote *remote, size_t n); + +/* -- refspec -- */ + +int git_refspec_direction(const git_refspec *spec); +int git_refspec_dst_matches(const git_refspec *refspec, const char *refname); +int git_refspec_rtransform(git_buf *out, const git_refspec *spec, const char *name); diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index f921f736..4e592b9b 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -193,13 +193,33 @@ public function getUntrackedFiles(): array; /** * Checks a revision out: HEAD points to the branch when $revision is the * name of a local branch, and to the commit otherwise. + * + * $revision names a commit, which WorkingCopy::checkout() checks before: + * it throws a {@see ReferenceNotFoundException} for a name that no + * revision has, where ``git checkout`` would create the local branch of a + * remote one, like "feature" for "origin/feature". $revision is never a + * path either, whose changes ``git checkout`` would discard. */ public function checkout(string $revision): void; - public function checkoutNewBranch(string $branch, string $startHash): void; + /** + * Creates the branch $branch at $startPoint and checks it out, like + * ``git checkout -b``: the branch tracks $startPoint when it is a remote + * branch, like "origin/feature", as with branch.autoSetupMerge=true, + * git's default, whatever the configuration. A failure leaves the + * branches and the files as they were: like ``git checkout -b``, no + * branch is created when the checkout fails. + * + * @param string $startPoint A revision naming a commit, which WorkingCopy::checkoutNewBranch() checks before + * + * @throws DriverException $branch exists already or isn't a valid name, $startPoint is the short name of several references, which git refuses, or the checkout would overwrite local changes + */ + public function checkoutNewBranch(string $branch, string $startPoint): void; /** * Merges $revision, a name the message of the merge commit names, into the current branch. + * + * @param string $revision A revision naming a commit, which WorkingCopy::merge() checks before */ public function merge(string $revision, FastForward $fastForward): void; } diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index 15430584..a23b04fd 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -37,8 +37,13 @@ public function getLog(array|string|null $paths = null, ?int $offset = null, ?in */ public function getCommit(): Commit { + $driver = $this->repository->getRepositoryDriver(); + try { - $hash = $this->repository->getRepositoryDriver()->resolveRevision($this->revision.'^{commit}'); + // "^{commit}" would be part of the text of ":/text", the commit + // whose message matches it: its hash first. + $revision = str_starts_with($this->revision, ':/') ? $driver->resolveRevision($this->revision) : $this->revision; + $hash = $driver->resolveRevision($revision.'^{commit}'); } catch (ReferenceNotFoundException $e) { throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist, or names no commit.', $this->revision), 0, $e); } diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index 988004ba..de19f555 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -13,8 +13,10 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; /** @@ -68,7 +70,14 @@ public function getDiffStaged(): Diff * Checks $revision out: HEAD points to it when it is a local branch, to * its commit otherwise. * + * Unlike `git checkout`, a name that no revision has throws, the name of + * a remote branch alone included, like "feature" for "origin/feature": + * create its local branch with checkoutNewBranch(). + * * Passing a branch to create as second argument is deprecated. + * + * @throws ReferenceNotFoundException $revision names no commit + * @throws DriverException The checkout would overwrite local changes */ public function checkout(Revision|string $revision, ?string $branch = null): static { @@ -78,6 +87,10 @@ public function checkout(Revision|string $revision, ?string $branch = null): sta return $this->checkoutNewBranch($branch, $revision); } + // A name that no revision has throws, where "git checkout" would create + // the branch of a remote one, or discard the changes of a file. + ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit(); + try { $this->repository->getRepositoryDriver()->checkout(self::name($revision)); } finally { @@ -88,14 +101,23 @@ public function checkout(Revision|string $revision, ?string $branch = null): sta } /** - * Creates the branch $branch at $startPoint, and checks it out. + * Creates the branch $branch at $startPoint, and checks it out, like + * `git checkout -b`. The branch tracks $startPoint when it is a remote + * branch, like "origin/feature", as with branch.autoSetupMerge=true, + * git's default, whatever the configuration. A failure leaves the + * branches and the files as they were: like `git checkout -b`, no + * branch is created when the checkout fails. + * + * @throws ReferenceNotFoundException $startPoint names no commit + * @throws DriverException $branch exists already or isn't a valid name, $startPoint is the short name of several references, which git refuses, or the checkout would overwrite local changes */ public function checkoutNewBranch(string $branch, Revision|string $startPoint = 'HEAD'): static { - $startHash = ($startPoint instanceof Revision ? $startPoint : $this->repository->getRevision($startPoint))->getCommit()->getHash(); + // Like checkout(). The driver gets the name, which tells a remote branch. + ($startPoint instanceof Revision ? $startPoint : $this->repository->getRevision($startPoint))->getCommit(); try { - $this->repository->getRepositoryDriver()->checkoutNewBranch($branch, $startHash); + $this->repository->getRepositoryDriver()->checkoutNewBranch($branch, $startPoint instanceof Revision ? $startPoint->getRevision() : $startPoint); } finally { $this->repository->getReferences()->invalidate(); } @@ -109,6 +131,9 @@ public function checkoutNewBranch(string $branch, Revision|string $startPoint = * An array of arguments of "git merge" ([], ["--no-ff"] or ["--ff-only"]) is deprecated. * * @param FastForward|string[] $fastForward + * + * @throws ReferenceNotFoundException $revision names no commit + * @throws DriverException The merge fails: a conflict, staged changes, an unrelated history... */ public function merge(Revision|string $revision, FastForward|array $fastForward = FastForward::Allow): static { @@ -123,6 +148,9 @@ public function merge(Revision|string $revision, FastForward|array $fastForward }; } + // Like checkout(). The driver gets the name, which the message of the merge commit has. + ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit(); + try { $this->repository->getRepositoryDriver()->merge(self::name($revision), $fastForward); } finally { diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index e9bdf793..24d1491c 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -404,4 +404,25 @@ public function testReplacedObjectsAreReadAsTheyAre(): void $this->assertSame($message, $repository->getLog(self::LONGFILE_COMMIT, null, null, 1)->getSingleCommit()->getMessage()); $this->assertSame($content, $repository->getBlob($blob->getHash())->getContent()); } + + /** + * Without "--", git reads a revision named like a file of the working + * tree as ambiguous. + */ + public function testAFileNamedLikeACommitChangesNothing(): void + { + $repository = self::createFoobarRepository(false); + $commit = $repository->getCommit(self::LONGFILE_COMMIT); + $parent = $commit->getParentHashes()[0]; + touch($repository->getWorkingDir().'/'.self::LONGFILE_COMMIT); + touch($repository->getWorkingDir().'/'.$parent); + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $commit = $repository->getCommit(self::LONGFILE_COMMIT); + + $this->assertSame('add a long file', $commit->getSubjectMessage()); + $this->assertSame(substr(self::LONGFILE_COMMIT, 0, 7), $commit->getShortHash()); + $this->assertSame(['long.php'], array_map(static fn (File $file): string => $file->getName(), $commit->getDiff()->getFiles())); + $this->assertNotSame([], $repository->getWorkingCopy()->getDiff($parent)->getFiles()); + } } diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 4867cdff..8c22a64e 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -12,12 +12,14 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\LogicException; -use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\FastForward; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; @@ -117,11 +119,104 @@ public function testDiffPendingWithAbbreviatedHashesLikeOldGit(): void public function testCheckoutUnexisting(): void { - $this->expectException(RuntimeException::class); + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The revision "foobar" does not exist, or names no commit.'); self::createFoobarRepository(false)->getWorkingCopy()->checkout('foobar'); } + /** + * "rev-parse --verify" refuses a range, which names two commits, after + * printing them. + */ + public function testCheckoutOfARangeThrows(): void + { + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The revision "master...origin/new-feature" does not exist, or names no commit.'); + + self::createFoobarRepository(false)->getWorkingCopy()->checkout('master...origin/new-feature'); + } + + /** + * Like "git checkout :/text": the youngest commit whose message matches. + */ + public function testCheckoutOfACommitByItsMessage(): void + { + $repository = self::createFoobarRepository(false); + + $repository->getWorkingCopy()->checkout(':/add a long file'); + + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); + } + + /** + * "git checkout README.md" restores the file, discarding its changes. + */ + #[DataProvider('providePaths')] + public function testCheckoutOfAPathIsRefused(string $path): void + { + $repository = self::createFoobarRepository(false); + $file = $repository->getWorkingDir().'/README.md'; + file_put_contents($file, 'local change'); + + try { + $repository->getWorkingCopy()->checkout($path); + $this->fail('A path is no revision.'); + } catch (ReferenceNotFoundException $e) { + $this->assertSame(\sprintf('The revision "%s" does not exist, or names no commit.', $path), $e->getMessage()); + } + + $this->assertSame('local change', file_get_contents($file)); + } + + /** + * The driver refuses a path too, whatever its caller resolved. + */ + #[DataProvider('providePaths')] + public function testTheDriverChecksOutNoPath(string $path): void + { + $repository = self::createFoobarRepository(false); + $file = $repository->getWorkingDir().'/README.md'; + file_put_contents($file, 'local change'); + + try { + $repository->getRepositoryDriver()->checkout($path); + $this->fail('A path is no revision.'); + } catch (DriverException) { + } + + $this->assertSame('local change', file_get_contents($file)); + } + + /** + * @return iterable + */ + public static function providePaths(): iterable + { + yield 'file' => ['README.md']; + yield 'working tree' => ['.']; + } + + /** + * Unlike "git checkout feature", which creates the local branch of the + * only remote one of this name. + */ + public function testCheckoutOfTheNameOfARemoteBranchIsRefused(): void + { + $repository = self::createFoobarRepository(false); + + try { + $repository->getWorkingCopy()->checkout('new-feature'); + $this->fail('No revision has this name.'); + } catch (ReferenceNotFoundException $e) { + $this->assertSame('The revision "new-feature" does not exist, or names no commit.', $e->getMessage()); + } + + $this->assertFalse($repository->getReferences()->hasBranch('new-feature')); + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + } + public function testAttachedHead(): void { $repository = self::createFoobarRepository(false); @@ -171,6 +266,14 @@ public function testMerge(): void $this->assertFileExists($repository->getWorkingDir().'/script_A.php'); } + public function testMergeOfAnUnknownRevisionThrows(): void + { + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The revision "foobar" does not exist, or names no commit.'); + + self::createFoobarRepository(false)->getWorkingCopy()->merge('foobar'); + } + public function testMergeConflict(): void { $repository = self::createFoobarRepository(false); @@ -267,6 +370,7 @@ public function testCheckoutRefusesToOverwriteLocalChanges(): void $this->assertSame('local change', file_get_contents($file)); $repository = new Repository($repository->getPath(), driver: self::getDriver()); $this->assertFalse($repository->getReferences()->hasBranch('pagination'), 'No branch is created when the checkout fails'); + $this->assertSame([], self::branchConfiguration($repository, 'pagination'), 'Nor its upstream'); } public function testUntrackedFilesInNewDirectories(): void @@ -295,6 +399,91 @@ public function testCheckoutPrefersTheBranchToATagOfTheSameName(): void $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); } + /** + * Like "git checkout -b", whatever the configuration: the one of the + * tests sets branch.autoSetupMerge and branch.autoSetupRebase to "always". + * + * @param array $upstream + */ + #[DataProvider('provideStartPoints')] + public function testCheckoutNewBranchTracksARemoteBranch(string $startPoint, array $upstream): void + { + $repository = self::createFoobarRepository(false); + + $repository->getWorkingCopy()->checkoutNewBranch('feature', $startPoint); + + $this->assertSame('refs/heads/feature', self::headBranch($repository)->getFullname()); + $this->assertSame($upstream, self::branchConfiguration($repository, 'feature')); + } + + /** + * @return iterable}> + */ + public static function provideStartPoints(): iterable + { + $upstream = ['branch.feature.merge' => 'refs/heads/new-feature', 'branch.feature.remote' => 'origin']; + + yield 'remote branch' => ['origin/new-feature', $upstream]; + yield 'remote branch by its full name' => ['refs/remotes/origin/new-feature', $upstream]; + yield 'local branch' => ['master', []]; + yield 'tag' => ['0.1', []]; + yield 'hash' => [self::INITIAL_COMMIT, []]; + } + + public function testCheckoutNewBranchFromARemoteBranchObjectTracksIt(): void + { + $repository = self::createFoobarRepository(false); + + $repository->getWorkingCopy()->checkoutNewBranch('feature', $repository->getReferences()->getRemoteBranch('origin/new-feature')); + + $this->assertSame(['branch.feature.merge' => 'refs/heads/new-feature', 'branch.feature.remote' => 'origin'], self::branchConfiguration($repository, 'feature')); + } + + /** + * git_branch_set_upstream() prefers a local branch of the name of the + * remote one, unlike git. + */ + public function testCheckoutNewBranchTracksARemoteBranchByItsFullName(): void + { + $repository = self::createFoobarRepository(false); + $repository->getReferences()->createBranch('origin/new-feature', 'master'); + + $repository->getWorkingCopy()->checkoutNewBranch('feature', 'refs/remotes/origin/new-feature'); + + $this->assertSame('refs/heads/feature', self::headBranch($repository)->getFullname()); + $this->assertSame(['branch.feature.merge' => 'refs/heads/new-feature', 'branch.feature.remote' => 'origin'], self::branchConfiguration($repository, 'feature')); + } + + /** + * Like git, which refuses a start point several references have the + * name of. "git checkout -b" checks it out before, and leaves its files. + */ + public function testCheckoutNewBranchRefusesAnAmbiguousStartPoint(): void + { + $repository = self::createFoobarRepository(false); + $head = $repository->getHeadCommit()->getHash(); + $repository->getReferences()->createBranch('origin/new-feature', self::INITIAL_COMMIT); + + try { + $repository->getWorkingCopy()->checkoutNewBranch('feature', 'origin/new-feature'); + $this->fail('The start point is ambiguous.'); + } catch (DriverException $e) { + if (self::getDriver() instanceof CliDriver) { + $this->assertMatchesRegularExpression('/fatal: [Aa]mbiguous object name: \'origin\/new-feature\'/', $e->getMessage()); + } else { + $this->assertSame('The name "origin/new-feature" is ambiguous: "refs/heads/origin/new-feature", "refs/remotes/origin/new-feature".', $e->getMessage()); + } + } + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + $this->assertSame($head, $repository->getHeadCommit()->getHash()); + $this->assertFalse($repository->getReferences()->hasBranch('feature')); + $this->assertSame([], self::branchConfiguration($repository, 'feature')); + $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); + $this->assertSame([], $repository->getWorkingCopy()->getDiffPending()->getFiles()); + } + public function testCheckoutNewBranchWithAnInvalidNameChangesNothing(): void { $repository = self::createFoobarRepository(false); @@ -339,4 +528,21 @@ public function testMergeRefusesStagedChanges(): void $this->assertSame($head, $repository->getHeadCommit()->getHash()); } + + /** + * @return array The configuration of the branch $branch, sorted by key + */ + private static function branchConfiguration(Repository $repository, string $branch): array + { + $configuration = []; + foreach (explode("\n", self::git($repository, 'config', ['--list'])) as $line) { + if (str_starts_with($line, 'branch.'.$branch.'.')) { + [$key, $value] = explode('=', $line, 2); + $configuration[$key] = $value; + } + } + ksort($configuration); + + return $configuration; + } } diff --git a/tests/fixtures/home/.gitconfig b/tests/fixtures/home/.gitconfig index 8dc6cdb2..e6de08bd 100644 --- a/tests/fixtures/home/.gitconfig +++ b/tests/fixtures/home/.gitconfig @@ -14,6 +14,8 @@ showEmail = true showRoot = true [branch] + autoSetupMerge = always + autoSetupRebase = always sort = -committerdate [color] branch = always From a44391d6922c941921c5e7cd63b436ade141cac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 103/150] Read the diff of submodules, and refuse output the parser doesn't know With diff.submodule=log, "git diff" writes the log of a submodule instead of its patch: DiffParser stopped there, silently, and the files after the submodule were lost. CliDriver passes --submodule=short to the diffs of the working tree. DiffParser reads an unmerged path ending a diff, and throws a RuntimeException for anything left it can't read. Its message quotes the start of the line only, as the lines of a diff come from the files of the repository. --- CHANGELOG.md | 4 +- UPGRADE-2.0.md | 6 +- doc/diff.md | 11 ++-- doc/repository.md | 5 +- src/Gitonomy/Git/Diff/Diff.php | 2 + .../Git/Driver/Cli/CliRepositoryDriver.php | 5 +- src/Gitonomy/Git/Parser/DiffParser.php | 26 ++++++-- tests/Gitonomy/Git/Tests/DiffTest.php | 63 +++++++++++++++++++ 8 files changed, 105 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 808660fb..324113d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,8 +137,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). ### Bug fixes - Fix the configuration of the user changing what `CliDriver` reads: `color.ui=always`, - `log.showSignature`, `diff.noprefix`, `diff.external`, textconv filters, `column.ui`... - made reads throw or return wrong data, and `diff.algorithm`, `diff.context`, + `log.showSignature`, `diff.noprefix`, `diff.external`, `diff.submodule`, textconv filters, + `column.ui`... made reads throw or return wrong data, and `diff.algorithm`, `diff.context`, `diff.orderFile`, `log.follow` or `branch.sort` changed diffs, logs and the branches of a commit. gitlib sets them back to git's defaults; see ["Git configuration"](doc/repository.md#git-configuration). diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 21bbd422..b7660f51 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -443,9 +443,9 @@ Git configuration branch: colors, columns, `branch.autoSetupMerge`, `branch.autoSetupRebase`, `branch.sort`, `log.follow`, `log.showSignature`, and for diffs `diff.algorithm`, `diff.context`, - `diff.external`, `diff.orderFile`, the prefixes, the textconv - filters... Before, they made reads throw or return other data: a log of - a path following its renames with `log.follow`, for instance. See + `diff.external`, `diff.orderFile`, `diff.submodule`, the prefixes, the + textconv filters... Before, they made reads throw or return other data: a + log of a path following its renames with `log.follow`, for instance. See ["Git configuration"](doc/repository.md#git-configuration). * `CliDriver` ignores the replacements of objects (`git replace`, `refs/replace/`), like `LibGit2Driver`: `run()` and `runProcess()` too. diff --git a/doc/diff.md b/doc/diff.md index 415816a9..18660de6 100644 --- a/doc/diff.md +++ b/doc/diff.md @@ -174,14 +174,17 @@ Parsing a diff `Diff::parse()` reads the output of `git diff --raw --patch`, with `-z` or not. The raw information gives the names, modes, hashes and status of the -files: a patch without it throws an `InvalidArgumentException`. Pass the -repository to read the blobs of the files. Here, the output comes from -`Repository::run()`, which needs `CliDriver`: +files: a patch without it throws an `InvalidArgumentException`. What the +parser doesn't know throws a `RuntimeException`, like the log of a submodule +that `git diff` writes instead of its patch, when the configuration sets +`diff.submodule`: pass `--submodule=short`. Pass the repository to read the +blobs of the files. Here, the output comes from `Repository::run()`, which +needs `CliDriver`: ```php use Gitonomy\Git\Diff\Diff; -$output = $repository->run('diff', ['--raw', '--patch', '0.1', 'master']); +$output = $repository->run('diff', ['--raw', '--patch', '--submodule=short', '0.1', 'master']); $diff = Diff::parse($output, $repository); ``` diff --git a/doc/repository.md b/doc/repository.md index 69579ae6..fef76988 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -451,8 +451,9 @@ creates, `CliDriver` sets it back to git's defaults: - `branch.sort`, `log.follow` and `log.showSignature`; - for diffs, `diff.algorithm`, `diff.context`, `diff.external`, `diff.indentHeuristic`, `diff.interHunkContext`, `diff.orderFile`, - `diff.suppressBlankEmpty`, the prefixes (`diff.noprefix`, - `diff.mnemonicPrefix`...), and the textconv filters, of blames too. + `diff.submodule`, `diff.suppressBlankEmpty`, the prefixes + (`diff.noprefix`, `diff.mnemonicPrefix`...), and the textconv filters, of + blames too. Like libgit2, `CliDriver` reads the objects as they are, ignoring their replacements (`git replace`, `refs/replace/`): whoever can push such a diff --git a/src/Gitonomy/Git/Diff/Diff.php b/src/Gitonomy/Git/Diff/Diff.php index 43e13920..bb26cc61 100644 --- a/src/Gitonomy/Git/Diff/Diff.php +++ b/src/Gitonomy/Git/Diff/Diff.php @@ -15,6 +15,7 @@ use Gitonomy\Git\Driver\Data\DiffData; use Gitonomy\Git\Driver\Data\DiffFileData; use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Parser\DiffParser; use Gitonomy\Git\Repository; @@ -42,6 +43,7 @@ public function __construct( * @param Repository|null $repository The repository to read the blobs of the files from * * @throws InvalidArgumentException The diff has no raw information + * @throws RuntimeException The diff holds what the parser doesn't know, like the log of a submodule */ public static function parse(string $rawDiff, ?Repository $repository = null): self { diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index 85a4f31f..acb40c7b 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -63,11 +63,12 @@ * -z: the raw information gives the names as they are, without quoting. * --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". * The others set back to git's defaults the configuration "git diff" reads, - * unlike diff-tree: diff.algorithm, diff.context, diff.external, diff.noprefix... + * unlike diff-tree: diff.algorithm, diff.context, diff.external, diff.noprefix, + * diff.submodule... */ private const array DIFF_ARGS = [ '-r', '-p', '--raw', '-z', '-M', '--full-index', '--no-abbrev', - '--diff-algorithm=myers', '--dst-prefix=b/', '--inter-hunk-context=0', '--no-color', '--no-ext-diff', '--no-textconv', '-O/dev/null', '--src-prefix=a/', '-U3', + '--diff-algorithm=myers', '--dst-prefix=b/', '--inter-hunk-context=0', '--no-color', '--no-ext-diff', '--no-textconv', '-O/dev/null', '--src-prefix=a/', '--submodule=short', '-U3', ]; /** diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index c5540658..7c5a343d 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -71,6 +71,16 @@ protected function doParse(): void 'C' === $entry['status'], ); } + + $this->consumeUnmergedPaths(); + + // What the parser doesn't know would be lost: the patches of the next + // files, after the log of a submodule of "git diff --submodule=log"... + // The message has the start of its line only: the lines of a diff come + // from the files of the repository. + if (!$this->isFinished()) { + throw new RuntimeException(\sprintf('Unable to parse the diff from "%s".', explode("\n", substr($this->content, $this->cursor, 100), 2)[0])); + } } /** @@ -147,10 +157,7 @@ private function consumeName(bool $nul): string private function consumePatch(?string $oldName, ?string $newName, ?string $oldMode, ?string $newMode, ?string $oldHash, ?string $newHash, bool $isCopy): DiffFileData { - while ($this->expects('* Unmerged path ')) { - $this->consumeTo("\n"); - $this->consumeNewLine(); - } + $this->consumeUnmergedPaths(); // Without --patch. if (!$this->expects('diff --git ')) { @@ -195,6 +202,17 @@ private function consumePatch(?string $oldName, ?string $newName, ?string $oldMo return new DiffFileData($oldName, $newName, $oldMode, $newMode, $oldHash, $newHash, $isBinary, $isCopy, $changes); } + /** + * During a conflict, an unmerged path has this line instead of its patch. + */ + private function consumeUnmergedPaths(): void + { + while ($this->expects('* Unmerged path ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + } + private function consumeHunk(): FileChange { if (!preg_match('/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[^\n]*\n/A', $this->content, $vars, \PREG_UNMATCHED_AS_NULL, $this->cursor)) { diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index f22434bc..c207f661 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -278,6 +278,41 @@ public function testParseRequiresTheRawInformation(): void Diff::parse("diff --git a/a.out b/a.out\nold mode 100755\nnew mode 100644\n"); } + /** + * What the parser doesn't know would be lost, with the files after it. + * The message has the start of its line only, which may come from a file. + */ + public function testParseRefusesWhatItDoesNotKnow(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to parse the diff from "Submodule sub 1111111..2222222:".'); + + Diff::parse(":160000 160000 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 M\tsub\n\nSubmodule sub 1111111..2222222:\n > Second\n"); + } + + /** + * "git diff --staged" during a conflict, whose unmerged path comes last. + */ + public function testParseLeavesOutTheLastUnmergedPath(): void + { + $diff = Diff::parse(<<<'DIFF' + :000000 100644 0000000000000000000000000000000000000000 e45c9c2666d44e0327c1f9c239a74c508336053e A other + :100644 000000 ba2906d0666cf726c7eaadd2cd3db615dedfdf3a 0000000000000000000000000000000000000000 U zconflict + + diff --git a/other b/other + new file mode 100644 + index 0000000000000000000000000000000000000000..e45c9c2666d44e0327c1f9c239a74c508336053e + --- /dev/null + +++ b/other + @@ -0,0 +1 @@ + +other + * Unmerged path zconflict + + DIFF); + + $this->assertSame([['other', [[0, 0, 1, 1, [['Addition', 'other']]]]]], self::changes($diff)); + } + public function testParseAnEmptyDiff(): void { $this->assertSame([], Diff::parse('')->getFiles()); @@ -543,6 +578,34 @@ public function testDirtySubmodule(): void $this->assertSame('sub', $firstFile->getNewName()); } + /** + * With diff.submodule=log, which the configuration of the tests sets, "git + * diff" writes the log of a submodule instead of its patch. + */ + public function testTheFilesAfterASubmoduleKeepTheirPatch(): void + { + $repository = self::createWorkingCopy(); + $submodule = Repository::init($repository->getWorkingDir().'/sub', self::getCliDriver()); + self::git($submodule, 'config', ['user.name', 'Unit Test']); + self::git($submodule, 'config', ['user.email', 'unit_test@unit-test.com']); + self::git($submodule, 'commit', ['-q', '--allow-empty', '-m', 'First']); + $first = trim(self::git($submodule, 'rev-parse', ['HEAD'])); + self::git($repository, 'update-index', ['--add', '--cacheinfo', '160000', $first, 'sub']); + self::commitFile($repository, 'z.txt', "a\n"); + + self::git($submodule, 'commit', ['-q', '--allow-empty', '-m', 'Second']); + $second = trim(self::git($submodule, 'rev-parse', ['HEAD'])); + file_put_contents($repository->getWorkingDir().'/z.txt', "b\n"); + + $diff = $repository->getWorkingCopy()->getDiffPending(); + + $this->assertSame([ + ['sub', [[1, 1, 1, 1, [['Deletion', 'Subproject commit '.$first], ['Addition', 'Subproject commit '.$second]]]]], + ['z.txt', [[1, 1, 1, 1, [['Deletion', 'a'], ['Addition', 'b']]]]], + ], self::changes($diff)); + $this->assertSame(sha1("blob 2\0b\n"), $diff->getFiles()[1]->getNewIndex()); + } + /** * Unlike diff-tree, "git diff" reads diff.algorithm, diff.context, * diff.orderFile... which the configuration of the tests changes (see From 8bfb31df3fd46588e9f39dc44d6cc62857d481bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 104/150] Keep the UTC offset of dates The dates of commits and tags were in UTC: the offset of the author, committer or tagger was lost, as in 1.x. Both drivers return them at this offset, like git log shows them. The instant doesn't change, nor do comparisons and getTimestamp(), but format() returns the local time of the author. An offset of more than 14 hours or 59 minutes, which no timezone has, in a corrupt object, gives UTC, like libgit2 does. --- CHANGELOG.md | 3 + UPGRADE-2.0.md | 17 +++++ doc/commit.md | 6 ++ doc/references.md | 5 +- src/Gitonomy/Git/Commit.php | 6 +- .../Git/Driver/Cli/Parser/LogParser.php | 4 +- src/Gitonomy/Git/Driver/Data/CommitData.php | 4 +- src/Gitonomy/Git/Driver/Data/TagData.php | 3 +- .../Driver/Ffi/LibGit2RepositoryDriver.php | 12 +++- src/Gitonomy/Git/Parser/ParserBase.php | 18 ++++-- src/Gitonomy/Git/Reference/Tag.php | 2 +- tests/Gitonomy/Git/Tests/CommitTest.php | 64 ++++++++++++++++++- tests/Gitonomy/Git/Tests/ReferenceTest.php | 21 ++++++ 13 files changed, 148 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 324113d5..721a42de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ deprecated until 3.0. lines and logs are typed objects; the subject of a message is its first paragraph. See [UPGRADE-2.0.md](UPGRADE-2.0.md) for every change. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Return the dates of commits and tags at the UTC offset of their author, committer or tagger, + like `git log` shows them, instead of in UTC: the instant doesn't change, but `format()` + returns their local time. - Require PHP 8.4 or later, Symfony 6.4/7.4/8.1+: most classes are `final` (some `readonly`), and every method has native types. ([#238](https://github.com/gitonomy/gitlib/pull/238)) - Remove `Gitonomy\Git\Util\StringHelper`, replaced by `symfony/string`, and its configurable diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index b7660f51..6ad136ed 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -458,6 +458,23 @@ Dates `Reference\Tag::getTaggerDate()` return a `\DateTimeImmutable` instead of a `\DateTime`. Code that modifies the returned date must use the returned value of `modify()`, `setTimezone()`... + * These dates are at the UTC offset of their author, committer or tagger, + like `git log` shows them (`2016-08-17T12:00:00+02:00`), instead of in + UTC. The instant doesn't change: comparisons and `getTimestamp()` return + the same results, but `format()` returns the local time of the author, + committer or tagger. To keep UTC, convert the date: + + Before: + + ```php + $date = $commit->getAuthorDate()->format('Y-m-d H:i'); + ``` + + After: + + ```php + $date = $commit->getAuthorDate()->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i'); + ``` Extending gitlib ---------------- diff --git a/doc/commit.md b/doc/commit.md index f75b119f..5b06c423 100644 --- a/doc/commit.md +++ b/doc/commit.md @@ -97,6 +97,12 @@ $commit->getCommitterEmail(); $commit->getCommitterDate(); // A DateTimeImmutable ``` +Like `git log`, a date is at the UTC offset of its author or committer: +`2012-12-31T15:21:03+01:00` for a commit made in Paris in winter. Use +`setTimezone()` to show it in another timezone. Comparing dates doesn't +depend on their offsets. An offset of more than 14 hours or 59 minutes, +which no timezone has, gives UTC. + Message ------- diff --git a/doc/references.md b/doc/references.md index a9c0ed1a..d6e3dce4 100644 --- a/doc/references.md +++ b/doc/references.md @@ -126,8 +126,9 @@ if ($tag->isAnnotated()) { ``` - `getTaggerName()`, `getTaggerEmail()` and `getTaggerDate()` (a - `DateTimeImmutable`) return `null` for a lightweight tag, and for an - annotated tag without tagger; + `DateTimeImmutable`, at the UTC offset of the tagger, like the dates of + a [commit](commit.md#author-and-committer)) return `null` for a + lightweight tag, and for an annotated tag without tagger; - `getMessage()` returns the message, without the signature, `null` for a lightweight tag; - `getSubjectMessage()` and `getBodyMessage()` split the message like diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 4a1af38f..f56668ab 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -226,7 +226,8 @@ public function getAuthorEmail(): string } /** - * Returns the authoring date. + * Returns the authoring date, at the UTC offset of the author, like git + * log shows it. */ public function getAuthorDate(): \DateTimeImmutable { @@ -250,7 +251,8 @@ public function getCommitterEmail(): string } /** - * Returns the committing date. + * Returns the committing date, at the UTC offset of the committer, like + * git log shows it. */ public function getCommitterDate(): \DateTimeImmutable { diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php index ac729fd0..1fe8decd 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php @@ -84,10 +84,10 @@ protected function doParse(): void */ private static function parseNameEmailDate(string $value): array { - if (!preg_match('/^(.*) <(.*)> (\d+) [+-]\d{4}$/', $value, $vars)) { + if (!preg_match('/^(.*) <(.*)> (\d+) ([+-]\d{4})$/', $value, $vars)) { throw new RuntimeException(\sprintf('Unable to parse the name, email and date of "%s".', $value)); } - return [$vars[1], $vars[2], self::timestamp($vars[3])]; + return [$vars[1], $vars[2], self::signatureDate($vars[3], $vars[4])]; } } diff --git a/src/Gitonomy/Git/Driver/Data/CommitData.php b/src/Gitonomy/Git/Driver/Data/CommitData.php index f71aef3f..e01dca73 100644 --- a/src/Gitonomy/Git/Driver/Data/CommitData.php +++ b/src/Gitonomy/Git/Driver/Data/CommitData.php @@ -21,7 +21,9 @@ final readonly class CommitData { /** - * @param string[] $parentHashes + * @param string[] $parentHashes + * @param \DateTimeImmutable $authorDate At the UTC offset of the author, like git log shows it + * @param \DateTimeImmutable $committerDate At the UTC offset of the committer, like git log shows it */ public function __construct( public string $hash, diff --git a/src/Gitonomy/Git/Driver/Data/TagData.php b/src/Gitonomy/Git/Driver/Data/TagData.php index 03b5983f..87b8044c 100644 --- a/src/Gitonomy/Git/Driver/Data/TagData.php +++ b/src/Gitonomy/Git/Driver/Data/TagData.php @@ -25,7 +25,8 @@ private const string SIGNATURE = '-----BEGIN (PGP SIGNATURE|PGP MESSAGE|SIGNED MESSAGE|SSH SIGNATURE)-----'; /** - * @param string|null $taggerName Null for a tag without a tagger, like the oldest tags of git itself + * @param string|null $taggerName Null for a tag without a tagger, like the oldest tags of git itself + * @param \DateTimeImmutable|null $taggerDate At the UTC offset of the tagger, like git log shows it */ public function __construct( public string $objectHash, diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index 67f1cee3..93335362 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -1706,10 +1706,18 @@ private function toUtf8(string $text, ?string $encoding): string } } + /** + * @return \DateTimeImmutable The date at the UTC offset of the signature, like CliDriver's parsers + */ private function signatureDate(CData $signature): \DateTimeImmutable { - // Same timezone as CliDriver's parsers. - return new \DateTimeImmutable('@'.$signature->when->time)->setTimezone(new \DateTimeZone('UTC')); + // In minutes. libgit2 gives 0 for an offset of more than 14 hours or + // 59 minutes, which no timezone has, in a corrupt object. + $offset = $signature->when->offset; + // @phpstan-ignore argument.type (libgit2.h declares the offset an int, which PHPStan can't see.) + $timezone = new \DateTimeZone(\sprintf('%s%02d%02d', $offset < 0 ? '-' : '+', intdiv(abs($offset), 60), abs($offset) % 60)); + + return new \DateTimeImmutable('@'.$signature->when->time)->setTimezone($timezone); } private function objectType(int $type): ObjectType diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index fd61640c..bfb7f07c 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -131,24 +131,34 @@ protected function consumeNewLine(): string */ protected function consumeNameEmailDate(): array { - if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+) [+-]\d{4})/A', $this->content, $vars, 0, $this->cursor)) { + if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+) ([+-]\d{4}))/A', $this->content, $vars, 0, $this->cursor)) { throw new RuntimeException('Unable to parse name, email and date.'); } $this->cursor += \strlen($vars[1]); - return [$vars[2], $vars[3], self::timestamp($vars[4])]; + return [$vars[2], $vars[3], self::signatureDate($vars[4], $vars[5])]; } /** * @param string $timestamp Seconds since the epoch, as git writes them + * @param string $offset The UTC offset of the author, committer or tagger, as git writes it: "+0200", "-0330"... + * + * @return \DateTimeImmutable The date at $offset, like git log shows it * * @throws RuntimeException The date is out of range, in a corrupt object */ - protected static function timestamp(string $timestamp): \DateTimeImmutable + protected static function signatureDate(string $timestamp, string $offset): \DateTimeImmutable { + // Like libgit2, which LibGit2Driver reads dates with: an offset of + // more than 14 hours or 59 minutes, which no timezone has, in a + // corrupt object, gives UTC. + if ((int) substr($offset, 1, 2) > 14 || (int) substr($offset, 3) > 59) { + $offset = '+0000'; + } + try { - return new \DateTimeImmutable('@'.$timestamp)->setTimezone(new \DateTimeZone('UTC')); + return new \DateTimeImmutable('@'.$timestamp)->setTimezone(new \DateTimeZone($offset)); } catch (\DateMalformedStringException $e) { throw new RuntimeException(\sprintf('The date "%s" is out of range.', $timestamp), 0, $e); } diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index 17066c33..ee6a4a80 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -78,7 +78,7 @@ public function getTaggerEmail(): ?string } /** - * @return \DateTimeImmutable|null The tagging date, null for a lightweight tag or a tag without tagger + * @return \DateTimeImmutable|null The tagging date, at the UTC offset of the tagger, like git log shows it; null for a lightweight tag or a tag without tagger */ public function getTaggerDate(): ?\DateTimeImmutable { diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 24d1491c..48eb8dbe 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -47,6 +47,66 @@ public function testADateOutOfRangeThrows(): void $repository->getCommit($hash)->getAuthorDate(); } + /** + * Like git log, at the UTC offsets of the author and of the committer. + */ + public function testTheDatesAreAtTheOffsetsOfTheAuthorAndOfTheCommitter(): void + { + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + $git = new Repository($repository->getPath(), driver: self::getCliDriver([ + 'GIT_AUTHOR_DATE' => '1700000000 +0530', + 'GIT_AUTHOR_EMAIL' => 'author@example.com', + 'GIT_AUTHOR_NAME' => 'Author', + 'GIT_COMMITTER_DATE' => '1700000001 -0330', + 'GIT_COMMITTER_EMAIL' => 'committer@example.com', + 'GIT_COMMITTER_NAME' => 'Committer', + ])); + $hash = trim($git->run('commit-tree', [$tree, '-m', 'Dates'])); + + $commit = $repository->getCommit($hash); + + $this->assertSame('2023-11-15T03:43:20+05:30', $commit->getAuthorDate()->format(\DATE_ATOM)); + $this->assertSame(1700000000, $commit->getAuthorDate()->getTimestamp()); + $this->assertSame('2023-11-14T18:43:21-03:30', $commit->getCommitterDate()->format(\DATE_ATOM)); + $this->assertSame(1700000001, $commit->getCommitterDate()->getTimestamp()); + } + + /** + * Like git log, which shows "-0000" as "+0000". An offset of more than 14 + * hours or 59 minutes, which no timezone has, gives UTC, like libgit2. + */ + #[DataProvider('provideOffsets')] + public function testADateIsAtTheOffsetOfItsSignature(string $offset, string $date): void + { + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + $hash = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1700000000 {$offset}\ncommitter A 1700000000 {$offset}\n\nOffset\n"); + + $commit = $repository->getCommit($hash); + + $this->assertSame($date, $commit->getAuthorDate()->format(\DATE_ATOM)); + $this->assertSame($date, $commit->getCommitterDate()->format(\DATE_ATOM)); + $this->assertSame(1700000000, $commit->getAuthorDate()->getTimestamp()); + } + + /** + * @return iterable + */ + public static function provideOffsets(): iterable + { + yield 'half an hour' => ['+0530', '2023-11-15T03:43:20+05:30']; + yield 'three quarters of an hour' => ['+0545', '2023-11-15T03:58:20+05:45']; + yield 'negative' => ['-0700', '2023-11-14T15:13:20-07:00']; + yield 'negative, with minutes' => ['-0330', '2023-11-14T18:43:20-03:30']; + yield 'negative, under an hour' => ['-0030', '2023-11-14T21:43:20-00:30']; + yield 'unknown' => ['-0000', '2023-11-14T22:13:20+00:00']; + yield 'easternmost' => ['+1400', '2023-11-15T12:13:20+14:00']; + yield 'westernmost' => ['-1200', '2023-11-14T10:13:20-12:00']; + yield 'beyond 14 hours' => ['+1500', '2023-11-14T22:13:20+00:00']; + yield 'beyond 59 minutes' => ['+0560', '2023-11-14T22:13:20+00:00']; + } + #[DataProvider('provideBare')] public function testGetHash(bool $bare): void { @@ -159,7 +219,7 @@ public function testGetAuthorDate(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertSame('2012-12-31 14:21:03', $commit->getAuthorDate()->format('Y-m-d H:i:s'), 'Author date'); + $this->assertSame('2012-12-31T15:21:03+01:00', $commit->getAuthorDate()->format(\DATE_ATOM), 'Author date'); } #[DataProvider('provideBare')] @@ -186,7 +246,7 @@ public function testGetCommitterDate(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertSame('2012-12-31 14:21:03', $commit->getCommitterDate()->format('Y-m-d H:i:s'), 'Committer date'); + $this->assertSame('2012-12-31T15:21:03+01:00', $commit->getCommitterDate()->format(\DATE_ATOM), 'Committer date'); } #[DataProvider('provideBare')] diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index b941c93f..2085eb6a 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -87,6 +87,7 @@ public function testAnnotatedTag(bool $bare): void $this->assertSame('Graham Campbell', $tag->getTaggerName(), 'Tagger name is correct'); $this->assertSame('graham@alt-three.com', $tag->getTaggerEmail(), 'Tagger email is correct'); $this->assertSame(1471428000, $tag->getTaggerDate()?->getTimestamp(), 'Tag date is correct'); + $this->assertSame('2016-08-17T12:00:00+02:00', $tag->getTaggerDate()->format(\DATE_ATOM), 'Tag date is at the offset of the tagger'); // Without an empty line after its first one, the message is one paragraph: its subject, like "git log --format=%s". $this->assertSame('heading body body', $tag->getSubjectMessage(), 'Message heading is correct'); @@ -99,6 +100,26 @@ public function testAnnotatedTag(bool $bare): void $this->assertContains($tag, $tag->getCommit()->resolveReferences()); } + /** + * Like git log, at the UTC offset of the tagger, not of the author or of + * the committer of the commit. + */ + public function testTheTaggerDateIsAtTheOffsetOfTheTagger(): void + { + $repository = self::createFoobarRepository(); + $git = new Repository($repository->getPath(), driver: self::getCliDriver([ + 'GIT_COMMITTER_DATE' => '1700000002 -0700', + 'GIT_COMMITTER_EMAIL' => 'tagger@example.com', + 'GIT_COMMITTER_NAME' => 'Tagger', + ])); + $git->run('tag', ['-a', '-m', 'Dates', 'dates', self::LONGFILE_COMMIT]); + + $date = new Repository($repository->getPath(), driver: self::getDriver())->getReferences()->getTag('dates')->getTaggerDate(); + + $this->assertSame('2023-11-14T15:13:22-07:00', $date?->format(\DATE_ATOM)); + $this->assertSame(1700000002, $date->getTimestamp()); + } + #[DataProvider('provideBare')] public function testAnnotatedTagBodyMessageWithBlankLineSeparator(bool $bare): void { From f93f723d49438b2b8d8c6fe6c3f19449d0ce8710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 105/150] Align LibGit2Driver with git LibGit2Driver differed from git, and from CliDriver: - paths weren't normalized: "./README.md", "dir/../file" or "dir//file" matched nothing in a log or a blame, and a path starting with ":", git's magic, matched a file of this name. It now throws an UnsupportedOperationException; - a reference other than a tag pointing to an annotated tag, like one a fetch puts under refs/remotes/, wasn't peeled to its commit, nor listed among the branches of the commit; - checkout('HEAD') detached HEAD, and @{-1}, the branch checked out before, wasn't understood; - the message of a merge named a full reference name, "branch~1" or @{-1} unlike git, without the message of an annotated tag, and a merge into a branch yet to be born threw instead of fast-forwarding it; - a file added with "git add -N" was a staged empty file, unlike git since 2.19, and a file the working tree changed back after staging it was listed in the diff of the working tree; - a reftable repository or a bundle threw a DriverException, instead of the UnsupportedOperationException of an operation the driver can't perform; - /etc/gitconfig was read whatever GIT_CONFIG_NOSYSTEM. The checks for a blame hunk and a patch that libgit2 1.9 never leaves null go: they were dead code. --- doc/log.md | 4 + doc/repository.md | 20 +- doc/workingcopy.md | 12 +- src/Gitonomy/Git/Driver/Ffi/LibGit2.php | 12 + src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 16 + .../Driver/Ffi/LibGit2RepositoryDriver.php | 377 ++++++++++++++---- src/Gitonomy/Git/Driver/Ffi/libgit2.h | 36 ++ .../Git/Driver/RepositoryDriverInterface.php | 6 +- src/Gitonomy/Git/Repository.php | 5 +- src/Gitonomy/Git/WorkingCopy.php | 6 +- tests/Gitonomy/Git/Tests/BlameTest.php | 11 + .../Gitonomy/Git/Tests/LibGit2DriverTest.php | 75 ++++ tests/Gitonomy/Git/Tests/LogTest.php | 47 +++ tests/Gitonomy/Git/Tests/ReferenceTest.php | 12 + tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 163 ++++++++ 15 files changed, 717 insertions(+), 85 deletions(-) diff --git a/doc/log.md b/doc/log.md index 29bdea25..ceb9df81 100644 --- a/doc/log.md +++ b/doc/log.md @@ -79,6 +79,10 @@ $log = $repository->getLog('master', '*.php'); $log = $repository->getLog('master', 'src'); ``` +With `CliDriver`, they support the magic of git's pathspecs too, like +`:(exclude)src`, which git knows since 1.9. `LibGit2Driver` doesn't: a path +starting with `:` throws an `UnsupportedOperationException`. + Iterating --------- diff --git a/doc/repository.md b/doc/repository.md index fef76988..a0411b07 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -216,8 +216,9 @@ or empty data instead. Every exception of gitlib implements `getOutput()` return the output of git, or a `ProcessTimedOutException`, when git runs longer than [the timeout](#configuring-clidriver). - `UnsupportedOperationException`, a `RuntimeException`: the driver can't - perform the operation at all, where another driver may: `run()` with - `LibGit2Driver`, libgit2 that isn't installed... + perform the operation at all, where another driver may: `run()`, cloning + a bundle or opening a reftable repository with `LibGit2Driver`, libgit2 + that isn't installed... - `InvalidArgumentException`: an argument gitlib doesn't support, like an option of `git log` in the revisions of a log, a string holding a NUL byte, which git doesn't accept, a revision, a reference, a hash or a URL @@ -300,11 +301,18 @@ Differences with `CliDriver`: - SSH and git's credential helpers aren't supported: clone a private repository through HTTPS, with the credentials in the URL (`https://user:token@example.com/repo.git`). -- Bundles (`.bundle` files) can't be cloned. -- Repositories storing their references in the reftable format can't be - opened. +- Bundles (`.bundle` files) can't be cloned, and repositories storing + their references in the reftable format can't be opened: both throw an + `UnsupportedOperationException`. +- The paths of a log don't support the magic of git's pathspecs + (`:(exclude)src`, `:/src`...): a path starting with `:` throws an + `UnsupportedOperationException`. - Neither hooks nor external filters (Git LFS...) are run: a checkout or a merge writes the files without them. +- Like git, `LibGit2Driver` doesn't read `/etc/gitconfig` when the + `GIT_CONFIG_NOSYSTEM` environment variable is true, but it doesn't read + `/etc/gitattributes` either, which git only skips with + `GIT_ATTR_NOSYSTEM`. - `Diff::getRawDiff()` returns an empty string. - Short hashes (`Commit::getShortHash()`) have 7 characters, more when 7 are ambiguous: git adapts their length to the size of the repository. @@ -312,7 +320,7 @@ Differences with `CliDriver`: merges: a conflict throws instead of leaving conflict markers in the working copy. A merge commit is never signed, whatever `commit.gpgSign`, and its message never lists the merged commits, - whatever `merge.log`. + whatever `merge.log`, nor has the output of gpg checking a signed tag. - Blame is much slower on a long history: see ["Performance"](#performance). - Rename detection in diffs may pair files differently. diff --git a/doc/workingcopy.md b/doc/workingcopy.md index d4670154..52431f15 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -19,8 +19,9 @@ Checkout a revision ------------------- You can checkout any revision using *checkout* method. Like `git checkout`, -it attaches `HEAD` to a local branch, and detaches it on anything else: a -tag, a commit, a remote branch... +it attaches `HEAD` to a local branch, or to `@{-1}`, the branch checked out +before (`@{-2}` the one before...), leaves it as it is for `HEAD`, and +detaches it on anything else: a tag, a commit, a remote branch... ```php // git checkout master @@ -28,6 +29,9 @@ $wc->checkout('master'); // git checkout v1.0 (HEAD is detached) $wc->checkout('v1.0'); + +// git checkout @{-1}: the branch checked out before +$wc->checkout('@{-1}'); ``` You can also pass a *Revision*: a *Commit*, a *Reference*... A local *Branch* @@ -153,6 +157,10 @@ calling method `getDiffPending()`: $diff = $wc->getDiffPending(); ``` +Like with git 2.19 and later, a file added with `git add -N` (intent to add) +is no staged change: `getDiffPending()` lists it as created. With +`CliDriver`, older versions of git list it as an empty staged file. + During a merge conflict, `getDiffStaged()` and `getDiffPending()` leave the unmerged files out, and `getDiff()` compares them, conflict markers included, to the revision, like `git diff HEAD`. diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php index 6615a06a..05feed0a 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php @@ -28,7 +28,9 @@ final class LibGit2 { public const int GIT_ENOTFOUND = -3; + private const int GIT_CONFIG_LEVEL_SYSTEM = 2; private const int GIT_FEATURE_SHA256 = 1 << 11; + private const int GIT_OPT_SET_SEARCH_PATH = 5; /** * The only libgit2 version libgit2.h is written for: libgit2 changes its @@ -219,6 +221,16 @@ private static function initialize(\FFI $ffi): void if ($ffi->git_libgit2_init() < 0) { throw new UnsupportedOperationException(\sprintf('Unable to initialize libgit2: %s', self::lastErrorMessage($ffi))); } + + // Like git, no system configuration (/etc/gitconfig) when + // GIT_CONFIG_NOSYSTEM is true: libgit2 only reads this variable when + // it reads GIT_DIR, GIT_INDEX_FILE... too. An empty search path also + // leaves the system gitattributes out. FFI passes the level, a + // variadic argument, as a 64-bit integer, which reads as the int + // libgit2 expects. + if (filter_var(getenv('GIT_CONFIG_NOSYSTEM'), \FILTER_VALIDATE_BOOL) && $ffi->git_libgit2_opts(self::GIT_OPT_SET_SEARCH_PATH, self::GIT_CONFIG_LEVEL_SYSTEM, '') < 0) { + throw new UnsupportedOperationException(\sprintf('Unable to ignore the system configuration: %s', self::lastErrorMessage($ffi))); + } } private static function locate(): string diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index 44cc003b..dbb3e541 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -105,6 +105,7 @@ public function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?str NulByte::assertNone($url, $path, $branch ?? ''); // libgit2 runs no command for a URL, unlike git: refused like by CliDriver. RemoteUrl::assertSafe($url); + self::assertNotBundle($url); // git_clone() only checks a branch out, where git also checks a tag out, HEAD detached. $tag = null !== $branch && $this->isTagOnly($url, $branch) ? $branch : null; @@ -149,6 +150,7 @@ public function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, { NulByte::assertNone($url, $path, $branch ?? ''); RemoteUrl::assertSafe($url); + self::assertNotBundle($url); // Like git, only the branches (and the tags). $this->fetchInto($path, $url, '+refs/heads/*:refs/heads/*', $branch, ['remote.origin.url' => $url]); @@ -158,6 +160,7 @@ public function mirrorFrom(#[\SensitiveParameter] string $url, string $path): vo { NulByte::assertNone($url, $path); RemoteUrl::assertSafe($url); + self::assertNotBundle($url); $this->fetchInto($path, $url, '+refs/*:refs/*', null, [ 'remote.origin.url' => $url, @@ -183,6 +186,19 @@ public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, s return \in_array('refs/heads/'.$branch, $this->listRemoteReferences($url) ?? [], true); } + /** + * git clones a bundle file like a repository, libgit2 doesn't. + * + * @throws UnsupportedOperationException $url is a bundle + */ + private static function assertNotBundle(string $url): void + { + // The signatures of the formats of bundles. + if (is_file($url) && is_readable($url) && \in_array(file_get_contents($url, length: 16), ["# v2 git bundle\n", "# v3 git bundle\n"], true)) { + throw new UnsupportedOperationException(\sprintf('"%s" is a bundle, which LibGit2Driver doesn\'t support; use CliDriver.', $url)); + } + } + private function initRepository(string $path, bool $bare): CData { $repository = $this->ffi->new('git_repository*'); diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index 93335362..9f33f60d 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -27,6 +27,7 @@ use Gitonomy\Git\Driver\RepositoryDriverInterface; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\FastForward; use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\NulByte; @@ -63,21 +64,34 @@ private const int GIT_DIFF_INDENT_HEURISTIC = 1 << 18; private const int GIT_DIFF_FIND_RENAMES = 1 << 0; private const int GIT_DIRECTION_FETCH = 0; + private const int GIT_INDEX_ENTRY_INTENT_TO_ADD = 1 << 13; private const int GIT_STATUS_OPT_INCLUDE_UNTRACKED = 1 << 0; private const int GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS = 1 << 4; private const int GIT_STATUS_WT_NEW = 1 << 7; + /** + * The hash of the empty file, which `git add -N` stages. + */ + private const string EMPTY_BLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'; + private CData $repo; /** * @param string $gitDir The git directory * @param string|null $workingDir The working tree, null for a bare repository + * + * @throws UnsupportedOperationException The repository stores its references in the reftable format */ public function __construct( private \FFI $ffi, private string $gitDir, private ?string $workingDir, ) { + // libgit2 1.9 refuses to open it, as any repository with an extension it doesn't know. + if (is_dir($gitDir.'/reftable')) { + throw new UnsupportedOperationException(\sprintf('The repository "%s" stores its references in the reftable format, which LibGit2Driver doesn\'t support; use CliDriver.', $gitDir)); + } + $out = $this->ffi->new('git_repository*'); LibGit2::check($this->ffi, $this->ffi->git_repository_open(\FFI::addr($out), $gitDir), \sprintf('Unable to open the repository "%s"', $gitDir)); @@ -335,8 +349,8 @@ public function getReferences(): array } $fullname = $this->ffi->git_reference_name($ref); - // Only a tag may point to an annotated tag. - $commitHash = str_starts_with($fullname, 'refs/tags/') ? $this->peeledHex($ref) : $hash; + // Like git, through an annotated tag, whatever the name of the reference: a fetch can put one under refs/remotes/. + $commitHash = $this->peeledHex($ref); $result[$fullname] = new ReferenceData($fullname, $commitHash, $commitHash === $hash ? null : $hash); } @@ -398,7 +412,11 @@ public function getIncludingBranches(string $hash): array $tips = []; foreach ($this->iterateBranches() as [$fullname, $ref]) { - $tips[$fullname] = $this->oidToHex($this->ffi->git_reference_target($ref)); + // Like git, through an annotated tag, and without the branches pointing to no commit. + $commit = $this->peeledCommitHex($ref); + if (null !== $commit) { + $tips[$fullname] = $commit; + } } // Like git: the local branches, then the remote ones, sorted by name. ksort($tips, \SORT_STRING); @@ -455,7 +473,8 @@ public function getWorkingTreeDiff(string $hash): DiffData $opts = $this->diffOptions(); LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_workdir_with_index(\FFI::addr($diff), $this->repo, $tree, \FFI::addr($opts)), 'Unable to compute working copy diff'); $unmerged = $this->unmergedPaths($diff); - $files = $this->buildDiff($diff)->files; + // libgit2 keeps a file the working tree changes back to the commit, after the index changed it, or `git add -N` added it. + $files = array_values(array_filter($this->buildDiff($diff)->files, static fn (DiffFileData $file): bool => [] !== $file->changes || $file->oldName !== $file->newName || $file->oldMode !== $file->newMode || $file->oldIndex !== $file->newIndex)); if ([] !== $unmerged) { // Like git diff , which compares the unmerged files, conflict markers included, to the commit. @@ -483,7 +502,30 @@ public function getDiffPending(): DiffData $opts = $this->diffOptions(); LibGit2::check($this->ffi, $this->ffi->git_diff_index_to_workdir(\FFI::addr($diff), $this->repo, null, \FFI::addr($opts)), 'Unable to compute working copy diff'); - return $this->buildDiff($diff); + // Like git since 2.19, which diffs the files `git add -N` added as + // absent from the index: created. Like any git, one deleted since is + // deleted, from the empty file the index has for it. + $intentToAdd = $this->intentToAddFiles(); + $files = []; + foreach ($this->buildDiff($diff)->files as $file) { + if (isset($intentToAdd[$file->name]) && null !== $file->newName) { + $files[] = new DiffFileData(null, $file->newName, null, $file->newMode, null, $file->newIndex, $file->isBinary, $file->isCopy, $file->changes); + } else { + $files[] = $file; + } + unset($intentToAdd[$file->name]); + } + + if ([] !== $intentToAdd) { + // Still empty, as `git add -N` staged them: libgit2 sees no change. + foreach ($intentToAdd as $path => $mode) { + $files[] = new DiffFileData(null, $path, null, $mode, null, self::EMPTY_BLOB, false, false, []); + } + // Sorted by name, like git. + usort($files, static fn (DiffFileData $a, DiffFileData $b): int => strcmp($a->name, $b->name)); + } + + return new DiffData($files, ''); } public function getDiffStaged(): DiffData @@ -500,7 +542,18 @@ public function getDiffStaged(): DiffData } } - return $this->buildDiff($diff); + // Like git since 2.19, which diffs the files `git add -N` added as absent from the index: not staged, or deleted when HEAD has them. + $intentToAdd = $this->intentToAddFiles(); + $files = []; + foreach ($this->buildDiff($diff)->files as $file) { + if (!isset($intentToAdd[$file->name])) { + $files[] = $file; + } elseif (null !== $file->oldName) { + $files[] = new DiffFileData($file->oldName, null, $file->oldMode, null, $file->oldIndex, null, $file->isBinary, $file->isCopy, $file->changes); + } + } + + return new DiffData($files, ''); } public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array @@ -533,15 +586,6 @@ public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int for ($i = $firstLine; $i <= min($lastLine ?? $count, $count); ++$i) { $hunk = $this->ffi->git_blame_hunk_byline($blame, $i); $line = $this->ffi->git_blame_line_byindex($blame, $i); - - if (null === $hunk || null === $line) { - // git_blame_linecount() can overcount by one for files not - // ending in a newline; stop rather than dereference NULL. - // (A NULL `const git_blame_line *` return is auto-converted - // to PHP null by FFI, unlike a NULL struct-field pointer.) - break; - } - $content = rtrim(\FFI::string($line->ptr, $line->len), "\n"); $originalLine = $hunk->orig_start_line_number + ($i - $hunk->final_start_line_number); @@ -585,9 +629,12 @@ public function checkout(string $revision): void LeadingDash::assertNone($revision); // `git checkout ` attaches HEAD when names a - // local branch, before a tag of the same name, and detaches it for - // anything else (a tag, a hash, a remote branch, a full reference name...). - $branch = $this->referenceExists('refs/heads/'.$revision) ? 'refs/heads/'.$revision : null; + // local branch, before a tag of the same name, or is @{-}, the nth + // branch checked out before. It leaves HEAD as it is for HEAD, and + // detaches it for anything else (a tag, a hash, a remote branch, a + // full reference name...). + $name = $this->expandPriorCheckout($revision); + $branch = $this->referenceExists('refs/heads/'.$name) ? 'refs/heads/'.$name : null; // Resolved before changing anything. $hash = $this->resolveCommit($branch ?? $revision); @@ -595,7 +642,7 @@ public function checkout(string $revision): void if (null !== $branch) { LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, $branch), 'Unable to update HEAD'); - } else { + } elseif ('HEAD' !== $revision) { $oid = $this->hexToOid($hash); LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($this->repo, \FFI::addr($oid)), 'Unable to detach HEAD'); } @@ -653,8 +700,20 @@ public function merge(string $revision, FastForward $fastForward): void LeadingDash::assertNone($revision); $branch = $this->getHeadReference(); - $headHash = $this->resolveCommit('HEAD'); $theirHash = $this->resolveCommit($revision); + + // Like git, which fast-forwards a branch yet to be born. + if (null !== $branch && !$this->referenceExists($branch)) { + if (FastForward::Never === $fastForward) { + throw new DriverException(\sprintf('Unable to merge "%s" without a fast-forward: the branch "%s" has no commit yet.', $revision, $branch)); + } + + $this->fastForward($branch, $theirHash); + + return; + } + + $headHash = $this->resolveCommit('HEAD'); $head = $this->hexToOid($headHash); $their = $this->hexToOid($theirHash); @@ -669,14 +728,7 @@ public function merge(string $revision, FastForward $fastForward): void } if (FastForward::Never !== $fastForward && $canFastForward) { - $target = $this->revparse($theirHash); - - try { - $this->checkoutObject($target, 'Unable to fast-forward'); - } finally { - $this->ffi->git_object_free($target); - } - $this->moveHead($branch, $theirHash); + $this->fastForward($branch, $theirHash); return; } @@ -781,17 +833,9 @@ private function referencedCommits(): array { $hashes = []; foreach ($this->iterateReferences() as $ref) { - $commit = $this->ffi->new('git_object*'); - $rc = $this->ffi->git_reference_peel(\FFI::addr($commit), $ref, $this->ffi->GIT_OBJECT_COMMIT); - if (\in_array($rc, [LibGit2::GIT_ENOTFOUND, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { - continue; - } - LibGit2::check($this->ffi, $rc, \sprintf('Unable to peel "%s"', $this->ffi->git_reference_name($ref))); - - try { - $hashes[] = $this->oidToHex($this->ffi->git_object_id($commit)); - } finally { - $this->ffi->git_object_free($commit); + $commit = $this->peeledCommitHex($ref); + if (null !== $commit) { + $hashes[] = $commit; } } @@ -800,6 +844,8 @@ private function referencedCommits(): array /** * @param string[] $paths + * + * @throws UnsupportedOperationException A path uses the magic of git's pathspecs */ private function pathFilter(array $paths): ?PathFilter { @@ -807,10 +853,14 @@ private function pathFilter(array $paths): ?PathFilter return null; } - // Like git, "." and "./x" are relative to the root, which "" names. - $paths = array_map(fn (string $path): string => trim((string) preg_replace('#^\.(/|$)#', '', trim($this->toRepoRelativePath($path), '/')), '/'), $paths); + foreach ($paths as $path) { + // git reads magic in a path starting with ":", like ":(exclude)src" or ":/src": libgit2 would match it as a name. + if (str_starts_with($path, ':')) { + throw new UnsupportedOperationException(\sprintf('The path "%s" uses the magic of git\'s pathspecs, which LibGit2Driver doesn\'t support; use CliDriver.', $path)); + } + } - return new PathFilter($this->ffi, $this->repo, array_values($paths), $this->diffOptions()); + return new PathFilter($this->ffi, $this->repo, array_values(array_map($this->toRepoRelativePath(...), $paths)), $this->diffOptions()); } /** @@ -910,23 +960,53 @@ private function simplifyCommit(string $hash, PathFilter $pathFilter, ?array $re } /** - * git also accepts the absolute paths of files in the working tree, where - * libgit2 only deals with paths relative to the repository. + * git normalizes paths, and also accepts the absolute paths of files in + * the working tree, where libgit2 only deals with normalized paths + * relative to the repository. * - * @throws DriverException $path is an absolute path outside the working tree + * @return string $path relative to the root of the repository, which "" names, without "." nor ".." components, nor extra slashes + * + * @throws DriverException $path is outside the repository */ private function toRepoRelativePath(string $path): string { - if (!str_starts_with($path, '/')) { - return $path; + $components = self::pathComponents($path); + + if (str_starts_with($path, '/')) { + $root = null === $this->workingDir ? null : self::pathComponents($this->workingDir); + if (null === $root || \array_slice($components, 0, \count($root)) !== $root) { + // Like git. + throw new DriverException(\sprintf('The path "%s" is outside the repository.', $path)); + } + + $components = \array_slice($components, \count($root)); } - if (null !== $this->workingDir && ($path === $this->workingDir || str_starts_with($path, $this->workingDir.'/'))) { - return substr($path, \strlen($this->workingDir) + 1); + return implode('/', $components); + } + + /** + * @return list The components of $path, once "." and ".." are resolved, like git does + * + * @throws DriverException ".." goes above the first component of $path + */ + private static function pathComponents(string $path): array + { + $components = []; + foreach (explode('/', $path) as $component) { + if ('..' === $component && [] === $components) { + // Like git. + throw new DriverException(\sprintf('The path "%s" is outside the repository.', $path)); + } + + if ('..' === $component) { + array_pop($components); + } elseif ('' !== $component && '.' !== $component) { + $components[] = $component; + } } - // Like git. - throw new DriverException(\sprintf('The path "%s" is outside the repository.', $path)); + return $components; } // -- diff ---------------------------------------------------------------- @@ -966,6 +1046,35 @@ private function unmergedPaths(CData $diff): array return $paths; } + /** + * @return array The mode of the files `git add -N` added to the index, by path + */ + private function intentToAddFiles(): array + { + $index = $this->ffi->new('git_index*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_index(\FFI::addr($index), $this->repo), 'Unable to open the index'); + + try { + LibGit2::check($this->ffi, $this->ffi->git_index_read($index, 0), 'Unable to read the index'); + // git writes an index of version 2, without the extended flags, unless an entry has one. + if ($this->ffi->git_index_version($index) < 3) { + return []; + } + + $files = []; + for ($i = 0, $count = $this->ffi->git_index_entrycount($index); $i < $count; ++$i) { + $entry = $this->ffi->git_index_get_byindex($index, $i); + if (0 !== ($entry->flags_extended & self::GIT_INDEX_ENTRY_INTENT_TO_ADD)) { + $files[$entry->path] = \sprintf('%06o', $entry->mode); + } + } + + return $files; + } finally { + $this->ffi->git_index_free($index); + } + } + /** * Restricts a diff to $paths, matched as they are. * @@ -1035,12 +1144,6 @@ private function patchToFile(CData $diff, int $index): DiffFileData $patch = $this->ffi->new('git_patch*'); LibGit2::check($this->ffi, $this->ffi->git_patch_from_diff(\FFI::addr($patch), $diff, $index), 'Unable to compute patch'); - // libgit2 documents no patch for a binary or unchanged file, though - // 1.9 creates one: computing it flagged the delta accordingly. - if (\FFI::isNull($patch)) { - return $this->deltaToFile($this->ffi->git_diff_get_delta($diff, $index), []); - } - try { $changes = []; $hunkCount = $this->ffi->git_patch_num_hunks($patch); @@ -1125,6 +1228,21 @@ private function checkoutObject(CData $treeish, string $context): void LibGit2::check($this->ffi, $this->ffi->git_checkout_tree($this->repo, $treeish, \FFI::addr($opts)), $context); } + /** + * Checks $hash out, and points the checked out branch, or a detached HEAD, to it. + */ + private function fastForward(?string $branch, string $hash): void + { + $target = $this->revparse($hash); + + try { + $this->checkoutObject($target, 'Unable to fast-forward'); + } finally { + $this->ffi->git_object_free($target); + } + $this->moveHead($branch, $hash); + } + /** * Points the checked out branch, or a detached HEAD, to $hash. */ @@ -1212,25 +1330,19 @@ private function createMergeCommit(CData $tree, string $oursHash, string $theirs } /** - * The message `git merge` writes (see git's fmt-merge-msg). + * The message `git merge` writes (see git's merge_name() and fmt-merge-msg). */ private function mergeMessage(string $revision, ?string $branch): string { - $message = null; - // Same lookup order as git's revision parsing. - foreach ([ - 'refs/tags/' => "Merge tag '%s'", - 'refs/heads/' => "Merge branch '%s'", - 'refs/remotes/' => "Merge remote-tracking branch '%s'", - ] as $prefix => $format) { - $name = str_starts_with($revision, $prefix) ? substr($revision, \strlen($prefix)) : $revision; - if ($this->referenceExists($prefix.$name)) { - $message = \sprintf($format, $name); + $object = $this->revparse($revision); - break; - } + try { + $tag = $this->ffi->git_object_type($object) === $this->ffi->GIT_OBJECT_TAG ? $this->oidToHex($this->ffi->git_object_id($object)) : null; + } finally { + $this->ffi->git_object_free($object); } - $message ??= \sprintf("Merge commit '%s'", $revision); + + $message = 'Merge '.$this->mergedName($this->expandPriorCheckout($revision), $tag); // git leaves out the destination when merging into main or master. if (null === $branch) { @@ -1239,11 +1351,75 @@ private function mergeMessage(string $revision, ?string $branch): string $message .= ' into '.substr($branch, \strlen('refs/heads/')); } - return $message."\n"; + // Then the message of the annotated tag, without its signature. + if (null !== $tag) { + $message .= "\n\n".$this->getTagData($tag)->message; + } + + return $this->prettifyMessage($message); + } + + /** + * @param string $name The revision merged, @{-} expanded + * @param string|null $tag The annotated tag $name names, if any + * + * @return string How git's merge_name() names $name: as given, after what it names + */ + private function mergedName(string $name, ?string $tag): string + { + $reference = $this->dwimReference($name) ?? ''; + foreach (['refs/heads/' => 'branch', 'refs/tags/' => 'tag', 'refs/remotes/' => 'remote-tracking branch'] as $prefix => $kind) { + if (str_starts_with($reference, $prefix)) { + return \sprintf("%s '%s'", $kind, $name); + } + } + + // ~ or ^: an early part of the branch, unless is 0. + if (1 === preg_match('/^(.+?)(\^+|~\d*)$/', $name, $match) && $this->referenceExists('refs/heads/'.$match[1])) { + return \sprintf("branch '%s'%s", $match[1], 1 === preg_match('/^~0+$/', $match[2]) ? '' : ' (early part)'); + } + + return null === $tag ? \sprintf("commit '%s'", $name) : \sprintf("tag '%s'", $name); + } + + /** + * Cleans a message up like git does without an editor: without + * whitespace at the end of its lines, nor blank lines at its start, at + * its end or in a row. + */ + private function prettifyMessage(string $message): string + { + $buffer = $this->ffi->new('git_buf'); + LibGit2::check($this->ffi, $this->ffi->git_message_prettify(\FFI::addr($buffer), $message, 0, '#'), 'Unable to clean the message up'); + + try { + return \FFI::string($buffer->ptr, $buffer->size); + } finally { + $this->ffi->git_buf_dispose(\FFI::addr($buffer)); + } } // -- references ---------------------------------------------------------- + /** + * @return string|null The full name of the reference $name names, like git's dwim_ref(), null when it names none + */ + private function dwimReference(string $name): ?string + { + $ref = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_dwim(\FFI::addr($ref), $this->repo, $name); + if (LibGit2::GIT_ENOTFOUND === $rc || self::GIT_EINVALIDSPEC === $rc) { + return null; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up the reference "%s"', $name)); + + try { + return $this->ffi->git_reference_name($ref); + } finally { + $this->ffi->git_reference_free($ref); + } + } + /** * Like git's dwim_ref(), which counts the references a name may be the * short name of, unlike git_reference_dwim(). @@ -1285,6 +1461,44 @@ private function dwimReferences(string $name): array return $references; } + /** + * Like git, replaces @{-} at the start of $revision with the nth + * branch checked out before, or commit when HEAD was detached. + */ + private function expandPriorCheckout(string $revision): string + { + if (1 !== preg_match('/^@\{-([1-9]\d*)\}/', $revision, $match)) { + return $revision; + } + + $prior = $this->priorCheckout((int) $match[1]); + + return null === $prior ? $revision : $prior.substr($revision, \strlen($match[0])); + } + + /** + * @return string|null The nth branch checked out before, or commit when HEAD was detached, read from the reflog of HEAD like git does; null after fewer checkouts + */ + private function priorCheckout(int $n): ?string + { + $reflog = $this->ffi->new('git_reflog*'); + LibGit2::check($this->ffi, $this->ffi->git_reflog_read(\FFI::addr($reflog), $this->repo, 'HEAD'), 'Unable to read the reflog of HEAD'); + + try { + // The newest entry first. + for ($i = 0, $count = $this->ffi->git_reflog_entrycount($reflog); $i < $count; ++$i) { + $message = $this->ffi->git_reflog_entry_message($this->ffi->git_reflog_entry_byindex($reflog, $i)); + if (null !== $message && 1 === preg_match('/^checkout: moving from (.+?) to /', $message, $match) && 0 === --$n) { + return $match[1]; + } + } + + return null; + } finally { + $this->ffi->git_reflog_free($reflog); + } + } + /** * Like git with branch.autoSetupMerge=true, its default, a branch created * from a remote branch tracks it. @@ -1497,6 +1711,25 @@ private function peeledHex(CData $ref): string } } + /** + * @return string|null The commit $ref points to, through annotated tags, or null when it points to another object or to nothing + */ + private function peeledCommitHex(CData $ref): ?string + { + $commit = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_reference_peel(\FFI::addr($commit), $ref, $this->ffi->GIT_OBJECT_COMMIT); + if (\in_array($rc, [LibGit2::GIT_ENOTFOUND, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { + return null; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to peel "%s"', $this->ffi->git_reference_name($ref))); + + try { + return $this->oidToHex($this->ffi->git_object_id($commit)); + } finally { + $this->ffi->git_object_free($commit); + } + } + private function isDescendantOf(CData $commit, CData $ancestor): bool { $rc = $this->ffi->git_graph_descendant_of($this->repo, \FFI::addr($commit), \FFI::addr($ancestor)); diff --git a/src/Gitonomy/Git/Driver/Ffi/libgit2.h b/src/Gitonomy/Git/Driver/Ffi/libgit2.h index 6b0eca74..029005f9 100644 --- a/src/Gitonomy/Git/Driver/Ffi/libgit2.h +++ b/src/Gitonomy/Git/Driver/Ffi/libgit2.h @@ -66,6 +66,8 @@ typedef struct git_blob git_blob; typedef struct git_tag git_tag; typedef struct git_reference git_reference; typedef struct git_reference_iterator git_reference_iterator; +typedef struct git_reflog git_reflog; +typedef struct git_reflog_entry git_reflog_entry; typedef struct git_branch_iterator git_branch_iterator; typedef struct git_revwalk git_revwalk; typedef struct git_index git_index; @@ -82,6 +84,7 @@ typedef struct git_config git_config; int git_libgit2_init(void); int git_libgit2_version(int *major, int *minor, int *rev); int git_libgit2_features(void); +int git_libgit2_opts(int option, ...); /* -- buffers / signatures / arrays -- */ @@ -90,6 +93,7 @@ int git_signature_default_from_env(git_signature **author_out, git_signature **c void git_signature_free(git_signature *sig); void git_oidarray_dispose(git_oidarray *array); void git_strarray_dispose(git_strarray *array); +int git_message_prettify(git_buf *out, const char *message, int strip_comments, char comment_char); /* -- errors -- */ @@ -111,6 +115,7 @@ int git_repository_head(git_reference **out, git_repository *repo); int git_repository_set_head(git_repository *repo, const char *refname); int git_repository_set_head_detached(git_repository *repo, const git_oid *committish); int git_repository_config(git_config **out, git_repository *repo); +int git_repository_index(git_index **out, git_repository *repo); /* -- config -- */ @@ -183,6 +188,7 @@ void git_revwalk_free(git_revwalk *walk); /* -- refs / branch -- */ int git_reference_lookup(git_reference **out, git_repository *repo, const char *name); +int git_reference_dwim(git_reference **out, git_repository *repo, const char *shorthand); int git_reference_resolve(git_reference **out, const git_reference *ref); const git_oid *git_reference_target(const git_reference *ref); const char *git_reference_symbolic_target(const git_reference *ref); @@ -196,6 +202,12 @@ int git_reference_next(git_reference **out, git_reference_iterator *iter); void git_reference_iterator_free(git_reference_iterator *iter); int git_reference_peel(git_object **out, const git_reference *ref, git_object_t type); +int git_reflog_read(git_reflog **out, git_repository *repo, const char *name); +size_t git_reflog_entrycount(git_reflog *reflog); +const git_reflog_entry *git_reflog_entry_byindex(const git_reflog *reflog, size_t idx); +const char *git_reflog_entry_message(const git_reflog_entry *entry); +void git_reflog_free(git_reflog *reflog); + int git_branch_create(git_reference **out, git_repository *repo, const char *branch_name, const git_commit *target, int force); int git_branch_delete(git_reference *branch); int git_branch_name_is_valid(int *valid, const char *name); @@ -366,6 +378,30 @@ void git_status_list_free(git_status_list *statuslist); /* -- index -- */ +typedef struct { + int32_t seconds; + uint32_t nanoseconds; +} git_index_time; + +typedef struct git_index_entry { + git_index_time ctime; + git_index_time mtime; + uint32_t dev; + uint32_t ino; + uint32_t mode; + uint32_t uid; + uint32_t gid; + uint32_t file_size; + git_oid id; + uint16_t flags; + uint16_t flags_extended; + const char *path; +} git_index_entry; + +int git_index_read(git_index *index, int force); +unsigned int git_index_version(git_index *index); +size_t git_index_entrycount(const git_index *index); +const git_index_entry *git_index_get_byindex(git_index *index, size_t n); void git_index_free(git_index *index); int git_index_write_tree_to(git_oid *out, git_index *index, git_repository *repo); int git_index_has_conflicts(const git_index *index); diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index 4e592b9b..2a8cd55f 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -191,8 +191,10 @@ public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int public function getUntrackedFiles(): array; /** - * Checks a revision out: HEAD points to the branch when $revision is the - * name of a local branch, and to the commit otherwise. + * Checks a revision out, like ``git checkout``: HEAD points to the branch + * when $revision is the name of a local branch, or @{-}, the nth + * branch checked out before, stays as it is for "HEAD", and points to + * the commit otherwise (a tag, a hash, a remote branch...). * * $revision names a commit, which WorkingCopy::checkout() checks before: * it throws a {@see ReferenceNotFoundException} for a name that no diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 424b5f7b..db3dd4c6 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -102,7 +102,7 @@ final class Repository * * @throws InvalidArgumentException $dir doesn't exist or isn't a repository, or an option is unknown * @throws DriverException The driver can't open the repository - * @throws UnsupportedOperationException The repository uses SHA-256 hashes + * @throws UnsupportedOperationException The repository uses SHA-256 hashes, or with LibGit2Driver the reftable format */ public function __construct(string $dir, array|string|null $workingDir = null, DriverInterface $driver = new CliDriver()) { @@ -162,6 +162,7 @@ public static function initBare(string $path, DriverInterface $driver = new CliD * @param string|null $branch The branch to check out, the default branch of $url when null * * @throws DriverException + * @throws UnsupportedOperationException $url is a bundle, with LibGit2Driver */ public static function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self { @@ -176,6 +177,7 @@ public static function cloneFrom(#[\SensitiveParameter] string $url, string $pat * @param string|null $branch The branch HEAD points to, the default branch of $url when null * * @throws DriverException + * @throws UnsupportedOperationException $url is a bundle, with LibGit2Driver */ public static function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self { @@ -189,6 +191,7 @@ public static function cloneBareFrom(#[\SensitiveParameter] string $url, string * to mirror $url when fetching. * * @throws DriverException + * @throws UnsupportedOperationException $url is a bundle, with LibGit2Driver */ public static function mirrorFrom(#[\SensitiveParameter] string $url, string $path, DriverInterface $driver = new CliDriver()): self { diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index de19f555..a7374be9 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -67,8 +67,10 @@ public function getDiffStaged(): Diff } /** - * Checks $revision out: HEAD points to it when it is a local branch, to - * its commit otherwise. + * Checks $revision out, like `git checkout`: HEAD points to it when it is + * a local branch, or @{-}, the nth branch checked out before, stays + * as it is for "HEAD", and points to the commit otherwise: a tag, a hash, + * a remote branch... * * Unlike `git checkout`, a name that no revision has throws, the name of * a remote branch alone included, like "feature" for "origin/feature": diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index 96db58cc..1a367a3b 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; @@ -95,6 +96,16 @@ public function testBlameOfARangeAfterTheEndThrows(): void $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 100)->getLines(); } + public function testBlameOfAPathNormalizesIt(): void + { + $repository = self::createFoobarRepository(); + $lines = static fn (string $path): array => array_map(static fn (Line $line): array => [$line->getCommit()->getHash(), $line->getContent()], $repository->getBlame(self::LONGFILE_COMMIT, $path)->getLines()); + + // Like git. + $this->assertSame($lines('README.md'), $lines('./README.md')); + $this->assertSame($lines('README.md'), $lines('dir/../README.md')); + } + #[DataProvider('provideBare')] #[Group('legacy')] #[IgnoreDeprecations] diff --git a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php index 01a65e4c..e48c1ab4 100644 --- a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php +++ b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php @@ -13,6 +13,9 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use Symfony\Component\Process\Process; @@ -66,4 +69,76 @@ public function testPreloadKeepsLibgit2LoadedForTheProcess(): void $this->assertSame($repository->getHeadCommit()->getHash(), $process->getOutput()); } + + #[RequiresPhpExtension('ffi')] + public function testTheSystemConfigurationIsLeftOutWithGitConfigNoSystem(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + + // libgit2 is initialized once per process. 4 and 2 read the search + // path (GIT_OPT_GET_SEARCH_PATH) of the system configuration + // (GIT_CONFIG_LEVEL_SYSTEM), "/etc" on Linux. + $code = <<<'PHP' + require $argv[1]; + $ffi = Gitonomy\Git\Driver\Ffi\LibGit2::load(null); + $path = $ffi->new('git_buf'); + $ffi->git_libgit2_opts(4, 2, FFI::addr($path)); + echo FFI::string($path->ptr, $path->size); + $ffi->git_buf_dispose(FFI::addr($path)); + PHP; + + $process = new Process([\PHP_BINARY, '-r', $code, self::AUTOLOAD], env: ['GIT_CONFIG_NOSYSTEM' => '1']); + $process->mustRun(); + $this->assertSame('', $process->getOutput()); + + $process = new Process([\PHP_BINARY, '-r', $code, self::AUTOLOAD], env: ['GIT_CONFIG_NOSYSTEM' => false]); + $process->mustRun(); + $this->assertNotSame('', $process->getOutput()); + } + + public function testReftableRepositoriesAreUnsupported(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + if (version_compare(self::gitVersion(), '2.45.0', '<')) { + $this->markTestSkipped('Reftable repositories require git 2.45.'); + } + + $dir = self::createTempDir(); + new Process([self::gitCommand(), 'init', '-q', '--ref-format=reftable', $dir])->mustRun(); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('stores its references in the reftable format, which LibGit2Driver doesn\'t support; use CliDriver.'); + + new Repository($dir, driver: new LibGit2Driver()); + } + + /** + * @param \Closure(LibGit2Driver, string): void $clone + */ + #[DataProvider('provideClones')] + public function testBundlesAreUnsupported(\Closure $clone): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage(\sprintf('"%s" is a bundle, which LibGit2Driver doesn\'t support; use CliDriver.', self::REPOSITORY_URL)); + + $clone(new LibGit2Driver(), self::createTempDir().'/clone'); + } + + /** + * @return iterable + */ + public static function provideClones(): iterable + { + yield 'clone' => [static fn (LibGit2Driver $driver, string $path) => $driver->cloneFrom(self::REPOSITORY_URL, $path, null)]; + yield 'bare clone' => [static fn (LibGit2Driver $driver, string $path) => $driver->cloneBareFrom(self::REPOSITORY_URL, $path, null)]; + yield 'mirror' => [static fn (LibGit2Driver $driver, string $path) => $driver->mirrorFrom(self::REPOSITORY_URL, $path)]; + } } diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 358bad7d..c0a844e8 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -14,9 +14,11 @@ use Gitonomy\Git\Commit; use Gitonomy\Git\Driver\Cli\Parser\LogParser; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\Log; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; @@ -248,6 +250,51 @@ public function testLogOnWildcardPath(): void $this->assertSame([$text], self::hashes($repository->getLog('main', 'dir/*.txt'))); } + public function testLogOfAPathNormalizesIt(): void + { + $repository = self::createRepositoryToCommitTo(); + mkdir($repository->getWorkingDir().'/dir'); + $file = self::commit($repository, 'dir/file.txt', 'file'); + self::commit($repository, 'other.txt', 'other'); + + // Like git. + foreach (['dir/.', 'dir//file.txt', 'other/../dir/file.txt', './dir/', $repository->getWorkingDir().'/dir/../dir'] as $path) { + $this->assertSame([$file], self::hashes($repository->getLog('main', $path)), $path); + } + } + + public function testLogOfAPathAboveTheRootThrows(): void + { + $repository = self::createRepositoryToCommitTo(); + self::commit($repository, 'file.txt', 'file'); + + // Like git. + $this->expectException(DriverException::class); + if (self::getDriver() instanceof LibGit2Driver) { + $this->expectExceptionMessage('The path "dir/../../file.txt" is outside the repository.'); + } else { + $this->expectExceptionMessageMatches('/\'dir\/\.\.\/\.\.\/file\.txt\' is outside repository/'); + } + + $repository->getLog('main', 'dir/../../file.txt')->getCommits(); + } + + public function testLogOfAPathWithMagic(): void + { + $repository = self::createRepositoryToCommitTo(); + mkdir($repository->getWorkingDir().'/dir'); + self::commit($repository, 'dir/file.txt', 'file'); + $other = self::commit($repository, 'other.txt', 'other'); + + if (self::getDriver() instanceof LibGit2Driver) { + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('The path ":/other.txt" uses the magic of git\'s pathspecs, which LibGit2Driver doesn\'t support; use CliDriver.'); + } + + // From the root of the repository, whatever the current directory. + $this->assertSame([$other], self::hashes($repository->getLog('main', ':/other.txt'))); + } + public function testLogOfAllReferencesIgnoresTheOnesToOtherObjects(): void { $repository = self::createRepositoryToCommitTo(); diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 2085eb6a..80a420e0 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -470,6 +470,18 @@ public function testTheHeadOfARemoteNamedWithASlashIsNoBranch(): void $this->assertNotContains('refs/remotes/foo/bar/HEAD', $names); } + public function testABranchPointingToAnAnnotatedTagPointsToItsCommit(): void + { + $repository = self::createFoobarRepository(); + // Like a fetch of the tags of a remote under refs/remotes/. + self::git($repository, 'update-ref', ['refs/remotes/origin/tags/annotated', trim(self::git($repository, 'rev-parse', ['refs/tags/annotated']))]); + + $branch = $repository->getReferences()->getRemoteBranch('origin/tags/annotated'); + + $this->assertSame('fbde681b329a39e08b63dc54b341a3274c0380c0', $branch->getCommitHash()); + $this->assertContains($branch, $repository->getCommit(self::INITIAL_COMMIT)->getIncludingBranches()); + } + /** * Old tags (e.g. in git.git or linux) have no "tagger" line. */ diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 8c22a64e..5ac082fe 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Diff\File; use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\LogicException; @@ -22,6 +23,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\Attributes\TestWith; class WorkingCopyTest extends AbstractTestCase { @@ -102,6 +104,72 @@ public function testDiffPending(): void $this->assertCount(1, $diffPending->getFiles()); } + public function testFilesAddedWithIntentToAddAreCreatedNotStaged(): void + { + $this->skipUnlessGitHidesIntentToAddFromTheIndex(); + + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/new.txt', "new\n"); + touch($repository->getWorkingDir().'/empty.txt'); + self::git($repository, 'add', ['-N', 'new.txt', 'empty.txt']); + $wc = $repository->getWorkingCopy(); + + $this->assertSame([], $wc->getDiffStaged()->getFiles()); + $files = $wc->getDiffPending()->getFiles(); + $this->assertSame(['empty.txt', 'new.txt'], array_map(static fn (File $file): string => $file->getName(), $files)); + $this->assertTrue($files[0]->isCreation()); + $this->assertTrue($files[1]->isCreation()); + $this->assertSame(1, $files[1]->getAdditions()); + } + + public function testAFileAddedAgainWithIntentToAddIsDeletedFromTheIndex(): void + { + $this->skipUnlessGitHidesIntentToAddFromTheIndex(); + + $repository = self::createFoobarRepository(false); + self::git($repository, 'rm', ['-q', '--cached', 'AUTHORS']); + self::git($repository, 'add', ['-N', 'AUTHORS']); + $wc = $repository->getWorkingCopy(); + + $staged = $wc->getDiffStaged()->getFiles(); + $this->assertCount(1, $staged); + $this->assertTrue($staged[0]->isDeletion()); + $pending = $wc->getDiffPending()->getFiles(); + $this->assertCount(1, $pending); + $this->assertTrue($pending[0]->isCreation()); + $this->assertSame([], $wc->getDiff()->getFiles()); + } + + /** + * Like any version of git: deleted from the empty file the index has for it. + */ + public function testAFileAddedWithIntentToAddThenDeletedIsDeleted(): void + { + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/gone.txt', "gone\n"); + self::git($repository, 'add', ['-N', 'gone.txt']); + unlink($repository->getWorkingDir().'/gone.txt'); + $wc = $repository->getWorkingCopy(); + + $files = $wc->getDiffPending()->getFiles(); + $this->assertSame(['gone.txt'], array_map(static fn (File $file): string => $file->getName(), $files)); + $this->assertTrue($files[0]->isDeletion()); + $this->assertSame('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', $files[0]->getOldIndex()); + $this->assertSame([], $wc->getDiff()->getFiles()); + } + + public function testDiffLeavesOutAFileTheWorkingTreeChangesBack(): void + { + $repository = self::createFoobarRepository(false); + $file = $repository->getWorkingDir().'/AUTHORS'; + $content = file_get_contents($file); + file_put_contents($file, 'staged'); + self::git($repository, 'add', ['AUTHORS']); + file_put_contents($file, $content); + + $this->assertSame([], $repository->getWorkingCopy()->getDiff()->getFiles()); + } + public function testDiffPendingWithAbbreviatedHashesLikeOldGit(): void { $this->skipUnlessCliDriver(); @@ -343,6 +411,70 @@ public function testMergeOfAnAncestorDoesNothing(): void $this->assertSame($head->getHash(), $repository->getHeadCommit()->getHash()); } + public function testMergeOfAnAnnotatedTagAddsItsMessage(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + // With whitespace "git tag" would have cleaned up. + $commit = $repository->getReferences()->getRemoteBranch('origin/diff-features')->getCommitHash(); + $tag = self::writeObject($repository, 'tag', "object {$commit}\ntype commit\ntag features\ntagger A 1000000000 +0000\n\nFeatures \n\n\nThe diff ones."); + self::git($repository, 'update-ref', ['refs/tags/features', $tag]); + + $repository->getWorkingCopy()->merge('features'); + + // Cleaned up like git does. Then, with CliDriver, the merged commits: the configuration of the tests sets merge.log. + $this->assertStringStartsWith("Merge tag 'features'\n\nFeatures\n\nThe diff ones.\n", $repository->getHeadCommit()->getMessage()); + } + + #[TestWith(['refs/heads/feature', "Merge branch 'refs/heads/feature'"])] + #[TestWith(['feature~0', "Merge branch 'feature'"])] + #[TestWith(['feature~1', "Merge branch 'feature' (early part)"])] + #[TestWith(['refs/remotes/origin/diff-features', "Merge remote-tracking branch 'refs/remotes/origin/diff-features'"])] + #[TestWith(['@{-1}', "Merge branch 'feature'"])] + public function testMergeNamesTheRevisionLikeGit(string $revision, string $message): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + $repository->getReferences()->createBranch('feature', 'origin/diff-features'); + $wc = $repository->getWorkingCopy(); + $wc->checkout('feature'); + $wc->checkout('master'); + + $wc->merge($revision); + + $this->assertSame($message, $repository->getHeadCommit()->getSubjectMessage()); + } + + public function testMergeIntoABranchYetToBeBornFastForwardsIt(): void + { + $repository = self::createFoobarRepository(false); + $master = $repository->getHeadCommit()->getHash(); + self::git($repository, 'checkout', ['-q', '--orphan', 'unborn']); + self::git($repository, 'rm', ['-q', '-r', '-f', '.']); + $wc = $repository->getWorkingCopy(); + + try { + $wc->merge('master', FastForward::Never); + $this->fail('A merge commit should have been refused.'); + } catch (DriverException $e) { + $this->assertStringContainsString(self::getDriver() instanceof CliDriver + ? 'fatal: Non-fast-forward commit does not make sense into an empty head' + : 'Unable to merge "master" without a fast-forward: the branch "refs/heads/unborn" has no commit yet.', $e->getMessage()); + } + $this->assertSame('refs/heads/unborn', $repository->getRepositoryDriver()->getHeadReference()); + $this->assertFalse($repository->getReferences()->hasBranch('unborn'), 'The branch has no commit yet'); + $this->assertSame([], $wc->getUntrackedFiles()); + + $wc->merge('master'); + + $this->assertSame('refs/heads/unborn', self::headBranch($repository)->getFullname()); + $this->assertSame($master, $repository->getHeadCommit()->getHash()); + $this->assertSame([], $wc->getDiffStaged()->getFiles()); + $this->assertSame([], $wc->getDiffPending()->getFiles()); + } + public function testCheckoutKeepsLocalChangesItDoesNotConflictWith(): void { $repository = self::createFoobarRepository(false); @@ -399,6 +531,30 @@ public function testCheckoutPrefersTheBranchToATagOfTheSameName(): void $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); } + public function testCheckoutOfHeadLeavesItAttached(): void + { + $repository = self::createFoobarRepository(false); + + $repository->getWorkingCopy()->checkout('HEAD'); + + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + } + + public function testCheckoutOfThePriorBranchPrefersItToATagOfTheSameName(): void + { + $repository = self::createFoobarRepository(false); + $references = $repository->getReferences(); + $references->createTag('twin', self::INITIAL_COMMIT); + $references->createBranch('twin', 'master'); + $wc = $repository->getWorkingCopy(); + $wc->checkout('twin'); + $wc->checkout('master'); + + $wc->checkout('@{-1}'); + + $this->assertSame('refs/heads/twin', self::headBranch($repository)->getFullname()); + } + /** * Like "git checkout -b", whatever the configuration: the one of the * tests sets branch.autoSetupMerge and branch.autoSetupRebase to "always". @@ -545,4 +701,11 @@ private static function branchConfiguration(Repository $repository, string $bran return $configuration; } + + private function skipUnlessGitHidesIntentToAddFromTheIndex(): void + { + if (self::getDriver() instanceof CliDriver && version_compare(self::gitVersion(), '2.19.0', '<')) { + $this->markTestSkipped('Before 2.19, git diffs a file added with "git add -N" as an empty file of the index.'); + } + } } From eea720c045724940edac36e87efde62324293c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 106/150] Pass the revisions of a log on the standard input A log of thousands of revisions, the branches of a large repository for instance, exceeded the limit of the command line, 2 MB by default on Linux: git couldn't start. CliDriver passes the hashes of a log and of countCommits() to git on its standard input, with --stdin. --- CHANGELOG.md | 2 ++ .../Git/Driver/Cli/CliRepositoryDriver.php | 26 ++++++++++++++----- tests/Gitonomy/Git/Tests/LogTest.php | 15 +++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 721a42de..e3a8a6b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). ["Git configuration"](doc/repository.md#git-configuration). - Fix `Commit::getShortHash()`, `Commit::getDiff()` and `Repository::getDiff()` failing when a file of the working tree has the name of the revision they read, which git found ambiguous. +- Fix logs of thousands of revisions exceeding the limit of the command line: `CliDriver` passes + them to git on its standard input. - Fix `Commit::getDiff()` returning an empty diff for a root commit, and throwing for a merge commit (`CliDriver`). ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix `Repository::getDiff()`, `WorkingCopy::getDiffPending()` and `WorkingCopy::getDiffStaged()` diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index acb40c7b..cf6b7964 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -169,7 +169,7 @@ public function getCommitLog(?array $includes, array $excludes, array $paths, in } $parser = new LogParser(); - $parser->parse($this->run('log', [...$args, ...self::revisions($includes, $excludes), '--', ...$paths])); + $parser->parse($this->runOnRevisions('log', $args, $includes, $excludes, $paths)); return $parser->commits; } @@ -182,7 +182,7 @@ public function countCommits(?array $includes, array $excludes, array $paths): i return 0; } - return (int) $this->run('rev-list', ['--count', ...self::revisions($includes, $excludes), '--', ...$paths]); + return (int) $this->runOnRevisions('rev-list', ['--count'], $includes, $excludes, $paths); } public function getMergeBases(string $hash1, string $hash2): array @@ -473,14 +473,26 @@ private function runOnObject(string $hash, ?ObjectType $type, string $command, a } /** - * @param list|null $includes - * @param list $excludes + * Runs $command on the commits reachable from $includes, but not from + * $excludes. + * + * The hashes go through the standard input: thousands of them would + * exceed the limit of the command line. * - * @return list + * @param string[] $args Arguments of $command + * @param list|null $includes Null for every reference and HEAD + * @param list $excludes + * @param string[] $paths */ - private static function revisions(?array $includes, array $excludes): array + private function runOnRevisions(string $command, array $args, ?array $includes, array $excludes, array $paths): string { - return [...$includes ?? ['--all'], ...array_map(static fn (string $hash): string => '^'.$hash, $excludes)]; + // A hash per line, "^" excluding it. The standard input takes no option, like "--all". + $input = ''; + foreach ([...$includes ?? [], ...array_map(static fn (string $hash): string => '^'.$hash, $excludes)] as $revision) { + $input .= $revision."\n"; + } + + return $this->runProcess($command, [...$args, ...null === $includes ? ['--all'] : [], '--stdin', '--', ...$paths], $input)->getOutput(); } /** diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index c0a844e8..2be8bcd6 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -362,6 +362,21 @@ public function testLogOfExcludedAndSymmetricRevisions(): void $this->assertSame([$main, $base], self::hashes($repository->getLog('main'))); } + /** + * The command line of a process has a limit, 2 MB by default on Linux: a + * log of thousands of branches would exceed it. + */ + public function testLogOfMoreRevisionsThanACommandLineTakes(): void + { + $repository = self::createFoobarRepository(); + // 4 MB of hashes. + $log = $repository->getLog(array_fill(0, 100_000, $repository->getCommit(self::LONGFILE_COMMIT))); + + $hashes = self::hashes($repository->getLog(self::LONGFILE_COMMIT)); + $this->assertSame($hashes, self::hashes($log)); + $this->assertCount(\count($hashes), $log); + } + public function testLogOfAnUnknownRevisionNamesIt(): void { $repository = self::createFoobarRepository(); From 85640b36f6628425159a4d06719e9447d655c798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 107/150] Make the message of a failed git command one sentence The message of a ProcessException held the command line, the whole error output and the whole output of git, over many lines: no sentence, and the data of the repository in the logs. It is one line, with the git command, its exit code and the error of git, the first line of its error output starting with "fatal:" or "error:", after the warnings, or its first line otherwise: 'The git command "clone" failed (exit 128): fatal: ...'. getErrorOutput() and getOutput() still return the outputs of git. The constructor takes the git command, optional. When git can't start, a missing binary, a command line too long or a working directory removed, CliDriver throws a DriverException instead of a ProcessException, which says so in one line, without the command line: 'The git command "clone" could not start: ...'. --- CHANGELOG.md | 8 ++ UPGRADE-2.0.md | 17 ++- doc/admin.md | 4 +- doc/repository.md | 25 ++-- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 2 +- .../Git/Driver/Cli/CliRepositoryDriver.php | 10 +- src/Gitonomy/Git/Driver/Cli/GitRunner.php | 40 +++++- .../Git/Exception/ProcessException.php | 34 +++-- src/Gitonomy/Git/Repository.php | 2 + tests/Gitonomy/Git/Tests/AdminTest.php | 5 +- tests/Gitonomy/Git/Tests/CredentialsTest.php | 29 ++++- tests/Gitonomy/Git/Tests/LogTest.php | 3 +- .../Git/Tests/ProcessExceptionTest.php | 116 ++++++++++++++++++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 10 +- tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 7 +- 15 files changed, 266 insertions(+), 46 deletions(-) create mode 100644 tests/Gitonomy/Git/Tests/ProcessExceptionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a8a6b7..5a0e58a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,14 @@ deprecated until 3.0. - `Diff::parse()` throws for a diff without its raw information, deprecated since 1.6.0 ([#229](https://github.com/gitonomy/gitlib/pull/229)): parse the output of `git diff --raw --patch`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- `ProcessException` has a message of one line, with the error of git, the first line of its + error output starting with `fatal:` or `error:`, after the warnings: + `The git command "clone" failed (exit 128): fatal: ...`. The command line, the rest of the + error output and the output are no longer in the message: `getErrorOutput()` and + `getOutput()` return the outputs of git. +- When git can't start, a missing binary for instance, `CliDriver` throws a `DriverException` + instead of a `ProcessException`, with a message of one line, without the command line: + `The git command "clone" could not start: ...`. - `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a name that no revision has, with both drivers: the name of a remote branch alone, like `feature` for `origin/feature`, created the local branch. Use `checkoutNewBranch()`. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 6ad136ed..5b88df00 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -99,7 +99,7 @@ Creating repositories `release` no longer matches `feature/release`, nor `rel*` `release`. * `isValidRepository()` and `isValidRepositoryAndBranch()` read a relative path as a path, never as the name of a remote of PHP's working directory, - and throw a `ProcessException` when git can't run, instead of returning + and throw a `DriverException` when git can't start, instead of returning `false`. Before: @@ -399,6 +399,18 @@ Errors * `ProcessException` extends the new `Gitonomy\Git\Exception\DriverException`, itself a `RuntimeException`: catching `RuntimeException` or `GitExceptionInterface` still works. + * The message of a `ProcessException` is one line, with the error of git: + the first line of its error output starting with `fatal:` or `error:`, + after the warnings, or its first line: `The git command "clone" failed + (exit 128): fatal: ...`, or `The git command "diff" failed (exit 1).` + when git writes no error. The command line, the rest of the error output + and the output are no longer in the message: `getErrorOutput()` and + `getOutput()` return the outputs of git. Its constructor takes the git + command as second argument, optional. + * When git can't start, a missing binary for instance, `CliDriver` throws + a `DriverException` instead of a `ProcessException`, with a message of + one line, without the command line: `The git command "clone" could not + start: ...`. * These methods throw instead of returning a fallback value: * `Commit::getIncludingBranches()` threw nothing and returned `[]` when git failed; @@ -410,7 +422,8 @@ Errors exist, or can't be written, instead of a `LogicException` or a silent failure, and creates the hooks directory. * The `Admin` methods throw a `ProcessException`, still a - `RuntimeException`, with git's error output as message. + `RuntimeException`, with the first line of git's error output in its + message, or a `DriverException` when git can't start. * An argument holding a NUL byte throws an `InvalidArgumentException`, where PHP threw a `ValueError`: git and libgit2 read their arguments as C strings, which end at the first NUL byte. diff --git a/doc/admin.md b/doc/admin.md index 0b95e4ac..6e42f23b 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -98,8 +98,8 @@ When git fails (an unknown branch, a directory with files, a network error...), the methods creating repositories throw a `Gitonomy\Git\Exception\DriverException`: a `ProcessException`, with git's error output, when `CliDriver` runs git. With `CliDriver`, every method, -`isValidRepository()` included, throws a `ProcessException` when git can't -run, like a missing binary. See [Errors](repository.md#errors). +`isValidRepository()` included, throws a `DriverException` when git can't +start, like a missing binary. See [Errors](repository.md#errors). A URL that makes git run a command throws an `InvalidArgumentException`, with both drivers: diff --git a/doc/repository.md b/doc/repository.md index a0411b07..a2cbe639 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -189,12 +189,13 @@ $process = $repository->runProcess('push', ['origin', 'main']); echo $process->getErrorOutput(); ``` -Both throw a `ProcessException` when git fails, and an -`UnsupportedOperationException` with another driver than `CliDriver`. The -relative paths of the arguments are relative to the repository. The -arguments go to git as they are: a value from your users starting with a -dash is an option for git, unless `--` comes before it. gitlib reads the -references again after the command, which may have written them. +Both throw a `ProcessException` when git fails, a `DriverException` when it +can't start, and an `UnsupportedOperationException` with another driver +than `CliDriver`. The relative paths of the arguments are relative to the +repository. The arguments go to git as they are: a value from your users +starting with a dash is an option for git, unless `--` comes before it. +gitlib reads the references again after the command, which may have written +them. `shell()` is deprecated since 2.0: run `git shell` with Symfony Process instead. @@ -211,10 +212,14 @@ or empty data instead. Every exception of gitlib implements a reference or an object doesn't exist (`getRevision('unknown')->getCommit()`, `getReferences()->getBranch('unknown')`...). - `DriverException`, a `RuntimeException`: git reports a failure, like a - merge conflict or a clone of an unknown repository. With `CliDriver`, it - is mostly a `ProcessException`, whose `getErrorOutput()` and - `getOutput()` return the output of git, or a `ProcessTimedOutException`, - when git runs longer than [the timeout](#configuring-clidriver). + merge conflict or a clone of an unknown repository, or can't start, like + a missing binary (`The git command "clone" could not start: ...`). With + `CliDriver`, it is mostly a `ProcessException`, whose message has the + error of git, the first `fatal:` or `error:` line of its error output + (`The git command "clone" failed (exit 128): fatal: ...`), and whose + `getErrorOutput()` and `getOutput()` return the output of git, or a + `ProcessTimedOutException`, when git runs longer than + [the timeout](#configuring-clidriver). - `UnsupportedOperationException`, a `RuntimeException`: the driver can't perform the operation at all, where another driver may: `run()`, cloning a bundle or opening a reftable repository with `LibGit2Driver`, libgit2 diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index a798580a..2f73b713 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -146,7 +146,7 @@ private function mustRun(string $command, #[\SensitiveParameter] array $args): v $process = $this->runner()->run($command, $args); if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, $command); } } diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index cf6b7964..025699ea 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -103,7 +103,7 @@ public function getHeadReference(): ?string } if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, 'symbolic-ref'); } return trim($process->getOutput()); @@ -122,7 +122,7 @@ public function resolveRevision(string $revision): string } if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, 'rev-parse'); } return trim($process->getOutput()); @@ -198,7 +198,7 @@ public function getMergeBases(string $hash1, string $hash2): array } if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, 'merge-base'); } return explode("\n", trim($process->getOutput())); @@ -236,7 +236,7 @@ public function getReferences(): array } if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, 'show-ref'); } $parser = new ReferenceParser(); @@ -392,7 +392,7 @@ public function runProcess(string $command, #[\SensitiveParameter] array $args = $process = $this->execute($command, array_map(strval(...), $args), $input); if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, $command); } return $process; diff --git a/src/Gitonomy/Git/Driver/Cli/GitRunner.php b/src/Gitonomy/Git/Driver/Cli/GitRunner.php index 8a861192..268343e6 100644 --- a/src/Gitonomy/Git/Driver/Cli/GitRunner.php +++ b/src/Gitonomy/Git/Driver/Cli/GitRunner.php @@ -14,11 +14,11 @@ use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ProcessTimedOutException; use Gitonomy\Git\Util\Credentials; use Gitonomy\Git\Util\NulByte; use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Exception\ProcessStartFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException; use Symfony\Component\Process\Exception\RuntimeException as SymfonyRuntimeException; use Symfony\Component\Process\Process; @@ -51,7 +51,7 @@ public function __construct( * @param string|null $input The standard input of git * * @throws InvalidArgumentException An argument contains a NUL byte - * @throws ProcessException The git binary can't be run + * @throws DriverException git can't start, or is killed */ public function run(string $command, #[\SensitiveParameter] array $args, array $globalOptions = [], ?string $workingDirectory = null, ?string $input = null): Process { @@ -71,17 +71,32 @@ public function run(string $command, #[\SensitiveParameter] array $args, array $ // The exceptions of Symfony aren't chained: their messages may hold the // command line, with the credentials of its URLs. try { - $process->run(); + $process->start(); + } catch (SymfonyRuntimeException $e) { + // Too many arguments (E2BIG), a working directory removed since the + // repository was opened... + $error = $e->getMessage(); + if ($e instanceof ProcessStartFailedException) { + // Its message ends with the error, after the command line. + $lines = explode("\n", $error); + $error = substr(end($lines), \strlen('Error: ')); + } + + throw self::notStarted($command, $error); + } + + try { + $process->wait(); } catch (SymfonyProcessTimedOutException) { throw new ProcessTimedOutException(\sprintf('The git command "%s" ran longer than %s seconds.', $command, $this->timeout)); } catch (SymfonyRuntimeException $e) { - // A working directory removed since the repository was opened... + // Killed by a signal... throw new DriverException(Credentials::redact(\sprintf('Unable to run the git command "%s": %s', $command, $e->getMessage()))); } // Symfony Process runs git through a shell, which exits with them when it can't find or run git. if (\in_array($process->getExitCode(), [126, 127], true)) { - throw new ProcessException($process); + throw self::notStarted($command, $process->getErrorOutput()); } if (null !== $this->logger) { @@ -95,4 +110,19 @@ public function run(string $command, #[\SensitiveParameter] array $args, array $ return $process; } + + /** + * Like the one of ProcessException, the message is a sentence, with the + * first line of $error. + */ + private static function notStarted(string $command, string $error): DriverException + { + $message = \sprintf('The git command "%s" could not start', $command); + $error = trim(explode("\n", trim($error), 2)[0]); + if ('' !== $error) { + $message .= ': '.$error; + } + + return new DriverException(Credentials::redact(1 === preg_match('/[.!?]$/', $message) ? $message : $message.'.')); + } } diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index c747e5bd..b7668463 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -18,18 +18,30 @@ final class ProcessException extends DriverException implements GitExceptionInterface { /** - * The message hides the credentials of the URLs, unlike getErrorOutput() and getOutput(). + * The message is a sentence, with the error of git, from its error + * output: "The git command "clone" failed (exit 128): fatal: ...". It + * hides the credentials of the URLs, unlike getErrorOutput() and getOutput(). + * + * @param string|null $command The git command, like "clone", which the message names */ - public function __construct(private readonly Process $process) - { - parent::__construct(Credentials::redact( - "Error while running git command:\n". - $process->getCommandLine()."\n". - "\n". - $process->getErrorOutput()."\n". - "\n". - $process->getOutput() - )); + public function __construct( + private readonly Process $process, + ?string $command = null, + ) { + $message = (null === $command ? 'The git command' : \sprintf('The git command "%s"', $command)).\sprintf(' failed (exit %d)', $process->getExitCode()); + // One line of the error output only: getOutput() may be long, and + // hold the data of the repository. The first error, as warnings may + // come before it, like the one of a HOME git can't read; the first + // line otherwise. A colon announces the next lines: "error: Your + // local changes ... would be overwritten by checkout:". + $errorOutput = $process->getErrorOutput(); + $error = 1 === preg_match('/^(?:fatal|error):.*$/m', $errorOutput, $matches) ? $matches[0] : explode("\n", trim($errorOutput), 2)[0]; + $error = rtrim(trim($error), ':'); + if ('' !== $error) { + $message .= ': '.$error; + } + + parent::__construct(Credentials::redact(1 === preg_match('/[.!?]$/', $message) ? $message : $message.'.')); } public function getErrorOutput(): string diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index db3dd4c6..7746bf55 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -622,6 +622,7 @@ public function setDescription(string $description): static * @return string output of the process * * @throws ProcessException Error while executing git command + * @throws DriverException git can't start, or is killed * @throws UnsupportedOperationException The driver isn't a CliDriver */ public function run(string $command, #[\SensitiveParameter] array $args = []): string @@ -641,6 +642,7 @@ public function run(string $command, #[\SensitiveParameter] array $args = []): s * @param array $args Arguments of git command * * @throws ProcessException Error while executing git command + * @throws DriverException git can't start, or is killed * @throws UnsupportedOperationException The driver isn't a CliDriver */ public function runProcess(string $command, #[\SensitiveParameter] array $args = []): Process diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index 86a3cf28..d310ab3e 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -13,8 +13,8 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Admin; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Reference\Branch; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; @@ -136,7 +136,8 @@ public function testOptionsConfigureGitAndTheRepository(): void public function testCommandOptionRunsAnotherGitBinary(): void { - $this->expectException(ProcessException::class); + $this->expectException(DriverException::class); + $this->expectExceptionMessage('The git command "init" could not start: '); Admin::init(self::createTempDir(), options: ['command' => 'not-git']); } diff --git a/tests/Gitonomy/Git/Tests/CredentialsTest.php b/tests/Gitonomy/Git/Tests/CredentialsTest.php index b52bdea6..1df80bbd 100644 --- a/tests/Gitonomy/Git/Tests/CredentialsTest.php +++ b/tests/Gitonomy/Git/Tests/CredentialsTest.php @@ -17,6 +17,7 @@ use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\GitExceptionInterface; use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ProcessTimedOutException; use Gitonomy\Git\Repository; use Gitonomy\Git\Util\Credentials; @@ -41,7 +42,30 @@ public function testAFailedCloneHidesTheCredentials(): void } catch (DriverException $e) { // What loggers print: the message, the trace, and the chained exceptions. $this->assertStringNotContainsString('secret', (string) $e); - $this->assertStringContainsString('http://***@127.0.0.1:1/repository.git', $e->getMessage()); + if (self::getDriver() instanceof CliDriver) { + // The error of git: recent versions name the URL without its credentials, and the old + // builds of the tests, without http support, do not name it. + $this->assertStringStartsWith('The git command "clone" failed (exit 128): fatal: ', $e->getMessage()); + } else { + $this->assertStringStartsWith('Unable to clone "http://***@127.0.0.1:1/repository.git": ', $e->getMessage()); + } + } + } + + /** + * git writes the name of a branch it refuses in its error output. + */ + public function testTheMessageOfAProcessExceptionHidesTheCredentials(): void + { + $this->skipUnlessCliDriver(); + + try { + self::createFoobarRepository()->run('branch', [self::URL]); + $this->fail('A URL is no valid branch name.'); + } catch (ProcessException $e) { + $this->assertStringStartsWith('The git command "branch" failed (exit 128): fatal: \'http://***@127.0.0.1:1/repository.git\' is not a valid branch name', $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + $this->assertStringContainsString(self::URL, $e->getErrorOutput(), 'The error output is the one of git'); } } @@ -80,6 +104,9 @@ public function testAProcessThatCannotStartHidesTheCredentials(): void Repository::isValidRepository(self::URL.'/'.str_repeat('a', 4 * 1024 * 1024), self::getDriver()); $this->fail('The command line is too long.'); } catch (DriverException $e) { + // One line, the error of the system, like "posix_spawn() failed: Argument list too long", without the command line. + $this->assertMatchesRegularExpression('/^The git command "ls-remote" could not start: [^\n]+\.$/', $e->getMessage()); + $this->assertStringNotContainsString('/repository.git/aaa', $e->getMessage()); $this->assertStringNotContainsString('secret', (string) $e); } } diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 2be8bcd6..fd6e311e 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -273,7 +273,8 @@ public function testLogOfAPathAboveTheRootThrows(): void if (self::getDriver() instanceof LibGit2Driver) { $this->expectExceptionMessage('The path "dir/../../file.txt" is outside the repository.'); } else { - $this->expectExceptionMessageMatches('/\'dir\/\.\.\/\.\.\/file\.txt\' is outside repository/'); + // git 1.8.4 doesn't repeat the path first, and recent versions name the repository after it. + $this->expectExceptionMessageMatches('/^The git command "log" failed \(exit 128\): fatal: (?:dir\/\.\.\/\.\.\/file\.txt: )?\'dir\/\.\.\/\.\.\/file\.txt\' is outside repository/'); } $repository->getLog('main', 'dir/../../file.txt')->getCommits(); diff --git a/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php b/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php new file mode 100644 index 00000000..512d2c08 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php @@ -0,0 +1,116 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Process\Process; + +/** + * The message is a sentence, on one line: the outputs of git are whole in + * getErrorOutput() and getOutput(). + */ +class ProcessExceptionTest extends AbstractTestCase +{ + public function testTheMessageHasTheFirstLineOfTheErrorOutput(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/AUTHORS', 'local change'); + + try { + $repository->run('checkout', ['origin/pagination']); + $this->fail('The checkout would overwrite the local changes.'); + } catch (ProcessException $e) { + $this->assertSame('The git command "checkout" failed (exit 1): error: Your local changes to the following files would be overwritten by checkout.', $e->getMessage()); + $this->assertStringContainsString("\tAUTHORS\n", $e->getErrorOutput()); + } + } + + /** + * git warns about a HOME it can't read before its error. + */ + public function testTheMessageHasTheErrorAfterTheWarnings(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/AUTHORS', 'local change'); + $home = self::createTempDir(); + mkdir($home.'/.config'); + $repository = new Repository($repository->getPath(), driver: self::getCliDriver(['HOME' => $home])); + + chmod($home.'/.config', 0); + + try { + if (is_readable($home.'/.config')) { + $this->markTestSkipped('The user of the tests reads any directory, like root.'); + } + + $repository->getWorkingCopy()->checkout('origin/pagination'); + $this->fail('The checkout would overwrite the local changes.'); + } catch (ProcessException $e) { + $this->assertSame('The git command "checkout" failed (exit 1): error: Your local changes to the following files would be overwritten by checkout.', $e->getMessage()); + $this->assertStringStartsWith(\sprintf("warning: unable to access '%s/.config/git/", $home), $e->getErrorOutput()); + } finally { + // Or the directory can't be deleted. + chmod($home.'/.config', 0o700); + } + } + + #[DataProvider('provideErrorOutputs')] + public function testTheMessageHasTheFirstError(string $errorOutput, string $message): void + { + $process = new Process([\PHP_BINARY, '-r', 'fwrite(STDERR, $argv[1]); exit(128);', '--', $errorOutput]); + $process->run(); + + $this->assertSame($message, new ProcessException($process, 'merge')->getMessage()); + } + + /** + * @return iterable + */ + public static function provideErrorOutputs(): iterable + { + yield 'warning first' => ["warning: refname 'feature' is ambiguous.\nfatal: ambiguous object name: 'feature'\n", 'The git command "merge" failed (exit 128): fatal: ambiguous object name: \'feature\'.']; + yield 'errors' => ["error: first\nfatal: second\n", 'The git command "merge" failed (exit 128): error: first.']; + yield 'no error' => ["\nwarning: something\nmerge: nope - not something we can merge\n", 'The git command "merge" failed (exit 128): warning: something.']; + } + + public function testTheMessageOfAFailureWithoutErrorOutputEndsWithTheExitCode(): void + { + $this->skipUnlessCliDriver(); + + try { + self::createFoobarRepository()->run('diff', ['--exit-code', '--no-ext-diff', '--no-textconv', self::INITIAL_COMMIT, self::LONGFILE_COMMIT]); + $this->fail('The commits differ.'); + } catch (ProcessException $e) { + $this->assertSame('The git command "diff" failed (exit 1).', $e->getMessage()); + $this->assertSame('', $e->getErrorOutput()); + $this->assertStringContainsString('diff --git', $e->getOutput()); + } + } + + /** + * Like in 1.x, which had no other argument. + */ + public function testTheCommandIsOptional(): void + { + $process = new Process([self::gitCommand(), 'not-work'], self::createTempDir(), self::gitEnvs()); + $process->run(); + + $this->assertSame('The git command failed (exit 1): git: \'not-work\' is not a git command. See \'git --help\'.', new ProcessException($process)->getMessage()); + } +} diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 44d07e6c..47453573 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -190,9 +190,13 @@ public function testIsValidRepositoryThrowsWhenGitCantRun(): void { $this->skipUnlessCliDriver(); - $this->expectException(ProcessException::class); - - Repository::isValidRepository(self::repositoryUrl(), new CliDriver('not-git')); + try { + Repository::isValidRepository(self::repositoryUrl(), new CliDriver('not-git')); + $this->fail('git is missing.'); + } catch (DriverException $e) { + // One line, the error of the shell, like "sh: 1: exec: not-git: not found". + $this->assertMatchesRegularExpression('/^The git command "ls-remote" could not start: [^\n]+\.$/', $e->getMessage()); + } } public function testCloneATagAsBranchDetachesHead(): void diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 5ac082fe..87ba273e 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -459,8 +459,8 @@ public function testMergeIntoABranchYetToBeBornFastForwardsIt(): void $wc->merge('master', FastForward::Never); $this->fail('A merge commit should have been refused.'); } catch (DriverException $e) { - $this->assertStringContainsString(self::getDriver() instanceof CliDriver - ? 'fatal: Non-fast-forward commit does not make sense into an empty head' + $this->assertSame(self::getDriver() instanceof CliDriver + ? 'The git command "merge" failed (exit 128): fatal: Non-fast-forward commit does not make sense into an empty head.' : 'Unable to merge "master" without a fast-forward: the branch "refs/heads/unborn" has no commit yet.', $e->getMessage()); } $this->assertSame('refs/heads/unborn', $repository->getRepositoryDriver()->getHeadReference()); @@ -625,7 +625,8 @@ public function testCheckoutNewBranchRefusesAnAmbiguousStartPoint(): void $this->fail('The start point is ambiguous.'); } catch (DriverException $e) { if (self::getDriver() instanceof CliDriver) { - $this->assertMatchesRegularExpression('/fatal: [Aa]mbiguous object name: \'origin\/new-feature\'/', $e->getMessage()); + // Capitalized, with a period, by older versions of git. + $this->assertMatchesRegularExpression('/^The git command "branch" failed \(exit 128\): fatal: [Aa]mbiguous object name: \'origin\/new-feature\'\.$/', $e->getMessage()); } else { $this->assertSame('The name "origin/new-feature" is ambiguous: "refs/heads/origin/new-feature", "refs/remotes/origin/new-feature".', $e->getMessage()); } From 8f2f50e6b884b875c59f62d03007d108d2ddfd71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 108/150] Tighten the API - Repository::getHead() returns a Reference\Branch or a Commit: Reference didn't declare getName(), which the doc calls. It now does, abstract. HEAD pointing to a tag, which git allows, throws a ReferenceNotFoundException. - The constructors of Commit, Tree, Blob, Revision, the references and ReferenceBag are internal: Repository returns the same commit, tree or blob while the application uses it, and reads the references again after its writes. Extending Revision or Reference isn't supported. - getSize() returned kilobytes, followed links, and threw for a link to a missing file in the git directory, like a hook not deployed yet. getSizeInBytes() returns bytes, follows no link, and leaves out a file git deletes meanwhile. getSize() is deprecated. - Exception\UnexpectedValueException, which gitlib no longer throws, is deprecated. - getBlob(), getTree() or getCommit() of an abbreviated hash throws 'The object "56953e3" is not a blob.' or 'The object "56953e3" does not exist.', instead of 'The revision "56953e3^{blob}" does not exist.'. - BlameLineData names its line numbers like Blame\Line: number and originalNumber. --- CHANGELOG.md | 12 ++- UPGRADE-2.0.md | 27 ++++++- doc/repository.md | 14 +++- src/Gitonomy/Git/Blame.php | 2 +- src/Gitonomy/Git/Blob.php | 9 ++- src/Gitonomy/Git/Commit.php | 2 + .../Git/Driver/Cli/Parser/BlameParser.php | 2 +- .../Git/Driver/Data/BlameLineData.php | 8 +- .../Driver/Ffi/LibGit2RepositoryDriver.php | 4 +- .../Exception/UnexpectedValueException.php | 3 + src/Gitonomy/Git/Reference.php | 10 +++ src/Gitonomy/Git/Reference/Tag.php | 2 + src/Gitonomy/Git/ReferenceBag.php | 3 + src/Gitonomy/Git/Repository.php | 76 +++++++++++++----- src/Gitonomy/Git/Revision.php | 8 ++ src/Gitonomy/Git/Tree.php | 3 + tests/Gitonomy/Git/Tests/ReferenceTest.php | 19 +++++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 77 +++++++++++++++++-- 18 files changed, 239 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0e58a7..f2625a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ deprecated until 3.0. - `Diff::parse()` throws for a diff without its raw information, deprecated since 1.6.0 ([#229](https://github.com/gitonomy/gitlib/pull/229)): parse the output of `git diff --raw --patch`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- The constructors of `Commit`, `Tree`, `Blob`, `Revision`, the references and `ReferenceBag` + are internal: get these objects from `Repository`, which returns the same commit, tree or + blob while the application uses it, and reads the references again after its writes. + Extending `Revision` or `Reference` isn't supported anymore. - `ProcessException` has a message of one line, with the error of git, the first line of its error output starting with `fatal:` or `error:`, after the warnings: `The git command "clone" failed (exit 128): fatal: ...`. The command line, the rest of the @@ -66,7 +70,8 @@ deprecated until 3.0. `isValidRepository()` and `isValidRepositoryAndBranch()`; see ["Create and access git repositories"](doc/admin.md). ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Add `Repository::getDiff($from, $to)`, `Repository::getMergeBase()` and `WorkingCopy::getDiff()`, +- Add `Repository::getDiff($from, $to)`, `Repository::getMergeBase()`, + `Repository::getSizeInBytes()` and `WorkingCopy::getDiff()`, `WorkingCopy::checkoutNewBranch()`, `Branch::moveTo()` and `Branch::isMergedInto()`, `Log::withOffset()` and `withLimit()`, `Blame::getHunks()` and the line numbers of `Blame\Line`, `TreeEntry` and `Submodule`, `Diff\DiffLine` and `Diff\DiffLineType`, @@ -113,6 +118,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). `WorkingCopy::merge()`. - `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, `PushReference::getReference()` and `getRevision()`. +- `Repository::getSize()`, in kilobytes: use `getSizeInBytes()`. +- `Exception\UnexpectedValueException`, which gitlib no longer throws. ### Security @@ -187,6 +194,9 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - Fix blame lines numbered from 1 for a range of lines. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix `Hooks` failing silently, in a repository without hooks directory, and writing the hooks of a linked worktree where git doesn't run them. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `Repository::getSize()` throwing for a link to a missing file in the git directory, like + a hook not deployed yet: it no longer follows links. A file git deletes meanwhile, like a lock + during a `gc` or a push, no longer makes it throw either. - Fix blame lines starting with a TAB character (`Blame\Line::getContent()`): code removing it must stop doing so. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix reading a commit whose `encoding` header isn't UTF-8, which threw: its message, author and diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 5b88df00..ddd2c018 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -173,6 +173,10 @@ References * `ReferenceBag::getFirstBranch()` returns `null` instead of `false` when there is no branch, and only branches. + * `Repository::getHead()` returns a `Reference\Branch` or a `Commit`. + `HEAD` pointing to another reference, like a tag, which git allows, + throws a `ReferenceNotFoundException`, where 1.x returned the reference: + `getHeadCommit()` still returns its commit. * The getters of `Reference\Tag` (`getTaggerName()`, `getTaggerEmail()`, `getTaggerDate()`, `getMessage()`, `getSubjectMessage()`, `getBodyMessage()`, `getGpgSignature()`) return `null` instead of `false` @@ -289,7 +293,8 @@ Trees * `CommitReference` is deprecated: it is `Submodule`. So is `Tree::getCommitReferenceEntries()`: use `getSubmoduleEntries()`. * `Tree::resolvePath()` throws an `InvalidArgumentException` explaining - why a path doesn't resolve, and resolves `/` to the tree. + why a path doesn't resolve, a path through a submodule included, which + threw an `UnexpectedValueException`, and resolves `/` to the tree. Working copy ------------ @@ -416,6 +421,9 @@ Errors failed; * `ReferenceBag` (`Repository::getReferences()`) only returns an empty bag for a repository without references, and throws for any other failure. + * `Gitonomy\Git\Exception\UnexpectedValueException` is deprecated: gitlib + no longer throws it. Catch `InvalidArgumentException` for + `Tree::resolvePath()`. * `Commit::getShortHash()` throws a `ReferenceNotFoundException` for an unknown commit, instead of a `ProcessException`. * `Hooks` throws a `RuntimeException` when a hook exists already, doesn't @@ -501,9 +509,16 @@ Extending gitlib * These classes are `final readonly`: `Blame\Line`, `Diff\Diff`, `Diff\FileChange`, `Hooks`, `RevisionList`, `WorkingCopy`. * Every method has native parameter and return types. A class extending - `Revision`, `Reference` or `Parser\ParserBase`, which remain extendable, - must declare the same types, and must not redeclare their properties. - The constructor of `Reference` requires the commit hash. + `Parser\ParserBase`, which remains extendable, must declare the same + types, and must not redeclare its properties. + * The constructors of `Commit`, `Tree`, `Blob`, `Revision`, `Reference`, + `Reference\Tag` and `ReferenceBag` are internal: get these objects from + `Repository` (`getCommit()`, `getTree()`, `getBlob()`, `getRevision()`, + `getReferences()`), which returns the same commit, tree or blob while your + application uses it, and reads the references again after its writes. + The constructor of `Reference` requires the commit hash. Extending + `Revision` or `Reference` isn't supported anymore, and won't be possible + in 3.0: `Reference` declares `getName()`, abstract. * To change how gitlib runs git, for example in tests, implement `Gitonomy\Git\Driver\DriverInterface` and pass it as the driver of `Repository`, instead of extending `Repository`: see @@ -563,3 +578,7 @@ Other changes `get()` throws a `RuntimeException`, as it can't read it, `set()` and `setSymlink()` refuse it, as a hook that exists already, and `remove()` removes the link. + * `Repository::getSize()` is deprecated: `getSizeInBytes()` returns bytes, + instead of kilobytes. Neither follows links: a link in the git directory, + to a hook not deployed yet for instance, counts for nothing, where + `getSize()` counted the file it points to, and threw for a missing one. diff --git a/doc/repository.md b/doc/repository.md index a2cbe639..f57532ce 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -89,6 +89,8 @@ if ($repository->isHeadDetached()) { In a repository without commits yet, `HEAD` points to a branch that doesn't exist yet: `getHead()` and `getHeadCommit()` throw a `ReferenceNotFoundException`, and `isHeadAttached()` returns `true`. +`getHead()` throws one too when `HEAD` points to a reference that isn't a +branch, like a tag, which git allows. Commits, trees and blobs ------------------------ @@ -160,15 +162,19 @@ $repository->setDescription('My project'); Compute size of a repository ---------------------------- -`getSize()` returns the size of the files of the git directory, working tree -excluded, in kilobytes (1,000 bytes): +`getSizeInBytes()` returns the size of the files of the git directory, +working tree excluded, in bytes. It doesn't follow links, and leaves out a +file git deletes meanwhile, like a lock during a `gc` or a push: ```php -$size = $repository->getSize(); +$size = $repository->getSizeInBytes(); -echo 'Your repository size is '.$size.' KB'; +echo 'Your repository size is '.round($size / 1000).' KB'; ``` +`getSize()`, which returns kilobytes (1,000 bytes), is deprecated since 2.0: +use `getSizeInBytes()`. + Running git commands -------------------- diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index 7901ed38..009ce6ee 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -113,7 +113,7 @@ public function getLines(): array $hash = $this->revision->getCommit()->getHash(); $this->lines = []; foreach ($this->repository->getRepositoryDriver()->getBlameLines($hash, $this->file, $this->firstLine, $this->lastLine) as $data) { - $this->lines[$data->finalLine] = new Line($this->repository->getCommit($data->commitHash), $data->finalLine, $data->originalLine, $data->content); + $this->lines[$data->number] = new Line($this->repository->getCommit($data->commitHash), $data->number, $data->originalNumber, $data->content); } return $this->lines; diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index c857a2ae..4d4d4f8e 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -12,10 +12,12 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; /** - * Representation of a Blob commit. + * Representation of a blob, the content of a file. * * @author Alexandre Salomé */ @@ -35,6 +37,8 @@ final class Blob /** * @param Repository $repository Repository where the blob is located * @param string $hash Hash of the blob + * + * @internal Get it from Repository::getBlob(), which returns the same object while the application uses it */ public function __construct( private readonly Repository $repository, @@ -50,7 +54,8 @@ public function getHash(): string /** * @return string content of the blob * - * @throws Exception\DriverException Error occurred while getting content of blob + * @throws ReferenceNotFoundException The blob doesn't exist, or the object of its hash isn't a blob + * @throws DriverException git fails to read it */ public function getContent(): string { diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index f56668ab..ed3b35b6 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -39,6 +39,8 @@ final class Commit extends Revision /** * @param Repository $repository Repository of the commit * @param string $hash Hash of the commit + * + * @internal Get it from Repository::getCommit(), which returns the same object while the application uses it */ public function __construct(Repository $repository, string $hash, ?CommitData $data = null) { diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php index 20281f45..844805a5 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php @@ -69,7 +69,7 @@ protected function doParse(): void $content = $this->consumeTo("\n"); $this->consumeNewLine(); - $this->lines[] = new BlameLineData($hash, (int) $vars[1], (int) $vars[2], $content); + $this->lines[] = new BlameLineData($hash, (int) $vars[2], (int) $vars[1], $content); } } } diff --git a/src/Gitonomy/Git/Driver/Data/BlameLineData.php b/src/Gitonomy/Git/Driver/Data/BlameLineData.php index 31ed9ce7..fc651c91 100644 --- a/src/Gitonomy/Git/Driver/Data/BlameLineData.php +++ b/src/Gitonomy/Git/Driver/Data/BlameLineData.php @@ -22,13 +22,13 @@ final readonly class BlameLineData { /** - * @param int $originalLine The number of the line in the file of $commitHash - * @param int $finalLine The number of the line in the blamed file + * @param int $number The number of the line in the blamed file + * @param int $originalNumber The number of the line in the file of $commitHash */ public function __construct( public string $commitHash, - public int $originalLine, - public int $finalLine, + public int $number, + public int $originalNumber, public string $content, ) { } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index 9f33f60d..3162bef3 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -587,9 +587,9 @@ public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $hunk = $this->ffi->git_blame_hunk_byline($blame, $i); $line = $this->ffi->git_blame_line_byindex($blame, $i); $content = rtrim(\FFI::string($line->ptr, $line->len), "\n"); - $originalLine = $hunk->orig_start_line_number + ($i - $hunk->final_start_line_number); + $originalNumber = $hunk->orig_start_line_number + ($i - $hunk->final_start_line_number); - $result[] = new BlameLineData($this->oidToHex(\FFI::addr($hunk->final_commit_id)), $originalLine, $i, $content); + $result[] = new BlameLineData($this->oidToHex(\FFI::addr($hunk->final_commit_id)), $i, $originalNumber, $content); } return $result; diff --git a/src/Gitonomy/Git/Exception/UnexpectedValueException.php b/src/Gitonomy/Git/Exception/UnexpectedValueException.php index c88d8ed3..cfe3e4c3 100644 --- a/src/Gitonomy/Git/Exception/UnexpectedValueException.php +++ b/src/Gitonomy/Git/Exception/UnexpectedValueException.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Exception; +/** + * @deprecated since gitonomy/gitlib 2.0, gitlib no longer throws it: Tree::resolvePath(), which threw it in 1.x, throws an InvalidArgumentException instead + */ final class UnexpectedValueException extends \UnexpectedValueException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Reference.php b/src/Gitonomy/Git/Reference.php index 751fd199..1f977967 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -15,6 +15,9 @@ /** * Reference in a Git repository. * + * Branch, Tag and Stash extend it: extending it outside gitlib isn't + * supported, and won't be possible in 3.0. + * * @author Alexandre Salomé * @author Julien DIDIER */ @@ -22,6 +25,8 @@ abstract class Reference extends Revision { /** * @param string $commitHash What the reference points to, through an annotated tag + * + * @internal Get it from Repository::getReferences(), which gitlib reads again after its writes */ public function __construct( Repository $repository, @@ -31,6 +36,11 @@ public function __construct( parent::__construct($repository, $fullname); } + /** + * @return string The name, without its prefix: "master", "origin/master" for a remote branch, "0.1" for a tag + */ + abstract public function getName(): string; + public function getFullname(): string { return $this->revision; diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index ee6a4a80..d316e92b 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -34,6 +34,8 @@ final class Tag extends Reference /** * @param string $commitHash What the tag points to, through its annotated tag object * @param string|null $tagHash The annotated tag object, null for a lightweight tag + * + * @internal Get it from Repository::getReferences(), which gitlib reads again after its writes */ public function __construct( Repository $repository, diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 85395b51..530d7828 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -35,6 +35,9 @@ final class ReferenceBag implements \Countable, \IteratorAggregate */ private ?array $references = null; + /** + * @internal Get it from Repository::getReferences(), which gitlib reads again after its writes + */ public function __construct( private readonly Repository $repository, ) { diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 7746bf55..49c6de5b 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -25,6 +25,7 @@ use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Util\WeakCache; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; @@ -238,24 +239,20 @@ public function isBare(): bool /** * Returns the HEAD resolved as a commit. + * + * @throws ReferenceNotFoundException HEAD points to a branch without commits yet */ public function getHeadCommit(): Commit { - $head = $this->getHead(); - - if ($head instanceof Reference) { - return $head->getCommit(); - } - - return $head; + return $this->getRevision('HEAD')->getCommit(); } /** - * @return Reference|Commit The branch HEAD points to, or the commit when HEAD is detached + * @return Branch|Commit The branch HEAD points to, or the commit when HEAD is detached * - * @throws ReferenceNotFoundException HEAD points to a branch without commits yet + * @throws ReferenceNotFoundException HEAD points to a branch without commits yet, or to a reference that isn't a branch */ - public function getHead(): Reference|Commit + public function getHead(): Branch|Commit { $reference = $this->repositoryDriver->getHeadReference(); @@ -263,7 +260,13 @@ public function getHead(): Reference|Commit return $this->getCommit($this->repositoryDriver->resolveRevision('HEAD')); } - return $this->getReferences()->get($reference); + $head = $this->getReferences()->get($reference); + // git lets HEAD point to any reference under "refs/", a tag included. + if (!$head instanceof Branch) { + throw new ReferenceNotFoundException(\sprintf('HEAD points to "%s", which is not a branch.', $reference)); + } + + return $head; } public function isHeadDetached(): bool @@ -519,20 +522,45 @@ public function getLegacyDiff(RevisionList|Revision|string|array|null $revisions throw new InvalidArgumentException(\sprintf('Cannot compute a diff of "%s".', implode('", "', $revisions))); } + /** + * @return int The size of the files of the git directory, working tree excluded, in bytes + */ + public function getSizeInBytes(): int + { + $size = 0; + /** @var \SplFileInfo $file */ + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->gitDir, \FilesystemIterator::SKIP_DOTS)) as $file) { + // A link isn't followed: it may point to a file not deployed yet, like a hook. + if ($file->isLink()) { + continue; + } + + try { + $size += $file->getSize(); + } catch (\RuntimeException $e) { + // git deleted it since it was listed, during a gc or a push: + // a lock, a temporary pack... It no longer counts. + if (file_exists($file->getPathname())) { + throw $e; + } + } + } + + return $size; + } + /** * Returns the size of repository, in kilobytes. * * @return int A sum, in kilobytes + * + * @deprecated since gitonomy/gitlib 2.0, use getSizeInBytes() instead */ public function getSize(): int { - $totalBytes = 0; - /** @var \SplFileInfo $file */ - foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->gitDir, \FilesystemIterator::SKIP_DOTS)) as $file) { - $totalBytes += $file->getSize(); - } + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getSizeInBytes()" instead.', __METHOD__); - return (int) round($totalBytes / 1000); + return (int) round($this->getSizeInBytes() / 1000); } /** @@ -744,7 +772,19 @@ private function fullHash(string $hash, ObjectType $type): string return $hash; } - $fullHash = $this->repositoryDriver->resolveRevision($hash.'^{'.$type->value.'}'); + try { + $fullHash = $this->repositoryDriver->resolveRevision($hash.'^{'.$type->value.'}'); + } catch (ReferenceNotFoundException) { + // One more call, only on failure: an object of another type, or none. + try { + $this->repositoryDriver->resolveRevision($hash); + } catch (ReferenceNotFoundException) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" does not exist.', $hash)); + } + + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value)); + } + // "^{tree}" also peels a commit to its tree. if (!str_starts_with($fullHash, $hash)) { throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value)); diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index a23b04fd..bfafe0bd 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -15,10 +15,18 @@ use Gitonomy\Git\Exception\ReferenceNotFoundException; /** + * A name git resolves to a commit: a branch, a tag, a hash, "HEAD~2"... + * + * Commit and Reference extend it: extending it outside gitlib isn't + * supported, and won't be possible in 3.0. + * * @author Alexandre Salomé */ class Revision { + /** + * @internal Get it from Repository::getRevision() + */ public function __construct( protected readonly Repository $repository, protected readonly string $revision, diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index 2eb8e17b..bef883c4 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -25,6 +25,9 @@ final class Tree */ private ?array $entries = null; + /** + * @internal Get it from Repository::getTree(), which returns the same object while the application uses it + */ public function __construct( private readonly Repository $repository, private readonly string $hash, diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 80a420e0..b944031a 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -40,6 +40,25 @@ public function testGetBranch(bool $bare): void $this->assertSame($branch->getCommitHash(), $branch->getCommit()->getHash(), 'Hash is correctly resolved'); } + public function testEveryReferenceHasAName(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.email', 'test@example.com']); + self::git($repository, 'config', ['user.name', 'Test']); + file_put_contents($repository->getWorkingDir().'/README.md', "stashed\n"); + self::git($repository, 'stash'); + + $names = []; + foreach ($repository->getReferences() as $fullname => $reference) { + $names[$fullname] = $reference->getName(); + } + + $this->assertSame('master', $names['refs/heads/master']); + $this->assertSame('origin/master', $names['refs/remotes/origin/master']); + $this->assertSame('0.1', $names['refs/tags/0.1']); + $this->assertSame('stash', $names['refs/stash']); + } + #[DataProvider('provideBare')] public function testHasBranch(bool $bare): void { diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 47453573..a42e2b96 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -123,6 +123,38 @@ public function testDetachedHead(): void $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); } + public function testTheHeadOfARepositoryWithoutCommitsThrows(): void + { + $repository = self::createEmptyRepository(); + + // HEAD points to a branch without commits yet. + $this->assertTrue($repository->isHeadAttached()); + + try { + $repository->getHeadCommit(); + $this->fail('A repository without commits has no HEAD commit.'); + } catch (ReferenceNotFoundException) { + } + + $this->expectException(ReferenceNotFoundException::class); + + $repository->getHead(); + } + + public function testHeadPointingToATagIsNoBranch(): void + { + $repository = self::createFoobarRepository(); + // Like a branch for git, which commits to the tag. + self::git($repository, 'symbolic-ref', ['HEAD', 'refs/tags/0.1']); + + $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('HEAD points to "refs/tags/0.1", which is not a branch.'); + + $repository->getHead(); + } + public function testObjectsAreTheSameWhileUsed(): void { $repository = self::createFoobarRepository(); @@ -311,10 +343,22 @@ public function testGetMergeBase(): void public function testAnAbbreviatedHashNamesAnObjectOfTheRightType(): void { $repository = self::createFoobarRepository(); + $hash = substr($repository->getCommit(self::LONGFILE_COMMIT)->getTreeHash(), 0, 8); $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage(\sprintf('The object "%s" is not a blob.', $hash)); - $repository->getBlob(substr(self::LONGFILE_COMMIT, 0, 8)); + $repository->getBlob($hash); + } + + public function testAnAbbreviatedHashOfNoObject(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The object "12345678" does not exist.'); + + $repository->getTree('12345678'); } public function testAnAbbreviatedCommitHashNamesNoTree(): void @@ -539,13 +583,36 @@ public function testGetBlobWithExistingWorks(bool $bare): void } #[DataProvider('provideBare')] - public function testGetSize(bool $bare): void + public function testGetSizeInBytes(bool $bare): void { $repository = self::createFoobarRepository($bare); - $size = $repository->getSize(); + $size = $repository->getSizeInBytes(); // The exact figure depends on git's packing/compression, which varies across versions. - $this->assertGreaterThanOrEqual(40, $size, 'Repository is at least 40KB'); - $this->assertLessThan(84, $size, 'Repository is less than 84KB'); + $this->assertGreaterThanOrEqual(40_000, $size, 'Repository is at least 40KB'); + $this->assertLessThan(84_000, $size, 'Repository is less than 84KB'); + } + + public function testGetSizeInBytesFollowsNoLink(): void + { + $repository = self::createEmptyRepository(); + $size = $repository->getSizeInBytes(); + + file_put_contents($repository->getGitDir().'/file', str_repeat('a', 1000)); + // Like a hook linking to a file not deployed yet. + symlink($repository->getGitDir().'/missing', $repository->getGitDir().'/link'); + + $this->assertSame($size + 1000, $repository->getSizeInBytes()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetSizeInKilobytes(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::getSize()" method is deprecated, use "getSizeInBytes()" instead.'); + + $repository = self::createFoobarRepository(); + + $this->assertSame((int) round($repository->getSizeInBytes() / 1000), $repository->getSize()); } public function testIsBare(): void From 5dfc7407ebe4c1821949f6ad90603942b4adff68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 109/150] Fix the doc of the drivers - DriverInterface and RepositoryDriverInterface list the exceptions their methods throw: a ReferenceNotFoundException for a missing object, an UnsupportedOperationException, and the URLs and arguments they refuse. - DiffFileData::$newIndex is the hash of the content of a file of the working tree, which both drivers give, not null. - getCommitHash() is the object a tag points to, which may be a tree or a blob. - benchmark.php needs a clone of gitlib: Composer installs neither it nor symfony/console. - LibGit2Driver::isSupported() takes no path: the doc shows how to check one. - The errors list RuntimeException, and the ProcessTimedOutException of run(). - The CHANGELOG no longer announces Symfony 6.4, which gitlib doesn't support. - Typos. --- CHANGELOG.md | 8 ++-- doc/references.md | 9 +++-- doc/repository.md | 37 +++++++++++++++---- src/Gitonomy/Git/Commit.php | 2 +- src/Gitonomy/Git/Driver/Data/DiffFileData.php | 2 +- src/Gitonomy/Git/Driver/DriverInterface.php | 31 +++++++++++++--- .../Git/Driver/RepositoryDriverInterface.php | 17 ++++++--- src/Gitonomy/Git/Log.php | 2 +- src/Gitonomy/Git/Reference.php | 2 +- src/Gitonomy/Git/Repository.php | 5 ++- 10 files changed, 85 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2625a46..a992c0a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ deprecated until 3.0. - Return the dates of commits and tags at the UTC offset of their author, committer or tagger, like `git log` shows them, instead of in UTC: the instant doesn't change, but `format()` returns their local time. -- Require PHP 8.4 or later, Symfony 6.4/7.4/8.1+: most classes are `final` (some `readonly`), +- Require PHP 8.4 or later, Symfony 7.4/8.1+: most classes are `final` (some `readonly`), and every method has native types. ([#238](https://github.com/gitonomy/gitlib/pull/238)) - Remove `Gitonomy\Git\Util\StringHelper`, replaced by `symfony/string`, and its configurable encoding: gitlib reads everything in UTF-8. ([#252](https://github.com/gitonomy/gitlib/pull/252)) @@ -55,9 +55,9 @@ deprecated until 3.0. FFI extension, as an alternative to the default `Driver\Cli\CliDriver`: pass it as the driver of `Repository`. It avoids spawning a `git` process for every operation; see ["Choosing a driver"](doc/repository.md#choosing-a-driver) for its requirements, its - differences with `CliDriver`, and when it is faster. `benchmark.php` compares both drivers - on a repository. With php-fpm, `LibGit2Driver::preload()` keeps libgit2 loaded from - `opcache.preload`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) + differences with `CliDriver`, and when it is faster. `benchmark.php`, from a clone of gitlib, + compares both drivers on a repository. With php-fpm, `LibGit2Driver::preload()` keeps libgit2 + loaded from `opcache.preload`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - `Repository` takes the working directory and the driver as arguments: a `Driver\Cli\CliDriver` by default, configured through its constructor (git binary, environment, timeout and logger, which gets every command, clones included), or any `Driver\DriverInterface`, to replace it in diff --git a/doc/references.md b/doc/references.md index d6e3dce4..9feb1de7 100644 --- a/doc/references.md +++ b/doc/references.md @@ -76,9 +76,12 @@ $hash = $master->getCommitHash(); $lastModification = $master->getLastModification('README.md'); ``` -`getCommitHash()` is always a commit: for an annotated tag, the commit the -tag object points to. A reference object keeps the commit it pointed to when -it was read: get it again from the bag after a change. +`getCommitHash()` is the commit the reference points to: for an annotated +tag, the commit the tag object points to. A tag may point to a tree or a blob +instead: `getCommitHash()` is then its hash, and reading the `Commit` of +`getCommit()` throws a `ReferenceNotFoundException`. A reference object keeps +the commit it pointed to when it was read: get it again from the bag after a +change. Create and delete references ---------------------------- diff --git a/doc/repository.md b/doc/repository.md index f57532ce..998a6ad0 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -196,12 +196,13 @@ echo $process->getErrorOutput(); ``` Both throw a `ProcessException` when git fails, a `DriverException` when it -can't start, and an `UnsupportedOperationException` with another driver -than `CliDriver`. The relative paths of the arguments are relative to the -repository. The arguments go to git as they are: a value from your users -starting with a dash is an option for git, unless `--` comes before it. -gitlib reads the references again after the command, which may have written -them. +can't start, a `ProcessTimedOutException` when it runs longer than +[the timeout](#configuring-clidriver), and an `UnsupportedOperationException` +with another driver than `CliDriver`. The relative paths of the arguments are +relative to the repository. The arguments go to git as they are: a value from +your users starting with a dash is an option for git, unless `--` comes +before it. gitlib reads the references again after the command, which may +have written them. `shell()` is deprecated since 2.0: run `git shell` with Symfony Process instead. @@ -238,6 +239,10 @@ or empty data instead. Every exception of gitlib implements would run, a URL whose ssh host or user starts with a dash, which ssh would read as an option, or a reference to delete outside `refs/`, like `HEAD`. +- `RuntimeException`: any other failure, like a hook that exists already + (`Hooks::set()`), a file of the git directory that can't be written, or + an output of git that gitlib can't read. `DriverException` and + `UnsupportedOperationException` extend it. - `LogicException`: a call that makes no sense, like the working copy of a bare repository. @@ -304,6 +309,19 @@ path to the driver: $driver = new LibGit2Driver('/opt/libgit2/lib/libgit2.so.1.9'); ``` +`isSupported()` only looks for the usual names: to check a path, construct +the driver and catch the exception instead: + +```php +use Gitonomy\Git\Exception\UnsupportedOperationException; + +try { + $driver = new LibGit2Driver('/opt/libgit2/lib/libgit2.so.1.9'); +} catch (UnsupportedOperationException) { + $driver = new CliDriver(); +} +``` + Differences with `CliDriver`: - `Repository::run()` and `Repository::runProcess()` throw an @@ -418,9 +436,14 @@ an AMD Ryzen AI 9 HX 370. ([libgit2#3027](https://github.com/libgit2/libgit2/issues/3027)). Keep the default `CliDriver`, unless your application runs many small reads -in a long-running process, and check with your own repository: +in a long-running process, and check with your own repository, from a clone +of gitlib (Composer doesn't install `benchmark.php`, nor `symfony/console`, +which it requires): ```bash +git clone https://github.com/gitonomy/gitlib.git +cd gitlib +composer install php benchmark.php /path/to/repository path/to/a/file ``` diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index ed3b35b6..150f935e 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -245,7 +245,7 @@ public function getCommitterName(): string } /** - * Returns the comitter email. + * Returns the committer email. */ public function getCommitterEmail(): string { diff --git a/src/Gitonomy/Git/Driver/Data/DiffFileData.php b/src/Gitonomy/Git/Driver/Data/DiffFileData.php index 4ce29b66..aecb8682 100644 --- a/src/Gitonomy/Git/Driver/Data/DiffFileData.php +++ b/src/Gitonomy/Git/Driver/Data/DiffFileData.php @@ -34,7 +34,7 @@ * @param string|null $oldName Null for a creation * @param string|null $newName Null for a deletion * @param string|null $oldIndex The hash of the old blob, null when there is none - * @param string|null $newIndex The hash of the new blob, null when there is none, or for a file of the working tree + * @param string|null $newIndex The hash of the new blob, null when there is none; for a file of the working tree, the hash of its content, which the repository may not store * @param list $changes * * @throws InvalidArgumentException Both names are null diff --git a/src/Gitonomy/Git/Driver/DriverInterface.php b/src/Gitonomy/Git/Driver/DriverInterface.php index ea5c76cb..9323c764 100644 --- a/src/Gitonomy/Git/Driver/DriverInterface.php +++ b/src/Gitonomy/Git/Driver/DriverInterface.php @@ -24,8 +24,17 @@ * Pass one to Repository, which uses {@see CliDriver} by default, to * configure the backend, or to replace it in tests. * - * Every method throws an {@see InvalidArgumentException} when an argument - * contains a NUL byte, which git doesn't accept. + * Every method throws a {@see DriverException} when git reports a failure, + * an {@see UnsupportedOperationException} when the driver can't perform the + * operation at all, like LibGit2Driver cloning a bundle, and an + * {@see InvalidArgumentException} when an argument contains a NUL byte, which + * git doesn't accept. + * + * An application may get the URL of a repository from its users: every method + * taking one refuses, with an {@see InvalidArgumentException}, a URL git would + * misread, before running git. A URL starting with a dash is an option for + * git, an "ext::" URL runs its command, and an ssh host or user starting with + * a dash is an option for ssh. * * @author Grégoire Pineau */ @@ -39,7 +48,7 @@ interface DriverInterface * * @throws InvalidArgumentException $dir doesn't exist, or isn't a repository * @throws DriverException The driver can't open the repository - * @throws UnsupportedOperationException The repository uses SHA-256 hashes + * @throws UnsupportedOperationException The repository uses SHA-256 hashes, or a format the driver can't read, like reftable for LibGit2Driver */ public function open(string $dir, ?string $workingDir): RepositoryDriverInterface; @@ -58,7 +67,9 @@ public function initBare(string $path): void; * or the default branch of $url when null; like git, a tag named $branch * is checked out with HEAD detached, when $url has no such branch. * - * @throws DriverException + * @throws InvalidArgumentException $url is a URL git would misread + * @throws DriverException git fails to clone $url + * @throws UnsupportedOperationException The driver can't clone $url, like LibGit2Driver a bundle */ public function cloneFrom(string $url, string $path, ?string $branch): void; @@ -68,7 +79,9 @@ public function cloneFrom(string $url, string $path, ?string $branch): void; * when null; like git, HEAD is detached at a tag named $branch, when $url * has no such branch. * - * @throws DriverException + * @throws InvalidArgumentException $url is a URL git would misread + * @throws DriverException git fails to clone $url + * @throws UnsupportedOperationException The driver can't clone $url, like LibGit2Driver a bundle */ public function cloneBareFrom(string $url, string $path, ?string $branch): void; @@ -76,7 +89,9 @@ public function cloneBareFrom(string $url, string $path, ?string $branch): void; * Clones every reference of $url into a bare repository in $path, set up * to mirror $url when fetching. * - * @throws DriverException + * @throws InvalidArgumentException $url is a URL git would misread + * @throws DriverException git fails to clone $url + * @throws UnsupportedOperationException The driver can't clone $url, like LibGit2Driver a bundle */ public function mirrorFrom(string $url, string $path): void; @@ -84,12 +99,16 @@ public function mirrorFrom(string $url, string $path): void; * @param string $url A URL, or the path of a repository: never the name of a remote * * @return bool Whether $url can be reached + * + * @throws InvalidArgumentException $url is a URL git would misread */ public function isValidRepository(string $url): bool; /** * @param string $url Same as {@see isValidRepository()} * @param string $branch The name of the branch, exactly: "release" doesn't name "feature/release" + * + * @throws InvalidArgumentException $url is a URL git would misread */ public function isValidRepositoryAndBranch(string $url, string $branch): bool; } diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index 2a8cd55f..1cb15960 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -30,11 +30,18 @@ * Implementations know nothing of Repository: they read and write git data, * and the domain objects (Commit, Tree, Reference...) build on it. * - * Every method throws a {@see DriverException} when git reports a failure (an - * unknown revision, a conflict...), an {@see UnsupportedOperationException} - * when the driver can't perform the operation at all, and an - * {@see InvalidArgumentException} when an argument contains a NUL byte, which - * git doesn't accept. + * resolveRevision(), and the methods reading an object by its hash, like + * getCommitData(), throw a {@see ReferenceNotFoundException} when it doesn't + * exist. Every method throws a {@see DriverException} when git reports + * another failure (a conflict, a lock...), an + * {@see UnsupportedOperationException} when the driver can't perform the + * operation at all, and an {@see InvalidArgumentException} when an argument + * contains a NUL byte, which git doesn't accept. + * + * git reads an argument starting with a dash as an option: the revision + * "--output=/path" of a log writes a file. Every method refuses, with an + * {@see InvalidArgumentException}, a revision, a reference or a hash starting + * with a dash, before running git. A path may start with one. * * @author Grégoire Pineau */ diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index d2065ead..038edccd 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -35,7 +35,7 @@ final class Log implements \Countable, \IteratorAggregate private ?int $limit; /** - * Instanciates a git log object. + * Instantiates a git log object. * * The revisions are names, ranges ("a..b", "a...b"), names to exclude * ("^a"), or "--all". diff --git a/src/Gitonomy/Git/Reference.php b/src/Gitonomy/Git/Reference.php index 1f977967..6753aac8 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -52,7 +52,7 @@ public function delete(): void } /** - * @return string The commit the reference points to, when it was read: the commit an annotated tag points to, for one + * @return string The object the reference points to, when it was read, through an annotated tag: a commit, unless a tag points to a tree or a blob */ public function getCommitHash(): string { diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 49c6de5b..6acd377a 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -22,6 +22,7 @@ use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ProcessTimedOutException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Exception\UnsupportedOperationException; @@ -305,7 +306,7 @@ public function getWorkingDir(): ?string } /** - * Instanciates a revision. + * Instantiates a revision. * * @param string $name Name of the revision */ @@ -650,6 +651,7 @@ public function setDescription(string $description): static * @return string output of the process * * @throws ProcessException Error while executing git command + * @throws ProcessTimedOutException git runs longer than the timeout of the CliDriver * @throws DriverException git can't start, or is killed * @throws UnsupportedOperationException The driver isn't a CliDriver */ @@ -670,6 +672,7 @@ public function run(string $command, #[\SensitiveParameter] array $args = []): s * @param array $args Arguments of git command * * @throws ProcessException Error while executing git command + * @throws ProcessTimedOutException git runs longer than the timeout of the CliDriver * @throws DriverException git can't start, or is killed * @throws UnsupportedOperationException The driver isn't a CliDriver */ From 53cffe3d9f378002811922c471a94f3d0a0b8758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 110/150] Harden the test suite - The bootstrap removes every variable of git from the environment of the tests, but GIT_COMMAND: run from a hook, like pre-commit, they got GIT_DIR or GIT_INDEX_FILE, and wrote the repository of the hook. BootstrapTest checks it. - The tests of worktrees, reftable and SHA-256 skip by version of git, instead of on any failure of git. - The tests expecting a RuntimeException expect the exception gitlib throws, and check the messages, of hooks too. - Log::getDiff(), which ignores the paths of the log, and the pairing of renames, which differs between the drivers, have tests. - shivammathur/setup-php, a third-party action, is pinned by commit, like setup-castor. - PHPStan runs without baseline: the types it reported are declared. --- .gitattributes | 1 - .github/workflows/tests.yml | 10 +-- CHANGELOG.md | 7 ++- phpstan-baseline.neon | 62 ------------------- phpstan.neon | 1 - src/Gitonomy/Git/Parser/ParserBase.php | 3 + src/Gitonomy/Git/Repository.php | 23 +++---- src/Gitonomy/Git/Revision.php | 3 + src/Gitonomy/Git/RevisionList.php | 13 ++-- tests/Gitonomy/Git/Tests/BootstrapTest.php | 55 ++++++++++++++++ tests/Gitonomy/Git/Tests/DiffTest.php | 10 +-- tests/Gitonomy/Git/Tests/DriverParityTest.php | 29 +++++++++ tests/Gitonomy/Git/Tests/HooksTest.php | 26 +++++--- tests/Gitonomy/Git/Tests/LogTest.php | 17 +++++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 38 ++++++------ tests/bootstrap.php | 14 ++--- 16 files changed, 189 insertions(+), 123 deletions(-) delete mode 100644 phpstan-baseline.neon create mode 100644 tests/Gitonomy/Git/Tests/BootstrapTest.php diff --git a/.gitattributes b/.gitattributes index f8886cc3..cbf8b342 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,7 +8,6 @@ /.php-cs-fixer.php export-ignore /benchmark.php export-ignore /castor.php export-ignore -/phpstan-baseline.neon export-ignore /phpstan.neon export-ignore /phpunit.xml.dist export-ignore /test-git-versions.php export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 23a1fb82..c25cb5a1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@v7 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.4' @@ -55,7 +55,7 @@ jobs: uses: actions/checkout@v7 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.4' @@ -87,7 +87,7 @@ jobs: uses: actions/checkout@v7 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: ${{ matrix.php }} @@ -137,7 +137,7 @@ jobs: make -C "git-${{ matrix.git }}" prefix="$HOME/git" CFLAGS='-std=gnu17 -O2' NO_OPENSSL=1 NO_CURL=1 NO_EXPAT=1 NO_GETTEXT=1 NO_PERL=1 NO_PYTHON=1 NO_TCLTK=1 --jobs="$(nproc)" install - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.4' @@ -185,7 +185,7 @@ jobs: cmake --install libgit2-build - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: ${{ matrix.php }} extensions: ffi diff --git a/CHANGELOG.md b/CHANGELOG.md index a992c0a1..a6b03ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -231,10 +231,13 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). ### Minor - CI tests git 1.8.4 and 2.25.1, analyses the tests and the libgit2 driver with PHPStan, - checks the archive Composer installs, and the suite deletes what it creates, in any order. + without baseline, checks the archive Composer installs, pins the third-party actions by + commit, and the suite deletes what it creates, in any order. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - The tests run with a git configuration of their own, whatever the developer's - (`tests/fixtures/home`), which sets what gitlib sets back to git's defaults. + (`tests/fixtures/home`), which sets what gitlib sets back to git's defaults, and without the + variables of git of their environment: run from a hook, they wrote the repository of the hook + (`GIT_DIR`, `GIT_INDEX_FILE`...). - Add a regression test for reading a blob's content in a tree that also contains a submodule. ([#243](https://github.com/gitonomy/gitlib/pull/243)) - Replace the network-dependent test fixture (cloned from GitHub on every run) with a local, diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index 60e89c10..00000000 --- a/phpstan-baseline.neon +++ /dev/null @@ -1,62 +0,0 @@ -parameters: - ignoreErrors: - - - message: '#^Method Gitonomy\\Git\\Parser\\ParserBase\:\:consumeRegexp\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Parser/ParserBase.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:getLog\(\) has parameter \$paths with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:getLog\(\) has parameter \$revisions with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:run\(\) has parameter \$args with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:runProcess\(\) has parameter \$args with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:shell\(\) has parameter \$env with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Revision\:\:getLog\(\) has parameter \$paths with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Revision.php - - - - message: '#^Class Gitonomy\\Git\\RevisionList implements generic interface IteratorAggregate but does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/Gitonomy/Git/RevisionList.php - - - - message: '#^Method Gitonomy\\Git\\RevisionList\:\:__construct\(\) has parameter \$revisions with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/RevisionList.php - - - - message: '#^Method Gitonomy\\Git\\RevisionList\:\:getIterator\(\) return type with generic class ArrayIterator does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/Gitonomy/Git/RevisionList.php - diff --git a/phpstan.neon b/phpstan.neon index 7a65f6c4..781747b6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,4 @@ includes: - - phpstan-baseline.neon - tools/phpstan/vendor/phpstan/phpstan-phpunit/extension.neon - tools/phpstan/vendor/phpstan/phpstan-phpunit/rules.neon diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index bfb7f07c..5d1c6eb4 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -84,6 +84,9 @@ protected function consumeHash(): string return $vars[1]; } + /** + * @return array The matches, like the ones of preg_match() + */ protected function consumeRegexp(string $regexp): array { if (!preg_match($regexp.'A', $this->content, $vars, 0, $this->cursor)) { diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 6acd377a..1d95ca33 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -444,12 +444,12 @@ public function getBlame(Revision|string $revision, string $file, int|string|nul * * All those values can be null, meaning everything. * - * @param RevisionList|Revision|string|array|null $revisions An array of revisions to show logs from. Can be - * any text value type - * @param array|string|null $paths restrict log to modifications occurring on given - * paths - * @param int|null $offset start from a given offset in results - * @param int|null $limit limit number of total results + * @param RevisionList|Revision|string|array|null $revisions An array of revisions to show logs from. Can be + * any text value type + * @param string[]|string|null $paths restrict log to modifications occurring on given + * paths + * @param int|null $offset start from a given offset in results + * @param int|null $limit limit number of total results */ public function getLog(RevisionList|Revision|string|array|null $revisions = null, array|string|null $paths = null, ?int $offset = null, ?int $limit = null): Log { @@ -567,7 +567,8 @@ public function getSize(): int /** * Executes a shell command on the repository, using PHP pipes. * - * @param string $command The command to execute + * @param string $command The command to execute + * @param array $env Environment variables of the command * * @deprecated since gitonomy/gitlib 2.0, run "git shell" with Symfony Process instead */ @@ -645,8 +646,8 @@ public function setDescription(string $description): static * stderr even when they succeed: use {@see runProcess()} instead if you * need access to it. * - * @param string $command Git command to run (checkout, branch, tag) - * @param array $args Arguments of git command + * @param string $command Git command to run (checkout, branch, tag) + * @param array $args Arguments of git command * * @return string output of the process * @@ -668,8 +669,8 @@ public function run(string $command, #[\SensitiveParameter] array $args = []): s * meaningful output to stderr even when they succeed, so it can't be * read through run(). * - * @param string $command Git command to run (checkout, branch, tag) - * @param array $args Arguments of git command + * @param string $command Git command to run (checkout, branch, tag) + * @param array $args Arguments of git command * * @throws ProcessException Error while executing git command * @throws ProcessTimedOutException git runs longer than the timeout of the CliDriver diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index bfafe0bd..8c8f5e96 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -33,6 +33,9 @@ public function __construct( ) { } + /** + * @param string[]|string|null $paths Paths to filter on, with git's wildcards + */ public function getLog(array|string|null $paths = null, ?int $offset = null, ?int $limit = null): Log { return $this->repository->getLog($this, $paths, $offset, $limit); diff --git a/src/Gitonomy/Git/RevisionList.php b/src/Gitonomy/Git/RevisionList.php index c31c0972..da325c2d 100644 --- a/src/Gitonomy/Git/RevisionList.php +++ b/src/Gitonomy/Git/RevisionList.php @@ -16,18 +16,20 @@ /** * @author Alexandre Salomé + * + * @implements \IteratorAggregate */ final readonly class RevisionList implements \IteratorAggregate, \Countable { /** - * @var Revision[] + * @var list */ private array $revisions; /** * Constructs a revision list from a variety of types. * - * @param string|Revision|array $revisions can be a string, a Revision, an array of strings or an array of Revision, Branch, Tag, Commit + * @param string|Revision|array $revisions can be a string, a Revision, an array of strings or an array of Revision, Branch, Tag, Commit */ public function __construct(Repository $repository, string|Revision|array $revisions) { @@ -45,7 +47,7 @@ public function __construct(Repository $repository, string|Revision|array $revis foreach ($revisions as $revision) { if (\is_string($revision)) { $revision = new Revision($repository, $revision); - } elseif (!$revision instanceof Revision) { + } elseif (!$revision instanceof Revision) { // @phpstan-ignore instanceof.alwaysTrue (PHP doesn't check the types of the elements, unlike PHPStan.) throw new InvalidArgumentException(\sprintf('Expected a "Revision", got a "%s".', get_debug_type($revision))); } @@ -56,13 +58,16 @@ public function __construct(Repository $repository, string|Revision|array $revis } /** - * @return Revision[] + * @return list */ public function getAll(): array { return $this->revisions; } + /** + * @return \ArrayIterator + */ public function getIterator(): \ArrayIterator { return new \ArrayIterator($this->revisions); diff --git a/tests/Gitonomy/Git/Tests/BootstrapTest.php b/tests/Gitonomy/Git/Tests/BootstrapTest.php new file mode 100644 index 00000000..78289031 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/BootstrapTest.php @@ -0,0 +1,55 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Symfony\Component\Process\Process; + +/** + * The tests run git in an environment of their own, whatever the developer's. + */ +class BootstrapTest extends AbstractTestCase +{ + private const string BOOTSTRAP = __DIR__.'/../../../bootstrap.php'; + + public function testTheBootstrapRemovesTheVariablesOfGit(): void + { + // The variables git gives a hook, like pre-commit, running the tests. + $dir = self::createTempDir(); + $environment = [ + 'GIT_AUTHOR_NAME' => 'Hook', + 'GIT_COMMAND' => 'git', + 'GIT_CONFIG_NOSYSTEM' => '0', + 'GIT_DIR' => $dir.'/.git', + 'GIT_INDEX_FILE' => $dir.'/.git/index', + 'GIT_OBJECT_DIRECTORY' => $dir.'/.git/objects', + 'GIT_WORK_TREE' => $dir, + ]; + + $code = <<<'PHP' + require $argv[1]; + $variables = []; + foreach (['getenv' => getenv(), '_ENV' => $_ENV, '_SERVER' => $_SERVER] as $source => $values) { + $variables[$source] = array_filter($values, static fn (string $name): bool => str_starts_with($name, 'GIT_'), \ARRAY_FILTER_USE_KEY); + ksort($variables[$source]); + } + echo json_encode($variables); + PHP; + + // With $_ENV, which php.ini usually leaves empty. + $process = new Process([\PHP_BINARY, '-d', 'variables_order=EGPCS', '-r', $code, self::BOOTSTRAP], env: $environment); + $process->mustRun(); + + $expected = ['GIT_COMMAND' => 'git', 'GIT_CONFIG_NOSYSTEM' => '1']; + $this->assertSame(['getenv' => $expected, '_ENV' => $expected, '_SERVER' => $expected], json_decode($process->getOutput(), true)); + } +} diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index c207f661..05b95bd9 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -63,10 +63,10 @@ public function testGetFilesModification(bool $bare): void $this->assertTrue($files[0]->isModification(), 'image.jpg modified'); - $this->assertSame('image.jpg', $files[0]->getOldName(), 'Second file name is image.jpg'); - $this->assertSame('image.jpg', $files[0]->getNewName(), 'Second file name is image.jpg'); - $this->assertSame('100644', $files[0]->getOldMode(), 'Second file mode is a new file'); - $this->assertSame('100644', $files[0]->getNewMode(), 'Second file mode is correct'); + $this->assertSame('image.jpg', $files[0]->getOldName(), 'Old file name is image.jpg'); + $this->assertSame('image.jpg', $files[0]->getNewName(), 'New file name is image.jpg'); + $this->assertSame('100644', $files[0]->getOldMode(), 'Old file mode is a regular file'); + $this->assertSame('100644', $files[0]->getNewMode(), 'New file mode is a regular file'); $this->assertTrue($files[0]->isBinary(), 'binary file'); $this->assertSame(0, $files[0]->getAdditions(), '0 lines added'); @@ -679,7 +679,7 @@ protected function verifyCreateCommitDiff(Diff $diff): void { $files = $diff->getFiles(); - $this->assertCount(2, $files, '1 file in diff'); + $this->assertCount(2, $files, '2 files in diff'); $this->assertTrue($files[0]->isCreation(), 'script_A.php created'); diff --git a/tests/Gitonomy/Git/Tests/DriverParityTest.php b/tests/Gitonomy/Git/Tests/DriverParityTest.php index da19c7c1..c27752ef 100644 --- a/tests/Gitonomy/Git/Tests/DriverParityTest.php +++ b/tests/Gitonomy/Git/Tests/DriverParityTest.php @@ -14,6 +14,7 @@ use Gitonomy\Git\Diff\DiffLine; use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Driver\Data\DiffFileData; use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use Gitonomy\Git\Driver\RepositoryDriverInterface; @@ -91,6 +92,22 @@ public function testDiffs(): void } } + /** + * doc/repository.md: rename detection in diffs may pair files differently. + */ + public function testRenameDetectionMayPairFilesDifferently(): void + { + $master = $this->cli->resolveRevision('master'); + $cli = $this->cli->getDiff(self::INITIAL_COMMIT, $master)->files; + $libgit2 = $this->libgit2->getDiff(self::INITIAL_COMMIT, $master)->files; + + // git deletes README and creates README.md, which libgit2 finds similar enough for a rename. + $this->assertSame([['README', null], [null, 'README.md']], self::readmeNames($cli)); + $this->assertSame([['README', 'README.md']], self::readmeNames($libgit2)); + $this->assertCount(13, $cli); + $this->assertCount(12, $libgit2); + } + public function testBlame(): void { $master = $this->cli->resolveRevision('master'); @@ -127,6 +144,18 @@ private function assertSameBlob(string $hash): void } } + /** + * @param list $files + * + * @return list The old and new names of the files named README or README.md + */ + private static function readmeNames(array $files): array + { + $names = array_map(static fn (DiffFileData $file): array => [$file->oldName, $file->newName], $files); + + return array_values(array_filter($names, static fn (array $pair): bool => [] !== array_intersect($pair, ['README', 'README.md']))); + } + private function assertSameData(mixed $expected, mixed $actual, string $message = ''): void { $this->assertSame(self::normalize($expected), self::normalize($actual), $message); diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 03db4213..cbde3a5f 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -13,7 +13,6 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\BeforeClass; @@ -129,6 +128,17 @@ public function testGetInvalidNameThrowsException(bool $bare): void { $repository = self::createFoobarRepository($bare); $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"../config" is not a valid hook name: it must be the name of a file of the hooks directory, like "pre-commit".'); + + $repository->getHooks()->get('../config'); + } + + #[DataProvider('provideBare')] + public function testGetMissingThrowsException(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The hook "foo" does not exist.'); $repository->getHooks()->get('foo'); } @@ -165,6 +175,7 @@ public function testSymlinkWithExistingThrows(bool $bare): void { $repository = self::createFoobarRepository($bare); $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The hook "foo" exists already.'); $this->markAsSkippedIfSymlinkIsMissing(); @@ -197,6 +208,7 @@ public function testSetExistingThrows(bool $bare): void $repository->getHooks()->set('foo', 'bar'); $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The hook "foo" exists already.'); $repository->getHooks()->set('foo', 'bar'); } @@ -218,6 +230,7 @@ public function testRemoveNotExistingThrows(bool $bare): void { $repository = self::createFoobarRepository($bare); $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The hook "foo" does not exist.'); $repository->getHooks()->remove('foo'); } @@ -270,15 +283,14 @@ public function testSetCreatesTheHooksDirectory(): void public function testALinkedWorktreeSharesTheHooksOfItsRepository(): void { - $repository = self::createFoobarRepository(false); - $dir = self::createTempDir().'/worktree'; - - try { - self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); - } catch (ProcessException) { + if (version_compare(self::gitVersion(), '2.5.0', '<')) { $this->markTestSkipped('git worktree requires git 2.5.'); } + $repository = self::createFoobarRepository(false); + $dir = self::createTempDir().'/worktree'; + self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); + $worktree = new Repository($dir, driver: self::getDriver()); $worktree->getHooks()->set('pre-commit', "#!/bin/sh\nexit 1\n"); diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index fd6e311e..597001ef 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Commit; +use Gitonomy\Git\Diff\File; use Gitonomy\Git\Driver\Cli\Parser\LogParser; use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use Gitonomy\Git\Exception\DriverException; @@ -346,6 +347,22 @@ public function testLegacySetLimit(): void $this->assertCount(1, $log->getCommits()); } + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetDiffIgnoresThePathsOfTheLog(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Log::getDiff()" method is deprecated, use "Gitonomy\Git\Repository::getDiff()" instead.'); + + $repository = self::createFoobarRepository(); + $diff = $repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(); + $legacyDiff = $repository->getLog(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT, 'README.md')->getDiff(); + + // A log of README.md, but the diff of the range, which changes long.php only. + $this->assertSame(['long.php'], array_map(static fn (File $file): string => $file->getName(), $legacyDiff->getFiles())); + $this->assertSame($diff, $legacyDiff->toArray()); + $this->assertSame($diff, $repository->getLog([self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT])->getDiff()->toArray()); + } + public function testLogOfExcludedAndSymmetricRevisions(): void { $repository = self::createRepositoryToCommitTo(); diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index a42e2b96..50fd6782 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -24,7 +24,6 @@ use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ProcessTimedOutException; use Gitonomy\Git\Exception\ReferenceNotFoundException; -use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; @@ -55,20 +54,19 @@ public function testInit(): void $this->assertFalse($repository->isBare(), 'Repository is not bare'); $this->assertDirectoryExists($dir.'/.git/objects', 'objects/ folder is present'); $this->assertSame($dir.'/.git', $repository->getGitDir(), 'git dir as subfolder of argument'); - $this->assertSame($dir, $repository->getWorkingDir(), 'working dir present in bare repository'); + $this->assertSame($dir, $repository->getWorkingDir(), 'The folder passed as argument is the working dir'); } public function testOpensALinkedWorktree(): void { - $repository = self::createFoobarRepository(false); - $dir = self::createTempDir().'/worktree'; - - try { - self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); - } catch (ProcessException) { + if (version_compare(self::gitVersion(), '2.5.0', '<')) { $this->markTestSkipped('git worktree requires git 2.5.'); } + $repository = self::createFoobarRepository(false); + $dir = self::createTempDir().'/worktree'; + self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); + // Its ".git" is a file pointing to a directory of the main repository. $worktree = new Repository($dir, driver: self::getDriver()); @@ -95,13 +93,13 @@ public function testReadsTheHeadOfAReftableRepository(): void { // libgit2 1.9 doesn't read reftable repositories. $this->skipUnlessCliDriver(); - - $dir = self::createTempDir(); - $process = new Process([self::gitCommand(), 'init', '-q', '--ref-format=reftable', '--initial-branch=trunk', $dir]); - if (0 !== $process->run()) { + if (version_compare(self::gitVersion(), '2.45.0', '<')) { $this->markTestSkipped('Reftable repositories require git 2.45.'); } + $dir = self::createTempDir(); + new Process([self::gitCommand(), 'init', '-q', '--ref-format=reftable', '--initial-branch=trunk', $dir])->mustRun(); + $repository = new Repository($dir, driver: self::getDriver()); $this->assertTrue($repository->isHeadAttached()); @@ -166,12 +164,13 @@ public function testObjectsAreTheSameWhileUsed(): void public function testRefusesSha256Repositories(): void { - $dir = self::createTempDir(); - $process = new Process([self::gitCommand(), 'init', '-q', '--object-format=sha256', $dir]); - if (0 !== $process->run()) { + if (version_compare(self::gitVersion(), '2.29.0', '<')) { $this->markTestSkipped('SHA-256 repositories require git 2.29.'); } + $dir = self::createTempDir(); + new Process([self::gitCommand(), 'init', '-q', '--object-format=sha256', $dir])->mustRun(); + $this->expectException(UnsupportedOperationException::class); $this->expectExceptionMessage('uses SHA-256 hashes'); @@ -374,11 +373,13 @@ public function testAnAbbreviatedCommitHashNamesNoTree(): void public function testInitOnAnExistingFileFails(): void { - $this->expectException(RuntimeException::class); - $file = self::createTempDir().'/test'; touch($file); + $this->expectException(DriverException::class); + // git and libgit2 name the file they can't make a directory of. + $this->expectExceptionMessage($file); + Repository::initBare($file, driver: self::getDriver()); } @@ -652,7 +653,8 @@ public function testLoggerOk(bool $bare): void #[DataProvider('provideBare')] public function testLoggerNOk(bool $bare): void { - $this->expectException(RuntimeException::class); + $this->expectException(ProcessException::class); + $this->expectExceptionMessage('The git command "not-work" failed (exit 1): git: \'not-work\' is not a git command. See \'git --help\'.'); $logger = $this->createMock(LoggerInterface::class); $logger->expects($this->once()) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 42937796..00793165 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -13,15 +13,15 @@ require __DIR__.'/../vendor/autoload.php'; // git and libgit2 read the configuration of fixtures/home only, whatever the -// developer's: see fixtures/home/.gitconfig. Symfony Process passes on the -// variables of getenv() that $_SERVER has too, $_ENV taking precedence. +// developer's: see fixtures/home/.gitconfig. No other variable of git reaches +// them either: run from a hook, the tests would get GIT_DIR or GIT_INDEX_FILE, +// and write the repository of the hook. GIT_COMMAND is the git binary of the +// tests. Symfony Process passes on the variables of getenv() that $_SERVER has +// too, $_ENV taking precedence. +$gitVariables = array_filter(array_keys([...getenv(), ...$_ENV]), static fn (string $name): bool => str_starts_with($name, 'GIT_') && 'GIT_COMMAND' !== $name); foreach ([ - 'GIT_CONFIG' => null, - 'GIT_CONFIG_COUNT' => null, - 'GIT_CONFIG_GLOBAL' => null, + ...array_fill_keys($gitVariables, null), 'GIT_CONFIG_NOSYSTEM' => '1', - 'GIT_CONFIG_PARAMETERS' => null, - 'GIT_CONFIG_SYSTEM' => null, 'HOME' => __DIR__.'/fixtures/home', 'XDG_CONFIG_HOME' => null, ] as $name => $value) { From b5fa2e943051bf65f8299182087e9c72dd5072e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:54:23 +0000 Subject: [PATCH 111/150] Bump friendsofphp/php-cs-fixer in /tools/php-cs-fixer Bumps [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) from 3.95.24 to 3.95.27. - [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases) - [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md) - [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.95.24...v3.95.27) --- updated-dependencies: - dependency-name: friendsofphp/php-cs-fixer dependency-version: 3.95.27 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- tools/php-cs-fixer/composer.lock | 111 ++++++++++++++++--------------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/tools/php-cs-fixer/composer.lock b/tools/php-cs-fixer/composer.lock index 59437796..f3bfd23b 100644 --- a/tools/php-cs-fixer/composer.lock +++ b/tools/php-cs-fixer/composer.lock @@ -148,16 +148,16 @@ }, { "name": "composer/semver", - "version": "3.4.4", + "version": "3.5.1", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + "reference": "528948210cc46f967a4b98e83b19e76f2b504993" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993", "shasum": "" }, "require": { @@ -199,7 +199,7 @@ "homepage": "http://robbast.nl" } ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", "keywords": [ "semantic", "semver", @@ -209,7 +209,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" + "source": "https://github.com/composer/semver/tree/3.5.1" }, "funding": [ { @@ -221,7 +221,7 @@ "type": "github" } ], - "time": "2025-08-20T19:15:30+00:00" + "time": "2026-10-05T13:12:12+00:00" }, { "name": "composer/xdebug-handler", @@ -407,23 +407,23 @@ }, { "name": "fidry/cpu-core-counter", - "version": "1.3.0", + "version": "1.4.1", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/d384a7d0d6e67aa497327260ca95023e92dc2032", + "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "fidry/makefile": "^0.2.0", + "fidry/makefile": "^0.2.0 || ^1.1.2", "fidry/php-cs-fixer-config": "^1.1.2", "phpstan/extension-installer": "^1.2.0", "phpstan/phpstan": "^2.0", @@ -431,6 +431,7 @@ "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "symfony/polyfill-mbstring": "^1.33", "webmozarts/strict-phpunit": "^7.5" }, "type": "library", @@ -456,7 +457,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.4.1" }, "funding": [ { @@ -464,20 +465,20 @@ "type": "github" } ], - "time": "2025-08-14T07:29:31+00:00" + "time": "2026-09-29T22:11:39+00:00" }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.24", + "version": "v3.95.27", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "9a4b8050f8768646bf3693ee7416978ceeca6206" + "reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/9a4b8050f8768646bf3693ee7416978ceeca6206", - "reference": "9a4b8050f8768646bf3693ee7416978ceeca6206", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/1d0c68ee5f37446d8ad5970b82384cd1bb0a3317", + "reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317", "shasum": "" }, "require": { @@ -511,15 +512,15 @@ "require-dev": { "facile-it/paraunit": "^1.3.1 || ^2.11.0", "infection/infection": "^0.32.7", - "justinrainbow/json-schema": "^6.10.0", + "justinrainbow/json-schema": "^6.12.0", "keradus/cli-executor": "^2.3", "php-coveralls/php-coveralls": "^2.9.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", - "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", - "symfony/polyfill-php85": "^1.38", - "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", - "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" + "phpunit/phpunit": "^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.35 || ^13.3.3", + "symfony/polyfill-php85": "^1.41", + "symfony/var-dumper": "^5.4.48 || ^6.4.45 || ^7.4.18 || ^8.1.7", + "symfony/yaml": "^5.4.53 || ^6.4.45 || ^7.4.18 || ^8.1.6" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -560,7 +561,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.24" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.27" }, "funding": [ { @@ -568,7 +569,7 @@ "type": "github" } ], - "time": "2026-08-31T15:31:12+00:00" + "time": "2026-09-22T09:54:55+00:00" }, { "name": "psr/container", @@ -1948,16 +1949,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.41.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", "shasum": "" }, "require": { @@ -2006,7 +2007,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" }, "funding": [ { @@ -2026,20 +2027,20 @@ "type": "tidelift" } ], - "time": "2026-07-28T08:25:59+00:00" + "time": "2026-09-25T15:46:47+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.42.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", "shasum": "" }, "require": { @@ -2091,7 +2092,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" }, "funding": [ { @@ -2111,20 +2112,20 @@ "type": "tidelift" } ], - "time": "2026-08-07T06:33:24+00:00" + "time": "2026-09-23T22:02:09+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", "shasum": "" }, "require": { @@ -2176,7 +2177,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" }, "funding": [ { @@ -2196,20 +2197,20 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-09-24T15:19:04+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.37.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", "shasum": "" }, "require": { @@ -2260,7 +2261,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" }, "funding": [ { @@ -2280,7 +2281,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-09-25T15:50:05+00:00" }, { "name": "symfony/polyfill-php81", @@ -2364,16 +2365,16 @@ }, { "name": "symfony/polyfill-php84", - "version": "v1.38.1", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", "shasum": "" }, "require": { @@ -2420,7 +2421,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" }, "funding": [ { @@ -2440,7 +2441,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-09-23T22:01:09+00:00" }, { "name": "symfony/process", From a50a4505e7a8b6563341c530fc4d70383c969a33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:54:27 +0000 Subject: [PATCH 112/150] Bump phpstan/phpstan-phpunit from 2.0.21 to 2.1.1 in /tools/phpstan Bumps [phpstan/phpstan-phpunit](https://github.com/phpstan/phpstan-phpunit) from 2.0.21 to 2.1.1. - [Release notes](https://github.com/phpstan/phpstan-phpunit/releases) - [Commits](https://github.com/phpstan/phpstan-phpunit/compare/2.0.21...2.1.1) --- updated-dependencies: - dependency-name: phpstan/phpstan-phpunit dependency-version: 2.1.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- tools/phpstan/composer.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tools/phpstan/composer.lock b/tools/phpstan/composer.lock index 99a5babc..b92b4a12 100644 --- a/tools/phpstan/composer.lock +++ b/tools/phpstan/composer.lock @@ -59,11 +59,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.12", + "version": "2.3.1", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/174b0d88710f00a42598886504dd7a146f91ace5", - "reference": "174b0d88710f00a42598886504dd7a146f91ace5", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "reference": "6155239b488e95d26d1ebd95c6b4bb294d7f09db", "shasum": "" }, "require": { @@ -119,26 +119,26 @@ "type": "github" } ], - "time": "2026-08-31T19:09:43+00:00" + "time": "2026-10-08T21:09:11+00:00" }, { "name": "phpstan/phpstan-phpunit", - "version": "2.0.21", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "8dbdc3a3e2d7c5dfea655d20ecca5bc9b1b1e441" + "reference": "8d16b8478e523a47f8c02e83bce8981b5b5a3b03" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/8dbdc3a3e2d7c5dfea655d20ecca5bc9b1b1e441", - "reference": "8dbdc3a3e2d7c5dfea655d20ecca5bc9b1b1e441", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/8d16b8478e523a47f8c02e83bce8981b5b5a3b03", + "reference": "8d16b8478e523a47f8c02e83bce8981b5b5a3b03", "shasum": "" }, "require": { "phar-io/version": "^3.2", "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.2.6" + "phpstan/phpstan": "^2.3.0" }, "conflict": { "phpunit/phpunit": "<7.0" @@ -175,9 +175,9 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.21" + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.1.1" }, - "time": "2026-10-01T11:59:29+00:00" + "time": "2026-10-06T18:12:32+00:00" } ], "packages-dev": [], From 887329f08f23ab61a93a6091ae5936eab93521b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:54:30 +0000 Subject: [PATCH 113/150] Bump phpstan/phpstan from 2.2.12 to 2.3.1 in /tools/phpstan Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.12 to 2.3.1. - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.3.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- tools/phpstan/composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/phpstan/composer.lock b/tools/phpstan/composer.lock index 99a5babc..0a5c8713 100644 --- a/tools/phpstan/composer.lock +++ b/tools/phpstan/composer.lock @@ -59,11 +59,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.12", + "version": "2.3.1", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/174b0d88710f00a42598886504dd7a146f91ace5", - "reference": "174b0d88710f00a42598886504dd7a146f91ace5", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "reference": "6155239b488e95d26d1ebd95c6b4bb294d7f09db", "shasum": "" }, "require": { @@ -119,7 +119,7 @@ "type": "github" } ], - "time": "2026-08-31T19:09:43+00:00" + "time": "2026-10-08T21:09:11+00:00" }, { "name": "phpstan/phpstan-phpunit", From 0a966db494666a6664cdccf432ed18e752026bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:56:56 +0200 Subject: [PATCH 114/150] Indent the lists of UPGRADE-2.0.md and CHANGELOG.md like doc/ GitHub nests the blocks of a list item at the column of its text, 2 spaces after "- " and 3 after " * ". Python-Markdown, which builds the documentation site, nests them at 4 spaces per level: it rendered the nested lists and the code blocks of these files out of their items. Their items are written like the pages of doc/ write them, "- text", with a blank line between a nested block and the next item of a loose list. Only whitespace changes: GitHub renders both files the same. --- CHANGELOG.md | 436 +++++++++++----------- UPGRADE-2.0.md | 987 +++++++++++++++++++++++++------------------------ 2 files changed, 712 insertions(+), 711 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6b03ec2..e4427a87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,242 +10,242 @@ deprecated until 3.0. ### Breaking changes -- Run every git operation through a driver (`Gitonomy\Git\Driver`): the domain objects call a - `Driver\RepositoryDriverInterface`, typed (hashes, enums, value objects of `Driver\Data`), - instead of building processes and parsing their output. A failing operation always throws, a - missing revision or object a `ReferenceNotFoundException`, a timeout a - `ProcessTimedOutException`; dates are `\DateTimeImmutable`; names in diffs and trees are - UTF-8; references are read again after every write gitlib makes, and peel annotated tags; - the diff of a merge commit is against its first parent; blame lines, tree entries, diff - lines and logs are typed objects; the subject of a message is its first paragraph. See - [UPGRADE-2.0.md](UPGRADE-2.0.md) for every change. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Return the dates of commits and tags at the UTC offset of their author, committer or tagger, - like `git log` shows them, instead of in UTC: the instant doesn't change, but `format()` - returns their local time. -- Require PHP 8.4 or later, Symfony 7.4/8.1+: most classes are `final` (some `readonly`), - and every method has native types. ([#238](https://github.com/gitonomy/gitlib/pull/238)) -- Remove `Gitonomy\Git\Util\StringHelper`, replaced by `symfony/string`, and its configurable - encoding: gitlib reads everything in UTF-8. ([#252](https://github.com/gitonomy/gitlib/pull/252)) -- Require git 1.8.4 or later, the oldest version the test suite passes with: it simplifies the - log of a range filtered by path like the current versions, and `Log::count()` needs git - 1.7.2. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- `Diff::parse()` throws for a diff without its raw information, deprecated since 1.6.0 - ([#229](https://github.com/gitonomy/gitlib/pull/229)): parse the output of - `git diff --raw --patch`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- The constructors of `Commit`, `Tree`, `Blob`, `Revision`, the references and `ReferenceBag` - are internal: get these objects from `Repository`, which returns the same commit, tree or - blob while the application uses it, and reads the references again after its writes. - Extending `Revision` or `Reference` isn't supported anymore. -- `ProcessException` has a message of one line, with the error of git, the first line of its - error output starting with `fatal:` or `error:`, after the warnings: - `The git command "clone" failed (exit 128): fatal: ...`. The command line, the rest of the - error output and the output are no longer in the message: `getErrorOutput()` and - `getOutput()` return the outputs of git. -- When git can't start, a missing binary for instance, `CliDriver` throws a `DriverException` - instead of a `ProcessException`, with a message of one line, without the command line: - `The git command "clone" could not start: ...`. -- `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a name that no revision has, - with both drivers: the name of a remote branch alone, like `feature` for `origin/feature`, - created the local branch. Use `checkoutNewBranch()`. +- Run every git operation through a driver (`Gitonomy\Git\Driver`): the domain objects call a + `Driver\RepositoryDriverInterface`, typed (hashes, enums, value objects of `Driver\Data`), + instead of building processes and parsing their output. A failing operation always throws, a + missing revision or object a `ReferenceNotFoundException`, a timeout a + `ProcessTimedOutException`; dates are `\DateTimeImmutable`; names in diffs and trees are + UTF-8; references are read again after every write gitlib makes, and peel annotated tags; + the diff of a merge commit is against its first parent; blame lines, tree entries, diff + lines and logs are typed objects; the subject of a message is its first paragraph. See + [UPGRADE-2.0.md](UPGRADE-2.0.md) for every change. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Return the dates of commits and tags at the UTC offset of their author, committer or tagger, + like `git log` shows them, instead of in UTC: the instant doesn't change, but `format()` + returns their local time. +- Require PHP 8.4 or later, Symfony 7.4/8.1+: most classes are `final` (some `readonly`), + and every method has native types. ([#238](https://github.com/gitonomy/gitlib/pull/238)) +- Remove `Gitonomy\Git\Util\StringHelper`, replaced by `symfony/string`, and its configurable + encoding: gitlib reads everything in UTF-8. ([#252](https://github.com/gitonomy/gitlib/pull/252)) +- Require git 1.8.4 or later, the oldest version the test suite passes with: it simplifies the + log of a range filtered by path like the current versions, and `Log::count()` needs git + 1.7.2. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- `Diff::parse()` throws for a diff without its raw information, deprecated since 1.6.0 + ([#229](https://github.com/gitonomy/gitlib/pull/229)): parse the output of + `git diff --raw --patch`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- The constructors of `Commit`, `Tree`, `Blob`, `Revision`, the references and `ReferenceBag` + are internal: get these objects from `Repository`, which returns the same commit, tree or + blob while the application uses it, and reads the references again after its writes. + Extending `Revision` or `Reference` isn't supported anymore. +- `ProcessException` has a message of one line, with the error of git, the first line of its + error output starting with `fatal:` or `error:`, after the warnings: + `The git command "clone" failed (exit 128): fatal: ...`. The command line, the rest of the + error output and the output are no longer in the message: `getErrorOutput()` and + `getOutput()` return the outputs of git. +- When git can't start, a missing binary for instance, `CliDriver` throws a `DriverException` + instead of a `ProcessException`, with a message of one line, without the command line: + `The git command "clone" could not start: ...`. +- `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a name that no revision has, + with both drivers: the name of a remote branch alone, like `feature` for `origin/feature`, + created the local branch. Use `checkoutNewBranch()`. ### Features -- Add an experimental `Driver\Ffi\LibGit2Driver`, running libgit2 1.9 in-process through PHP's - FFI extension, as an alternative to the default `Driver\Cli\CliDriver`: pass it as the driver of - `Repository`. It avoids spawning a `git` process for every operation; see - ["Choosing a driver"](doc/repository.md#choosing-a-driver) for its requirements, its - differences with `CliDriver`, and when it is faster. `benchmark.php`, from a clone of gitlib, - compares both drivers on a repository. With php-fpm, `LibGit2Driver::preload()` keeps libgit2 - loaded from `opcache.preload`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- `Repository` takes the working directory and the driver as arguments: a `Driver\Cli\CliDriver` - by default, configured through its constructor (git binary, environment, timeout and logger, - which gets every command, clones included), or any `Driver\DriverInterface`, to replace it in - tests; see ["Using your own driver"](doc/repository.md#using-your-own-driver). The driver - finds the repository: linked worktrees, submodules and reftable repositories open, and a - directory that isn't a repository throws right away. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Add static methods to `Repository` creating repositories, `init()`, `initBare()`, - `cloneFrom()`, `cloneBareFrom()` and `mirrorFrom()`, and checking a repository URL, - `isValidRepository()` and `isValidRepositoryAndBranch()`; see - ["Create and access git repositories"](doc/admin.md). - ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Add `Repository::getDiff($from, $to)`, `Repository::getMergeBase()`, - `Repository::getSizeInBytes()` and `WorkingCopy::getDiff()`, - `WorkingCopy::checkoutNewBranch()`, `Branch::moveTo()` and `Branch::isMergedInto()`, - `Log::withOffset()` and `withLimit()`, `Blame::getHunks()` and the line numbers of - `Blame\Line`, `TreeEntry` and `Submodule`, `Diff\DiffLine` and `Diff\DiffLineType`, - `File::isModeChange()`, `FileChange::getAdditions()` and `getDeletions()`, - `PushReference::getFullname()` and `getRange()`, and the SSH and X.509 signatures of tags. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Add `WorkingCopy::merge()` to support `git merge`, fast-forwarding or not, with a - `FastForward` enum. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) -- Add `Repository::runProcess()` to expose stderr from successful commands: `run()` only - ever returned stdout, even on success, but some git commands (e.g. `push`) write - meaningful output to stderr even when they succeed. `run()` now delegates to it and keeps - its exact original behavior. - ([#247](https://github.com/gitonomy/gitlib/pull/247), fixes #205) -- Support git's mnemonic diff prefixes (`diff.mnemonicPrefix`) in the diff parser: enabling - this option replaces the default `a/`/`b/` prefixes with context-specific ones - (`c/`/`i/`/`o/`/`w/`), which used to raise a `RuntimeException` in tools like GrumPHP that - enable it. ([#245](https://github.com/gitonomy/gitlib/pull/245), fixes #114) -- Support `"copy from"`/`"copy to"` in the diff parser: diffs produced with copy detection - enabled (e.g. `diff.renames = copies`) used to fail to parse. `File` gains a new `isCopy()` - flag, and `isRename()` no longer reports `true` for copies, since a copy's source file - still exists afterward. - ([#244](https://github.com/gitonomy/gitlib/pull/244), fixes #14) +- Add an experimental `Driver\Ffi\LibGit2Driver`, running libgit2 1.9 in-process through PHP's + FFI extension, as an alternative to the default `Driver\Cli\CliDriver`: pass it as the driver of + `Repository`. It avoids spawning a `git` process for every operation; see + ["Choosing a driver"](doc/repository.md#choosing-a-driver) for its requirements, its + differences with `CliDriver`, and when it is faster. `benchmark.php`, from a clone of gitlib, + compares both drivers on a repository. With php-fpm, `LibGit2Driver::preload()` keeps libgit2 + loaded from `opcache.preload`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- `Repository` takes the working directory and the driver as arguments: a `Driver\Cli\CliDriver` + by default, configured through its constructor (git binary, environment, timeout and logger, + which gets every command, clones included), or any `Driver\DriverInterface`, to replace it in + tests; see ["Using your own driver"](doc/repository.md#using-your-own-driver). The driver + finds the repository: linked worktrees, submodules and reftable repositories open, and a + directory that isn't a repository throws right away. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Add static methods to `Repository` creating repositories, `init()`, `initBare()`, + `cloneFrom()`, `cloneBareFrom()` and `mirrorFrom()`, and checking a repository URL, + `isValidRepository()` and `isValidRepositoryAndBranch()`; see + ["Create and access git repositories"](doc/admin.md). + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Add `Repository::getDiff($from, $to)`, `Repository::getMergeBase()`, + `Repository::getSizeInBytes()` and `WorkingCopy::getDiff()`, + `WorkingCopy::checkoutNewBranch()`, `Branch::moveTo()` and `Branch::isMergedInto()`, + `Log::withOffset()` and `withLimit()`, `Blame::getHunks()` and the line numbers of + `Blame\Line`, `TreeEntry` and `Submodule`, `Diff\DiffLine` and `Diff\DiffLineType`, + `File::isModeChange()`, `FileChange::getAdditions()` and `getDeletions()`, + `PushReference::getFullname()` and `getRange()`, and the SSH and X.509 signatures of tags. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Add `WorkingCopy::merge()` to support `git merge`, fast-forwarding or not, with a + `FastForward` enum. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) +- Add `Repository::runProcess()` to expose stderr from successful commands: `run()` only + ever returned stdout, even on success, but some git commands (e.g. `push`) write + meaningful output to stderr even when they succeed. `run()` now delegates to it and keeps + its exact original behavior. + ([#247](https://github.com/gitonomy/gitlib/pull/247), fixes #205) +- Support git's mnemonic diff prefixes (`diff.mnemonicPrefix`) in the diff parser: enabling + this option replaces the default `a/`/`b/` prefixes with context-specific ones + (`c/`/`i/`/`o/`/`w/`), which used to raise a `RuntimeException` in tools like GrumPHP that + enable it. ([#245](https://github.com/gitonomy/gitlib/pull/245), fixes #114) +- Support `"copy from"`/`"copy to"` in the diff parser: diffs produced with copy detection + enabled (e.g. `diff.renames = copies`) used to fail to parse. `File` gains a new `isCopy()` + flag, and `isRename()` no longer reports `true` for copies, since a copy's source file + still exists afterward. + ([#244](https://github.com/gitonomy/gitlib/pull/244), fixes #14) ### Deprecations All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- `Admin`, use the static methods of `Repository`, and `Repository::cloneTo()`. -- The array of options of `Repository` and `Admin` (`working_dir`, `logger`, `command`, - `environment_variables`, `inherit_environment_variables`, `process_timeout`, `debug`): pass - the working directory and a `CliDriver` instead; `inherit_environment_variables` has no - counterpart, pass the variables git needs instead. `Repository::setLogger()` and `getLogger()`. -- Passing `$reload` to `Repository::getReferences()`, `ReferenceBag::update()`, - `Branch::isMergedTo()`, and arguments to `Commit::getIncludingBranches()`. -- Calling `Repository::getDiff()` with one argument, `Log::getDiff()`, `Diff::setRepository()`, - `File::setRepository()`, `addChange()`, `isChangeMode()`, `isDelete()` and `getAnchor()`, - `FileChange::getCount()` and its `LINE_*` constants, reading diff lines as arrays. -- `Log::setOffset()` and `setLimit()`. -- A range of lines as a string in `Repository::getBlame()`, `Blame::getGroupedLines()` and - `Blame\Line::getLine()`. -- `CommitReference`, `Tree::getCommitReferenceEntries()`, reading tree entries as arrays. -- The branch to create of `WorkingCopy::checkout()`, the arguments of `git merge` of - `WorkingCopy::merge()`. -- `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, - `PushReference::getReference()` and `getRevision()`. -- `Repository::getSize()`, in kilobytes: use `getSizeInBytes()`. -- `Exception\UnexpectedValueException`, which gitlib no longer throws. +- `Admin`, use the static methods of `Repository`, and `Repository::cloneTo()`. +- The array of options of `Repository` and `Admin` (`working_dir`, `logger`, `command`, + `environment_variables`, `inherit_environment_variables`, `process_timeout`, `debug`): pass + the working directory and a `CliDriver` instead; `inherit_environment_variables` has no + counterpart, pass the variables git needs instead. `Repository::setLogger()` and `getLogger()`. +- Passing `$reload` to `Repository::getReferences()`, `ReferenceBag::update()`, + `Branch::isMergedTo()`, and arguments to `Commit::getIncludingBranches()`. +- Calling `Repository::getDiff()` with one argument, `Log::getDiff()`, `Diff::setRepository()`, + `File::setRepository()`, `addChange()`, `isChangeMode()`, `isDelete()` and `getAnchor()`, + `FileChange::getCount()` and its `LINE_*` constants, reading diff lines as arrays. +- `Log::setOffset()` and `setLimit()`. +- A range of lines as a string in `Repository::getBlame()`, `Blame::getGroupedLines()` and + `Blame\Line::getLine()`. +- `CommitReference`, `Tree::getCommitReferenceEntries()`, reading tree entries as arrays. +- The branch to create of `WorkingCopy::checkout()`, the arguments of `git merge` of + `WorkingCopy::merge()`. +- `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, + `PushReference::getReference()` and `getRevision()`. +- `Repository::getSize()`, in kilobytes: use `getSizeInBytes()`. +- `Exception\UnexpectedValueException`, which gitlib no longer throws. ### Security -- Refuse a revision, a reference, a hash or a URL starting with a dash, which git read as an - option: `getRevision('--output=/path')`, `WorkingCopy::checkout()` or `merge()` could write - a file, and `Repository::isValidRepository('--upload-pack=...')` run a command. A path may - start with a dash: `init()` and the clones end the options of git with `--`. -- Refuse an `ext::` URL, like `ext::sh -c ...`, whose command git runs, by default before git - 2.12. -- Refuse an ssh URL whose host or user starts with a dash, even encoded (`%2D`), like - `ssh://-oProxyCommand=.../repository`: git before 2.14.1 passes it to ssh, which reads it as an - option and runs the command (CVE-2017-1000117). A `git://` URL whose host starts with a dash - is refused too: git passes it to the command of `core.gitProxy`. -- Hide the credentials of URLs (`https://user:token@host`), a password holding a `#` or a `?` - included, in the messages of exceptions, in their traces (`#[\SensitiveParameter]`), and in - the logs. The exceptions of Symfony Process, which hold the command line, no longer leak, not - even as the previous exception. -- Refuse a hook name that isn't the one of a file of the hooks directory: - `Hooks::set('../config', ...)` and `remove('../config')` wrote and deleted the - configuration of the repository. -- Fix `Hooks` writing through a symbolic link to a missing file, out of the hooks directory: - `has()` returned `false` for it, and `set()` followed it. -- Ignore the replacements of objects (`git replace`, `refs/replace/`) with `CliDriver`, like - `LibGit2Driver`: whoever could push such a reference to a repository changed the message of a - commit, or the content of a blob, gitlib read for a hash. -- Fix `WorkingCopy::checkout()` of a path, like `checkout('README.md')`, discarding the changes - of the file: a path is no revision, and throws a `ReferenceNotFoundException`. -- Fix moving or deleting a symbolic reference, which changed the branch it points to instead: - `ReferenceBag::delete('refs/remotes/origin/HEAD')` deleted `origin/main`. -- Fix `ReferenceBag::delete('HEAD')` deleting `HEAD`, which left no repository: a name outside - `refs/` throws an `InvalidArgumentException`. +- Refuse a revision, a reference, a hash or a URL starting with a dash, which git read as an + option: `getRevision('--output=/path')`, `WorkingCopy::checkout()` or `merge()` could write + a file, and `Repository::isValidRepository('--upload-pack=...')` run a command. A path may + start with a dash: `init()` and the clones end the options of git with `--`. +- Refuse an `ext::` URL, like `ext::sh -c ...`, whose command git runs, by default before git + 2.12. +- Refuse an ssh URL whose host or user starts with a dash, even encoded (`%2D`), like + `ssh://-oProxyCommand=.../repository`: git before 2.14.1 passes it to ssh, which reads it as an + option and runs the command (CVE-2017-1000117). A `git://` URL whose host starts with a dash + is refused too: git passes it to the command of `core.gitProxy`. +- Hide the credentials of URLs (`https://user:token@host`), a password holding a `#` or a `?` + included, in the messages of exceptions, in their traces (`#[\SensitiveParameter]`), and in + the logs. The exceptions of Symfony Process, which hold the command line, no longer leak, not + even as the previous exception. +- Refuse a hook name that isn't the one of a file of the hooks directory: + `Hooks::set('../config', ...)` and `remove('../config')` wrote and deleted the + configuration of the repository. +- Fix `Hooks` writing through a symbolic link to a missing file, out of the hooks directory: + `has()` returned `false` for it, and `set()` followed it. +- Ignore the replacements of objects (`git replace`, `refs/replace/`) with `CliDriver`, like + `LibGit2Driver`: whoever could push such a reference to a repository changed the message of a + commit, or the content of a blob, gitlib read for a hash. +- Fix `WorkingCopy::checkout()` of a path, like `checkout('README.md')`, discarding the changes + of the file: a path is no revision, and throws a `ReferenceNotFoundException`. +- Fix moving or deleting a symbolic reference, which changed the branch it points to instead: + `ReferenceBag::delete('refs/remotes/origin/HEAD')` deleted `origin/main`. +- Fix `ReferenceBag::delete('HEAD')` deleting `HEAD`, which left no repository: a name outside + `refs/` throws an `InvalidArgumentException`. ### Bug fixes -- Fix the configuration of the user changing what `CliDriver` reads: `color.ui=always`, - `log.showSignature`, `diff.noprefix`, `diff.external`, `diff.submodule`, textconv filters, - `column.ui`... made reads throw or return wrong data, and `diff.algorithm`, `diff.context`, - `diff.orderFile`, `log.follow` or `branch.sort` changed diffs, logs and the branches of a - commit. gitlib sets them back to git's defaults; see - ["Git configuration"](doc/repository.md#git-configuration). -- Fix `Commit::getShortHash()`, `Commit::getDiff()` and `Repository::getDiff()` failing when a - file of the working tree has the name of the revision they read, which git found ambiguous. -- Fix logs of thousands of revisions exceeding the limit of the command line: `CliDriver` passes - them to git on its standard input. -- Fix `Commit::getDiff()` returning an empty diff for a root commit, and throwing for a merge - commit (`CliDriver`). ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix `Repository::getDiff()`, `WorkingCopy::getDiffPending()` and `WorkingCopy::getDiffStaged()` - failing to parse the output of git before 2.16, which writes "..." after abbreviated hashes, - and throwing during a conflict. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix the names of files with spaces in diffs, split at the wrong place, the hashes of the files - following a type change, shifted, and hunks of one line, counted as empty. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix annotated tags never resolving to their commit (`ReferenceBag::resolveTags()`, - `Commit::resolveReferences()`), tags created or deleted missing from `getTags()`, and - `getHead()` throwing after a checkout of a new branch: references are read again after - every write gitlib makes. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix `createBranch()` and `createTag()` moving an existing reference instead of refusing it, - a branch named "remotes/x" taken for a remote one, and branches whose name ends with "HEAD" - missing. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix `isValidRepositoryAndBranch()` matching the end of the name of a branch ("release" for - "feature/release"), and `isValidRepository()` reading a relative path as the name of a remote - of PHP's working directory, or returning `false` when git can't run. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix `getTree()` reading the tree of a commit, and arguments holding a NUL byte throwing a - `ValueError`: they throw a `ReferenceNotFoundException` and an `InvalidArgumentException`. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix the log of a merge of an unsigned or SSH-signed tag throwing, or swallowing commits up to - the next PGP signature. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix `Commit::getBodyMessage()` losing the second line of a message without an empty line - after its subject, tags without tagger throwing, and SSH-signed tags not seen as signed. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix blame lines numbered from 1 for a range of lines. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix `Hooks` failing silently, in a repository without hooks directory, and writing the hooks - of a linked worktree where git doesn't run them. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix `Repository::getSize()` throwing for a link to a missing file in the git directory, like - a hook not deployed yet: it no longer follows links. A file git deletes meanwhile, like a lock - during a `gc` or a push, no longer makes it throw either. -- Fix blame lines starting with a TAB character (`Blame\Line::getContent()`): code removing it - must stop doing so. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix reading a commit whose `encoding` header isn't UTF-8, which threw: its message, author and - committer are now re-encoded to UTF-8, like `git log` does. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix file names holding non-ASCII or special characters (tab, quote, backslash) in trees - (`Tree::getEntries()`), and special characters in diffs, which kept git's C-style quoting. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix relative paths not resolving against the repository in `Repository::run()`: the git - process spawned by `run()` never had its working directory set, so commands such as - `apply` resolved paths against the calling script's cwd instead of `--work-tree`, failing - when run from outside the repository directory. - ([#249](https://github.com/gitonomy/gitlib/pull/249), fixes #67) -- Fix diff parsing for dirty submodules without an index line: a submodule whose working - tree changed but still points at the same commit is reported by git without an `index ` - line, which made the parser choke on the next hunk. - ([#246](https://github.com/gitonomy/gitlib/pull/246), fixes #165) -- Fix bugs and hardening issues found by a code audit: - ([#239](https://github.com/gitonomy/gitlib/pull/239)) - - `ReferenceBag::getFirstBranch()` could return a non-branch reference, or throw, when no - branch existed. - - `Tag::getBodyMessage()` dropped or truncated a line for annotated tags using the - standard `subject\n\nbody` convention. - - With the `debug` option set to `false`, several call sites (`Tag::isAnnotated()`, - `PushReference::isForce()`, `Blob::getContent()`, ...) produced wrong data or crashed - when git failed. - - `Repository::shell()` interpolated the repository path into the shell command without - escaping it; it is now passed through `escapeshellarg()`. - - `Hooks::set()` created hook scripts world-writable (`0777`); they are now created - `0700`. - - Fixed the `isSuccessFul()` casing typo in `Admin`. +- Fix the configuration of the user changing what `CliDriver` reads: `color.ui=always`, + `log.showSignature`, `diff.noprefix`, `diff.external`, `diff.submodule`, textconv filters, + `column.ui`... made reads throw or return wrong data, and `diff.algorithm`, `diff.context`, + `diff.orderFile`, `log.follow` or `branch.sort` changed diffs, logs and the branches of a + commit. gitlib sets them back to git's defaults; see + ["Git configuration"](doc/repository.md#git-configuration). +- Fix `Commit::getShortHash()`, `Commit::getDiff()` and `Repository::getDiff()` failing when a + file of the working tree has the name of the revision they read, which git found ambiguous. +- Fix logs of thousands of revisions exceeding the limit of the command line: `CliDriver` passes + them to git on its standard input. +- Fix `Commit::getDiff()` returning an empty diff for a root commit, and throwing for a merge + commit (`CliDriver`). ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `Repository::getDiff()`, `WorkingCopy::getDiffPending()` and `WorkingCopy::getDiffStaged()` + failing to parse the output of git before 2.16, which writes "..." after abbreviated hashes, + and throwing during a conflict. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix the names of files with spaces in diffs, split at the wrong place, the hashes of the files + following a type change, shifted, and hunks of one line, counted as empty. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix annotated tags never resolving to their commit (`ReferenceBag::resolveTags()`, + `Commit::resolveReferences()`), tags created or deleted missing from `getTags()`, and + `getHead()` throwing after a checkout of a new branch: references are read again after + every write gitlib makes. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `createBranch()` and `createTag()` moving an existing reference instead of refusing it, + a branch named "remotes/x" taken for a remote one, and branches whose name ends with "HEAD" + missing. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `isValidRepositoryAndBranch()` matching the end of the name of a branch ("release" for + "feature/release"), and `isValidRepository()` reading a relative path as the name of a remote + of PHP's working directory, or returning `false` when git can't run. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `getTree()` reading the tree of a commit, and arguments holding a NUL byte throwing a + `ValueError`: they throw a `ReferenceNotFoundException` and an `InvalidArgumentException`. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix the log of a merge of an unsigned or SSH-signed tag throwing, or swallowing commits up to + the next PGP signature. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `Commit::getBodyMessage()` losing the second line of a message without an empty line + after its subject, tags without tagger throwing, and SSH-signed tags not seen as signed. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix blame lines numbered from 1 for a range of lines. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `Hooks` failing silently, in a repository without hooks directory, and writing the hooks + of a linked worktree where git doesn't run them. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `Repository::getSize()` throwing for a link to a missing file in the git directory, like + a hook not deployed yet: it no longer follows links. A file git deletes meanwhile, like a lock + during a `gc` or a push, no longer makes it throw either. +- Fix blame lines starting with a TAB character (`Blame\Line::getContent()`): code removing it + must stop doing so. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix reading a commit whose `encoding` header isn't UTF-8, which threw: its message, author and + committer are now re-encoded to UTF-8, like `git log` does. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix file names holding non-ASCII or special characters (tab, quote, backslash) in trees + (`Tree::getEntries()`), and special characters in diffs, which kept git's C-style quoting. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix relative paths not resolving against the repository in `Repository::run()`: the git + process spawned by `run()` never had its working directory set, so commands such as + `apply` resolved paths against the calling script's cwd instead of `--work-tree`, failing + when run from outside the repository directory. + ([#249](https://github.com/gitonomy/gitlib/pull/249), fixes #67) +- Fix diff parsing for dirty submodules without an index line: a submodule whose working + tree changed but still points at the same commit is reported by git without an `index ` + line, which made the parser choke on the next hunk. + ([#246](https://github.com/gitonomy/gitlib/pull/246), fixes #165) +- Fix bugs and hardening issues found by a code audit: + ([#239](https://github.com/gitonomy/gitlib/pull/239)) + - `ReferenceBag::getFirstBranch()` could return a non-branch reference, or throw, when no + branch existed. + - `Tag::getBodyMessage()` dropped or truncated a line for annotated tags using the + standard `subject\n\nbody` convention. + - With the `debug` option set to `false`, several call sites (`Tag::isAnnotated()`, + `PushReference::isForce()`, `Blob::getContent()`, ...) produced wrong data or crashed + when git failed. + - `Repository::shell()` interpolated the repository path into the shell command without + escaping it; it is now passed through `escapeshellarg()`. + - `Hooks::set()` created hook scripts world-writable (`0777`); they are now created + `0700`. + - Fixed the `isSuccessFul()` casing typo in `Admin`. ### Minor -- CI tests git 1.8.4 and 2.25.1, analyses the tests and the libgit2 driver with PHPStan, - without baseline, checks the archive Composer installs, pins the third-party actions by - commit, and the suite deletes what it creates, in any order. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- The tests run with a git configuration of their own, whatever the developer's - (`tests/fixtures/home`), which sets what gitlib sets back to git's defaults, and without the - variables of git of their environment: run from a hook, they wrote the repository of the hook - (`GIT_DIR`, `GIT_INDEX_FILE`...). -- Add a regression test for reading a blob's content in a tree that also contains a - submodule. ([#243](https://github.com/gitonomy/gitlib/pull/243)) -- Replace the network-dependent test fixture (cloned from GitHub on every run) with a local, - audited git bundle, plus a Console tool to regenerate it. - ([#241](https://github.com/gitonomy/gitlib/pull/241), - [#242](https://github.com/gitonomy/gitlib/pull/242)) -- CI: bump `actions/checkout` to v7 to fix a Node 20 deprecation warning. - ([#240](https://github.com/gitonomy/gitlib/pull/240)) +- CI tests git 1.8.4 and 2.25.1, analyses the tests and the libgit2 driver with PHPStan, + without baseline, checks the archive Composer installs, pins the third-party actions by + commit, and the suite deletes what it creates, in any order. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- The tests run with a git configuration of their own, whatever the developer's + (`tests/fixtures/home`), which sets what gitlib sets back to git's defaults, and without the + variables of git of their environment: run from a hook, they wrote the repository of the hook + (`GIT_DIR`, `GIT_INDEX_FILE`...). +- Add a regression test for reading a blob's content in a tree that also contains a + submodule. ([#243](https://github.com/gitonomy/gitlib/pull/243)) +- Replace the network-dependent test fixture (cloned from GitHub on every run) with a local, + audited git bundle, plus a Console tool to regenerate it. + ([#241](https://github.com/gitonomy/gitlib/pull/241), + [#242](https://github.com/gitonomy/gitlib/pull/242)) +- CI: bump `actions/checkout` to v7 to fix a Node 20 deprecation warning. + ([#240](https://github.com/gitonomy/gitlib/pull/240)) ## [v1.6.0] - 2025-12-09 diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index ddd2c018..e44b082c 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -15,570 +15,571 @@ changes, listed below too, break code that relies on them. Requirements ------------ - * PHP 8.4 or later is required. - * git 1.8.4 or later is required by `CliDriver`, the default driver: before, - git simplifies the log of a range filtered by path differently, and it - can't count commits (`Log::count()`) before 1.7.2. - * `symfony/process` must be `^6.4`, `^7.4` or `^8.1`. - * `symfony/string` `^7.4.6` or `^8.0.6`, `symfony/deprecation-contracts` - `^2.5` or `^3`, and `psr/log` `^1.1`, `^2.0` or `^3.0` are new - dependencies. - * Repositories using SHA-256 hashes are refused, with an - `UnsupportedOperationException`, by both drivers. +* PHP 8.4 or later is required. +* git 1.8.4 or later is required by `CliDriver`, the default driver: before, + git simplifies the log of a range filtered by path differently, and it + can't count commits (`Log::count()`) before 1.7.2. +* `symfony/process` must be `^6.4`, `^7.4` or `^8.1`. +* `symfony/string` `^7.4.6` or `^8.0.6`, `symfony/deprecation-contracts` + `^2.5` or `^3`, and `psr/log` `^1.1`, `^2.0` or `^3.0` are new + dependencies. +* Repositories using SHA-256 hashes are refused, with an + `UnsupportedOperationException`, by both drivers. Opening a repository -------------------- - * The second argument of `Repository` is the working directory, when the - git directory is kept apart from it. An array of options there is - deprecated: its `working_dir` option is this argument, and its `logger`, - `command`, `environment_variables` and `process_timeout` options configure - a `Gitonomy\Git\Driver\Cli\CliDriver`, which `Repository` takes as its last - argument. They can't be combined with this argument. - * The `inherit_environment_variables` option has no counterpart in - `CliDriver`: git inherits the environment variables of PHP anyway, and - `$_SERVER` holds, under php-fpm, the headers and the credentials of the - request. Pass the variables git needs to the `environmentVariables` - argument instead. - - Before: - - ```php - $repository = new Repository($path, [ - 'command' => '/usr/bin/git', - 'logger' => $logger, - 'process_timeout' => 60, - ]); - ``` - - After: - - ```php - use Gitonomy\Git\Driver\Cli\CliDriver; - - $repository = new Repository($path, driver: new CliDriver(command: '/usr/bin/git', processTimeout: 60, logger: $logger)); - ``` - - * The logger is an argument of `CliDriver`, which logs every git command it - runs, cloning and initializing included: `Repository::setLogger()` and - `Repository::getLogger()` are deprecated. `LibGit2Driver` runs no command, - and logs nothing. - * An unknown option throws an `InvalidArgumentException`, instead of being - ignored. - * The `debug` option is deprecated, and setting it to `false` throws an - `InvalidArgumentException`: see [Errors](#errors). - * `$path` may be a linked worktree or a submodule, whose `.git` is a file. - * A directory that isn't a repository throws an `InvalidArgumentException` - right away, instead of a `ProcessException` at the first operation. +* The second argument of `Repository` is the working directory, when the + git directory is kept apart from it. An array of options there is + deprecated: its `working_dir` option is this argument, and its `logger`, + `command`, `environment_variables` and `process_timeout` options configure + a `Gitonomy\Git\Driver\Cli\CliDriver`, which `Repository` takes as its last + argument. They can't be combined with this argument. +* The `inherit_environment_variables` option has no counterpart in + `CliDriver`: git inherits the environment variables of PHP anyway, and + `$_SERVER` holds, under php-fpm, the headers and the credentials of the + request. Pass the variables git needs to the `environmentVariables` + argument instead. + + Before: + + ```php + $repository = new Repository($path, [ + 'command' => '/usr/bin/git', + 'logger' => $logger, + 'process_timeout' => 60, + ]); + ``` + + After: + + ```php + use Gitonomy\Git\Driver\Cli\CliDriver; + + $repository = new Repository($path, driver: new CliDriver(command: '/usr/bin/git', processTimeout: 60, logger: $logger)); + ``` + +* The logger is an argument of `CliDriver`, which logs every git command it + runs, cloning and initializing included: `Repository::setLogger()` and + `Repository::getLogger()` are deprecated. `LibGit2Driver` runs no command, + and logs nothing. +* An unknown option throws an `InvalidArgumentException`, instead of being + ignored. +* The `debug` option is deprecated, and setting it to `false` throws an + `InvalidArgumentException`: see [Errors](#errors). +* `$path` may be a linked worktree or a submodule, whose `.git` is a file. +* A directory that isn't a repository throws an `InvalidArgumentException` + right away, instead of a `ProcessException` at the first operation. Creating repositories --------------------- - * `Admin` is deprecated: use the static methods of `Repository`. They take - the URL before the path, have a method for each kind of repository - instead of the `$bare` argument, whose default was a bare repository, and - take the driver as last argument: - - | 1.x | 2.0 | - |-----------------------------------------------------|--------------------------------------------------------------| - | `Admin::init($path)` | `Repository::initBare($path)` | - | `Admin::init($path, false)` | `Repository::init($path)` | - | `Admin::cloneTo($path, $url)` | `Repository::cloneBareFrom($url, $path)` | - | `Admin::cloneTo($path, $url, false)` | `Repository::cloneFrom($url, $path)` | - | `Admin::cloneBranchTo($path, $url, $branch)` | `Repository::cloneBareFrom($url, $path, $branch)` | - | `Admin::cloneBranchTo($path, $url, $branch, false)` | `Repository::cloneFrom($url, $path, $branch)` | - | `Admin::mirrorTo($path, $url)` | `Repository::mirrorFrom($url, $path)` | - | `Admin::isValidRepository($url)` | `Repository::isValidRepository($url)` | - | `Admin::isValidRepositoryAndBranch($url, $branch)` | `Repository::isValidRepositoryAndBranch($url, $branch)` | - | `$repository->cloneTo($path)` | `Repository::cloneBareFrom($repository->getGitDir(), $path)` | - | `$repository->cloneTo($path, false)` | `Repository::cloneFrom($repository->getGitDir(), $path)` | - - Until 3.0, `Admin` keeps working like in 1.x, with `CliDriver` and the - options of 1.x. - * `isValidRepositoryAndBranch()` checks the exact name of the branch: - `release` no longer matches `feature/release`, nor `rel*` `release`. - * `isValidRepository()` and `isValidRepositoryAndBranch()` read a relative - path as a path, never as the name of a remote of PHP's working directory, - and throw a `DriverException` when git can't start, instead of returning - `false`. - - Before: - - ```php - $repository = Admin::cloneTo($path, $url, false, ['process_timeout' => 60]); - ``` - - After: - - ```php - use Gitonomy\Git\Driver\Cli\CliDriver; - - $repository = Repository::cloneFrom($url, $path, driver: new CliDriver(processTimeout: 60)); - ``` - - * `Repository::cloneTo()` cloned with the driver of the repository: pass it - to `cloneFrom()` or `cloneBareFrom()` as well. - * `Admin::cloneRepository()` is deprecated, with no replacement for the - arguments of `git clone` other than `--bare`, `--branch` and `--mirror`: - run `git clone` yourself, then open the repository with - `new Repository($path)`. +* `Admin` is deprecated: use the static methods of `Repository`. They take + the URL before the path, have a method for each kind of repository + instead of the `$bare` argument, whose default was a bare repository, and + take the driver as last argument: + + | 1.x | 2.0 | + |-----------------------------------------------------|--------------------------------------------------------------| + | `Admin::init($path)` | `Repository::initBare($path)` | + | `Admin::init($path, false)` | `Repository::init($path)` | + | `Admin::cloneTo($path, $url)` | `Repository::cloneBareFrom($url, $path)` | + | `Admin::cloneTo($path, $url, false)` | `Repository::cloneFrom($url, $path)` | + | `Admin::cloneBranchTo($path, $url, $branch)` | `Repository::cloneBareFrom($url, $path, $branch)` | + | `Admin::cloneBranchTo($path, $url, $branch, false)` | `Repository::cloneFrom($url, $path, $branch)` | + | `Admin::mirrorTo($path, $url)` | `Repository::mirrorFrom($url, $path)` | + | `Admin::isValidRepository($url)` | `Repository::isValidRepository($url)` | + | `Admin::isValidRepositoryAndBranch($url, $branch)` | `Repository::isValidRepositoryAndBranch($url, $branch)` | + | `$repository->cloneTo($path)` | `Repository::cloneBareFrom($repository->getGitDir(), $path)` | + | `$repository->cloneTo($path, false)` | `Repository::cloneFrom($repository->getGitDir(), $path)` | + + Until 3.0, `Admin` keeps working like in 1.x, with `CliDriver` and the + options of 1.x. + +* `isValidRepositoryAndBranch()` checks the exact name of the branch: + `release` no longer matches `feature/release`, nor `rel*` `release`. +* `isValidRepository()` and `isValidRepositoryAndBranch()` read a relative + path as a path, never as the name of a remote of PHP's working directory, + and throw a `DriverException` when git can't start, instead of returning + `false`. + + Before: + + ```php + $repository = Admin::cloneTo($path, $url, false, ['process_timeout' => 60]); + ``` + + After: + + ```php + use Gitonomy\Git\Driver\Cli\CliDriver; + + $repository = Repository::cloneFrom($url, $path, driver: new CliDriver(processTimeout: 60)); + ``` + +* `Repository::cloneTo()` cloned with the driver of the repository: pass it + to `cloneFrom()` or `cloneBareFrom()` as well. +* `Admin::cloneRepository()` is deprecated, with no replacement for the + arguments of `git clone` other than `--bare`, `--branch` and `--mirror`: + run `git clone` yourself, then open the repository with + `new Repository($path)`. References ---------- - * `Repository::getReferences()` reads the references again after every - write gitlib makes (creating or deleting a reference, a checkout, a merge, - `run()`...): passing `true` to read them again is deprecated. Open a new - `Repository` to see the writes of other processes. A `Reference` object - keeps the commit it pointed to when it was read. - * `Reference::getCommitHash()` returns the commit an annotated tag points - to, instead of the hash of the tag object: `ReferenceBag::resolve()`, - `resolveTags()` and `Commit::resolveReferences()` find annotated tags. - * `ReferenceBag::createBranch()` and `createTag()` throw an - `InvalidArgumentException` when the reference exists, instead of moving - it, and take a `Revision` or a revision name as second argument, instead - of a commit hash only. - * `ReferenceBag::update()` is deprecated: use `Branch::moveTo()`. - - Before: - - ```php - $repository->getReferences()->update(new Branch($repository, 'refs/heads/main', $hash)); - ``` - - After: - - ```php - $repository->getReferences()->getBranch('main')->moveTo($hash); - ``` - - * `Branch::isMergedTo()` is deprecated: use `isMergedInto()`, which takes - the destination as a `Revision` or a revision name, without a default - (`'master'` before) nor `$compareOnlyWithRemote`. - * Passing arguments to `Commit::getIncludingBranches()` is deprecated: it - returns the local and remote branches, which `Branch::isLocal()` and - `isRemote()` filter. - - Before: - - ```php - $branches = $commit->getIncludingBranches(true, false); - ``` - - After: - - ```php - $branches = array_filter($commit->getIncludingBranches(), static fn (Branch $branch): bool => $branch->isLocal()); - ``` - - * `ReferenceBag::getFirstBranch()` returns `null` instead of `false` when - there is no branch, and only branches. - * `Repository::getHead()` returns a `Reference\Branch` or a `Commit`. - `HEAD` pointing to another reference, like a tag, which git allows, - throws a `ReferenceNotFoundException`, where 1.x returned the reference: - `getHeadCommit()` still returns its commit. - * The getters of `Reference\Tag` (`getTaggerName()`, `getTaggerEmail()`, - `getTaggerDate()`, `getMessage()`, `getSubjectMessage()`, - `getBodyMessage()`, `getGpgSignature()`) return `null` instead of `false` - for a lightweight tag. The tagger is `null` for an annotated tag without - one too, where gitlib threw. `getGpgSignature()` returns `null` for an - unsigned tag instead of throwing, and finds X.509 and SSH signatures too. - * A branch whose name ends with `HEAD` is listed: only the `HEAD` of a - remote is left out. - * Moving or deleting a symbolic reference, like `refs/remotes/origin/HEAD`, - changes it, instead of the branch it points to. - * `ReferenceBag::delete()` throws an `InvalidArgumentException` for a name - outside `refs/`: `HEAD`, which `CliDriver` deleted, leaving no - repository, or a short name, like `master`, which did nothing. +* `Repository::getReferences()` reads the references again after every + write gitlib makes (creating or deleting a reference, a checkout, a merge, + `run()`...): passing `true` to read them again is deprecated. Open a new + `Repository` to see the writes of other processes. A `Reference` object + keeps the commit it pointed to when it was read. +* `Reference::getCommitHash()` returns the commit an annotated tag points + to, instead of the hash of the tag object: `ReferenceBag::resolve()`, + `resolveTags()` and `Commit::resolveReferences()` find annotated tags. +* `ReferenceBag::createBranch()` and `createTag()` throw an + `InvalidArgumentException` when the reference exists, instead of moving + it, and take a `Revision` or a revision name as second argument, instead + of a commit hash only. +* `ReferenceBag::update()` is deprecated: use `Branch::moveTo()`. + + Before: + + ```php + $repository->getReferences()->update(new Branch($repository, 'refs/heads/main', $hash)); + ``` + + After: + + ```php + $repository->getReferences()->getBranch('main')->moveTo($hash); + ``` + +* `Branch::isMergedTo()` is deprecated: use `isMergedInto()`, which takes + the destination as a `Revision` or a revision name, without a default + (`'master'` before) nor `$compareOnlyWithRemote`. +* Passing arguments to `Commit::getIncludingBranches()` is deprecated: it + returns the local and remote branches, which `Branch::isLocal()` and + `isRemote()` filter. + + Before: + + ```php + $branches = $commit->getIncludingBranches(true, false); + ``` + + After: + + ```php + $branches = array_filter($commit->getIncludingBranches(), static fn (Branch $branch): bool => $branch->isLocal()); + ``` + +* `ReferenceBag::getFirstBranch()` returns `null` instead of `false` when + there is no branch, and only branches. +* `Repository::getHead()` returns a `Reference\Branch` or a `Commit`. + `HEAD` pointing to another reference, like a tag, which git allows, + throws a `ReferenceNotFoundException`, where 1.x returned the reference: + `getHeadCommit()` still returns its commit. +* The getters of `Reference\Tag` (`getTaggerName()`, `getTaggerEmail()`, + `getTaggerDate()`, `getMessage()`, `getSubjectMessage()`, + `getBodyMessage()`, `getGpgSignature()`) return `null` instead of `false` + for a lightweight tag. The tagger is `null` for an annotated tag without + one too, where gitlib threw. `getGpgSignature()` returns `null` for an + unsigned tag instead of throwing, and finds X.509 and SSH signatures too. +* A branch whose name ends with `HEAD` is listed: only the `HEAD` of a + remote is left out. +* Moving or deleting a symbolic reference, like `refs/remotes/origin/HEAD`, + changes it, instead of the branch it points to. +* `ReferenceBag::delete()` throws an `InvalidArgumentException` for a name + outside `refs/`: `HEAD`, which `CliDriver` deleted, leaving no + repository, or a short name, like `master`, which did nothing. History ------- - * `Log::setOffset()` and `setLimit()` are deprecated: `withOffset()` and - `withLimit()` return another `Log`. - * `count($log)` returns the number of commits the log iterates, after its - offset and up to its limit; `countCommits()` still counts them all. - * The revisions of a log are names, ranges (`a..b`, `a...b`), names to - exclude (`^a`) or `--all`: any other option of `git log` throws an - `InvalidArgumentException`, instead of being passed to git. An unknown - revision throws a `ReferenceNotFoundException` naming it. - * `Log::getDiff()` is deprecated: it ignored the paths of the log. Use - `Repository::getDiff()`. +* `Log::setOffset()` and `setLimit()` are deprecated: `withOffset()` and + `withLimit()` return another `Log`. +* `count($log)` returns the number of commits the log iterates, after its + offset and up to its limit; `countCommits()` still counts them all. +* The revisions of a log are names, ranges (`a..b`, `a...b`), names to + exclude (`^a`) or `--all`: any other option of `git log` throws an + `InvalidArgumentException`, instead of being passed to git. An unknown + revision throws a `ReferenceNotFoundException` naming it. +* `Log::getDiff()` is deprecated: it ignored the paths of the log. Use + `Repository::getDiff()`. Diffs ----- - * `Repository::getDiff()` takes the commits to compare: `$from` and `$to`. - Calling it with one argument is deprecated. - - | 1.x | 2.0 | - |------------------------------------|------------------------------------------------------------------| - | `$repository->getDiff('a..b')` | `$repository->getDiff('a', 'b')` | - | `$repository->getDiff(['a', 'b'])` | `$repository->getDiff('a', 'b')` | - | `$repository->getDiff('a...b')` | `$repository->getDiff($repository->getMergeBase('a', 'b'), 'b')` | - | `$repository->getDiff('a')` | `$repository->getWorkingCopy()->getDiff('a')` | - - * The diff of a merge commit, `Commit::getDiff()`, is against its first - parent, like GitHub shows it, with both drivers. - * `Diff`, `Diff\File` and `Diff\FileChange` get their repository and their - changes when constructed: `Diff::setRepository()`, - `File::setRepository()` and `File::addChange()` are deprecated. - `Diff::parse()` and `fromArray()` take the repository to read blobs from. - * The lines of a hunk, `FileChange::getLines()`, are `Diff\DiffLine` - objects, with `getType()`, a `Diff\DiffLineType`, and `getContent()`: - reading them as arrays is deprecated, as the `FileChange::LINE_*` - constants and `FileChange::getCount()`, replaced by `getAdditions()` and - `getDeletions()`. - - Before: - - ```php - foreach ($change->getLines() as [$type, $content]) { - if (FileChange::LINE_ADD === $type) { - // ... - } - } - ``` - - After: - - ```php - use Gitonomy\Git\Diff\DiffLineType; - - foreach ($change->getLines() as $line) { - if (DiffLineType::Addition === $line->getType()) { - // ... $line->getContent() - } - } - ``` - - * `File::isChangeMode()`, `isDelete()` and `getAnchor()` are deprecated: - use `isModeChange()`, `isDeletion()`, and - `substr($file->getNewIndex(), 0, 12)`. - * `File::getOldIndex()` and `getNewIndex()` return `null` for a missing - hash, never an empty string; `getName()` returns a `string`. - * `File::getOldBlob()` and `getNewBlob()` throw a `LogicException` without - repository, and a `RuntimeException` without hash. - * `Diff::parse()` throws an `InvalidArgumentException` for a diff without - its raw information, deprecated since 1.6.0: parse the output of - `git diff --raw --patch`, with `-z` or not. It reads the names of the - files from the raw information: names with spaces are no longer split at - the wrong place. - * During a merge conflict, `WorkingCopy::getDiffPending()` and - `getDiffStaged()` leave the unmerged files out, where they threw, and - `WorkingCopy::getDiff()` compares them, conflict markers included, to the - revision, like `git diff HEAD`. +* `Repository::getDiff()` takes the commits to compare: `$from` and `$to`. + Calling it with one argument is deprecated. + + | 1.x | 2.0 | + |------------------------------------|------------------------------------------------------------------| + | `$repository->getDiff('a..b')` | `$repository->getDiff('a', 'b')` | + | `$repository->getDiff(['a', 'b'])` | `$repository->getDiff('a', 'b')` | + | `$repository->getDiff('a...b')` | `$repository->getDiff($repository->getMergeBase('a', 'b'), 'b')` | + | `$repository->getDiff('a')` | `$repository->getWorkingCopy()->getDiff('a')` | + +* The diff of a merge commit, `Commit::getDiff()`, is against its first + parent, like GitHub shows it, with both drivers. +* `Diff`, `Diff\File` and `Diff\FileChange` get their repository and their + changes when constructed: `Diff::setRepository()`, + `File::setRepository()` and `File::addChange()` are deprecated. + `Diff::parse()` and `fromArray()` take the repository to read blobs from. +* The lines of a hunk, `FileChange::getLines()`, are `Diff\DiffLine` + objects, with `getType()`, a `Diff\DiffLineType`, and `getContent()`: + reading them as arrays is deprecated, as the `FileChange::LINE_*` + constants and `FileChange::getCount()`, replaced by `getAdditions()` and + `getDeletions()`. + + Before: + + ```php + foreach ($change->getLines() as [$type, $content]) { + if (FileChange::LINE_ADD === $type) { + // ... + } + } + ``` + + After: + + ```php + use Gitonomy\Git\Diff\DiffLineType; + + foreach ($change->getLines() as $line) { + if (DiffLineType::Addition === $line->getType()) { + // ... $line->getContent() + } + } + ``` + +* `File::isChangeMode()`, `isDelete()` and `getAnchor()` are deprecated: + use `isModeChange()`, `isDeletion()`, and + `substr($file->getNewIndex(), 0, 12)`. +* `File::getOldIndex()` and `getNewIndex()` return `null` for a missing + hash, never an empty string; `getName()` returns a `string`. +* `File::getOldBlob()` and `getNewBlob()` throw a `LogicException` without + repository, and a `RuntimeException` without hash. +* `Diff::parse()` throws an `InvalidArgumentException` for a diff without + its raw information, deprecated since 1.6.0: parse the output of + `git diff --raw --patch`, with `-z` or not. It reads the names of the + files from the raw information: names with spaces are no longer split at + the wrong place. +* During a merge conflict, `WorkingCopy::getDiffPending()` and + `getDiffStaged()` leave the unmerged files out, where they threw, and + `WorkingCopy::getDiff()` compares them, conflict markers included, to the + revision, like `git diff HEAD`. Blame ----- - * `Repository::getBlame()` takes the first and the last lines to blame: - a range as a string (`'3,5'`, `'3,+3'`) is deprecated, and a regular - expression is no longer supported. - * `Blame::getLines()` and `getLine()` number the lines like the file, from - the first blamed line, instead of from 1. - * `Blame\Line::getNumber()` and `getOriginalNumber()` return the number of - the line in the file and in the commit that changed it, as integers: - `Line::getLine()` is deprecated. - * `Blame::getGroupedLines()` is deprecated: `getHunks()` returns - `Blame\Hunk` objects. +* `Repository::getBlame()` takes the first and the last lines to blame: + a range as a string (`'3,5'`, `'3,+3'`) is deprecated, and a regular + expression is no longer supported. +* `Blame::getLines()` and `getLine()` number the lines like the file, from + the first blamed line, instead of from 1. +* `Blame\Line::getNumber()` and `getOriginalNumber()` return the number of + the line in the file and in the commit that changed it, as integers: + `Line::getLine()` is deprecated. +* `Blame::getGroupedLines()` is deprecated: `getHunks()` returns + `Blame\Hunk` objects. Trees ----- - * `Tree::getEntries()`, `getTreeEntries()`, `getBlobEntries()` and - `getSubmoduleEntries()` return `TreeEntry` objects, with `getName()`, - `getMode()` and `getObject()`: reading them as arrays - (`[$mode, $object]`) is deprecated. - * `CommitReference` is deprecated: it is `Submodule`. So is - `Tree::getCommitReferenceEntries()`: use `getSubmoduleEntries()`. - * `Tree::resolvePath()` throws an `InvalidArgumentException` explaining - why a path doesn't resolve, a path through a submodule included, which - threw an `UnexpectedValueException`, and resolves `/` to the tree. +* `Tree::getEntries()`, `getTreeEntries()`, `getBlobEntries()` and + `getSubmoduleEntries()` return `TreeEntry` objects, with `getName()`, + `getMode()` and `getObject()`: reading them as arrays + (`[$mode, $object]`) is deprecated. +* `CommitReference` is deprecated: it is `Submodule`. So is + `Tree::getCommitReferenceEntries()`: use `getSubmoduleEntries()`. +* `Tree::resolvePath()` throws an `InvalidArgumentException` explaining + why a path doesn't resolve, a path through a submodule included, which + threw an `UnexpectedValueException`, and resolves `/` to the tree. Working copy ------------ - * `WorkingCopy::checkout()` takes a `Revision` or a revision name: a local - `Branch` object attaches `HEAD` to the branch, instead of detaching it. - Its second argument, a branch to create, is deprecated: use - `checkoutNewBranch($branch, $startPoint)`. - * `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a - name that no revision has, with both drivers: the name of a remote - branch alone, like `feature` for `origin/feature`, created the local - branch, and a path, like `README.md`, discarded its changes. +* `WorkingCopy::checkout()` takes a `Revision` or a revision name: a local + `Branch` object attaches `HEAD` to the branch, instead of detaching it. + Its second argument, a branch to create, is deprecated: use + `checkoutNewBranch($branch, $startPoint)`. +* `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a + name that no revision has, with both drivers: the name of a remote + branch alone, like `feature` for `origin/feature`, created the local + branch, and a path, like `README.md`, discarded its changes. - Before: + Before: - ```php - $workingCopy->checkout('feature'); - ``` + ```php + $workingCopy->checkout('feature'); + ``` - After: + After: - ```php - $workingCopy->checkoutNewBranch('feature', 'origin/feature'); - ``` + ```php + $workingCopy->checkoutNewBranch('feature', 'origin/feature'); + ``` - * `WorkingCopy::merge()` takes a `FastForward` instead of arguments of - `git merge`, deprecated. +* `WorkingCopy::merge()` takes a `FastForward` instead of arguments of + `git merge`, deprecated. - Before: + Before: - ```php - $workingCopy->merge('feature', ['--no-ff']); - ``` + ```php + $workingCopy->merge('feature', ['--no-ff']); + ``` - After: + After: - ```php - use Gitonomy\Git\FastForward; + ```php + use Gitonomy\Git\FastForward; - $workingCopy->merge('feature', FastForward::Never); - ``` + $workingCopy->merge('feature', FastForward::Never); + ``` Commits, trees and blobs ------------------------ - * `Repository::getCommit()` takes a full hash. `getTree()` and `getBlob()` - take a hash, abbreviated or not, instead of any revision: use - `getRevision($name)->getCommit()` for a name. - * Reading an object of another type throws a `ReferenceNotFoundException` - ("The object ... is not a tree."): `getTree()` read the tree of a commit, - and `getBlob()` threw a `ProcessException`. - * The subject of a commit or a tag is its first paragraph, on one line, and - its body what follows, like `git log --format=%s` and `%b`: a message - without an empty line after its first line has no body, where its second - line was lost. - * `Commit::getShortMessage()` and `getFixedShortHash()` are deprecated: use - `symfony/string` (`u($commit->getSubjectMessage())->truncate(50, '...')`) - and `substr()`. - * `Blob::getMimetype()` returns the mimetype alone (`text/plain`, instead - of `text/plain; charset=us-ascii`), and throws instead of returning - `false`. - * `Repository` keeps the objects it returns only while your application - uses them: memory no longer grows with every commit read. +* `Repository::getCommit()` takes a full hash. `getTree()` and `getBlob()` + take a hash, abbreviated or not, instead of any revision: use + `getRevision($name)->getCommit()` for a name. +* Reading an object of another type throws a `ReferenceNotFoundException` + ("The object ... is not a tree."): `getTree()` read the tree of a commit, + and `getBlob()` threw a `ProcessException`. +* The subject of a commit or a tag is its first paragraph, on one line, and + its body what follows, like `git log --format=%s` and `%b`: a message + without an empty line after its first line has no body, where its second + line was lost. +* `Commit::getShortMessage()` and `getFixedShortHash()` are deprecated: use + `symfony/string` (`u($commit->getSubjectMessage())->truncate(50, '...')`) + and `substr()`. +* `Blob::getMimetype()` returns the mimetype alone (`text/plain`, instead + of `text/plain; charset=us-ascii`), and throws instead of returning + `false`. +* `Repository` keeps the objects it returns only while your application + uses them: memory no longer grows with every commit read. Errors ------ - * A failing git operation always throws, a - `Gitonomy\Git\Exception\ProcessException` with `CliDriver`. Nothing - returns `null` or empty data instead anymore: `Repository::run()` returns - a `string`, and `Repository::getHead()` and `Repository::getHeadCommit()` - never return `null`. - - Before: - - ```php - $repository = new Repository($path, ['debug' => false]); - - if (null === $output = $repository->run('fetch', ['--all'])) { - // handle the failure - } - ``` - - After: - - ```php - $repository = new Repository($path); - - try { - $output = $repository->run('fetch', ['--all']); - } catch (ProcessException $e) { - // handle the failure - } - ``` - - * Failures are no longer logged at the `error` level: the exception reports - them, and the logger gets their error output at the `debug` level. Alerts - based on `error` logs must rely on exceptions instead. - * A missing revision, reference or object throws a - `ReferenceNotFoundException`, with both drivers: it extends - `Gitonomy\Git\Exception\InvalidArgumentException`, still an - `\InvalidArgumentException`, and takes a whole message instead of a name. - * A timeout throws a `Gitonomy\Git\Exception\ProcessTimedOutException`, - and the other failures of Symfony Process a `DriverException`: no - exception of Symfony or of PHP leaks anymore, not even as the previous - exception, as they hold the command line. - * `ProcessException` extends the new - `Gitonomy\Git\Exception\DriverException`, itself a `RuntimeException`: - catching `RuntimeException` or `GitExceptionInterface` still works. - * The message of a `ProcessException` is one line, with the error of git: - the first line of its error output starting with `fatal:` or `error:`, - after the warnings, or its first line: `The git command "clone" failed - (exit 128): fatal: ...`, or `The git command "diff" failed (exit 1).` - when git writes no error. The command line, the rest of the error output - and the output are no longer in the message: `getErrorOutput()` and - `getOutput()` return the outputs of git. Its constructor takes the git - command as second argument, optional. - * When git can't start, a missing binary for instance, `CliDriver` throws - a `DriverException` instead of a `ProcessException`, with a message of - one line, without the command line: `The git command "clone" could not - start: ...`. - * These methods throw instead of returning a fallback value: - * `Commit::getIncludingBranches()` threw nothing and returned `[]` when git - failed; - * `ReferenceBag` (`Repository::getReferences()`) only returns an empty bag - for a repository without references, and throws for any other failure. - * `Gitonomy\Git\Exception\UnexpectedValueException` is deprecated: gitlib - no longer throws it. Catch `InvalidArgumentException` for - `Tree::resolvePath()`. - * `Commit::getShortHash()` throws a `ReferenceNotFoundException` for an - unknown commit, instead of a `ProcessException`. - * `Hooks` throws a `RuntimeException` when a hook exists already, doesn't - exist, or can't be written, instead of a `LogicException` or a silent - failure, and creates the hooks directory. - * The `Admin` methods throw a `ProcessException`, still a - `RuntimeException`, with the first line of git's error output in its - message, or a `DriverException` when git can't start. - * An argument holding a NUL byte throws an `InvalidArgumentException`, - where PHP threw a `ValueError`: git and libgit2 read their arguments as C - strings, which end at the first NUL byte. - * A revision, a reference, a hash or a URL starting with a dash throws an - `InvalidArgumentException`, with both drivers: git read it as an option, - `getRevision('--output=/path')` writing a file, or - `isValidRepository('--upload-pack=...')` running a command. A path may - start with a dash. - * An `ext::` URL throws an `InvalidArgumentException`, with both drivers: - git runs its command, by default before git 2.12. - * An ssh URL whose host or user starts with a dash, even encoded (`%2D`), - like `ssh://-oProxyCommand=.../repository` or - `ssh://-oProxyCommand=...@host/path`, or a `git://` URL whose host does, - throws an `InvalidArgumentException`, with both drivers: git before 2.14.1 - passes it to ssh, or to the command of `core.gitProxy`, which reads it as - an option. - * `Hooks` throws an `InvalidArgumentException` for a name that isn't the one - of a file of the hooks directory, like `../config` or `.hidden`. - * The messages of exceptions, their traces (`#[\SensitiveParameter]`) and - the logs hide the credentials of URLs - (`https://***@example.com/repository.git`). `getErrorOutput()` and - `getOutput()` of `ProcessException` don't. - * Every message is a sentence ending with a dot. +* A failing git operation always throws, a + `Gitonomy\Git\Exception\ProcessException` with `CliDriver`. Nothing + returns `null` or empty data instead anymore: `Repository::run()` returns + a `string`, and `Repository::getHead()` and `Repository::getHeadCommit()` + never return `null`. + + Before: + + ```php + $repository = new Repository($path, ['debug' => false]); + + if (null === $output = $repository->run('fetch', ['--all'])) { + // handle the failure + } + ``` + + After: + + ```php + $repository = new Repository($path); + + try { + $output = $repository->run('fetch', ['--all']); + } catch (ProcessException $e) { + // handle the failure + } + ``` + +* Failures are no longer logged at the `error` level: the exception reports + them, and the logger gets their error output at the `debug` level. Alerts + based on `error` logs must rely on exceptions instead. +* A missing revision, reference or object throws a + `ReferenceNotFoundException`, with both drivers: it extends + `Gitonomy\Git\Exception\InvalidArgumentException`, still an + `\InvalidArgumentException`, and takes a whole message instead of a name. +* A timeout throws a `Gitonomy\Git\Exception\ProcessTimedOutException`, + and the other failures of Symfony Process a `DriverException`: no + exception of Symfony or of PHP leaks anymore, not even as the previous + exception, as they hold the command line. +* `ProcessException` extends the new + `Gitonomy\Git\Exception\DriverException`, itself a `RuntimeException`: + catching `RuntimeException` or `GitExceptionInterface` still works. +* The message of a `ProcessException` is one line, with the error of git: + the first line of its error output starting with `fatal:` or `error:`, + after the warnings, or its first line: `The git command "clone" failed + (exit 128): fatal: ...`, or `The git command "diff" failed (exit 1).` + when git writes no error. The command line, the rest of the error output + and the output are no longer in the message: `getErrorOutput()` and + `getOutput()` return the outputs of git. Its constructor takes the git + command as second argument, optional. +* When git can't start, a missing binary for instance, `CliDriver` throws + a `DriverException` instead of a `ProcessException`, with a message of + one line, without the command line: `The git command "clone" could not + start: ...`. +* These methods throw instead of returning a fallback value: + * `Commit::getIncludingBranches()` threw nothing and returned `[]` when git + failed; + * `ReferenceBag` (`Repository::getReferences()`) only returns an empty bag + for a repository without references, and throws for any other failure. +* `Gitonomy\Git\Exception\UnexpectedValueException` is deprecated: gitlib + no longer throws it. Catch `InvalidArgumentException` for + `Tree::resolvePath()`. +* `Commit::getShortHash()` throws a `ReferenceNotFoundException` for an + unknown commit, instead of a `ProcessException`. +* `Hooks` throws a `RuntimeException` when a hook exists already, doesn't + exist, or can't be written, instead of a `LogicException` or a silent + failure, and creates the hooks directory. +* The `Admin` methods throw a `ProcessException`, still a + `RuntimeException`, with the first line of git's error output in its + message, or a `DriverException` when git can't start. +* An argument holding a NUL byte throws an `InvalidArgumentException`, + where PHP threw a `ValueError`: git and libgit2 read their arguments as C + strings, which end at the first NUL byte. +* A revision, a reference, a hash or a URL starting with a dash throws an + `InvalidArgumentException`, with both drivers: git read it as an option, + `getRevision('--output=/path')` writing a file, or + `isValidRepository('--upload-pack=...')` running a command. A path may + start with a dash. +* An `ext::` URL throws an `InvalidArgumentException`, with both drivers: + git runs its command, by default before git 2.12. +* An ssh URL whose host or user starts with a dash, even encoded (`%2D`), + like `ssh://-oProxyCommand=.../repository` or + `ssh://-oProxyCommand=...@host/path`, or a `git://` URL whose host does, + throws an `InvalidArgumentException`, with both drivers: git before 2.14.1 + passes it to ssh, or to the command of `core.gitProxy`, which reads it as + an option. +* `Hooks` throws an `InvalidArgumentException` for a name that isn't the one + of a file of the hooks directory, like `../config` or `.hidden`. +* The messages of exceptions, their traces (`#[\SensitiveParameter]`) and + the logs hide the credentials of URLs + (`https://***@example.com/repository.git`). `getErrorOutput()` and + `getOutput()` of `ProcessException` don't. +* Every message is a sentence ending with a dot. Git configuration ----------------- - * `CliDriver` sets back to git's defaults the configuration of the user that - changes what git prints, what a read returns, or the upstream of a new - branch: colors, columns, `branch.autoSetupMerge`, - `branch.autoSetupRebase`, `branch.sort`, `log.follow`, - `log.showSignature`, and for diffs `diff.algorithm`, `diff.context`, - `diff.external`, `diff.orderFile`, `diff.submodule`, the prefixes, the - textconv filters... Before, they made reads throw or return other data: a - log of a path following its renames with `log.follow`, for instance. See - ["Git configuration"](doc/repository.md#git-configuration). - * `CliDriver` ignores the replacements of objects (`git replace`, - `refs/replace/`), like `LibGit2Driver`: `run()` and `runProcess()` too. - Run git yourself to read them. +* `CliDriver` sets back to git's defaults the configuration of the user that + changes what git prints, what a read returns, or the upstream of a new + branch: colors, columns, `branch.autoSetupMerge`, + `branch.autoSetupRebase`, `branch.sort`, `log.follow`, + `log.showSignature`, and for diffs `diff.algorithm`, `diff.context`, + `diff.external`, `diff.orderFile`, `diff.submodule`, the prefixes, the + textconv filters... Before, they made reads throw or return other data: a + log of a path following its renames with `log.follow`, for instance. See + ["Git configuration"](doc/repository.md#git-configuration). +* `CliDriver` ignores the replacements of objects (`git replace`, + `refs/replace/`), like `LibGit2Driver`: `run()` and `runProcess()` too. + Run git yourself to read them. Dates ----- - * `Commit::getAuthorDate()`, `Commit::getCommitterDate()` and - `Reference\Tag::getTaggerDate()` return a `\DateTimeImmutable` instead of - a `\DateTime`. Code that modifies the returned date must use the returned - value of `modify()`, `setTimezone()`... - * These dates are at the UTC offset of their author, committer or tagger, - like `git log` shows them (`2016-08-17T12:00:00+02:00`), instead of in - UTC. The instant doesn't change: comparisons and `getTimestamp()` return - the same results, but `format()` returns the local time of the author, - committer or tagger. To keep UTC, convert the date: +* `Commit::getAuthorDate()`, `Commit::getCommitterDate()` and + `Reference\Tag::getTaggerDate()` return a `\DateTimeImmutable` instead of + a `\DateTime`. Code that modifies the returned date must use the returned + value of `modify()`, `setTimezone()`... +* These dates are at the UTC offset of their author, committer or tagger, + like `git log` shows them (`2016-08-17T12:00:00+02:00`), instead of in + UTC. The instant doesn't change: comparisons and `getTimestamp()` return + the same results, but `format()` returns the local time of the author, + committer or tagger. To keep UTC, convert the date: - Before: + Before: - ```php - $date = $commit->getAuthorDate()->format('Y-m-d H:i'); - ``` + ```php + $date = $commit->getAuthorDate()->format('Y-m-d H:i'); + ``` - After: + After: - ```php - $date = $commit->getAuthorDate()->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i'); - ``` + ```php + $date = $commit->getAuthorDate()->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i'); + ``` Extending gitlib ---------------- - * These classes are `final`: `Admin`, `Blame`, `Blob`, `Commit`, - `Diff\File`, `Log`, `Parser\DiffParser`, `PushReference`, - `Reference\Branch`, `Reference\Stash`, `Reference\Tag`, `ReferenceBag`, - `Repository`, `Tree`, and the exceptions `LogicException`, - `ProcessException`, `ReferenceNotFoundException` and - `UnexpectedValueException`. - * These classes are `final readonly`: `Blame\Line`, `Diff\Diff`, - `Diff\FileChange`, `Hooks`, `RevisionList`, `WorkingCopy`. - * Every method has native parameter and return types. A class extending - `Parser\ParserBase`, which remains extendable, must declare the same - types, and must not redeclare its properties. - * The constructors of `Commit`, `Tree`, `Blob`, `Revision`, `Reference`, - `Reference\Tag` and `ReferenceBag` are internal: get these objects from - `Repository` (`getCommit()`, `getTree()`, `getBlob()`, `getRevision()`, - `getReferences()`), which returns the same commit, tree or blob while your - application uses it, and reads the references again after its writes. - The constructor of `Reference` requires the commit hash. Extending - `Revision` or `Reference` isn't supported anymore, and won't be possible - in 3.0: `Reference` declares `getName()`, abstract. - * To change how gitlib runs git, for example in tests, implement - `Gitonomy\Git\Driver\DriverInterface` and pass it as the driver of - `Repository`, instead of extending `Repository`: see - ["Using your own driver"](doc/repository.md#using-your-own-driver). - * `Commit::__construct()` takes a `Gitonomy\Git\Driver\Data\CommitData` - instead of an array, and `Commit::setData()` is internal. - * `Gitonomy\Git\Util\StringHelper` is removed, with its configurable - encoding: use `symfony/string`. Logs are always read in UTF-8. - * The parsers of the git output moved to `Gitonomy\Git\Driver\Cli\Parser` - and are `final`, except `Parser\DiffParser` and `Parser\ParserBase`. - `CommitParser` is merged into `LogParser`, and their results are objects - of `Gitonomy\Git\Driver\Data`: `LogParser::$commits` (instead of `$log`), - `TagParser::$data`, `ReferenceParser::$references`, `TreeParser::$entries` - and `BlameParser::$lines`. `TreeParser` parses `git ls-tree -z`, and - `BlameParser` no longer takes a `Repository`. `ParserBase::parse()` - takes a `string`. - * Objects serialized with 1.x (a `Commit` or a `Diff` in a cache...) can't - be unserialized: clear such caches. `Diff::toArray()` and - `Diff::fromArray()` remain compatible. +* These classes are `final`: `Admin`, `Blame`, `Blob`, `Commit`, + `Diff\File`, `Log`, `Parser\DiffParser`, `PushReference`, + `Reference\Branch`, `Reference\Stash`, `Reference\Tag`, `ReferenceBag`, + `Repository`, `Tree`, and the exceptions `LogicException`, + `ProcessException`, `ReferenceNotFoundException` and + `UnexpectedValueException`. +* These classes are `final readonly`: `Blame\Line`, `Diff\Diff`, + `Diff\FileChange`, `Hooks`, `RevisionList`, `WorkingCopy`. +* Every method has native parameter and return types. A class extending + `Parser\ParserBase`, which remains extendable, must declare the same + types, and must not redeclare its properties. +* The constructors of `Commit`, `Tree`, `Blob`, `Revision`, `Reference`, + `Reference\Tag` and `ReferenceBag` are internal: get these objects from + `Repository` (`getCommit()`, `getTree()`, `getBlob()`, `getRevision()`, + `getReferences()`), which returns the same commit, tree or blob while your + application uses it, and reads the references again after its writes. + The constructor of `Reference` requires the commit hash. Extending + `Revision` or `Reference` isn't supported anymore, and won't be possible + in 3.0: `Reference` declares `getName()`, abstract. +* To change how gitlib runs git, for example in tests, implement + `Gitonomy\Git\Driver\DriverInterface` and pass it as the driver of + `Repository`, instead of extending `Repository`: see + ["Using your own driver"](doc/repository.md#using-your-own-driver). +* `Commit::__construct()` takes a `Gitonomy\Git\Driver\Data\CommitData` + instead of an array, and `Commit::setData()` is internal. +* `Gitonomy\Git\Util\StringHelper` is removed, with its configurable + encoding: use `symfony/string`. Logs are always read in UTF-8. +* The parsers of the git output moved to `Gitonomy\Git\Driver\Cli\Parser` + and are `final`, except `Parser\DiffParser` and `Parser\ParserBase`. + `CommitParser` is merged into `LogParser`, and their results are objects + of `Gitonomy\Git\Driver\Data`: `LogParser::$commits` (instead of `$log`), + `TagParser::$data`, `ReferenceParser::$references`, `TreeParser::$entries` + and `BlameParser::$lines`. `TreeParser` parses `git ls-tree -z`, and + `BlameParser` no longer takes a `Repository`. `ParserBase::parse()` + takes a `string`. +* Objects serialized with 1.x (a `Commit` or a `Diff` in a cache...) can't + be unserialized: clear such caches. `Diff::toArray()` and + `Diff::fromArray()` remain compatible. Data returned ------------- - * File names in diffs (`Diff\File`) and tree entries (`Tree`) are returned - in UTF-8, instead of git's quoted form (`"caf\303\251.txt"`) for names - with non-ASCII or special characters. - * `Blame\Line::getContent()` no longer starts with a TAB. - * `Commit::getDiff()` lists the files of a root commit, instead of - returning an empty diff. - * A commit read through `Repository::getCommit()` has its message as - `git log` prints it, like a commit of a `Log` already had: without blank - lines around it, nor whitespace at the end of its lines. It also comes - re-encoded to UTF-8 when the commit has an `encoding` header, instead of - throwing. - * `Reference\Tag::isSigned()` returns `false` for a lightweight tag. - * `PushReference::isForce()` returns `true` for histories without a common - ancestor, where the constructor threw. `PushReference` runs git when - `isForce()` needs it, not when constructed. `getReference()` and - `getRevision()` are deprecated: use `getFullname()` and `getRange()`. - * `Blob::getContent()` only reads blobs, where `git cat-file -p` also - printed trees and commits. - * `WorkingCopy::getUntrackedFiles()` and `RevisionList::getAll()` return - lists, without gaps in their keys. - * `Diff\File::toArray()` has a new `is_copy` key. +* File names in diffs (`Diff\File`) and tree entries (`Tree`) are returned + in UTF-8, instead of git's quoted form (`"caf\303\251.txt"`) for names + with non-ASCII or special characters. +* `Blame\Line::getContent()` no longer starts with a TAB. +* `Commit::getDiff()` lists the files of a root commit, instead of + returning an empty diff. +* A commit read through `Repository::getCommit()` has its message as + `git log` prints it, like a commit of a `Log` already had: without blank + lines around it, nor whitespace at the end of its lines. It also comes + re-encoded to UTF-8 when the commit has an `encoding` header, instead of + throwing. +* `Reference\Tag::isSigned()` returns `false` for a lightweight tag. +* `PushReference::isForce()` returns `true` for histories without a common + ancestor, where the constructor threw. `PushReference` runs git when + `isForce()` needs it, not when constructed. `getReference()` and + `getRevision()` are deprecated: use `getFullname()` and `getRange()`. +* `Blob::getContent()` only reads blobs, where `git cat-file -p` also + printed trees and commits. +* `WorkingCopy::getUntrackedFiles()` and `RevisionList::getAll()` return + lists, without gaps in their keys. +* `Diff\File::toArray()` has a new `is_copy` key. Other changes ------------- - * Relative paths, in the arguments of `Repository::run()` and in the paths - of a log, are relative to the repository instead of the current directory - of PHP. - * `Hooks::set()` makes hooks executable by their owner only (`0700`, - instead of `0777`): change their permissions if another user runs them. - * `Repository::shell()` is deprecated: it ignored the driver. Run - `git shell` with Symfony Process instead. - * `Hooks::has()` returns `true` for a symbolic link to a missing file: - `get()` throws a `RuntimeException`, as it can't read it, `set()` and - `setSymlink()` refuse it, as a hook that exists already, and `remove()` - removes the link. - * `Repository::getSize()` is deprecated: `getSizeInBytes()` returns bytes, - instead of kilobytes. Neither follows links: a link in the git directory, - to a hook not deployed yet for instance, counts for nothing, where - `getSize()` counted the file it points to, and threw for a missing one. +* Relative paths, in the arguments of `Repository::run()` and in the paths + of a log, are relative to the repository instead of the current directory + of PHP. +* `Hooks::set()` makes hooks executable by their owner only (`0700`, + instead of `0777`): change their permissions if another user runs them. +* `Repository::shell()` is deprecated: it ignored the driver. Run + `git shell` with Symfony Process instead. +* `Hooks::has()` returns `true` for a symbolic link to a missing file: + `get()` throws a `RuntimeException`, as it can't read it, `set()` and + `setSymlink()` refuse it, as a hook that exists already, and `remove()` + removes the link. +* `Repository::getSize()` is deprecated: `getSizeInBytes()` returns bytes, + instead of kilobytes. Neither follows links: a link in the git directory, + to a hook not deployed yet for instance, counts for nothing, where + `getSize()` counted the file it points to, and threw for a missing one. From 27ba5748ae677648bd7a1ac5dc369c9e7b28f810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:56:56 +0200 Subject: [PATCH 115/150] Publish the documentation on GitHub Pages doc/, README.md, UPGRADE-2.0.md and CHANGELOG.md become a site, built by Zensical with its theme: a light and a dark mode, a search, the fonts of the system, and no request to Google Fonts. Zensical reads the pages from a single directory, which can't be the root of the repository, and runs no hooks: tools/zensical/pages.py copies them to var/docs first, and rewrites the links between the files of the root and doc/, which stay relative paths of the repository, as GitHub shows them. The Documentation workflow builds the site, strictly, on the pull requests changing it, and deploys it to GitHub Pages from main. Dependabot updates its Python dependencies. zensical.toml stays out of the archive Composer installs. --- .gitattributes | 1 + .github/dependabot.yml | 8 +++ .github/workflows/docs.yml | 76 +++++++++++++++++++++++++ .gitignore | 1 + tools/zensical/assets/favicon.svg | 1 + tools/zensical/pages.py | 74 ++++++++++++++++++++++++ tools/zensical/requirements.txt | 13 +++++ zensical.toml | 95 +++++++++++++++++++++++++++++++ 8 files changed, 269 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 tools/zensical/assets/favicon.svg create mode 100644 tools/zensical/pages.py create mode 100644 tools/zensical/requirements.txt create mode 100644 zensical.toml diff --git a/.gitattributes b/.gitattributes index cbf8b342..e39bddbb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,4 @@ /test-git-versions.php export-ignore /tests export-ignore /tools export-ignore +/zensical.toml export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 27976684..ca8ca8b2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,11 @@ updates: - /tools/phpstan schedule: interval: monthly + + - package-ecosystem: pip + directory: /tools/zensical + schedule: + interval: monthly + groups: + zensical: + patterns: ['*'] diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..14513081 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,76 @@ +name: Documentation + +on: + push: + branches: [ main ] + pull_request: + paths: + - .github/workflows/docs.yml + - CHANGELOG.md + - README.md + - UPGRADE-2.0.md + - doc/** + - tools/zensical/** + - zensical.toml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Let a deployment finish: the next one waits. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Build the Documentation + runs-on: ubuntu-24.04 + steps: + - name: Checkout Code + uses: actions/checkout@v7 + with: + # The site is published: keep the token out of the workspace. + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: '3.14' + cache: pip + cache-dependency-path: tools/zensical/requirements.txt + + - name: Install Zensical + run: pip install -r tools/zensical/requirements.txt + + - name: Write the pages + run: python tools/zensical/pages.py + + # Strict, as configured in zensical.toml: a broken link or anchor fails. + - name: Build the site + run: zensical build --clean + + - name: Upload the site + if: github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v5 + with: + path: site + + deploy: + name: Deploy to GitHub Pages + if: github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Setup Pages + uses: actions/configure-pages@v6 + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index 0b49effe..3bac1c17 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /.phpunit.cache /composer.lock /phpunit.xml +/site/ /var/ /vendor /git-builds/ diff --git a/tools/zensical/assets/favicon.svg b/tools/zensical/assets/favicon.svg new file mode 100644 index 00000000..cfdd625d --- /dev/null +++ b/tools/zensical/assets/favicon.svg @@ -0,0 +1 @@ + diff --git a/tools/zensical/pages.py b/tools/zensical/pages.py new file mode 100644 index 00000000..b0af78d9 --- /dev/null +++ b/tools/zensical/pages.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Write the pages of the documentation site, as GitHub shows them, to the docs_dir of zensical.toml. + +The site is doc/, with README.md, UPGRADE-2.0.md and CHANGELOG.md, which live at the root of the +repository. Zensical reads its pages from a single directory, which can't be the root, and runs +no hooks: this script copies them there, with the assets of the site. The links between the +pages are relative paths of the repository, like "doc/blob.md" or "../UPGRADE-2.0.md#errors": +they are rewritten to the pages of the site. + +Usage, from the root of the repository: python3 tools/zensical/pages.py && zensical build +""" + +import posixpath +import re +import shutil +import sys +from pathlib import Path + +REPOSITORY_DIR = Path(__file__).resolve().parents[2] +# The docs_dir of zensical.toml: /var/ is ignored by git. +OUTPUT_DIR = REPOSITORY_DIR / 'var/docs' +ASSETS_DIR = Path(__file__).resolve().parent / 'assets' + +# The files of the root of the repository published as pages, and their page on the site. +ROOT_PAGES = { + 'CHANGELOG.md': 'changelog.md', + 'README.md': 'index.md', + 'UPGRADE-2.0.md': 'upgrade-2.0.md', +} + +# A relative link to a Markdown file: "](doc/blob.md)", "](../UPGRADE-2.0.md#errors)"... +MARKDOWN_LINK = re.compile(r'\]\((?P(?![a-z]+:)[^)\s#]+\.md)(?P#[^)\s]*)?\)') + + +def main() -> None: + if OUTPUT_DIR.exists(): + shutil.rmtree(OUTPUT_DIR) + shutil.copytree(REPOSITORY_DIR / 'doc', OUTPUT_DIR) + shutil.copytree(ASSETS_DIR, OUTPUT_DIR / 'assets') + + pages = dict(ROOT_PAGES) + for file in sorted((REPOSITORY_DIR / 'doc').rglob('*.md')): + path = file.relative_to(REPOSITORY_DIR).as_posix() + pages[path] = path.removeprefix('doc/') + + for path, site_path in pages.items(): + markdown = (REPOSITORY_DIR / path).read_text(encoding='utf-8') + (OUTPUT_DIR / site_path).write_text(rewrite_links(markdown, path, site_path), encoding='utf-8') + + print(f'{len(pages)} pages written to {OUTPUT_DIR.relative_to(REPOSITORY_DIR)}/.', file=sys.stderr) + + +def rewrite_links(markdown: str, path: str, site_path: str) -> str: + """Rewrite the links of the file path of the repository, published as site_path.""" + + def rewrite(match: re.Match[str]) -> str: + target = posixpath.normpath(posixpath.join(posixpath.dirname(path), match['path'])) + if target in ROOT_PAGES: + target_site_path = ROOT_PAGES[target] + elif target.startswith('doc/'): + target_site_path = target.removeprefix('doc/') + else: + # Not a page: the strict build reports the link. + return match[0] + + link = posixpath.relpath(target_site_path, posixpath.dirname(site_path) or '.') + + return '](' + link + (match['fragment'] or '') + ')' + + return MARKDOWN_LINK.sub(rewrite, markdown) + + +if __name__ == '__main__': + main() diff --git a/tools/zensical/requirements.txt b/tools/zensical/requirements.txt new file mode 100644 index 00000000..ce2e03aa --- /dev/null +++ b/tools/zensical/requirements.txt @@ -0,0 +1,13 @@ +# The documentation site (zensical.toml), with Python 3.14: `pip install zensical` in a new +# virtualenv, then `pip freeze`. Dependabot updates them. +click==8.5.0 +deepmerge==3.0.1 +jinja2==3.1.6 +markdown==3.11.1 +markupsafe==3.0.4 +pathspec==1.1.1 +pygments==2.21.0 +pymdown-extensions==12.2 +pyyaml==6.0.3 +tomli==2.5.0 +zensical==0.0.70 diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 00000000..654552d5 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,95 @@ +# The documentation site, published on GitHub Pages by .github/workflows/docs.yml. Its pages are +# doc/, README.md, UPGRADE-2.0.md and CHANGELOG.md, which tools/zensical/pages.py copies to +# docs_dir first. With the Python packages of tools/zensical/requirements.txt: +# +# python3 tools/zensical/pages.py && zensical serve + +[project] +site_name = "gitlib" +site_url = "https://gitonomy.github.io/gitlib/" +site_description = "Access git repositories from PHP, through the git binary or libgit2." +copyright = "gitlib is released under the MIT license." +repo_url = "https://github.com/gitonomy/gitlib" +repo_name = "gitonomy/gitlib" +docs_dir = "var/docs" +site_dir = "site" +strict = true + +nav = [ + { "Getting started" = "index.md" }, + { "Repository and drivers" = [ + { "Opening a repository" = "repository.md" }, + { "Creating repositories" = "admin.md" }, + ] }, + { "Objects" = [ + { "Revisions" = "revision.md" }, + { "Commits" = "commit.md" }, + { "Log" = "log.md" }, + { "Trees" = "tree.md" }, + { "Blobs" = "blob.md" }, + { "Diffs" = "diff.md" }, + { "Blame" = "blame.md" }, + { "Tags and branches" = "references.md" }, + { "Branches" = "branch.md" }, + ] }, + { "Working copy" = "workingcopy.md" }, + { "Hooks" = "hooks.md" }, + { "Upgrade and changelog" = [ + { "Upgrading to 2.0" = "upgrade-2.0.md" }, + { "Changelog" = "changelog.md" }, + ] }, +] + +[project.theme] +language = "en" +favicon = "assets/favicon.svg" +# The fonts of the system: no request to Google Fonts. +font = false +features = [ + "content.code.copy", + "navigation.footer", + "navigation.instant", + "navigation.path", + "navigation.sections", + "navigation.top", + "navigation.tracking", + "search.highlight", + "toc.follow", +] + +[project.theme.icon] +logo = "lucide/git-branch" +repo = "fontawesome/brands/github" + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "lucide/sun-moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +primary = "teal" +accent = "teal" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "teal" +accent = "teal" +toggle.icon = "lucide/moon" +toggle.name = "Switch to system preference" + +[project.markdown_extensions] +admonition = {} +attr_list = {} +toc.permalink = true +# The examples start without " Date: Wed, 14 Oct 2026 16:12:38 +0200 Subject: [PATCH 116/150] Group the Dependabot updates by language, once a month Dependabot opened a pull request per dependency and per directory: three for the PHP tools this month. Each language now gets one pull request a month at most, for releases a week old at least: - the PHP tools, whose Composer projects live in two directories, share a multi-ecosystem group, which updates both in one pull request; - the GitHub Actions and the Python packages of the site each have a group. Security updates still come as soon as they are published, grouped for GitHub Actions and Python, like in jolicode/docker-starter. --- .github/dependabot.yml | 43 +++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ca8ca8b2..29129b89 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,21 +1,50 @@ version: 2 -updates: - - package-ecosystem: github-actions - directory: / + +# At most one pull request per language and per month, for releases a week old at least. Security +# updates come as soon as they are published. +multi-ecosystem-groups: + # The PHP tools, one Composer project per directory: one pull request for all of them. + php: schedule: interval: monthly +updates: + - package-ecosystem: composer + directory: /tools/php-cs-fixer + multi-ecosystem-group: php + patterns: ['*'] + cooldown: + default-days: 7 + - package-ecosystem: composer - directories: - - /tools/php-cs-fixer - - /tools/phpstan + directory: /tools/phpstan + multi-ecosystem-group: php + patterns: ['*'] + cooldown: + default-days: 7 + + - package-ecosystem: github-actions + directory: / schedule: interval: monthly + cooldown: + default-days: 7 + groups: + github-actions: + patterns: ['*'] + github-actions-security: + applies-to: security-updates + patterns: ['*'] - package-ecosystem: pip directory: /tools/zensical schedule: interval: monthly + cooldown: + default-days: 7 groups: - zensical: + pip: + patterns: ['*'] + pip-security: + applies-to: security-updates patterns: ['*'] From 62faec26b0db70ae6581d20cdda1f2c047d96098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 16:21:57 +0200 Subject: [PATCH 117/150] Read the .git file of a working tree like git Since 2.0, a working tree whose .git is a file pointing to its git directory opens: a linked worktree, a submodule, or a clone with --separate-git-dir. Only linked worktrees had a test, and the file was read unlike git: - the "\r" of a file written on Windows stayed in the path, which then didn't exist; - "gitdir: " was accepted on any line, where git requires it first; - a NUL byte in the path made realpath() throw a ValueError, which isn't an exception of gitlib; - a file that can't be read, without permission for instance, was reported as one that doesn't point to a git directory. Both drivers read the file like git. A submodule, whose .git file holds a relative path, and a clone with --separate-git-dir, whose one holds an absolute path, have tests. Fixes #200. Co-authored-by: Chris Stretton --- CHANGELOG.md | 6 +- UPGRADE-2.0.md | 3 +- doc/repository.md | 3 +- src/Gitonomy/Git/Driver/RepositoryPaths.php | 24 +++- tests/Gitonomy/Git/Tests/RepositoryTest.php | 121 ++++++++++++++++++++ 5 files changed, 146 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4427a87..1d9bfe66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,9 +62,9 @@ deprecated until 3.0. by default, configured through its constructor (git binary, environment, timeout and logger, which gets every command, clones included), or any `Driver\DriverInterface`, to replace it in tests; see ["Using your own driver"](doc/repository.md#using-your-own-driver). The driver - finds the repository: linked worktrees, submodules and reftable repositories open, and a - directory that isn't a repository throws right away. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) + finds the repository: linked worktrees, submodules, clones with `--separate-git-dir` and + reftable repositories open, and a directory that isn't a repository throws right away. + ([#253](https://github.com/gitonomy/gitlib/pull/253), fixes #200) - Add static methods to `Repository` creating repositories, `init()`, `initBare()`, `cloneFrom()`, `cloneBareFrom()` and `mirrorFrom()`, and checking a repository URL, `isValidRepository()` and `isValidRepositoryAndBranch()`; see diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index e44b082c..d3081488 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -67,7 +67,8 @@ Opening a repository ignored. * The `debug` option is deprecated, and setting it to `false` throws an `InvalidArgumentException`: see [Errors](#errors). -* `$path` may be a linked worktree or a submodule, whose `.git` is a file. +* `$path` may be a linked worktree, a submodule or a clone with + `--separate-git-dir`, whose `.git` is a file. * A directory that isn't a repository throws an `InvalidArgumentException` right away, instead of a `ProcessException` at the first operation. diff --git a/doc/repository.md b/doc/repository.md index 998a6ad0..1eb32776 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -23,7 +23,8 @@ The path may be: - a bare repository; - a working tree, with its `.git` directory; - a working tree whose `.git` is a file pointing to its git directory: a - linked worktree (`git worktree add`) or a submodule. + linked worktree (`git worktree add`), a submodule, or a clone with + `--separate-git-dir`. The constructor takes two more arguments: diff --git a/src/Gitonomy/Git/Driver/RepositoryPaths.php b/src/Gitonomy/Git/Driver/RepositoryPaths.php index 7cede834..71141e74 100644 --- a/src/Gitonomy/Git/Driver/RepositoryPaths.php +++ b/src/Gitonomy/Git/Driver/RepositoryPaths.php @@ -35,7 +35,7 @@ private function __construct( * @param string|null $workingDir The working tree of the git directory $dir * * @throws InvalidArgumentException $dir doesn't exist, or isn't a repository, or a path contains a NUL byte - * @throws DriverException The ".git" file of $dir doesn't point to a git directory + * @throws DriverException The ".git" file of $dir can't be read, or doesn't point to a git directory * @throws UnsupportedOperationException The repository uses SHA-256 hashes */ public static function resolve(string $dir, ?string $workingDir): self @@ -50,7 +50,7 @@ public static function resolve(string $dir, ?string $workingDir): self $paths = match (true) { null !== $workingDir => new self($realDir, $workingDir), is_dir($realDir.'/.git') => new self($realDir.'/.git', $realDir), - // In a linked worktree or a submodule, ".git" is a file pointing to the git directory. + // In a linked worktree, a submodule or a clone with --separate-git-dir, ".git" is a file pointing to the git directory. is_file($realDir.'/.git') => new self(self::readGitFile($realDir), $realDir), default => new self($realDir, null), }; @@ -69,17 +69,29 @@ public static function resolve(string $dir, ?string $workingDir): self } /** + * Reads the file like git: "gitdir: " then the path, absolute or relative + * to $dir, without the line ending, "\r\n" included. The path may end with + * a space. + * * @return string The git directory the ".git" file of $dir points to + * + * @throws DriverException The file can't be read, or doesn't point to a git directory */ private static function readGitFile(string $dir): string { - $content = file_get_contents($dir.'/.git'); - if (false === $content || !preg_match('/^gitdir: (.+)$/m', $content, $match)) { + $content = @file_get_contents($dir.'/.git'); + if (false === $content) { + throw new DriverException(\sprintf('Unable to read the "%s/.git" file.', $dir)); + } + + $path = rtrim(substr($content, \strlen('gitdir: ')), "\r\n"); + // realpath() refuses a NUL byte, where git would read the path up to it. + if (!str_starts_with($content, 'gitdir: ') || '' === $path || str_contains($path, "\0")) { throw new DriverException(\sprintf('The "%s/.git" file doesn\'t point to a git directory.', $dir)); } - $gitDir = str_starts_with($match[1], '/') || preg_match('/^[a-zA-Z]:[\\\\\/]/', $match[1]) ? $match[1] : $dir.'/'.$match[1]; + $gitDir = str_starts_with($path, '/') || preg_match('/^[a-zA-Z]:[\\\\\/]/', $path) ? $path : $dir.'/'.$path; - return realpath($gitDir) ?: throw new DriverException(\sprintf('The git directory "%s" of "%s" does not exist.', $match[1], $dir)); + return realpath($gitDir) ?: throw new DriverException(\sprintf('The git directory "%s" of "%s" does not exist.', $path, $dir)); } } diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 50fd6782..2900f1c1 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -79,6 +79,115 @@ public function testOpensALinkedWorktree(): void $this->assertSame($repository->getHeadCommit()->getHash(), $worktree->getHeadCommit()->getHash()); } + /** + * The ".git" file of a submodule points to its git directory, in the one + * of the superproject, with a relative path. + */ + public function testOpensASubmodule(): void + { + $superproject = self::createFoobarRepository(false); + $source = self::createFoobarRepository(); + // git refuses to clone a local submodule since 2.38.1, unless protocol.file.allow allows it. + new Process([self::gitCommand(), '-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', $source->getGitDir(), 'sub'], $superproject->getWorkingDir(), self::gitEnvs())->mustRun(); + $dir = $superproject->getWorkingDir().'/sub'; + $this->assertFileMatchesFormat('gitdir: ../.git/modules/sub', $dir.'/.git'); + + $submodule = new Repository($dir, driver: self::getDriver()); + + $this->assertSame(realpath($dir), $submodule->getWorkingDir()); + $this->assertSame(realpath($superproject->getGitDir().'/modules/sub'), $submodule->getGitDir()); + $this->assertFalse($submodule->isBare()); + $this->assertSame($source->getHeadCommit()->getHash(), $submodule->getHeadCommit()->getHash()); + } + + /** + * "git clone --separate-git-dir" writes a ".git" file with the absolute + * path of the git directory. + */ + public function testOpensACloneWithASeparateGitDir(): void + { + $source = self::createFoobarRepository(); + [$dir, $gitDir] = self::cloneWithASeparateGitDir($source); + + $repository = new Repository($dir, driver: self::getDriver()); + + $this->assertSame(realpath($dir), $repository->getWorkingDir()); + $this->assertSame(realpath($gitDir), $repository->getGitDir()); + $this->assertFalse($repository->isBare()); + $this->assertSame($source->getHeadCommit()->getHash(), $repository->getHeadCommit()->getHash()); + } + + /** + * Like git, which leaves out the line ending of a ".git" file only: a + * path may end with a space. + */ + #[DataProvider('provideLineEndings')] + public function testOpensAGitFileWhateverItsLineEnding(string $lineEnding): void + { + [$dir, $gitDir] = self::cloneWithASeparateGitDir(self::createFoobarRepository()); + file_put_contents($dir.'/.git', 'gitdir: '.$gitDir.$lineEnding); + + $this->assertSame(realpath($gitDir), new Repository($dir, driver: self::getDriver())->getGitDir()); + } + + /** + * @return iterable + */ + public static function provideLineEndings(): iterable + { + yield 'none' => ['']; + yield 'LF' => ["\n"]; + yield 'CRLF, written on Windows' => ["\r\n"]; + } + + #[DataProvider('provideGitFilesPointingToNoGitDirectory')] + public function testAGitFilePointingToNoGitDirectoryThrows(string $content, string $message): void + { + $dir = self::createTempDir(); + file_put_contents($dir.'/.git', $content); + + $this->expectException(DriverException::class); + $this->expectExceptionMessage(\sprintf($message, $dir)); + + new Repository($dir, driver: self::getDriver()); + } + + /** + * @return iterable + */ + public static function provideGitFilesPointingToNoGitDirectory(): iterable + { + $invalid = 'The "%s/.git" file doesn\'t point to a git directory.'; + + yield 'another format' => ["ref: refs/heads/master\n", $invalid]; + // Like git, which reads "gitdir: " at the start of the file only. + yield 'gitdir on the second line' => ["\ngitdir: missing\n", $invalid]; + yield 'no path' => ["gitdir: \n", $invalid]; + yield 'NUL byte' => ["gitdir: a\0b\n", $invalid]; + yield 'missing directory' => ["gitdir: missing\n", 'The git directory "missing" of "%s" does not exist.']; + } + + public function testAnUnreadableGitFileThrows(): void + { + $dir = self::createTempDir(); + file_put_contents($dir.'/.git', "gitdir: missing\n"); + chmod($dir.'/.git', 0); + + try { + if (is_readable($dir.'/.git')) { + $this->markTestSkipped('The user of the tests reads any file, like root.'); + } + + $this->expectException(DriverException::class); + $this->expectExceptionMessage(\sprintf('Unable to read the "%s/.git" file.', $dir)); + + new Repository($dir, driver: self::getDriver()); + } finally { + // Or the file can't be deleted on Windows. + chmod($dir.'/.git', 0o600); + } + } + public function testOpeningADirectoryThatIsNotARepositoryThrows(): void { $dir = self::createTempDir(); @@ -820,4 +929,16 @@ public function testDebugOptionSetToFalseIsRefused(): void new Repository(self::createTempDir(), ['debug' => false]); } + + /** + * @return array{string, string} The working tree of the clone, and its git directory + */ + private static function cloneWithASeparateGitDir(Repository $source): array + { + $dir = self::createTempDir().'/clone'; + $gitDir = self::createTempDir().'/git'; + new Process([self::gitCommand(), 'clone', '-q', '--separate-git-dir='.$gitDir, $source->getGitDir(), $dir], null, self::gitEnvs())->mustRun(); + + return [$dir, $gitDir]; + } } From d2bc5eaec48c32ece0492dcd8cb5f2a60b2251ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 17:13:53 +0200 Subject: [PATCH 118/150] Rewrite the README around an example The README opened on the drivers, then installing gitlib, before any code. It now starts with a line on what gitlib is, and an example reading a log, a diff and a blame, which runs as it is. Then what gitlib does, with links to the documentation, its site, the two drivers, how to install it, and the license. --- README.md | 125 +++++++++++++++++++++++++++++------------------------- 1 file changed, 67 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 7005d8b1..7499784a 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,54 @@ -Gitlib for Gitonomy -=================== +gitlib +====== -This library provides methods to access Git repository from PHP 8.4+. +Access git repositories from PHP 8.4+, through the `git` binary or libgit2. -By default, it runs the `git` binary (1.8.4 or later) for every operation: convenient, and it -doesn't need anything built to use. It can instead run [libgit2](https://libgit2.org/) in-process -via PHP's `FFI` extension, an experimental driver which is faster for small reads but slower on -long histories: see -["Choosing a driver"](doc/repository.md#choosing-a-driver) and -["Performance"](doc/repository.md#performance). - -Quick Start ------------ +```php +use Gitonomy\Git\Repository; -You can install gitlib using [Composer](https://getcomposer.org/). Simply require the version you need: +$repository = new Repository('/path/to/repository'); -```bash -$ composer require gitonomy/gitlib -``` +// The last 5 commits of main that changed src/ +foreach ($repository->getLog('main', 'src', limit: 5) as $commit) { + echo $commit->getShortHash().' '.$commit->getSubjectMessage().PHP_EOL; +} -or edit your `composer.json` file by hand: +// The files the last commit changed +foreach ($repository->getHeadCommit()->getDiff()->getFiles() as $file) { + echo $file->getName().': +'.$file->getAdditions().' -'.$file->getDeletions().PHP_EOL; +} -```json -{ - "require": { - "gitonomy/gitlib": "^2.0" - } +// Who wrote the first lines of README.md +foreach ($repository->getBlame('main', 'README.md', 1, 3)->getLines() as $line) { + echo $line->getCommit()->getAuthorName().': '.$line->getContent().PHP_EOL; } ``` -Upgrading from 1.x? Read [UPGRADE-2.0.md](UPGRADE-2.0.md). - -Example Usage -------------- - -```php -getReferences()->getBranches() as $branch) { - echo '- '.$branch->getName().PHP_EOL; -} +- **Open** a bare repository, a working tree, a linked worktree or a submodule, or + **create** one: init, clone, mirror. See [Opening a repository](doc/repository.md) and + [Creating repositories](doc/admin.md). +- **Read** commits, logs, trees, blobs, diffs, blames, branches and tags, as PHP objects. +- **Change** the working copy: checkout, new branches, merges, and the diffs of what is + pending or staged. See [Working copy](doc/workingcopy.md). +- **Manage** the hooks of a repository: see [Hooks](doc/hooks.md). +- **Run** any other git command: see + [Running git commands](doc/repository.md#running-git-commands). -echo $repository->getHeadCommit()->getSubjectMessage().PHP_EOL; +Two drivers +----------- -// Any git command, with the default driver only -$repository->run('fetch', ['--all']); -``` +`Repository` runs git through a driver, its last argument: -The last argument of `Repository` is the driver running git. It is `CliDriver`, which runs the -`git` binary, by default. To run libgit2 instead, pass a `LibGit2Driver`: everything above works -the same, except `run()`, which needs the `git` binary. +- `CliDriver`, the default, runs the `git` binary, 1.8.4 or later, and needs nothing else; +- `LibGit2Driver`, experimental, runs [libgit2](https://libgit2.org/) 1.9 in-process, through + PHP's `FFI` extension. It is faster for many small reads in a long-running process, and + slower on long histories, like blames: see + ["Choosing a driver"](doc/repository.md#choosing-a-driver). ```php use Gitonomy\Git\Driver\Ffi\LibGit2Driver; @@ -62,22 +57,31 @@ use Gitonomy\Git\Repository; $repository = new Repository('/path/to/repository', driver: new LibGit2Driver()); ``` -API Documentation ------------------ - -+ [Blame](doc/blame.md) -+ [Blob](doc/blob.md) -+ [Branch](doc/branch.md) -+ [Commit](doc/commit.md) -+ [Creating repositories](doc/admin.md) -+ [Diff](doc/diff.md) -+ [Hooks](doc/hooks.md) -+ [Log](doc/log.md) -+ [References](doc/references.md) -+ [Repository](doc/repository.md): opening a repository, drivers, errors -+ [Revision](doc/revision.md) -+ [Tree](doc/tree.md) -+ [Working Copy](doc/workingcopy.md) +Installation +------------ + +```bash +composer require gitonomy/gitlib +``` + +Upgrading from 1.x? Read [UPGRADE-2.0.md](UPGRADE-2.0.md) and the [changelog](CHANGELOG.md). + +Documentation +------------- + +- [Opening a repository](doc/repository.md): paths, drivers, errors, git configuration +- [Creating repositories](doc/admin.md) +- [Revisions](doc/revision.md) +- [Commits](doc/commit.md) +- [Log](doc/log.md) +- [Trees](doc/tree.md) +- [Blobs](doc/blob.md) +- [Diffs](doc/diff.md) +- [Blame](doc/blame.md) +- [Tags and branches](doc/references.md) +- [Branches](doc/branch.md) +- [Working copy](doc/workingcopy.md) +- [Hooks](doc/hooks.md) For Enterprise -------------- @@ -85,3 +89,8 @@ For Enterprise Available as part of the Tidelift Subscription The maintainers of `gitonomy/gitlib` and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/packagist-gitonomy-gitlib?utm_source=packagist-gitonomy-gitlib&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) + +License +------- + +gitlib is released under the [MIT license](https://github.com/gitonomy/gitlib/blob/main/LICENSE). From 6870d46ece5eec9c7dc421933b18bec223ab5dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:40:38 +0200 Subject: [PATCH 119/150] Ignore core.fsmonitor, and say gitlib trusts the repository it opens The configuration of a repository may name a command in core.fsmonitor, which git runs to read the working tree, and even to diff a commit: getUntrackedFiles(), the diffs of the working copy and Commit::getDiff() ran it. CliDriver sets core.fsmonitor back to false, on every command. The doc said safe.directory applies: it doesn't, as CliDriver runs git with --git-dir, which git trusts, whoever owns the repository. Its hooks and the filters of its .gitattributes still run, like with git: the doc says to only open a repository whose .git directory is trusted. --- CHANGELOG.md | 4 +++ UPGRADE-2.0.md | 2 ++ doc/repository.md | 25 +++++++++++++------ .../Git/Driver/Cli/CliRepositoryDriver.php | 4 +++ tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 21 ++++++++++++++++ 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d9bfe66..43afcfab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,6 +145,10 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - Ignore the replacements of objects (`git replace`, `refs/replace/`) with `CliDriver`, like `LibGit2Driver`: whoever could push such a reference to a repository changed the message of a commit, or the content of a blob, gitlib read for a hash. +- Ignore `core.fsmonitor` with `CliDriver`: git ran the command the configuration of a + repository names there to read the working tree, and even to diff a commit. Like git with + `--git-dir`, gitlib trusts the repositories it opens: `safe.directory` doesn't apply, see + ["Git configuration"](doc/repository.md#git-configuration). - Fix `WorkingCopy::checkout()` of a path, like `checkout('README.md')`, discarding the changes of the file: a path is no revision, and throws a `ReferenceNotFoundException`. - Fix moving or deleting a symbolic reference, which changed the branch it points to instead: diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index d3081488..62654f92 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -473,6 +473,8 @@ Git configuration * `CliDriver` ignores the replacements of objects (`git replace`, `refs/replace/`), like `LibGit2Driver`: `run()` and `runProcess()` too. Run git yourself to read them. +* `CliDriver` ignores `core.fsmonitor`, whose command git runs to read the + working tree: `run()` and `runProcess()` too. Dates ----- diff --git a/doc/repository.md b/doc/repository.md index 1eb32776..a3fa59fe 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -492,6 +492,8 @@ creates, `CliDriver` sets it back to git's defaults: `LibGit2Driver`, a branch `WorkingCopy::checkoutNewBranch()` creates from a remote branch tracks it, without rebasing; - `branch.sort`, `log.follow` and `log.showSignature`; +- `core.fsmonitor`, whose command git runs to read the working tree, and + even to diff a commit; - for diffs, `diff.algorithm`, `diff.context`, `diff.external`, `diff.indentHeuristic`, `diff.interHunkContext`, `diff.orderFile`, `diff.submodule`, `diff.suppressBlankEmpty`, the prefixes @@ -504,15 +506,24 @@ reference to a repository would change what your application shows for a hash. The rest of the configuration applies, like with git: the identity, the -credentials and the rewrites of URLs, `safe.directory`, line endings, hooks, -`merge.log`, `blame.ignoreRevsFile`, or `core.abbrev`, the length of short -hashes, with `LibGit2Driver` too. +credentials and the rewrites of URLs, line endings, hooks, `merge.log`, +`blame.ignoreRevsFile`, or `core.abbrev`, the length of short hashes, with +`LibGit2Driver` too. + +`safe.directory` doesn't apply: `CliDriver` runs git with `--git-dir`, which +git trusts, whoever owns the repository. Like git, gitlib then runs the +commands the configuration of the repository names: its hooks, and the +filters of its `.gitattributes`, when it checks files out, merges, or reads +the working tree. Only open a repository whose `.git` directory you trust: +not one another user of the machine owns, nor one extracted from an archive +your users send. `LibGit2Driver` runs neither hooks nor external filters. `run()` and `runProcess()` get `branch.autoSetupMerge`, -`branch.autoSetupRebase`, `branch.sort`, `log.follow`, `log.showSignature`, -`diff.indentHeuristic` and `diff.suppressBlankEmpty` set back too, and ignore -the replacements, but not the rest, which gitlib sets with the options of the -commands it runs: pass `--no-color` or `--no-ext-diff` yourself. +`branch.autoSetupRebase`, `branch.sort`, `core.fsmonitor`, `log.follow`, +`log.showSignature`, `diff.indentHeuristic` and `diff.suppressBlankEmpty` set +back too, and ignore the replacements, but not the rest, which gitlib sets with +the options of the commands it runs: pass `--no-color` or `--no-ext-diff` +yourself. Logger ------ diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index 025699ea..7d060dbc 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -47,12 +47,16 @@ * * branch.autoSetupMerge and branch.autoSetupRebase: a branch created from * a remote branch tracks it, without rebasing, like with LibGit2Driver. + * core.fsmonitor: git runs the command it names, which the configuration + * of a repository may set, to read the working tree, and even to diff a + * commit. * diff.indentHeuristic is the default since git 2.14, and LibGit2Driver's. */ private const array CONFIG = [ '-c', 'branch.autoSetupMerge=true', '-c', 'branch.autoSetupRebase=never', '-c', 'branch.sort=refname', + '-c', 'core.fsmonitor=false', '-c', 'diff.indentHeuristic=true', '-c', 'diff.suppressBlankEmpty=false', '-c', 'log.follow=false', diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 87ba273e..200af57b 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -158,6 +158,27 @@ public function testAFileAddedWithIntentToAddThenDeletedIsDeleted(): void $this->assertSame([], $wc->getDiff()->getFiles()); } + /** + * The configuration of a repository may name a command in core.fsmonitor, + * which git runs to read the working tree, and even to diff a commit. + */ + public function testTheCommandOfCoreFsmonitorDoesNotRun(): void + { + $repository = self::createFoobarRepository(false); + $marker = self::createTempDir().'/ran'; + self::git($repository, 'config', ['core.fsmonitor', 'touch '.$marker.'; false']); + file_put_contents($repository->getWorkingDir().'/README.md', 'local change'); + $wc = $repository->getWorkingCopy(); + + $wc->getUntrackedFiles(); + $wc->getDiffPending(); + $wc->getDiffStaged(); + $wc->getDiff(); + $repository->getHeadCommit()->getDiff(); + + $this->assertFileDoesNotExist($marker); + } + public function testDiffLeavesOutAFileTheWorkingTreeChangesBack(): void { $repository = self::createFoobarRepository(false); From 5cc0e41a12397b1d20f3bac17f2f90efa3c79384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:41:43 +0200 Subject: [PATCH 120/150] Look for a bundle in a local path only LibGit2Driver checks whether the URL of a clone is a bundle with is_file(), which hands a URL to the stream wrapper of its scheme: for "ftp://user:password@host/...", PHP connected to the host and sent the credentials, before libgit2 saw the URL. With "ssh://" or "git://", PHP warned that no wrapper exists, which error handlers turn into an exception whose trace held the URL. Only a local path, "file://" included, is looked at, and the URL is a #[\SensitiveParameter]. --- src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 13 +++- .../Gitonomy/Git/Tests/LibGit2DriverTest.php | 71 ++++++++++++++++--- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index dbb3e541..3d227ccf 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -191,10 +191,19 @@ public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, s * * @throws UnsupportedOperationException $url is a bundle */ - private static function assertNotBundle(string $url): void + private static function assertNotBundle(#[\SensitiveParameter] string $url): void { + // A local path only, a Windows one included: is_file() would hand any + // other URL to the stream wrapper of its scheme, like the one of + // "ftp://", which connects with the credentials of the URL. A + // "host:path" address isn't local either. + $path = str_starts_with($url, 'file://') ? substr($url, \strlen('file://')) : $url; + if (1 === preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $path) && 1 !== preg_match('#^[a-zA-Z]:[\\\/]#', $path)) { + return; + } + // The signatures of the formats of bundles. - if (is_file($url) && is_readable($url) && \in_array(file_get_contents($url, length: 16), ["# v2 git bundle\n", "# v3 git bundle\n"], true)) { + if (is_file($path) && is_readable($path) && \in_array(file_get_contents($path, length: 16), ["# v2 git bundle\n", "# v3 git bundle\n"], true)) { throw new UnsupportedOperationException(\sprintf('"%s" is a bundle, which LibGit2Driver doesn\'t support; use CliDriver.', $url)); } } diff --git a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php index e48c1ab4..a69fecfa 100644 --- a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php +++ b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php @@ -13,6 +13,7 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; @@ -117,28 +118,80 @@ public function testReftableRepositoriesAreUnsupported(): void } /** - * @param \Closure(LibGit2Driver, string): void $clone + * @param \Closure(LibGit2Driver, string, string): void $clone */ - #[DataProvider('provideClones')] - public function testBundlesAreUnsupported(\Closure $clone): void + #[DataProvider('provideBundleClones')] + public function testBundlesAreUnsupported(\Closure $clone, string $url): void { if (!LibGit2Driver::isSupported()) { $this->markTestSkipped('libgit2 1.9 is not installed.'); } $this->expectException(UnsupportedOperationException::class); - $this->expectExceptionMessage(\sprintf('"%s" is a bundle, which LibGit2Driver doesn\'t support; use CliDriver.', self::REPOSITORY_URL)); + $this->expectExceptionMessage(\sprintf('"%s" is a bundle, which LibGit2Driver doesn\'t support; use CliDriver.', $url)); + + $clone(new LibGit2Driver(), self::createTempDir().'/clone', $url); + } + + /** + * @return iterable + */ + public static function provideBundleClones(): iterable + { + foreach (self::provideClones() as $name => [$clone]) { + yield $name => [$clone, self::REPOSITORY_URL]; + yield $name.', file:// URL' => [$clone, 'file://'.self::REPOSITORY_URL]; + } + } + + /** + * is_file() hands a URL to the stream wrapper of its scheme: the one of + * "ftp://" connects to the host, with the credentials of the URL. + * + * @param \Closure(LibGit2Driver, string, string): void $clone + */ + #[DataProvider('provideClones')] + public function testOnlyALocalPathIsLookedAtForABundle(\Closure $clone): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + + $wrapper = new class { + /** + * @var list + */ + public static array $urls = []; + + public mixed $context; + + public function url_stat(string $path, int $flags): false + { + self::$urls[] = $path; + + return false; + } + }; + stream_wrapper_register('gitlib-test', $wrapper::class); + + try { + $clone(new LibGit2Driver(), self::createTempDir().'/clone', 'gitlib-test://user:secret@example.com/repository.git'); + $this->fail('libgit2 knows no "gitlib-test" transport.'); + } catch (DriverException) { + } finally { + stream_wrapper_unregister('gitlib-test'); + } - $clone(new LibGit2Driver(), self::createTempDir().'/clone'); + $this->assertSame([], $wrapper::$urls); } /** - * @return iterable + * @return iterable */ public static function provideClones(): iterable { - yield 'clone' => [static fn (LibGit2Driver $driver, string $path) => $driver->cloneFrom(self::REPOSITORY_URL, $path, null)]; - yield 'bare clone' => [static fn (LibGit2Driver $driver, string $path) => $driver->cloneBareFrom(self::REPOSITORY_URL, $path, null)]; - yield 'mirror' => [static fn (LibGit2Driver $driver, string $path) => $driver->mirrorFrom(self::REPOSITORY_URL, $path)]; + yield 'clone' => [static fn (LibGit2Driver $driver, string $path, string $url) => $driver->cloneFrom($url, $path, null)]; + yield 'bare clone' => [static fn (LibGit2Driver $driver, string $path, string $url) => $driver->cloneBareFrom($url, $path, null)]; + yield 'mirror' => [static fn (LibGit2Driver $driver, string $path, string $url) => $driver->mirrorFrom($url, $path)]; } } From a047731187098217d0ca3de685cc523c5438864d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:42:28 +0200 Subject: [PATCH 121/150] Refuse fd:: URLs An "fd::7" URL makes git talk through file descriptors it inherits from PHP: an open file, the socket of a database... isValidRepository() of such a URL, which an application may get from its users, made git wait on a socket of the application until it timed out, an hour by default. Both drivers refuse it with an InvalidArgumentException, like an "ext::" URL. --- CHANGELOG.md | 3 ++- UPGRADE-2.0.md | 3 ++- doc/admin.md | 7 +++++-- doc/repository.md | 6 +++--- src/Gitonomy/Git/Driver/DriverInterface.php | 5 +++-- src/Gitonomy/Git/Util/RemoteUrl.php | 13 ++++++++++--- tests/Gitonomy/Git/Tests/RemoteUrlTest.php | 13 +++++++++++++ 7 files changed, 38 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43afcfab..8b1682c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,7 +128,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). a file, and `Repository::isValidRepository('--upload-pack=...')` run a command. A path may start with a dash: `init()` and the clones end the options of git with `--`. - Refuse an `ext::` URL, like `ext::sh -c ...`, whose command git runs, by default before git - 2.12. + 2.12, and an `fd::` URL, which makes git talk through a file descriptor it inherits from PHP, + like the socket of a database, and wait on it. - Refuse an ssh URL whose host or user starts with a dash, even encoded (`%2D`), like `ssh://-oProxyCommand=.../repository`: git before 2.14.1 passes it to ssh, which reads it as an option and runs the command (CVE-2017-1000117). A `git://` URL whose host starts with a dash diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 62654f92..10ac016f 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -443,7 +443,8 @@ Errors `isValidRepository('--upload-pack=...')` running a command. A path may start with a dash. * An `ext::` URL throws an `InvalidArgumentException`, with both drivers: - git runs its command, by default before git 2.12. + git runs its command, by default before git 2.12. So does an `fd::` URL, + which makes git talk through a file descriptor it inherits from PHP. * An ssh URL whose host or user starts with a dash, even encoded (`%2D`), like `ssh://-oProxyCommand=.../repository` or `ssh://-oProxyCommand=...@host/path`, or a `git://` URL whose host does, diff --git a/doc/admin.md b/doc/admin.md index 6e42f23b..10e6cfe3 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -101,13 +101,16 @@ error output, when `CliDriver` runs git. With `CliDriver`, every method, `isValidRepository()` included, throws a `DriverException` when git can't start, like a missing binary. See [Errors](repository.md#errors). -A URL that makes git run a command throws an `InvalidArgumentException`, -with both drivers: +A URL that makes git run a command, or use a file of PHP, throws an +`InvalidArgumentException`, with both drivers: - a URL starting with a dash, which git would read as an option, like `--upload-pack=...`; a path may start with one; - an `ext::` URL, like `ext::sh -c ...`, whose command git runs, unless its `protocol.allow` setting forbids it, as it does by default since git 2.12; +- an `fd::` URL, like `fd::7`, which makes git talk through a file + descriptor it inherits from PHP, like the socket of a database: git waits + on it until it times out; - an ssh URL whose host or user starts with a dash, even encoded (`%2D`), like `ssh://-oProxyCommand=.../repository` or `ssh://-oProxyCommand=...@host/repository`: before 2.14.1, git passes it diff --git a/doc/repository.md b/doc/repository.md index a3fa59fe..46508ce2 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -237,9 +237,9 @@ or empty data instead. Every exception of gitlib implements byte, which git doesn't accept, a revision, a reference, a hash or a URL starting with a dash, which git would read as an option (`getRevision('--output=/path')`), an `ext::` URL, whose command git - would run, a URL whose ssh host or user starts with a dash, which ssh - would read as an option, or a reference to delete outside `refs/`, like - `HEAD`. + would run, an `fd::` URL, a URL whose ssh host or user starts with a + dash, which ssh would read as an option, or a reference to delete + outside `refs/`, like `HEAD`. - `RuntimeException`: any other failure, like a hook that exists already (`Hooks::set()`), a file of the git directory that can't be written, or an output of git that gitlib can't read. `DriverException` and diff --git a/src/Gitonomy/Git/Driver/DriverInterface.php b/src/Gitonomy/Git/Driver/DriverInterface.php index 9323c764..88ca48c1 100644 --- a/src/Gitonomy/Git/Driver/DriverInterface.php +++ b/src/Gitonomy/Git/Driver/DriverInterface.php @@ -33,8 +33,9 @@ * An application may get the URL of a repository from its users: every method * taking one refuses, with an {@see InvalidArgumentException}, a URL git would * misread, before running git. A URL starting with a dash is an option for - * git, an "ext::" URL runs its command, and an ssh host or user starting with - * a dash is an option for ssh. + * git, an "ext::" URL runs its command, an "fd::" URL talks through a file + * descriptor of PHP, and an ssh host or user starting with a dash is an + * option for ssh. * * @author Grégoire Pineau */ diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php index 2923b3a4..34a4bd1b 100644 --- a/src/Gitonomy/Git/Util/RemoteUrl.php +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -15,8 +15,8 @@ use Gitonomy\Git\Exception\InvalidArgumentException; /** - * Refuses the URLs of a repository that make git run a command, as an - * application may get them from its users. + * Refuses the URLs of a repository that make git run a command, or use a + * file descriptor of PHP, as an application may get them from its users. * * @author Grégoire Pineau * @@ -25,7 +25,7 @@ final class RemoteUrl { /** - * @throws InvalidArgumentException $url starts with a dash, uses the "ext::" transport, or has a host or a user starting with a dash, encoded or not + * @throws InvalidArgumentException $url starts with a dash, uses the "ext::" or "fd::" transport, or has a host or a user starting with a dash, encoded or not */ public static function assertSafe(#[\SensitiveParameter] string $url): void { @@ -37,6 +37,13 @@ public static function assertSafe(#[\SensitiveParameter] string $url): void throw new InvalidArgumentException(\sprintf('"%s" uses the "ext::" transport, which runs a command.', Credentials::redact($url))); } + // "fd::7" talks through the file descriptors git inherits from PHP: + // an open file, the socket of a database... git waits on it until it + // times out, an hour by default. + if (str_starts_with($url, 'fd::')) { + throw new InvalidArgumentException(\sprintf('"%s" uses the "fd::" transport, which talks through a file descriptor of PHP.', Credentials::redact($url))); + } + // Before 2.14.1 (CVE-2017-1000117), git passes the "user@host" of an ssh // URL to ssh, without its brackets: ssh reads "ssh://-oProxyCommand=..." // as an option, and runs the command. The command of core.gitProxy gets diff --git a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php index cdb56a2b..5df1f4a5 100644 --- a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php +++ b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php @@ -69,6 +69,19 @@ public function testAnExtUrlIsRefused(\Closure $call): void $this->assertFileDoesNotExist($file); } + /** + * git talks through the file descriptors of an "fd::" URL, which it + * inherits from PHP, like the socket of a database. + */ + #[DataProvider('provideCalls')] + public function testAnFdUrlIsRefused(\Closure $call): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"fd::0" uses the "fd::" transport, which talks through a file descriptor of PHP.'); + + $call('fd::0', self::createTempDir().'/clone'); + } + /** * @return iterable */ From 0624727a250de9b23cdd21095465afd54395fa9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:42:40 +0200 Subject: [PATCH 122/150] Say in the security policy that 1.x is no longer maintained The policy promised 12 months of support to the previous major release, but 1.x has no branch, and gets no fix for the security issues 2.0 fixes. It now says only 2.x gets security fixes, and keeps the promise for the next major releases. --- .github/SECURITY.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 0c3e8a24..e8e4f0c5 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,9 +2,15 @@ ## Supported Versions -After each new major release, the previous release will be supported for no -less than 12 months, unless explictly stated otherwise. This may mean that -there are multiple supported versions at any given time. +Only 2.x gets security fixes: 1.x is no longer maintained. 2.0 fixes security +issues 1.x has, which won't be fixed there, like revisions and URLs starting +with a dash, which git reads as options, or the credentials of URLs in the +messages of exceptions. To upgrade, see +[UPGRADE-2.0.md](https://github.com/gitonomy/gitlib/blob/main/UPGRADE-2.0.md). + +After each new major release from 3.0 on, the previous release will be +supported for no less than 12 months, unless explicitly stated otherwise. +This may mean that there are multiple supported versions at any given time. ## Reporting a Vulnerability From 25d448e6b4747679b5dc9243935093ecaf543359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:43:15 +0200 Subject: [PATCH 123/150] Remove the arguments of git merge of WorkingCopy::merge(), never released WorkingCopy::merge() came with #248, after 1.6.0: no release has its array of arguments of git merge ([], ["--no-ff"] or ["--ff-only"]), which 2.0 deprecated in favor of FastForward. 2.0 would have shipped a deprecation of its first day, and a FastForward|array argument for all of 2.x. merge() takes a FastForward only. The deprecation, its section of UPGRADE-2.0.md and its line in the CHANGELOG go. --- CHANGELOG.md | 3 +-- UPGRADE-2.0.md | 17 ----------------- doc/workingcopy.md | 3 --- src/Gitonomy/Git/WorkingCopy.php | 18 +----------------- tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 14 -------------- 5 files changed, 2 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b1682c9..e694425e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,8 +114,7 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - A range of lines as a string in `Repository::getBlame()`, `Blame::getGroupedLines()` and `Blame\Line::getLine()`. - `CommitReference`, `Tree::getCommitReferenceEntries()`, reading tree entries as arrays. -- The branch to create of `WorkingCopy::checkout()`, the arguments of `git merge` of - `WorkingCopy::merge()`. +- The branch to create of `WorkingCopy::checkout()`. - `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, `PushReference::getReference()` and `getRevision()`. - `Repository::getSize()`, in kilobytes: use `getSizeInBytes()`. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 10ac016f..6f328ded 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -322,23 +322,6 @@ Working copy $workingCopy->checkoutNewBranch('feature', 'origin/feature'); ``` -* `WorkingCopy::merge()` takes a `FastForward` instead of arguments of - `git merge`, deprecated. - - Before: - - ```php - $workingCopy->merge('feature', ['--no-ff']); - ``` - - After: - - ```php - use Gitonomy\Git\FastForward; - - $workingCopy->merge('feature', FastForward::Never); - ``` - Commits, trees and blobs ------------------------ diff --git a/doc/workingcopy.md b/doc/workingcopy.md index 52431f15..2e02ae64 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -117,9 +117,6 @@ $repo->run('merge', ['--abort']); > throws without changing anything, instead of leaving conflict markers in > the working copy. -Passing arguments of `git merge` as an array (`[]`, `['--no-ff']` or -`['--ff-only']`) is deprecated since 2.0: pass a `FastForward`. - Modifications since a revision ------------------------------ diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index a7374be9..c2cc2982 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -14,7 +14,6 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\DriverException; -use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\LogicException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; @@ -130,26 +129,11 @@ public function checkoutNewBranch(string $branch, Revision|string $startPoint = /** * Merges a revision into the current branch. * - * An array of arguments of "git merge" ([], ["--no-ff"] or ["--ff-only"]) is deprecated. - * - * @param FastForward|string[] $fastForward - * * @throws ReferenceNotFoundException $revision names no commit * @throws DriverException The merge fails: a conflict, staged changes, an unrelated history... */ - public function merge(Revision|string $revision, FastForward|array $fastForward = FastForward::Allow): static + public function merge(Revision|string $revision, FastForward $fastForward = FastForward::Allow): static { - if (\is_array($fastForward)) { - trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing arguments of "git merge" to "%s()" is deprecated, pass a "%s" instead.', __METHOD__, FastForward::class); - - $fastForward = match ($fastForward) { - [] => FastForward::Allow, - ['--no-ff'] => FastForward::Never, - ['--ff-only'] => FastForward::Only, - default => throw new InvalidArgumentException(\sprintf('Unsupported arguments "%s": pass a "%s" instead.', implode(' ', $fastForward), FastForward::class)), - }; - } - // Like checkout(). The driver gets the name, which the message of the merge commit has. ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit(); diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 200af57b..2d391936 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -388,20 +388,6 @@ public function testMergeOnlyFastForwards(): void $wc->merge('origin/diff-features', FastForward::Only); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyMergeArguments(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing arguments of "git merge" to "Gitonomy\Git\WorkingCopy::merge()" is deprecated, pass a "Gitonomy\Git\FastForward" instead.'); - - $repository = self::createFoobarRepository(false); - $head = $repository->getHeadCommit(); - - $repository->getWorkingCopy()->merge($head->getHash(), ['--no-ff']); - - $this->assertSame($head->getHash(), $repository->getHeadCommit()->getHash()); - } - public function testMergeFastForwardsTheCurrentBranch(): void { $repository = self::createFoobarRepository(false); From d94de0ae155aeb3e620455b7afb77b8377861df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:45:21 +0200 Subject: [PATCH 124/150] Refuse a negative offset or limit of a log A negative offset or limit gave wrong data, different with each driver: withLimit(-1) counted -1 commits, and iterated the whole history with CliDriver, but one commit with LibGit2Driver. withOffset(-5) counted more commits than it iterated. Log, Repository::getLog() and Revision::getLog() throw an InvalidArgumentException for one. A limit of 0 gives no commit, with both drivers. --- UPGRADE-2.0.md | 1 + src/Gitonomy/Git/Log.php | 42 ++++++++++++++++++++++++---- src/Gitonomy/Git/Repository.php | 2 ++ src/Gitonomy/Git/Revision.php | 3 ++ tests/Gitonomy/Git/Tests/LogTest.php | 34 ++++++++++++++++++++++ 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 6f328ded..b4638caa 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -200,6 +200,7 @@ History `withLimit()` return another `Log`. * `count($log)` returns the number of commits the log iterates, after its offset and up to its limit; `countCommits()` still counts them all. +* A negative offset or limit throws an `InvalidArgumentException`. * The revisions of a log are names, ranges (`a..b`, `a...b`), names to exclude (`^a`) or `--all`: any other option of `git log` throws an `InvalidArgumentException`, instead of being passed to git. An unknown diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index 038edccd..c099e0be 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -45,6 +45,8 @@ final class Log implements \Countable, \IteratorAggregate * @param string[]|string|null $paths paths to filter on, with git's wildcards * @param int|null $offset start list from a given position * @param int|null $limit limit number of fetched elements + * + * @throws InvalidArgumentException $offset or $limit is negative */ public function __construct( private readonly Repository $repository, @@ -65,8 +67,8 @@ public function __construct( $this->revisions = $revisions; $this->paths = $paths; - $this->offset = $offset; - $this->limit = $limit; + $this->offset = self::offset($offset); + $this->limit = self::limit($limit); } /** @@ -99,11 +101,13 @@ public function getOffset(): ?int /** * @return self The log, starting from the commit $offset + * + * @throws InvalidArgumentException $offset is negative */ public function withOffset(?int $offset): self { $log = clone $this; - $log->offset = $offset; + $log->offset = self::offset($offset); return $log; } @@ -115,7 +119,7 @@ public function setOffset(?int $offset): static { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "withOffset()" instead.', __METHOD__); - $this->offset = $offset; + $this->offset = self::offset($offset); return $this; } @@ -127,11 +131,13 @@ public function getLimit(): ?int /** * @return self The log, of $limit commits at most + * + * @throws InvalidArgumentException $limit is negative */ public function withLimit(?int $limit): self { $log = clone $this; - $log->limit = $limit; + $log->limit = self::limit($limit); return $log; } @@ -143,7 +149,7 @@ public function setLimit(?int $limit): static { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "withLimit()" instead.', __METHOD__); - $this->limit = $limit; + $this->limit = self::limit($limit); return $this; } @@ -262,4 +268,28 @@ private function commitHash(string $revision): string { return $this->repository->getRevision('' === $revision ? 'HEAD' : $revision)->getCommit()->getHash(); } + + /** + * @throws InvalidArgumentException $offset is negative + */ + private static function offset(?int $offset): ?int + { + if (null !== $offset && $offset < 0) { + throw new InvalidArgumentException(\sprintf('The offset of a log can\'t be negative, %d given.', $offset)); + } + + return $offset; + } + + /** + * @throws InvalidArgumentException $limit is negative + */ + private static function limit(?int $limit): ?int + { + if (null !== $limit && $limit < 0) { + throw new InvalidArgumentException(\sprintf('The limit of a log can\'t be negative, %d given.', $limit)); + } + + return $limit; + } } diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 1d95ca33..ca47ed04 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -450,6 +450,8 @@ public function getBlame(Revision|string $revision, string $file, int|string|nul * paths * @param int|null $offset start from a given offset in results * @param int|null $limit limit number of total results + * + * @throws InvalidArgumentException $offset or $limit is negative */ public function getLog(RevisionList|Revision|string|array|null $revisions = null, array|string|null $paths = null, ?int $offset = null, ?int $limit = null): Log { diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index 8c8f5e96..3e131205 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; /** @@ -35,6 +36,8 @@ public function __construct( /** * @param string[]|string|null $paths Paths to filter on, with git's wildcards + * + * @throws InvalidArgumentException $offset or $limit is negative */ public function getLog(array|string|null $paths = null, ?int $offset = null, ?int $limit = null): Log { diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 597001ef..d4d17433 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -380,6 +380,40 @@ public function testLogOfExcludedAndSymmetricRevisions(): void $this->assertSame([$main, $base], self::hashes($repository->getLog('main'))); } + /** + * @param \Closure(Repository): Log $log + */ + #[DataProvider('provideNegativeBounds')] + public function testANegativeOffsetOrLimitIsRefused(\Closure $log, string $message): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $log($repository); + } + + /** + * @return iterable + */ + public static function provideNegativeBounds(): iterable + { + yield 'offset' => [static fn (Repository $repository): Log => $repository->getLog(null, null, -1), 'The offset of a log can\'t be negative, -1 given.']; + yield 'limit' => [static fn (Repository $repository): Log => $repository->getLog(null, null, null, -1), 'The limit of a log can\'t be negative, -1 given.']; + yield 'offset of a revision' => [static fn (Repository $repository): Log => $repository->getRevision('master')->getLog(null, -2), 'The offset of a log can\'t be negative, -2 given.']; + yield 'withOffset()' => [static fn (Repository $repository): Log => $repository->getLog()->withOffset(-5), 'The offset of a log can\'t be negative, -5 given.']; + yield 'withLimit()' => [static fn (Repository $repository): Log => $repository->getLog()->withLimit(-1), 'The limit of a log can\'t be negative, -1 given.']; + } + + public function testALimitOfZeroGivesNoCommit(): void + { + $log = self::createFoobarRepository()->getLog(null, null, null, 0); + + $this->assertSame([], $log->getCommits()); + $this->assertCount(0, $log); + } + /** * The command line of a process has a limit, 2 MB by default on Linux: a * log of thousands of branches would exceed it. From 266ea1fe92cf52e83cef834307f74460df29685d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:46:39 +0200 Subject: [PATCH 125/150] Throw gitlib's exceptions for a missing hook and an unreadable directory Hooks threw an InvalidArgumentException for a hook that doesn't exist in get(), but a RuntimeException in remove(), where the UPGRADE announced a RuntimeException for both. remove() throws an InvalidArgumentException too, which is still the LogicException 1.x threw. getSizeInBytes() let PHP's UnexpectedValueException out for a directory it can't read, or which git deletes while it counts, like the quarantine of a push. It walks the git directory itself: a directory git deleted counts for nothing, and one it can't read throws a RuntimeException of gitlib, like a file. --- CHANGELOG.md | 5 +- UPGRADE-2.0.md | 8 ++- doc/hooks.md | 5 +- doc/repository.md | 3 +- src/Gitonomy/Git/Hooks.php | 7 ++- src/Gitonomy/Git/Repository.php | 68 +++++++++++++++------ tests/Gitonomy/Git/Tests/HooksTest.php | 2 +- tests/Gitonomy/Git/Tests/RepositoryTest.php | 23 +++++++ 8 files changed, 89 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e694425e..e3b8b687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,8 +199,9 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - Fix `Hooks` failing silently, in a repository without hooks directory, and writing the hooks of a linked worktree where git doesn't run them. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix `Repository::getSize()` throwing for a link to a missing file in the git directory, like - a hook not deployed yet: it no longer follows links. A file git deletes meanwhile, like a lock - during a `gc` or a push, no longer makes it throw either. + a hook not deployed yet: it no longer follows links. A file or a directory git deletes + meanwhile, like a lock during a `gc` or the quarantine of a push, no longer makes it throw + either, and one it can't read throws a `RuntimeException` of gitlib, instead of PHP's. - Fix blame lines starting with a TAB character (`Blame\Line::getContent()`): code removing it must stop doing so. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix reading a commit whose `encoding` header isn't UTF-8, which threw: its message, author and diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index b4638caa..ad0fbbda 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -412,9 +412,11 @@ Errors `Tree::resolvePath()`. * `Commit::getShortHash()` throws a `ReferenceNotFoundException` for an unknown commit, instead of a `ProcessException`. -* `Hooks` throws a `RuntimeException` when a hook exists already, doesn't - exist, or can't be written, instead of a `LogicException` or a silent - failure, and creates the hooks directory. +* `Hooks` throws a `RuntimeException` when a hook exists already, or can't + be written, instead of a `LogicException` or a silent failure, and + creates the hooks directory. `remove()` of a hook that doesn't exist + throws an `InvalidArgumentException`, like `get()`, still a + `LogicException`. * The `Admin` methods throw a `ProcessException`, still a `RuntimeException`, with the first line of git's error output in its message, or a `DriverException` when git can't start. diff --git a/doc/hooks.md b/doc/hooks.md index 21ca84df..06ccd801 100644 --- a/doc/hooks.md +++ b/doc/hooks.md @@ -95,5 +95,6 @@ To remove a hook from a repository, use the function *remove*: $hooks->remove('pre-receive'); ``` -It throws a `Gitonomy\Git\Exception\RuntimeException` if the hook does not -exist, or if it can't be removed. +It throws a `Gitonomy\Git\Exception\InvalidArgumentException` if the hook +does not exist, like *get*, and a `Gitonomy\Git\Exception\RuntimeException` +if it can't be removed. diff --git a/doc/repository.md b/doc/repository.md index 46508ce2..fbcf1939 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -165,7 +165,8 @@ Compute size of a repository `getSizeInBytes()` returns the size of the files of the git directory, working tree excluded, in bytes. It doesn't follow links, and leaves out a -file git deletes meanwhile, like a lock during a `gc` or a push: +file or a directory git deletes meanwhile, like a lock during a `gc` or the +quarantine of a push. One it can't read throws a `RuntimeException`: ```php $size = $repository->getSizeInBytes(); diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index 8f5d4abc..29d145ac 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -108,13 +108,14 @@ public function set(string $name, string $content): void * * @param string $name Name of the hook * - * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory - * @throws RuntimeException The hook doesn't exist, or can't be removed + * @throws InvalidArgumentException The hook doesn't exist, or the name isn't the one of a file of the hooks directory + * @throws RuntimeException The hook can't be removed */ public function remove(string $name): void { + // Like get(). if (!$this->has($name)) { - throw new RuntimeException(\sprintf('The hook "%s" does not exist.', $name)); + throw new InvalidArgumentException(\sprintf('The hook "%s" does not exist.', $name)); } if (false === @unlink($this->getPath($name))) { diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index ca47ed04..de2217d3 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -527,29 +527,12 @@ public function getLegacyDiff(RevisionList|Revision|string|array|null $revisions /** * @return int The size of the files of the git directory, working tree excluded, in bytes + * + * @throws RuntimeException A file or a directory of the git directory can't be read */ public function getSizeInBytes(): int { - $size = 0; - /** @var \SplFileInfo $file */ - foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->gitDir, \FilesystemIterator::SKIP_DOTS)) as $file) { - // A link isn't followed: it may point to a file not deployed yet, like a hook. - if ($file->isLink()) { - continue; - } - - try { - $size += $file->getSize(); - } catch (\RuntimeException $e) { - // git deleted it since it was listed, during a gc or a push: - // a lock, a temporary pack... It no longer counts. - if (file_exists($file->getPathname())) { - throw $e; - } - } - } - - return $size; + return self::directorySize($this->gitDir); } /** @@ -798,4 +781,49 @@ private function fullHash(string $hash, ObjectType $type): string return $fullHash; } + + /** + * git may delete a file or a directory while it is counted, during a gc + * or a push: a lock, a temporary pack, the quarantine of a push... It no + * longer counts. + * + * @throws RuntimeException A file or a directory of $dir can't be read + */ + private static function directorySize(string $dir): int + { + try { + $files = new \FilesystemIterator($dir, \FilesystemIterator::SKIP_DOTS); + } catch (\UnexpectedValueException $e) { + if (!file_exists($dir)) { + return 0; + } + + throw new RuntimeException(\sprintf('Unable to read the directory "%s".', $dir), 0, $e); + } + + $size = 0; + /** @var \SplFileInfo $file */ + foreach ($files as $file) { + // A link isn't followed: it may point to a file not deployed yet, like a hook. + if ($file->isLink()) { + continue; + } + + if ($file->isDir()) { + $size += self::directorySize($file->getPathname()); + + continue; + } + + try { + $size += $file->getSize(); + } catch (\RuntimeException $e) { + if (file_exists($file->getPathname())) { + throw new RuntimeException(\sprintf('Unable to read the size of "%s".', $file->getPathname()), 0, $e); + } + } + } + + return $size; + } } diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index cbde3a5f..8c780fca 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -229,7 +229,7 @@ public function testRemove(bool $bare): void public function testRemoveNotExistingThrows(bool $bare): void { $repository = self::createFoobarRepository($bare); - $this->expectException(RuntimeException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The hook "foo" does not exist.'); $repository->getHooks()->remove('foo'); diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 2900f1c1..85602ccb 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -24,6 +24,7 @@ use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ProcessTimedOutException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; @@ -714,6 +715,28 @@ public function testGetSizeInBytesFollowsNoLink(): void $this->assertSame($size + 1000, $repository->getSizeInBytes()); } + public function testGetSizeInBytesOfAnUnreadableDirectoryThrows(): void + { + $repository = self::createEmptyRepository(); + $dir = $repository->getGitDir().'/unreadable'; + mkdir($dir); + chmod($dir, 0); + + try { + if (is_readable($dir)) { + $this->markTestSkipped('The user of the tests reads any directory, like root.'); + } + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(\sprintf('Unable to read the directory "%s".', $dir)); + + $repository->getSizeInBytes(); + } finally { + // Or the directory can't be deleted. + chmod($dir, 0o700); + } + } + #[Group('legacy')] #[IgnoreDeprecations] public function testLegacyGetSizeInKilobytes(): void From e568d10ec18e32210d08adff749603c7693859d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:50:47 +0200 Subject: [PATCH 126/150] Read "@" as HEAD with LibGit2Driver git reads "@" as HEAD since 1.8.5, alone or followed by "~", "^" or ":". libgit2 only reads it alone, and Revision::getCommit() always adds "^{commit}": getRevision('@'), getLog('@~2..@'), getDiff('@~1', '@'), checkout('@') or merge('@') threw a ReferenceNotFoundException with LibGit2Driver, but not with CliDriver. LibGit2Driver reads it as HEAD before libgit2 parses the revision, and checkout('@') leaves HEAD attached, like checkout('HEAD') and git since 2.45. --- .../Driver/Ffi/LibGit2RepositoryDriver.php | 23 +++++++--- tests/Gitonomy/Git/Tests/RevisionTest.php | 43 +++++++++++++++++++ tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 11 ++++- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index 3162bef3..a71df02d 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -153,7 +153,7 @@ public function resolveRevision(string $revision): string LeadingDash::assertNone($revision); $object = $this->ffi->new('git_object*'); - $rc = $this->ffi->git_revparse_single(\FFI::addr($object), $this->repo, $revision); + $rc = $this->ffi->git_revparse_single(\FFI::addr($object), $this->repo, self::expandAt($revision)); if (\in_array($rc, [LibGit2::GIT_ENOTFOUND, self::GIT_EAMBIGUOUS, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist.', $revision)); } @@ -630,8 +630,8 @@ public function checkout(string $revision): void // `git checkout ` attaches HEAD when names a // local branch, before a tag of the same name, or is @{-}, the nth - // branch checked out before. It leaves HEAD as it is for HEAD, and - // detaches it for anything else (a tag, a hash, a remote branch, a + // branch checked out before. It leaves HEAD as it is for HEAD, or @, + // and detaches it for anything else (a tag, a hash, a remote branch, a // full reference name...). $name = $this->expandPriorCheckout($revision); $branch = $this->referenceExists('refs/heads/'.$name) ? 'refs/heads/'.$name : null; @@ -642,7 +642,7 @@ public function checkout(string $revision): void if (null !== $branch) { LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, $branch), 'Unable to update HEAD'); - } elseif ('HEAD' !== $revision) { + } elseif ('HEAD' !== self::expandAt($revision)) { $oid = $this->hexToOid($hash); LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($this->repo, \FFI::addr($oid)), 'Unable to detach HEAD'); } @@ -1461,6 +1461,19 @@ private function dwimReferences(string $name): array return $references; } + /** + * Like git, reads "@" as HEAD, alone or at the start of $revision before + * "~", "^" or ":": libgit2 only reads it alone. "@{...}" is left as it is. + */ + private static function expandAt(string $revision): string + { + if ('@' === $revision || (str_starts_with($revision, '@') && \in_array($revision[1], ['~', '^', ':'], true))) { + return 'HEAD'.substr($revision, 1); + } + + return $revision; + } + /** * Like git, replaces @{-} at the start of $revision with the nth * branch checked out before, or commit when HEAD was detached. @@ -1743,7 +1756,7 @@ private function isDescendantOf(CData $commit, CData $ancestor): bool private function revparse(string $spec): CData { $out = $this->ffi->new('git_object*'); - LibGit2::check($this->ffi, $this->ffi->git_revparse_single(\FFI::addr($out), $this->repo, $spec), \sprintf('Unable to resolve "%s"', $spec)); + LibGit2::check($this->ffi, $this->ffi->git_revparse_single(\FFI::addr($out), $this->repo, self::expandAt($spec)), \sprintf('Unable to resolve "%s"', $spec)); return $out; } diff --git a/tests/Gitonomy/Git/Tests/RevisionTest.php b/tests/Gitonomy/Git/Tests/RevisionTest.php index 41377da6..b0b267dd 100644 --- a/tests/Gitonomy/Git/Tests/RevisionTest.php +++ b/tests/Gitonomy/Git/Tests/RevisionTest.php @@ -12,6 +12,8 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Commit; +use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\ReferenceNotFoundException; use PHPUnit\Framework\Attributes\DataProvider; @@ -38,6 +40,47 @@ public function testGetFailingReference(bool $bare): void $repository->getRevision('non-existent-commit')->getCommit(); } + /** + * "@" is HEAD for git since 1.8.5, and for libgit2 alone only. + */ + #[DataProvider('provideAtRevisions')] + public function testAtIsHead(string $revision, string $expected): void + { + if (self::getDriver() instanceof CliDriver && version_compare(self::gitVersion(), '1.8.5', '<')) { + $this->markTestSkipped('git reads "@" as HEAD since 1.8.5.'); + } + + $repository = self::createFoobarRepository(); + $head = $repository->getHeadCommit(); + + $this->assertSame('HEAD' === $expected ? $head->getHash() : $head->getParentHashes()[0], $repository->getRevision($revision)->getCommit()->getHash()); + } + + /** + * @return iterable + */ + public static function provideAtRevisions(): iterable + { + yield 'alone' => ['@', 'HEAD']; + yield 'ancestor' => ['@~1', 'parent']; + yield 'parent' => ['@^', 'parent']; + yield 'peeled' => ['@^{commit}', 'HEAD']; + } + + public function testALogOfARangeFromAt(): void + { + if (self::getDriver() instanceof CliDriver && version_compare(self::gitVersion(), '1.8.5', '<')) { + $this->markTestSkipped('git reads "@" as HEAD since 1.8.5.'); + } + + $repository = self::createFoobarRepository(); + + $hashes = static fn (string $range): array => array_map(static fn (Commit $commit): string => $commit->getHash(), $repository->getLog($range)->getCommits()); + + $this->assertNotSame([], $hashes('HEAD~2..HEAD')); + $this->assertSame($hashes('HEAD~2..HEAD'), $hashes('@~2..@')); + } + #[DataProvider('provideBare')] public function testGetLog(bool $bare): void { diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 2d391936..0b756c06 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -538,11 +538,18 @@ public function testCheckoutPrefersTheBranchToATagOfTheSameName(): void $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); } - public function testCheckoutOfHeadLeavesItAttached(): void + #[TestWith(['HEAD'])] + #[TestWith(['@'])] + public function testCheckoutOfHeadLeavesItAttached(string $head): void { + // git 2.43 detaches HEAD for "@", 2.53 doesn't: git 2.45 learned that "@" is HEAD in checkout. + if ('@' === $head && self::getDriver() instanceof CliDriver && version_compare(self::gitVersion(), '2.45.0', '<')) { + $this->markTestSkipped('git checks "@" out like HEAD since 2.45.'); + } + $repository = self::createFoobarRepository(false); - $repository->getWorkingCopy()->checkout('HEAD'); + $repository->getWorkingCopy()->checkout($head); $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); } From 0401527062baca4d9bd3a18d9aa0fa55626e554c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:51:35 +0200 Subject: [PATCH 127/150] Walk from HEAD too in a log of every reference with LibGit2Driver A log without revisions is the one of every reference and HEAD, like "git log --all". LibGit2Driver only walked from the references: with a detached HEAD on a commit no reference has, like after the checkout of a hash, the log lacked commits, and count() was wrong, without error. --- .../Driver/Ffi/LibGit2RepositoryDriver.php | 21 +++++++++++++++++-- tests/Gitonomy/Git/Tests/LogTest.php | 18 ++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index a71df02d..c72b6d3e 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -759,7 +759,7 @@ public function merge(string $revision, FastForward $fastForward): void * yields the commits that change them, and simplifies the history like * git does. * - * @param list|null $includes null for every reference + * @param list|null $includes null for every reference and HEAD * @param list $excludes * @param string[] $paths * @@ -827,11 +827,28 @@ private function revwalk(array $tips, array $bottoms, int $sorting = self::GIT_S } /** - * @return list The commits the references point to; like `git log --all`, ignores the references to other objects and the dangling ones + * @return list The commits HEAD and the references point to; like `git log --all`, ignores the references to other objects and the dangling ones */ private function referencedCommits(): array { $hashes = []; + + // A detached HEAD may name a commit no reference has. + $head = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_repository_head(\FFI::addr($head), $this->repo); + if (self::GIT_EUNBORNBRANCH !== $rc && LibGit2::GIT_ENOTFOUND !== $rc) { + LibGit2::check($this->ffi, $rc, 'Unable to resolve HEAD'); + + try { + $commit = $this->peeledCommitHex($head); + if (null !== $commit) { + $hashes[] = $commit; + } + } finally { + $this->ffi->git_reference_free($head); + } + } + foreach ($this->iterateReferences() as $ref) { $commit = $this->peeledCommitHex($ref); if (null !== $commit) { diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index d4d17433..2c7e6841 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -380,6 +380,24 @@ public function testLogOfExcludedAndSymmetricRevisions(): void $this->assertSame([$main, $base], self::hashes($repository->getLog('main'))); } + /** + * Like "git log --all", which walks from HEAD too: a detached HEAD may + * name a commit no reference has, like after the checkout of a hash. + */ + public function testALogOfEveryReferenceIncludesADetachedHead(): void + { + $repository = self::createFoobarRepository(); + $master = $repository->getHeadCommit(); + $hash = self::writeObject($repository, 'commit', "tree {$master->getTreeHash()}\nparent {$master->getHash()}\nauthor A 1700000000 +0000\ncommitter A 1700000000 +0000\n\nDetached\n"); + self::git($repository, 'update-ref', ['--no-deref', 'HEAD', $hash]); + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + + $log = $repository->getLog(); + + $this->assertContains($hash, self::hashes($log)); + $this->assertCount(\count($log->getCommits()), $log); + } + /** * @param \Closure(Repository): Log $log */ From de9cd57def2d2c323f31cc8db1ec7c98b6db8673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:52:41 +0200 Subject: [PATCH 128/150] Don't throw for a branch another process created Commit::getIncludingBranches() and Repository::getHead() read the branches from git, then looked them up in the references gitlib read before: a branch another process created or checked out since then made them throw a ReferenceNotFoundException, which a long-running worker meets. getIncludingBranches() also threw for every commit of a regular refs/remotes/origin/HEAD, which a mirror leaves, and which the references leave out. Both read the references again for a branch they don't know. getIncludingBranches() then leaves out a branch still unknown: the default branch of a remote, or one deleted meanwhile. --- CHANGELOG.md | 4 +++ src/Gitonomy/Git/Commit.php | 13 ++++++++- src/Gitonomy/Git/Repository.php | 8 +++++- tests/Gitonomy/Git/Tests/CommitTest.php | 31 +++++++++++++++++++++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 12 ++++++++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b8b687..2d0b4ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,10 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). ### Bug fixes +- Fix `Commit::getIncludingBranches()` and `Repository::getHead()` throwing for a branch another + process created or checked out since gitlib read the references, and + `getIncludingBranches()` throwing for a regular `refs/remotes/origin/HEAD`, as a mirror leaves + it. - Fix the configuration of the user changing what `CliDriver` reads: `color.ui=always`, `log.showSignature`, `diff.noprefix`, `diff.external`, `diff.submodule`, textconv filters, `column.ui`... made reads throw or return wrong data, and `diff.algorithm`, `diff.context`, diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 150f935e..e57b42f0 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -198,10 +198,21 @@ public function getIncludingBranches(bool $local = true, bool $remote = true): a throw new InvalidArgumentException('You should a least set one argument to true.'); } + $fullnames = $this->repository->getRepositoryDriver()->getIncludingBranches($this->revision); $references = $this->repository->getReferences(); + // A branch another process created since gitlib read the references. + if ([] !== array_filter($fullnames, static fn (string $fullname): bool => !$references->has($fullname))) { + $references->invalidate(); + } $branches = []; - foreach ($this->repository->getRepositoryDriver()->getIncludingBranches($this->revision) as $fullname) { + foreach ($fullnames as $fullname) { + // Unknown still: the default branch of a remote, which the references leave out, even + // when a mirror leaves it as a regular reference, or a branch deleted meanwhile. + if (!$references->has($fullname)) { + continue; + } + $branch = $references->get($fullname); if ($branch instanceof Branch && ($branch->isLocal() ? $local : $remote)) { $branches[] = $branch; diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index de2217d3..6b9ca6bd 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -261,7 +261,13 @@ public function getHead(): Branch|Commit return $this->getCommit($this->repositoryDriver->resolveRevision('HEAD')); } - $head = $this->getReferences()->get($reference); + $references = $this->getReferences(); + // A branch another process checked out since gitlib read the references. + if (!$references->has($reference)) { + $references->invalidate(); + } + + $head = $references->get($reference); // git lets HEAD point to any reference under "refs/", a tag included. if (!$head instanceof Branch) { throw new ReferenceNotFoundException(\sprintf('HEAD points to "%s", which is not a branch.', $reference)); diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 48eb8dbe..5d414a83 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -18,10 +18,12 @@ use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use Symfony\Component\Process\Process; class CommitTest extends AbstractTestCase { @@ -383,6 +385,35 @@ public function testGetIncludingBranchesNamedLikeRemoteOnes(): void $this->assertContains($repository->getReferences()->getBranch('remotes/origin/fake'), $branches); } + /** + * gitlib doesn't see the writes of another process, but doesn't fail on them. + */ + public function testGetIncludingBranchesCreatedByAnotherProcess(): void + { + $repository = self::createFoobarRepository(); + $repository->getReferences()->getAll(); + new Process([self::gitCommand(), 'branch', 'elsewhere', self::INITIAL_COMMIT], $repository->getPath(), self::gitEnvs())->mustRun(); + + $fullnames = array_map(static fn (Branch $branch): string => $branch->getFullname(), $repository->getCommit(self::INITIAL_COMMIT)->getIncludingBranches()); + + $this->assertContains('refs/heads/elsewhere', $fullnames); + } + + /** + * A mirror leaves the default branch of a remote as a regular reference, + * which the branches leave out, like a symbolic one. + */ + public function testGetIncludingBranchesLeavesOutTheDefaultBranchOfARemote(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['--no-deref', 'refs/remotes/origin/HEAD', self::INITIAL_COMMIT]); + + $fullnames = array_map(static fn (Branch $branch): string => $branch->getFullname(), $repository->getCommit(self::INITIAL_COMMIT)->getIncludingBranches()); + + $this->assertNotContains('refs/remotes/origin/HEAD', $fullnames); + $this->assertContains('refs/heads/master', $fullnames); + } + #[DataProvider('provideBare')] public function testGetLastModification(bool $bare): void { diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 85602ccb..1c58b0c9 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -249,6 +249,18 @@ public function testTheHeadOfARepositoryWithoutCommitsThrows(): void $repository->getHead(); } + /** + * gitlib doesn't see the writes of another process, but doesn't fail on them. + */ + public function testTheHeadAnotherProcessChecksOut(): void + { + $repository = self::createFoobarRepository(false); + $repository->getReferences()->getAll(); + new Process([self::gitCommand(), 'checkout', '-q', '-b', 'elsewhere'], $repository->getWorkingDir(), self::gitEnvs())->mustRun(); + + $this->assertSame('refs/heads/elsewhere', self::headBranch($repository)->getFullname()); + } + public function testHeadPointingToATagIsNoBranch(): void { $repository = self::createFoobarRepository(); From 63c6a6a65a1742c0b593549229663cae48b2dd5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:53:31 +0200 Subject: [PATCH 129/150] Read the blame of a commit under two paths After a merge whose sides kept a file under two names, git blames a commit under both, and "git blame -p" repeats its "previous" and "filename" lines on each of its groups of lines: the parser only read them on the first one, and threw ('Expected "\t", got "filename b"'). LibGit2Driver read it. --- CHANGELOG.md | 2 + .../Git/Driver/Cli/Parser/BlameParser.php | 12 ++++++ tests/Gitonomy/Git/Tests/BlameTest.php | 43 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0b4ee8..d4989311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). process created or checked out since gitlib read the references, and `getIncludingBranches()` throwing for a regular `refs/remotes/origin/HEAD`, as a mirror leaves it. +- Fix the blame of a file throwing with `CliDriver` after a merge whose sides kept it under two + names: git repeats the path of a commit it blames under both, which gitlib didn't read. - Fix the configuration of the user changing what `CliDriver` reads: `color.ui=always`, `log.showSignature`, `diff.noprefix`, `diff.external`, `diff.submodule`, textconv filters, `column.ui`... made reads throw or return wrong data, and `diff.algorithm`, `diff.context`, diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php index 844805a5..6d739cba 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php @@ -64,6 +64,18 @@ protected function doParse(): void $this->consumeTo("\n"); // filename $this->consumeNewLine(); $this->seenHashes[$hash] = true; + } else { + // git repeats them on each group of a commit it blames under + // several paths, after a merge whose sides renamed the file. + if ($this->expects('previous ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + + if ($this->expects('filename ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } } $this->consume("\t"); $content = $this->consumeTo("\n"); diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index 1a367a3b..f05fe4ed 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -15,6 +15,8 @@ use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; @@ -96,6 +98,47 @@ public function testBlameOfARangeAfterTheEndThrows(): void $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 100)->getLines(); } + /** + * After a merge whose sides kept a file under two names, git blames a + * commit under both: "git blame -p" repeats its "filename" line on each + * of its groups of lines. + */ + public function testBlameOfACommitUnderTwoPaths(): void + { + $repository = Repository::init(self::createTempDir(), self::getDriver()); + $dir = $repository->getWorkingDir(); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + file_put_contents($dir.'/a', "L1\nL2\nL3\n"); + file_put_contents($dir.'/b', "L1\nL2\nL3\n"); + self::git($repository, 'add', ['a', 'b']); + self::git($repository, 'commit', ['-q', '-m', 'Both']); + $both = trim(self::git($repository, 'rev-parse', ['HEAD'])); + self::git($repository, 'checkout', ['-q', '-b', 'side']); + self::git($repository, 'rm', ['-q', 'a']); + self::git($repository, 'commit', ['-q', '-m', 'Without a']); + self::git($repository, 'checkout', ['-q', 'master']); + file_put_contents($dir.'/a', "L1\nL2\nNEW\n"); + self::git($repository, 'rm', ['-q', 'b']); + self::git($repository, 'commit', ['-q', '-a', '-m', 'Without b']); + + try { + // A conflict: a is deleted on one side, and changed on the other. + self::git($repository, 'merge', ['-q', '--no-edit', 'side']); + } catch (ProcessException) { + } + file_put_contents($dir.'/a', "L1\nL2x\nL3\n"); + self::git($repository, 'add', ['a']); + self::git($repository, 'commit', ['-q', '-m', 'Merge']); + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + + $lines = array_map(static fn (Line $line): array => [$line->getCommit()->getHash(), $line->getContent()], $repository->getBlame('HEAD', 'a')->getLines()); + + $this->assertSame([$both, 'L1'], $lines[1]); + $this->assertSame('L2x', $lines[2][1]); + $this->assertSame('L3', $lines[3][1]); + } + public function testBlameOfAPathNormalizesIt(): void { $repository = self::createFoobarRepository(); From 03142763c6e614ed2bf4fd4ecc591e2249658c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:55:39 +0200 Subject: [PATCH 130/150] Read malformed signatures like libgit2 Old commits, imported from other tools, have authors and committers git wouldn't write anymore: "Name", no date, an offset of 5 digits, no name nor email. git and libgit2 read them, but CliDriver required "Name timestamp +hhmm" exactly, and one such commit made the whole log throw. ParserBase::parseSignature() reads them like libgit2, which LibGit2Driver reads them with: the email between the last "<" and ">", the name before it, and the date and offset optional, the epoch in UTC without them. Tags read their tagger with it too. --- CHANGELOG.md | 3 ++ .../Git/Driver/Cli/Parser/LogParser.php | 16 +------- src/Gitonomy/Git/Parser/ParserBase.php | 41 +++++++++++++++---- tests/Gitonomy/Git/Tests/CommitTest.php | 30 ++++++++++++++ 4 files changed, 67 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4989311..321cca03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,6 +164,9 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). it. - Fix the blame of a file throwing with `CliDriver` after a merge whose sides kept it under two names: git repeats the path of a commit it blames under both, which gitlib didn't read. +- Fix a log throwing with `CliDriver` for a commit whose author or committer git wouldn't + write anymore, like `Name` or no date, as old imports have: it is read like libgit2 + reads it, the epoch in UTC without date. - Fix the configuration of the user changing what `CliDriver` reads: `color.ui=always`, `log.showSignature`, `diff.noprefix`, `diff.external`, `diff.submodule`, textconv filters, `column.ui`... made reads throw or return wrong data, and `diff.algorithm`, `diff.context`, diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php index 1fe8decd..4b91c5d9 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php @@ -52,8 +52,8 @@ protected function doParse(): void } match ($name) { - 'author' => $author = self::parseNameEmailDate($value), - 'committer' => $committer = self::parseNameEmailDate($value), + 'author' => $author = self::parseSignature($value), + 'committer' => $committer = self::parseSignature($value), 'parent' => $parentHashes[] = $value, 'tree' => $treeHash = $value, // encoding, gpgsig, mergetag, and the headers of other tools. @@ -78,16 +78,4 @@ protected function doParse(): void $this->commits[] = new CommitData($hash, $treeHash, $parentHashes, ...$author, ...$committer, message: $message); } } - - /** - * @return array{string, string, \DateTimeImmutable} - */ - private static function parseNameEmailDate(string $value): array - { - if (!preg_match('/^(.*) <(.*)> (\d+) ([+-]\d{4})$/', $value, $vars)) { - throw new RuntimeException(\sprintf('Unable to parse the name, email and date of "%s".', $value)); - } - - return [$vars[1], $vars[2], self::signatureDate($vars[3], $vars[4])]; - } } diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index 5d1c6eb4..56609014 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -131,21 +131,44 @@ protected function consumeNewLine(): string /** * @return array{string, string, \DateTimeImmutable} The name, email and date of an author, committer or tagger + * + * @throws RuntimeException The line has no email between "<" and ">" */ protected function consumeNameEmailDate(): array { - if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+) ([+-]\d{4}))/A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('Unable to parse name, email and date.'); + return self::parseSignature($this->consumeTo("\n")); + } + + /** + * Reads "Name 1700000000 +0200" like libgit2, which LibGit2Driver + * reads it with, and git: old commits, imported from other tools, have + * signatures git wouldn't write anymore, like "Name", or no date. + * + * @return array{string, string, \DateTimeImmutable} The name, email and date, the epoch in UTC without one + * + * @throws RuntimeException $signature has no email between "<" and ">" + */ + protected static function parseSignature(string $signature): array + { + $emailStart = strrpos($signature, '<'); + $emailEnd = strrpos($signature, '>'); + if (false === $emailStart || false === $emailEnd || $emailEnd < $emailStart) { + throw new RuntimeException(\sprintf('Unable to parse the name, email and date of "%s".', $signature)); } - $this->cursor += \strlen($vars[1]); + // The date, then the offset, are optional. + preg_match('/^\s*(\d+)(?:\s+([+-]\d+))?/', substr($signature, $emailEnd + 1), $date); - return [$vars[2], $vars[3], self::signatureDate($vars[4], $vars[5])]; + return [ + trim(substr($signature, 0, $emailStart)), + trim(substr($signature, $emailStart + 1, $emailEnd - $emailStart - 1)), + self::signatureDate($date[1] ?? '0', $date[2] ?? '+0000'), + ]; } /** * @param string $timestamp Seconds since the epoch, as git writes them - * @param string $offset The UTC offset of the author, committer or tagger, as git writes it: "+0200", "-0330"... + * @param string $offset The UTC offset of the author, committer or tagger, as git writes it: "+0200", "-0330"..., a sign then hours and minutes * * @return \DateTimeImmutable The date at $offset, like git log shows it * @@ -156,12 +179,12 @@ protected static function signatureDate(string $timestamp, string $offset): \Dat // Like libgit2, which LibGit2Driver reads dates with: an offset of // more than 14 hours or 59 minutes, which no timezone has, in a // corrupt object, gives UTC. - if ((int) substr($offset, 1, 2) > 14 || (int) substr($offset, 3) > 59) { - $offset = '+0000'; - } + $hoursAndMinutes = (int) substr($offset, 1); + [$hours, $minutes] = [intdiv($hoursAndMinutes, 100), $hoursAndMinutes % 100]; + $timezone = $hours > 14 || $minutes > 59 ? '+0000' : \sprintf('%s%02d%02d', $offset[0], $hours, $minutes); try { - return new \DateTimeImmutable('@'.$timestamp)->setTimezone(new \DateTimeZone($offset)); + return new \DateTimeImmutable('@'.$timestamp)->setTimezone(new \DateTimeZone($timezone)); } catch (\DateMalformedStringException $e) { throw new RuntimeException(\sprintf('The date "%s" is out of range.', $timestamp), 0, $e); } diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 5d414a83..5e1800c0 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -74,6 +74,36 @@ public function testTheDatesAreAtTheOffsetsOfTheAuthorAndOfTheCommitter(): void $this->assertSame(1700000001, $commit->getCommitterDate()->getTimestamp()); } + /** + * Old commits, imported from other tools, have signatures git wouldn't + * write anymore, which git and libgit2 read anyway: one of them made the + * whole log throw. + */ + #[DataProvider('provideMalformedSignatures')] + public function testAMalformedSignatureIsReadLikeGit(string $signature, string $name, string $email, string $date): void + { + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + $hash = self::writeObject($repository, 'commit', "tree {$tree}\nauthor {$signature}\ncommitter {$signature}\n\nMalformed\n"); + + $commits = $repository->getLog($hash)->getCommits(); + + $this->assertCount(1, $commits); + $this->assertSame([$name, $email, $date], [$commits[0]->getAuthorName(), $commits[0]->getAuthorEmail(), $commits[0]->getAuthorDate()->format(\DATE_ATOM)]); + $this->assertSame([$name, $email, $date], [$commits[0]->getCommitterName(), $commits[0]->getCommitterEmail(), $commits[0]->getCommitterDate()->format(\DATE_ATOM)]); + } + + /** + * @return iterable + */ + public static function provideMalformedSignatures(): iterable + { + yield 'no space before the email' => ['A 1700000000 +0200', 'A', 'a@example.com', '2023-11-15T00:13:20+02:00']; + yield 'no date' => ['A ', 'A', 'a@example.com', '1970-01-01T00:00:00+00:00']; + yield 'an offset of 5 digits' => ['A 1700000000 +05030', 'A', 'a@example.com', '2023-11-14T22:13:20+00:00']; + yield 'no name nor email' => ['<> 0 +0000', '', '', '1970-01-01T00:00:00+00:00']; + } + /** * Like git log, which shows "-0000" as "+0000". An offset of more than 14 * hours or 59 minutes, which no timezone has, gives UTC, like libgit2. From 2627fedf96f516bdab0176e451677f063a2f8469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:56:25 +0200 Subject: [PATCH 131/150] Name the kinds of push like the ones of a file PushReference::isCreate() and isDelete() named what Diff\File names isCreation() and isDeletion(), whose isDelete() 2.0 deprecates for this: one concept, two names. PushReference gets isCreation() and isDeletion(), and isCreate() and isDelete() are deprecated. The argument of its constructor holding the full name of the reference is named $fullname, like getFullname(). --- CHANGELOG.md | 5 +- UPGRADE-2.0.md | 7 ++- src/Gitonomy/Git/PushReference.php | 54 ++++++++++++++----- .../Gitonomy/Git/Tests/PushReferenceTest.php | 24 ++++++++- 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 321cca03..00aadb66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,7 +76,8 @@ deprecated until 3.0. `Log::withOffset()` and `withLimit()`, `Blame::getHunks()` and the line numbers of `Blame\Line`, `TreeEntry` and `Submodule`, `Diff\DiffLine` and `Diff\DiffLineType`, `File::isModeChange()`, `FileChange::getAdditions()` and `getDeletions()`, - `PushReference::getFullname()` and `getRange()`, and the SSH and X.509 signatures of tags. + `PushReference::getFullname()`, `getRange()`, `isCreation()` and `isDeletion()`, and the SSH + and X.509 signatures of tags. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Add `WorkingCopy::merge()` to support `git merge`, fast-forwarding or not, with a `FastForward` enum. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) @@ -116,7 +117,7 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - `CommitReference`, `Tree::getCommitReferenceEntries()`, reading tree entries as arrays. - The branch to create of `WorkingCopy::checkout()`. - `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, - `PushReference::getReference()` and `getRevision()`. + `PushReference::getReference()`, `getRevision()`, `isCreate()` and `isDelete()`. - `Repository::getSize()`, in kilobytes: use `getSizeInBytes()`. - `Exception\UnexpectedValueException`, which gitlib no longer throws. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index ad0fbbda..a1b18ffb 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -547,8 +547,11 @@ Data returned * `Reference\Tag::isSigned()` returns `false` for a lightweight tag. * `PushReference::isForce()` returns `true` for histories without a common ancestor, where the constructor threw. `PushReference` runs git when - `isForce()` needs it, not when constructed. `getReference()` and - `getRevision()` are deprecated: use `getFullname()` and `getRange()`. + `isForce()` needs it, not when constructed. `getReference()`, + `getRevision()`, `isCreate()` and `isDelete()` are deprecated: use + `getFullname()`, `getRange()`, `isCreation()` and `isDeletion()`, named + like the ones of `Diff\File`. The `$reference` argument of its + constructor is named `$fullname`. * `Blob::getContent()` only reads blobs, where `git cat-file -p` also printed trees and commits. * `WorkingCopy::getUntrackedFiles()` and `RevisionList::getAll()` return diff --git a/src/Gitonomy/Git/PushReference.php b/src/Gitonomy/Git/PushReference.php index 3cc87c94..4cf81c1c 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -27,13 +27,13 @@ final class PushReference private ?bool $isForce = null; /** - * @param string $reference The full name of the pushed reference - * @param string $before Its commit before the push, ZERO for a creation - * @param string $after Its commit after the push, ZERO for a deletion + * @param string $fullname The full name of the pushed reference + * @param string $before Its commit before the push, ZERO for a creation + * @param string $after Its commit after the push, ZERO for a deletion */ public function __construct( private readonly Repository $repository, - private readonly string $reference, + private readonly string $fullname, private readonly string $before, private readonly string $after, ) { @@ -49,7 +49,7 @@ public function getRepository(): Repository */ public function getFullname(): string { - return $this->reference; + return $this->fullname; } /** @@ -59,7 +59,7 @@ public function getReference(): string { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getFullname()" instead.', __METHOD__); - return $this->reference; + return $this->fullname; } public function getBefore(): string @@ -97,27 +97,53 @@ public function getRevision(): string */ public function getRange(): string { - if ($this->isDelete()) { + if ($this->isDeletion()) { throw new LogicException('A deletion pushes no commit.'); } - if ($this->isCreate()) { + if ($this->isCreation()) { return $this->getAfter(); } return $this->getBefore().'..'.$this->getAfter(); } - public function isCreate(): bool + /** + * @return bool Whether the push creates the reference, like File::isCreation() for a file + */ + public function isCreation(): bool { return $this->isZero($this->before); } - public function isDelete(): bool + /** + * @deprecated since gitonomy/gitlib 2.0, use isCreation() instead + */ + public function isCreate(): bool + { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "isCreation()" instead.', __METHOD__); + + return $this->isCreation(); + } + + /** + * @return bool Whether the push deletes the reference, like File::isDeletion() for a file + */ + public function isDeletion(): bool { return $this->isZero($this->after); } + /** + * @deprecated since gitonomy/gitlib 2.0, use isDeletion() instead + */ + public function isDelete(): bool + { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); + + return $this->isDeletion(); + } + public function isForce(): bool { return $this->isForce ??= $this->getForce(); @@ -125,17 +151,17 @@ public function isForce(): bool public function isFastForward(): bool { - return !$this->isDelete() && !$this->isCreate() && !$this->isForce(); + return !$this->isDeletion() && !$this->isCreation() && !$this->isForce(); } - private function isZero(string $reference): bool + private function isZero(string $hash): bool { - return self::ZERO === $reference; + return self::ZERO === $hash; } private function getForce(): bool { - if ($this->isDelete() || $this->isCreate()) { + if ($this->isDeletion() || $this->isCreation()) { return false; } diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index e3a0cf34..0f4c6235 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -15,6 +15,8 @@ use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\PushReference; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class PushReferenceTest extends AbstractTestCase { @@ -27,8 +29,8 @@ class PushReferenceTest extends AbstractTestCase public function testIsers(string $reference, string $before, string $after, int $mask): void { $reference = new PushReference(self::createFoobarRepository(), $reference, $before, $after); - $this->assertSame(0 !== ($mask & self::CREATE), $reference->isCreate(), 'Create value is correct.'); - $this->assertSame(0 !== ($mask & self::DELETE), $reference->isDelete(), 'Delete value is correct.'); + $this->assertSame(0 !== ($mask & self::CREATE), $reference->isCreation(), 'Create value is correct.'); + $this->assertSame(0 !== ($mask & self::DELETE), $reference->isDeletion(), 'Delete value is correct.'); $this->assertSame(0 !== ($mask & self::FORCE), $reference->isForce(), 'Force value is correct.'); $this->assertSame(0 !== ($mask & self::FAST_FORWARD), $reference->isFastForward(), 'FastForward value is correct.'); } @@ -47,6 +49,24 @@ public static function provideIsers(): array ]; } + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyIsCreate(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\PushReference::isCreate()" method is deprecated, use "isCreation()" instead.'); + + $this->assertTrue(new PushReference(self::createFoobarRepository(), 'foo', PushReference::ZERO, self::LONGFILE_COMMIT)->isCreate()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyIsDelete(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\PushReference::isDelete()" method is deprecated, use "isDeletion()" instead.'); + + $this->assertTrue(new PushReference(self::createFoobarRepository(), 'foo', self::LONGFILE_COMMIT, PushReference::ZERO)->isDelete()); + } + public function testUnknownBeforeIsAnErrorNotAForcePush(): void { $reference = new PushReference(self::createFoobarRepository(), 'foo', str_repeat('1', 40), self::LONGFILE_COMMIT); From 3acf83a09f0ed669b6784735e8f40c0763c54f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:56:44 +0200 Subject: [PATCH 132/150] Require the versions of symfony/process without advisories gitlib required symfony/process ^6.4 || ^7.4 || ^8.1, but the versions before 6.4.33 and 7.4.5 escape the arguments wrongly on Windows (CVE-2024-51736, CVE-2026-24739). Recent Composer versions refuse to install them, so the job of the lowest dependencies tested 6.4.33, not the 6.4.0 gitlib declared. It requires ^6.4.33 || ^7.4.5 || ^8.1. --- CHANGELOG.md | 2 ++ UPGRADE-2.0.md | 4 +++- composer.json | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00aadb66..c19bb36c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). option: `getRevision('--output=/path')`, `WorkingCopy::checkout()` or `merge()` could write a file, and `Repository::isValidRepository('--upload-pack=...')` run a command. A path may start with a dash: `init()` and the clones end the options of git with `--`. +- Require `symfony/process` 6.4.33, 7.4.5 or 8.1 and later: the older versions escape the + arguments wrongly on Windows (CVE-2024-51736, CVE-2026-24739). - Refuse an `ext::` URL, like `ext::sh -c ...`, whose command git runs, by default before git 2.12, and an `fd::` URL, which makes git talk through a file descriptor it inherits from PHP, like the socket of a database, and wait on it. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index a1b18ffb..d7d4acb1 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -19,7 +19,9 @@ Requirements * git 1.8.4 or later is required by `CliDriver`, the default driver: before, git simplifies the log of a range filtered by path differently, and it can't count commits (`Log::count()`) before 1.7.2. -* `symfony/process` must be `^6.4`, `^7.4` or `^8.1`. +* `symfony/process` must be `^6.4.33`, `^7.4.5` or `^8.1`: the older + versions escape the arguments wrongly on Windows (CVE-2024-51736, + CVE-2026-24739). * `symfony/string` `^7.4.6` or `^8.0.6`, `symfony/deprecation-contracts` `^2.5` or `^3`, and `psr/log` `^1.1`, `^2.0` or `^3.0` are new dependencies. diff --git a/composer.json b/composer.json index 7db091c7..38d97964 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "ext-pcre": "*", "psr/log": "^1.1 || ^2.0 || ^3.0", "symfony/deprecation-contracts": "^2.5 || ^3", - "symfony/process": "^6.4 || ^7.4 || ^8.1", + "symfony/process": "^6.4.33 || ^7.4.5 || ^8.1", "symfony/string": "^7.4.6 || ^8.0.6" }, "require-dev": { From 5b73305b5ea489128e99e5f11e6d7a583b68228e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:57:29 +0200 Subject: [PATCH 133/150] Say the interfaces of the drivers may get methods in 2.x The doc invites to implement DriverInterface and RepositoryDriverInterface, in tests for instance, without saying whether 2.x may add methods to them: any new method would have broken every implementation. Both interfaces say gitlib may add methods in a minor version: the promise of backward compatibility covers the code calling them, not the drivers implementing them. The parsers of Driver\Cli\Parser, which only CliDriver uses, are internal. --- UPGRADE-2.0.md | 7 +++++-- doc/repository.md | 4 ++++ src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php | 2 ++ src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php | 2 ++ src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php | 2 ++ src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php | 2 ++ src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php | 2 ++ src/Gitonomy/Git/Driver/DriverInterface.php | 4 ++++ src/Gitonomy/Git/Driver/RepositoryDriverInterface.php | 4 ++++ 9 files changed, 27 insertions(+), 2 deletions(-) diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index d7d4acb1..59566d66 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -516,12 +516,15 @@ Extending gitlib `Gitonomy\Git\Driver\DriverInterface` and pass it as the driver of `Repository`, instead of extending `Repository`: see ["Using your own driver"](doc/repository.md#using-your-own-driver). + gitlib may add methods to the interfaces of the drivers in a minor + version of 2.x. * `Commit::__construct()` takes a `Gitonomy\Git\Driver\Data\CommitData` instead of an array, and `Commit::setData()` is internal. * `Gitonomy\Git\Util\StringHelper` is removed, with its configurable encoding: use `symfony/string`. Logs are always read in UTF-8. -* The parsers of the git output moved to `Gitonomy\Git\Driver\Cli\Parser` - and are `final`, except `Parser\DiffParser` and `Parser\ParserBase`. +* The parsers of the git output moved to `Gitonomy\Git\Driver\Cli\Parser`, + and are `final` and internal, used by `CliDriver` only, except + `Parser\DiffParser` and `Parser\ParserBase`. `CommitParser` is merged into `LogParser`, and their results are objects of `Gitonomy\Git\Driver\Data`: `LogParser::$commits` (instead of `$log`), `TagParser::$data`, `ReferenceParser::$references`, `TreeParser::$entries` diff --git a/doc/repository.md b/doc/repository.md index fbcf1939..e9ce484e 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -587,3 +587,7 @@ $repository->getReferences()->getBranch('main')->getCommitHash(); // $hash The other methods of `DriverInterface` create repositories: `init()`, `initBare()`, `cloneFrom()`, `cloneBareFrom()`, `mirrorFrom()`, `isValidRepository()` and `isValidRepositoryAndBranch()`. + +gitlib may add methods to both interfaces in a minor version of 2.x: the +promise of backward compatibility covers the code calling them, not the +drivers implementing them, like a stub that implements every method. diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php index 6d739cba..10d6c44b 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php @@ -17,6 +17,8 @@ /** * Parses the output of ``git blame -p``. + * + * @internal Used by CliDriver only */ final class BlameParser extends ParserBase { diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php index 4b91c5d9..50116806 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php @@ -18,6 +18,8 @@ /** * Parses the output of ``git log --format=raw``. + * + * @internal Used by CliDriver only */ final class LogParser extends ParserBase { diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php index 16e5a0a7..7a5fe542 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php @@ -18,6 +18,8 @@ /** * Parses `` `` lines. + * + * @internal Used by CliDriver only */ final class ReferenceParser extends ParserBase { diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php index bd14f349..269ffd6b 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php @@ -19,6 +19,8 @@ /** * Parses a tag object, as ``git cat-file --batch`` gives it. + * + * @internal Used by CliDriver only */ final class TagParser extends ParserBase { diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php index 54c769f9..afc700fd 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php @@ -20,6 +20,8 @@ /** * Parses a tree object, as ``git cat-file --batch`` gives it: for each * entry, its mode, a space, its name, a NUL byte, and its hash in binary. + * + * @internal Used by CliDriver only */ final class TreeParser extends ParserBase { diff --git a/src/Gitonomy/Git/Driver/DriverInterface.php b/src/Gitonomy/Git/Driver/DriverInterface.php index 88ca48c1..dea15a12 100644 --- a/src/Gitonomy/Git/Driver/DriverInterface.php +++ b/src/Gitonomy/Git/Driver/DriverInterface.php @@ -37,6 +37,10 @@ * descriptor of PHP, and an ssh host or user starting with a dash is an * option for ssh. * + * gitlib may add methods to this interface in a minor version: the promise of + * backward compatibility covers the code calling it, not the drivers + * implementing it outside gitlib, like a stub in tests. + * * @author Grégoire Pineau */ interface DriverInterface diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index 1cb15960..89e59ac1 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -43,6 +43,10 @@ * {@see InvalidArgumentException}, a revision, a reference or a hash starting * with a dash, before running git. A path may start with one. * + * gitlib may add methods to this interface in a minor version: the promise of + * backward compatibility covers the code calling it, not the drivers + * implementing it outside gitlib, like a stub in tests. + * * @author Grégoire Pineau */ interface RepositoryDriverInterface From 3acf907e457a553d850d6cd09a1897abb2bd060d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:58:37 +0200 Subject: [PATCH 134/150] Complete the UPGRADE and the doc for the release - UPGRADE-2.0.md says an unknown revision throws a ReferenceNotFoundException, which a catch of RuntimeException doesn't catch, where getDiff(), getBlame() or checkout() threw a ProcessException; that getRawDiff() returns the output of "git diff --raw -z --patch", or nothing with LibGit2Driver; which parameters are renamed, for named arguments; and how ParserBase changes. Its examples import the classes they use. - doc/diff.md says what getRawDiff() returns. - The example of getHead() in doc/repository.md checks the class of HEAD, which PHPStan understands. - The differences of LibGit2Driver list the blame, which may attribute some lines to another commit, and ignores blame.ignoreRevsFile. - The doc says a password in a git:// URL, which takes no credentials, isn't hidden: git quotes such a URL without its scheme. --- UPGRADE-2.0.md | 24 +++++++++++++++++++++++- doc/diff.md | 6 ++++-- doc/repository.md | 16 +++++++++++----- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 59566d66..9dd77bf5 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -57,6 +57,7 @@ Opening a repository ```php use Gitonomy\Git\Driver\Cli\CliDriver; + use Gitonomy\Git\Repository; $repository = new Repository($path, driver: new CliDriver(command: '/usr/bin/git', processTimeout: 60, logger: $logger)); ``` @@ -116,6 +117,7 @@ Creating repositories ```php use Gitonomy\Git\Driver\Cli\CliDriver; + use Gitonomy\Git\Repository; $repository = Repository::cloneFrom($url, $path, driver: new CliDriver(processTimeout: 60)); ``` @@ -172,6 +174,8 @@ References After: ```php + use Gitonomy\Git\Reference\Branch; + $branches = array_filter($commit->getIncludingBranches(), static fn (Branch $branch): bool => $branch->isLocal()); ``` @@ -392,6 +396,11 @@ Errors * `ProcessException` extends the new `Gitonomy\Git\Exception\DriverException`, itself a `RuntimeException`: catching `RuntimeException` or `GitExceptionInterface` still works. +* An unknown revision throws a `ReferenceNotFoundException`, an + `InvalidArgumentException`, where `Repository::getDiff()`, `getBlame()` + or `WorkingCopy::checkout()` threw a `ProcessException`: a + `catch (RuntimeException)` doesn't catch it anymore. Catch + `GitExceptionInterface`, which every exception of gitlib implements. * The message of a `ProcessException` is one line, with the error of git: the first line of its error output starting with `fatal:` or `error:`, after the warnings, or its first line: `The git command "clone" failed @@ -512,6 +521,13 @@ Extending gitlib The constructor of `Reference` requires the commit hash. Extending `Revision` or `Reference` isn't supported anymore, and won't be possible in 3.0: `Reference` declares `getName()`, abstract. +* With named arguments, some parameters are renamed: + `Repository::__construct()` `$options` (`$workingDir`), `getBlame()` + `$lineRange` (`$firstLine`), `getDiff()` `$revisions` (`$from`), + `ReferenceBag::createBranch()` and `createTag()` `$commitHash` + (`$revision`), `resolve()`, `resolveBranches()` and `resolveTags()` + `$hash` (`$commit`), and `PushReference::__construct()` `$reference` + (`$fullname`). * To change how gitlib runs git, for example in tests, implement `Gitonomy\Git\Driver\DriverInterface` and pass it as the driver of `Repository`, instead of extending `Repository`: see @@ -524,7 +540,9 @@ Extending gitlib encoding: use `symfony/string`. Logs are always read in UTF-8. * The parsers of the git output moved to `Gitonomy\Git\Driver\Cli\Parser`, and are `final` and internal, used by `CliDriver` only, except - `Parser\DiffParser` and `Parser\ParserBase`. + `Parser\DiffParser` and `Parser\ParserBase`. `ParserBase` loses + `consumeGPGSignature()` and `consumeMergeTag()`, and gets + `consumeNameEmailDate()`, `parseSignature()` and `signatureDate()`. `CommitParser` is merged into `LogParser`, and their results are objects of `Gitonomy\Git\Driver\Data`: `LogParser::$commits` (instead of `$log`), `TagParser::$data`, `ReferenceParser::$references`, `TreeParser::$entries` @@ -562,6 +580,10 @@ Data returned * `WorkingCopy::getUntrackedFiles()` and `RevisionList::getAll()` return lists, without gaps in their keys. * `Diff\File::toArray()` has a new `is_copy` key. +* `Diff::getRawDiff()` of a diff gitlib reads returns the output of + `git diff --raw -z --patch`, NUL bytes included, with the options gitlib + sets, instead of the text of `--raw --patch`; with `LibGit2Driver`, an + empty string. Other changes ------------- diff --git a/doc/diff.md b/doc/diff.md index 18660de6..75fcd95a 100644 --- a/doc/diff.md +++ b/doc/diff.md @@ -188,8 +188,10 @@ $output = $repository->run('diff', ['--raw', '--patch', '--submodule=short', '0. $diff = Diff::parse($output, $repository); ``` -`getRawDiff()` returns the output a diff was parsed from. It is empty for -the diffs of `LibGit2Driver`, which runs no git command. +`getRawDiff()` returns the output a diff was parsed from: for a diff gitlib +reads with `CliDriver`, the one of `git diff --raw -z --patch`, NUL bytes +included, with the options gitlib sets. It is empty for the diffs of +`LibGit2Driver`, which runs no git command. Storing a diff -------------- diff --git a/doc/repository.md b/doc/repository.md index e9ce484e..0864ed5f 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -75,18 +75,20 @@ tree). Your `HEAD` can be attached (pointing to a branch) or detached (pointing to a commit). ```php +use Gitonomy\Git\Reference\Branch; + $head = $repository->getHead(); // the Branch HEAD points to, or the Commit when it is detached $commit = $repository->getHeadCommit(); // the Commit, in both cases -if ($repository->isHeadAttached()) { +if ($head instanceof Branch) { echo 'On branch '.$head->getName().PHP_EOL; -} - -if ($repository->isHeadDetached()) { +} else { echo 'HEAD detached at '.$commit->getShortHash().PHP_EOL; } ``` +`isHeadAttached()` and `isHeadDetached()` tell it too. + In a repository without commits yet, `HEAD` points to a branch that doesn't exist yet: `getHead()` and `getHeadCommit()` throw a `ReferenceNotFoundException`, and `isHeadAttached()` returns `true`. @@ -261,6 +263,8 @@ try { The message of an exception hides the credentials of the URLs it holds (`https://***@example.com/repository.git`): `getErrorOutput()` and `getOutput()` of a `ProcessException` return the output of git as it is. +A `git://` URL takes no credentials: git quotes one without its scheme in +its errors, so a password put there isn't hidden. Choosing a driver ----------------- @@ -353,7 +357,9 @@ Differences with `CliDriver`: `commit.gpgSign`, and its message never lists the merged commits, whatever `merge.log`, nor has the output of gpg checking a signed tag. - Blame is much slower on a long history: see - ["Performance"](#performance). + ["Performance"](#performance). It may attribute some lines to another + commit than git, which detects renames and aligns identical lines + differently, and it ignores `blame.ignoreRevsFile`. - Rename detection in diffs may pair files differently. - Network operations go through the proxy of `https_proxy`, or of `http.proxy`, for HTTPS only: libgit2 1.9 uses no proxy for a plain From e39a6c636089f830fb0ab07092dac975aa8d7074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 18:59:49 +0200 Subject: [PATCH 135/150] Fix the CHANGELOG for the release The entries of the work of #254 had no link, or the one of #253, and some were wrong for 2.0: - reftable repositories open with CliDriver only; - run() doesn't keep its "exact original behavior", but still returns the standard output; - Repository::cloneTo() read like a replacement of Admin, and $reload like an argument of ReferenceBag::update() and Branch::isMergedTo(), which are deprecated themselves; - the fix of #239 for the "debug" option set to false is gone: 2.0 refuses that option. The header stays "Unreleased", until the release date is known. --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c19bb36c..41b6a93f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ deprecated until 3.0. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Return the dates of commits and tags at the UTC offset of their author, committer or tagger, like `git log` shows them, instead of in UTC: the instant doesn't change, but `format()` - returns their local time. + returns their local time. ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Require PHP 8.4 or later, Symfony 7.4/8.1+: most classes are `final` (some `readonly`), and every method has native types. ([#238](https://github.com/gitonomy/gitlib/pull/238)) - Remove `Gitonomy\Git\Util\StringHelper`, replaced by `symfony/string`, and its configurable @@ -37,17 +37,20 @@ deprecated until 3.0. are internal: get these objects from `Repository`, which returns the same commit, tree or blob while the application uses it, and reads the references again after its writes. Extending `Revision` or `Reference` isn't supported anymore. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - `ProcessException` has a message of one line, with the error of git, the first line of its error output starting with `fatal:` or `error:`, after the warnings: `The git command "clone" failed (exit 128): fatal: ...`. The command line, the rest of the error output and the output are no longer in the message: `getErrorOutput()` and - `getOutput()` return the outputs of git. + `getOutput()` return the outputs of git. ([#254](https://github.com/gitonomy/gitlib/pull/254)) - When git can't start, a missing binary for instance, `CliDriver` throws a `DriverException` instead of a `ProcessException`, with a message of one line, without the command line: `The git command "clone" could not start: ...`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a name that no revision has, with both drivers: the name of a remote branch alone, like `feature` for `origin/feature`, created the local branch. Use `checkoutNewBranch()`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) ### Features @@ -63,8 +66,9 @@ deprecated until 3.0. which gets every command, clones included), or any `Driver\DriverInterface`, to replace it in tests; see ["Using your own driver"](doc/repository.md#using-your-own-driver). The driver finds the repository: linked worktrees, submodules, clones with `--separate-git-dir` and - reftable repositories open, and a directory that isn't a repository throws right away. - ([#253](https://github.com/gitonomy/gitlib/pull/253), fixes #200) + reftable repositories (with `CliDriver`) open, and a directory that isn't a repository + throws right away. ([#253](https://github.com/gitonomy/gitlib/pull/253), + [#261](https://github.com/gitonomy/gitlib/pull/261), fixes #200) - Add static methods to `Repository` creating repositories, `init()`, `initBare()`, `cloneFrom()`, `cloneBareFrom()` and `mirrorFrom()`, and checking a repository URL, `isValidRepository()` and `isValidRepositoryAndBranch()`; see @@ -77,14 +81,14 @@ deprecated until 3.0. `Blame\Line`, `TreeEntry` and `Submodule`, `Diff\DiffLine` and `Diff\DiffLineType`, `File::isModeChange()`, `FileChange::getAdditions()` and `getDeletions()`, `PushReference::getFullname()`, `getRange()`, `isCreation()` and `isDeletion()`, and the SSH - and X.509 signatures of tags. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) + and X.509 signatures of tags. ([#253](https://github.com/gitonomy/gitlib/pull/253), + [#254](https://github.com/gitonomy/gitlib/pull/254)) - Add `WorkingCopy::merge()` to support `git merge`, fast-forwarding or not, with a `FastForward` enum. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) - Add `Repository::runProcess()` to expose stderr from successful commands: `run()` only ever returned stdout, even on success, but some git commands (e.g. `push`) write - meaningful output to stderr even when they succeed. `run()` now delegates to it and keeps - its exact original behavior. + meaningful output to stderr even when they succeed. `run()` still returns the standard + output. ([#247](https://github.com/gitonomy/gitlib/pull/247), fixes #205) - Support git's mnemonic diff prefixes (`diff.mnemonicPrefix`) in the diff parser: enabling this option replaces the default `a/`/`b/` prefixes with context-specific ones @@ -101,13 +105,15 @@ deprecated until 3.0. All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- `Admin`, use the static methods of `Repository`, and `Repository::cloneTo()`. +- `Admin`, replaced by the static methods of `Repository`, and `Repository::cloneTo()`, + replaced by `cloneFrom()` and `cloneBareFrom()`. - The array of options of `Repository` and `Admin` (`working_dir`, `logger`, `command`, `environment_variables`, `inherit_environment_variables`, `process_timeout`, `debug`): pass the working directory and a `CliDriver` instead; `inherit_environment_variables` has no counterpart, pass the variables git needs instead. `Repository::setLogger()` and `getLogger()`. -- Passing `$reload` to `Repository::getReferences()`, `ReferenceBag::update()`, - `Branch::isMergedTo()`, and arguments to `Commit::getIncludingBranches()`. +- Passing `$reload` to `Repository::getReferences()`, and arguments to + `Commit::getIncludingBranches()`. `ReferenceBag::update()` and `Branch::isMergedTo()`, + replaced by `Branch::moveTo()` and `isMergedInto()`. - Calling `Repository::getDiff()` with one argument, `Log::getDiff()`, `Diff::setRepository()`, `File::setRepository()`, `addChange()`, `isChangeMode()`, `isDelete()` and `getAnchor()`, `FileChange::getCount()` and its `LINE_*` constants, reading diff lines as arrays. @@ -119,7 +125,9 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, `PushReference::getReference()`, `getRevision()`, `isCreate()` and `isDelete()`. - `Repository::getSize()`, in kilobytes: use `getSizeInBytes()`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - `Exception\UnexpectedValueException`, which gitlib no longer throws. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) ### Security @@ -127,37 +135,45 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). option: `getRevision('--output=/path')`, `WorkingCopy::checkout()` or `merge()` could write a file, and `Repository::isValidRepository('--upload-pack=...')` run a command. A path may start with a dash: `init()` and the clones end the options of git with `--`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Require `symfony/process` 6.4.33, 7.4.5 or 8.1 and later: the older versions escape the arguments wrongly on Windows (CVE-2024-51736, CVE-2026-24739). - Refuse an `ext::` URL, like `ext::sh -c ...`, whose command git runs, by default before git 2.12, and an `fd::` URL, which makes git talk through a file descriptor it inherits from PHP, like the socket of a database, and wait on it. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Refuse an ssh URL whose host or user starts with a dash, even encoded (`%2D`), like `ssh://-oProxyCommand=.../repository`: git before 2.14.1 passes it to ssh, which reads it as an option and runs the command (CVE-2017-1000117). A `git://` URL whose host starts with a dash is refused too: git passes it to the command of `core.gitProxy`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Hide the credentials of URLs (`https://user:token@host`), a password holding a `#` or a `?` included, in the messages of exceptions, in their traces (`#[\SensitiveParameter]`), and in the logs. The exceptions of Symfony Process, which hold the command line, no longer leak, not - even as the previous exception. + even as the previous exception. ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Refuse a hook name that isn't the one of a file of the hooks directory: `Hooks::set('../config', ...)` and `remove('../config')` wrote and deleted the - configuration of the repository. + configuration of the repository. ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Fix `Hooks` writing through a symbolic link to a missing file, out of the hooks directory: `has()` returned `false` for it, and `set()` followed it. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Ignore the replacements of objects (`git replace`, `refs/replace/`) with `CliDriver`, like `LibGit2Driver`: whoever could push such a reference to a repository changed the message of a commit, or the content of a blob, gitlib read for a hash. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Ignore `core.fsmonitor` with `CliDriver`: git ran the command the configuration of a repository names there to read the working tree, and even to diff a commit. Like git with `--git-dir`, gitlib trusts the repositories it opens: `safe.directory` doesn't apply, see ["Git configuration"](doc/repository.md#git-configuration). - Fix `WorkingCopy::checkout()` of a path, like `checkout('README.md')`, discarding the changes of the file: a path is no revision, and throws a `ReferenceNotFoundException`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Fix moving or deleting a symbolic reference, which changed the branch it points to instead: `ReferenceBag::delete('refs/remotes/origin/HEAD')` deleted `origin/main`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Fix `ReferenceBag::delete('HEAD')` deleting `HEAD`, which left no repository: a name outside `refs/` throws an `InvalidArgumentException`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) ### Bug fixes @@ -176,10 +192,12 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). `diff.orderFile`, `log.follow` or `branch.sort` changed diffs, logs and the branches of a commit. gitlib sets them back to git's defaults; see ["Git configuration"](doc/repository.md#git-configuration). + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Fix `Commit::getShortHash()`, `Commit::getDiff()` and `Repository::getDiff()` failing when a file of the working tree has the name of the revision they read, which git found ambiguous. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Fix logs of thousands of revisions exceeding the limit of the command line: `CliDriver` passes - them to git on its standard input. + them to git on its standard input. ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Fix `Commit::getDiff()` returning an empty diff for a root commit, and throwing for a merge commit (`CliDriver`). ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix `Repository::getDiff()`, `WorkingCopy::getDiffPending()` and `WorkingCopy::getDiffStaged()` @@ -214,6 +232,7 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). a hook not deployed yet: it no longer follows links. A file or a directory git deletes meanwhile, like a lock during a `gc` or the quarantine of a push, no longer makes it throw either, and one it can't read throws a `RuntimeException` of gitlib, instead of PHP's. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Fix blame lines starting with a TAB character (`Blame\Line::getContent()`): code removing it must stop doing so. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix reading a commit whose `encoding` header isn't UTF-8, which threw: its message, author and @@ -236,9 +255,6 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). branch existed. - `Tag::getBodyMessage()` dropped or truncated a line for annotated tags using the standard `subject\n\nbody` convention. - - With the `debug` option set to `false`, several call sites (`Tag::isAnnotated()`, - `PushReference::isForce()`, `Blob::getContent()`, ...) produced wrong data or crashed - when git failed. - `Repository::shell()` interpolated the repository path into the shell command without escaping it; it is now passed through `escapeshellarg()`. - `Hooks::set()` created hook scripts world-writable (`0777`); they are now created @@ -254,7 +270,7 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - The tests run with a git configuration of their own, whatever the developer's (`tests/fixtures/home`), which sets what gitlib sets back to git's defaults, and without the variables of git of their environment: run from a hook, they wrote the repository of the hook - (`GIT_DIR`, `GIT_INDEX_FILE`...). + (`GIT_DIR`, `GIT_INDEX_FILE`...). ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Add a regression test for reading a blob's content in a tree that also contains a submodule. ([#243](https://github.com/gitonomy/gitlib/pull/243)) - Replace the network-dependent test fixture (cloned from GitHub on every run) with a local, From 2ecb7b4d0fbddf11c61acb03778fc2b51e410b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 19:03:00 +0200 Subject: [PATCH 136/150] Link the CHANGELOG entries of this pull request to it The entries of #263 had no link: they get one, after the one of #254 when they complete its work. --- CHANGELOG.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b6a93f..9010d31b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,7 +82,7 @@ deprecated until 3.0. `File::isModeChange()`, `FileChange::getAdditions()` and `getDeletions()`, `PushReference::getFullname()`, `getRange()`, `isCreation()` and `isDeletion()`, and the SSH and X.509 signatures of tags. ([#253](https://github.com/gitonomy/gitlib/pull/253), - [#254](https://github.com/gitonomy/gitlib/pull/254)) + [#254](https://github.com/gitonomy/gitlib/pull/254), [#263](https://github.com/gitonomy/gitlib/pull/263)) - Add `WorkingCopy::merge()` to support `git merge`, fast-forwarding or not, with a `FastForward` enum. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) - Add `Repository::runProcess()` to expose stderr from successful commands: `run()` only @@ -138,10 +138,11 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). ([#254](https://github.com/gitonomy/gitlib/pull/254)) - Require `symfony/process` 6.4.33, 7.4.5 or 8.1 and later: the older versions escape the arguments wrongly on Windows (CVE-2024-51736, CVE-2026-24739). + ([#263](https://github.com/gitonomy/gitlib/pull/263)) - Refuse an `ext::` URL, like `ext::sh -c ...`, whose command git runs, by default before git 2.12, and an `fd::` URL, which makes git talk through a file descriptor it inherits from PHP, like the socket of a database, and wait on it. - ([#254](https://github.com/gitonomy/gitlib/pull/254)) + ([#254](https://github.com/gitonomy/gitlib/pull/254), [#263](https://github.com/gitonomy/gitlib/pull/263)) - Refuse an ssh URL whose host or user starts with a dash, even encoded (`%2D`), like `ssh://-oProxyCommand=.../repository`: git before 2.14.1 passes it to ssh, which reads it as an option and runs the command (CVE-2017-1000117). A `git://` URL whose host starts with a dash @@ -165,6 +166,7 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). repository names there to read the working tree, and even to diff a commit. Like git with `--git-dir`, gitlib trusts the repositories it opens: `safe.directory` doesn't apply, see ["Git configuration"](doc/repository.md#git-configuration). + ([#263](https://github.com/gitonomy/gitlib/pull/263)) - Fix `WorkingCopy::checkout()` of a path, like `checkout('README.md')`, discarding the changes of the file: a path is no revision, and throws a `ReferenceNotFoundException`. ([#254](https://github.com/gitonomy/gitlib/pull/254)) @@ -180,12 +182,13 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - Fix `Commit::getIncludingBranches()` and `Repository::getHead()` throwing for a branch another process created or checked out since gitlib read the references, and `getIncludingBranches()` throwing for a regular `refs/remotes/origin/HEAD`, as a mirror leaves - it. + it. ([#263](https://github.com/gitonomy/gitlib/pull/263)) - Fix the blame of a file throwing with `CliDriver` after a merge whose sides kept it under two names: git repeats the path of a commit it blames under both, which gitlib didn't read. + ([#263](https://github.com/gitonomy/gitlib/pull/263)) - Fix a log throwing with `CliDriver` for a commit whose author or committer git wouldn't write anymore, like `Name` or no date, as old imports have: it is read like libgit2 - reads it, the epoch in UTC without date. + reads it, the epoch in UTC without date. ([#263](https://github.com/gitonomy/gitlib/pull/263)) - Fix the configuration of the user changing what `CliDriver` reads: `color.ui=always`, `log.showSignature`, `diff.noprefix`, `diff.external`, `diff.submodule`, textconv filters, `column.ui`... made reads throw or return wrong data, and `diff.algorithm`, `diff.context`, @@ -232,7 +235,7 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). a hook not deployed yet: it no longer follows links. A file or a directory git deletes meanwhile, like a lock during a `gc` or the quarantine of a push, no longer makes it throw either, and one it can't read throws a `RuntimeException` of gitlib, instead of PHP's. - ([#254](https://github.com/gitonomy/gitlib/pull/254)) + ([#254](https://github.com/gitonomy/gitlib/pull/254), [#263](https://github.com/gitonomy/gitlib/pull/263)) - Fix blame lines starting with a TAB character (`Blame\Line::getContent()`): code removing it must stop doing so. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix reading a commit whose `encoding` header isn't UTF-8, which threw: its message, author and From 6e47aa2887a078e45a4bdee21e31abb59a4762f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 19:40:57 +0200 Subject: [PATCH 137/150] Run ls-remote out of the working directory of PHP, and refuse an empty URL isValidRepository() and isValidRepositoryAndBranch() ran ls-remote in the working directory of PHP, which may be a repository, like the checkout of an application: git read its configuration, its rewrites of URLs for instance, and an empty URL named it, which was then valid with CliDriver, not with LibGit2Driver. git 1.8.4 can't even run in a linked worktree, which made them return false. ls-remote runs in the temporary directory, and both drivers refuse an empty URL with an InvalidArgumentException. --- CHANGELOG.md | 4 ++ UPGRADE-2.0.md | 1 + doc/admin.md | 1 + src/Gitonomy/Git/Driver/Cli/CliDriver.php | 17 ++++++-- src/Gitonomy/Git/Util/RemoteUrl.php | 7 +++- tests/Gitonomy/Git/Tests/RepositoryTest.php | 44 +++++++++++++++++++++ 6 files changed, 70 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9010d31b..51456c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,10 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). process created or checked out since gitlib read the references, and `getIncludingBranches()` throwing for a regular `refs/remotes/origin/HEAD`, as a mirror leaves it. ([#263](https://github.com/gitonomy/gitlib/pull/263)) +- Fix `isValidRepository()` and `isValidRepositoryAndBranch()` reading, with `CliDriver`, the + configuration of the repository PHP runs in, like its rewrites of URLs, and returning `true` + for an empty URL there: it throws an `InvalidArgumentException`. + ([#263](https://github.com/gitonomy/gitlib/pull/263)) - Fix the blame of a file throwing with `CliDriver` after a merge whose sides kept it under two names: git repeats the path of a commit it blames under both, which gitlib didn't read. ([#263](https://github.com/gitonomy/gitlib/pull/263)) diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 9dd77bf5..8d922dee 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -439,6 +439,7 @@ Errors `getRevision('--output=/path')` writing a file, or `isValidRepository('--upload-pack=...')` running a command. A path may start with a dash. +* An empty URL throws an `InvalidArgumentException`, with both drivers. * An `ext::` URL throws an `InvalidArgumentException`, with both drivers: git runs its command, by default before git 2.12. So does an `fd::` URL, which makes git talk through a file descriptor it inherits from PHP. diff --git a/doc/admin.md b/doc/admin.md index 10e6cfe3..3159199b 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -104,6 +104,7 @@ start, like a missing binary. See [Errors](repository.md#errors). A URL that makes git run a command, or use a file of PHP, throws an `InvalidArgumentException`, with both drivers: +- an empty URL, which would make git read the repository PHP runs in; - a URL starting with a dash, which git would read as an option, like `--upload-pack=...`; a path may start with one; - an `ext::` URL, like `ext::sh -c ...`, whose command git runs, unless its diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index 2f73b713..005a2b04 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -79,7 +79,7 @@ public function isValidRepository(#[\SensitiveParameter] string $url): bool // ls-remote only takes "--" since git 2.8. RemoteUrl::assertSafe($url); - return $this->runner()->run('ls-remote', [self::absoluteUrl($url)])->isSuccessful(); + return $this->runner()->run('ls-remote', [self::absoluteUrl($url)], workingDirectory: self::neutralDirectory())->isSuccessful(); } public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch): bool @@ -87,7 +87,7 @@ public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, s RemoteUrl::assertSafe($url); // git lists the branches whose name ends with the pattern: "release" matches "feature/release". - $process = $this->runner()->run('ls-remote', ['--heads', self::absoluteUrl($url), 'refs/heads/'.$branch]); + $process = $this->runner()->run('ls-remote', ['--heads', self::absoluteUrl($url), 'refs/heads/'.$branch], workingDirectory: self::neutralDirectory()); if (!$process->isSuccessful()) { return false; } @@ -150,10 +150,21 @@ private function mustRun(string $command, #[\SensitiveParameter] array $args): v } } + /** + * Out of the working directory of PHP, which may be a repository, like + * the checkout of an application: ls-remote, unlike clone, would read its + * configuration, its rewrites of URLs for instance. git 1.8.4 can't even + * run in a linked worktree. + */ + private static function neutralDirectory(): string + { + return sys_get_temp_dir(); + } + /** * Run in a repository, ls-remote reads a relative path as the name of a * remote first: "origin" would be the URL of the remote of PHP's working - * directory. + * directory. The path stays relative to it. */ private static function absoluteUrl(#[\SensitiveParameter] string $url): string { diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php index 34a4bd1b..dda24342 100644 --- a/src/Gitonomy/Git/Util/RemoteUrl.php +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -25,10 +25,15 @@ final class RemoteUrl { /** - * @throws InvalidArgumentException $url starts with a dash, uses the "ext::" or "fd::" transport, or has a host or a user starting with a dash, encoded or not + * @throws InvalidArgumentException $url is empty, starts with a dash, uses the "ext::" or "fd::" transport, or has a host or a user starting with a dash, encoded or not */ public static function assertSafe(#[\SensitiveParameter] string $url): void { + // git would run in the working directory of PHP, which may be a repository. + if ('' === $url) { + throw new InvalidArgumentException('The URL of a repository can\'t be empty.'); + } + LeadingDash::assertNone($url); // "ext::sh -c ..." runs the command, to talk to it: git refuses it by diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 1c58b0c9..a03f2716 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -339,6 +339,50 @@ public function testIsValidRepositoryReadsANameAsAPath(): void } } + /** + * git reads the configuration of the repository it runs in, like its + * rewrites of URLs: an application may run from a checkout. + */ + public function testTheWorkingDirectoryOfPhpRewritesNoUrl(): void + { + $url = self::createFoobarRepository()->getGitDir(); + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['url.'.self::createTempDir().'/missing.git.insteadOf', $url]); + $cwd = getcwd(); + self::assertIsString($cwd); + + chdir($repository->getPath()); + + try { + $this->assertTrue(Repository::isValidRepository($url, self::getDriver())); + $this->assertTrue(Repository::isValidRepositoryAndBranch($url, 'master', self::getDriver())); + } finally { + chdir($cwd); + } + } + + /** + * git ran in the working directory of PHP, which may be a repository. + */ + #[DataProvider('provideEmptyUrlCalls')] + public function testAnEmptyUrlIsRefused(\Closure $call): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The URL of a repository can\'t be empty.'); + + $call(); + } + + /** + * @return iterable + */ + public static function provideEmptyUrlCalls(): iterable + { + yield 'valid repository' => [static fn (): bool => Repository::isValidRepository('', self::getDriver())]; + yield 'valid branch' => [static fn (): bool => Repository::isValidRepositoryAndBranch('', 'master', self::getDriver())]; + yield 'clone' => [static fn (): Repository => Repository::cloneFrom('', self::createTempDir().'/clone', driver: self::getDriver())]; + } + public function testIsValidRepositoryThrowsWhenGitCantRun(): void { $this->skipUnlessCliDriver(); From dbb8bfaa5f0d18d9c182f8d774f2fed94dceb945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 19:41:38 +0200 Subject: [PATCH 138/150] Remove the fields of TagData nothing reads TagData held the object a tag points to, its type and the name of the tag, which nothing reads: the reference gives the object already. A driver implemented outside gitlib had to fill them anyway. TagData keeps the tagger, the message and the signature. --- .../Git/Driver/Cli/Parser/TagParser.php | 22 ++++++------------- src/Gitonomy/Git/Driver/Data/TagData.php | 9 +++----- .../Driver/Ffi/LibGit2RepositoryDriver.php | 3 --- src/Gitonomy/Git/Driver/Ffi/libgit2.h | 3 --- 4 files changed, 10 insertions(+), 27 deletions(-) diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php index 269ffd6b..ebffed40 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php @@ -12,9 +12,7 @@ namespace Gitonomy\Git\Driver\Cli\Parser; -use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Driver\Data\TagData; -use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Parser\ParserBase; /** @@ -28,18 +26,12 @@ final class TagParser extends ParserBase protected function doParse(): void { - $this->consume('object '); - $object = $this->consumeHash(); - $this->consumeNewLine(); - - $this->consume('type '); - $typeName = $this->consumeTo("\n"); - $type = ObjectType::tryFrom($typeName) ?? throw new RuntimeException(\sprintf('The tag points to an object of the unknown type "%s".', $typeName)); - $this->consumeNewLine(); - - $this->consume('tag '); - $tag = $this->consumeTo("\n"); - $this->consumeNewLine(); + // The object the tag points to, which the reference gives already, and the name of the tag. + foreach (['object ', 'type ', 'tag '] as $header) { + $this->consume($header); + $this->consumeTo("\n"); + $this->consumeNewLine(); + } // The oldest tags, like the first ones of git itself, have no tagger. [$taggerName, $taggerEmail, $taggerDate] = [null, null, null]; @@ -50,6 +42,6 @@ protected function doParse(): void $this->consumeNewLine(); - $this->data = TagData::fromSignedMessage($object, $type, $tag, $taggerName, $taggerEmail, $taggerDate, $this->consumeAll()); + $this->data = TagData::fromSignedMessage($taggerName, $taggerEmail, $taggerDate, $this->consumeAll()); } } diff --git a/src/Gitonomy/Git/Driver/Data/TagData.php b/src/Gitonomy/Git/Driver/Data/TagData.php index 87b8044c..9635a41d 100644 --- a/src/Gitonomy/Git/Driver/Data/TagData.php +++ b/src/Gitonomy/Git/Driver/Data/TagData.php @@ -29,9 +29,6 @@ * @param \DateTimeImmutable|null $taggerDate At the UTC offset of the tagger, like git log shows it */ public function __construct( - public string $objectHash, - public ObjectType $objectType, - public string $tagName, public ?string $taggerName, public ?string $taggerEmail, public ?\DateTimeImmutable $taggerDate, @@ -43,12 +40,12 @@ public function __construct( /** * @param string $signedMessage The message of the tag object, followed by the signature git appends to it, if any */ - public static function fromSignedMessage(string $objectHash, ObjectType $objectType, string $tagName, ?string $taggerName, ?string $taggerEmail, ?\DateTimeImmutable $taggerDate, string $signedMessage): self + public static function fromSignedMessage(?string $taggerName, ?string $taggerEmail, ?\DateTimeImmutable $taggerDate, string $signedMessage): self { if (!preg_match('/^(.*?)'.self::SIGNATURE.'(.*?)-----END \2-----/s', $signedMessage, $vars)) { - return new self($objectHash, $objectType, $tagName, $taggerName, $taggerEmail, $taggerDate, $signedMessage, null); + return new self($taggerName, $taggerEmail, $taggerDate, $signedMessage, null); } - return new self($objectHash, $objectType, $tagName, $taggerName, $taggerEmail, $taggerDate, $vars[1], $vars[3]); + return new self($taggerName, $taggerEmail, $taggerDate, $vars[1], $vars[3]); } } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index c72b6d3e..fb569e03 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -326,9 +326,6 @@ public function getTagData(string $hash): TagData $tagger = $this->ffi->git_tag_tagger($tag); return TagData::fromSignedMessage( - $this->oidToHex($this->ffi->git_tag_target_id($tag)), - $this->objectType($this->ffi->git_tag_target_type($tag)), - $this->ffi->git_tag_name($tag), null === $tagger ? null : \FFI::string($tagger->name), null === $tagger ? null : \FFI::string($tagger->email), null === $tagger ? null : $this->signatureDate($tagger), diff --git a/src/Gitonomy/Git/Driver/Ffi/libgit2.h b/src/Gitonomy/Git/Driver/Ffi/libgit2.h index 029005f9..e8804746 100644 --- a/src/Gitonomy/Git/Driver/Ffi/libgit2.h +++ b/src/Gitonomy/Git/Driver/Ffi/libgit2.h @@ -168,9 +168,6 @@ uint64_t git_blob_rawsize(const git_blob *blob); int git_tag_lookup(git_tag **out, git_repository *repo, const git_oid *id); void git_tag_free(git_tag *tag); -const git_oid *git_tag_target_id(const git_tag *tag); -git_object_t git_tag_target_type(const git_tag *tag); -const char *git_tag_name(const git_tag *tag); const git_signature *git_tag_tagger(const git_tag *tag); const char *git_tag_message(const git_tag *tag); From 49d974a1f5c950067abeb8f012b840fe930c3104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 19:44:13 +0200 Subject: [PATCH 139/150] Reword the exception messages the review found unclear - A revision starting with a dash names the revision, without the "^{commit}" gitlib adds: Revision::getCommit() checks it first. - A git:// URL whose host starts with a dash names the command of core.gitProxy, not ssh only. - getIncludingBranches(false, false) says what to do, without "a least". - A timeout of one second is "1 second", and a timeout git reaches while it starts is a ProcessTimedOutException, instead of a DriverException holding the command line. - A single unknown option of Repository is "Unknown option". - getLastModification() of a path no commit changes names the path. - The ProcessException of a merge in conflict has the first CONFLICT line git writes on its output, instead of nothing. --- UPGRADE-2.0.md | 9 +++-- src/Gitonomy/Git/Commit.php | 11 +++++- src/Gitonomy/Git/Driver/Cli/GitRunner.php | 10 ++++- src/Gitonomy/Git/Driver/Cli/LegacyOptions.php | 2 +- .../Git/Exception/ProcessException.php | 5 +++ src/Gitonomy/Git/Revision.php | 3 ++ src/Gitonomy/Git/Util/RemoteUrl.php | 2 +- tests/Gitonomy/Git/Tests/CommitTest.php | 9 +++++ .../Git/Tests/ProcessExceptionTest.php | 25 ++++++++++++ tests/Gitonomy/Git/Tests/RemoteUrlTest.php | 2 +- tests/Gitonomy/Git/Tests/RepositoryTest.php | 38 +++++++++++++++++++ tests/Gitonomy/Git/Tests/RevisionTest.php | 12 ++++++ 12 files changed, 118 insertions(+), 10 deletions(-) diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 8d922dee..312308f5 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -405,10 +405,11 @@ Errors the first line of its error output starting with `fatal:` or `error:`, after the warnings, or its first line: `The git command "clone" failed (exit 128): fatal: ...`, or `The git command "diff" failed (exit 1).` - when git writes no error. The command line, the rest of the error output - and the output are no longer in the message: `getErrorOutput()` and - `getOutput()` return the outputs of git. Its constructor takes the git - command as second argument, optional. + when git writes no error, but for the conflict of a merge, which git + writes on its output (`CONFLICT (content): ...`). The command line, the + rest of the error output and the output are no longer in the message: + `getErrorOutput()` and `getOutput()` return the outputs of git. Its + constructor takes the git command as second argument, optional. * When git can't start, a missing binary for instance, `CliDriver` throws a `DriverException` instead of a `ProcessException`, with a message of one line, without the command line: `The git command "clone" could not diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index e57b42f0..22b1f29d 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -136,13 +136,20 @@ public function getTree(): Tree return $this->tree ??= $this->repository->getTree($this->getTreeHash()); } + /** + * @throws ReferenceNotFoundException No commit up to this one changes $path + */ public function getLastModification(?string $path = null): self { if (null !== $path && str_starts_with($path, '/')) { $path = substr($path, 1); } - return $this->getLog($path, null, 1)->getSingleCommit(); + try { + return $this->getLog($path, null, 1)->getSingleCommit(); + } catch (ReferenceNotFoundException $e) { + throw new ReferenceNotFoundException(\sprintf('No commit up to "%s" changes "%s".', $this->revision, $path), 0, $e); + } } /** @@ -195,7 +202,7 @@ public function getIncludingBranches(bool $local = true, bool $remote = true): a } if (!$local && !$remote) { - throw new InvalidArgumentException('You should a least set one argument to true.'); + throw new InvalidArgumentException('Keep the local branches, the remote ones, or both: $local and $remote can\'t both be false.'); } $fullnames = $this->repository->getRepositoryDriver()->getIncludingBranches($this->revision); diff --git a/src/Gitonomy/Git/Driver/Cli/GitRunner.php b/src/Gitonomy/Git/Driver/Cli/GitRunner.php index 268343e6..7cf1bb59 100644 --- a/src/Gitonomy/Git/Driver/Cli/GitRunner.php +++ b/src/Gitonomy/Git/Driver/Cli/GitRunner.php @@ -72,6 +72,9 @@ public function run(string $command, #[\SensitiveParameter] array $args, array $ // command line, with the credentials of its URLs. try { $process->start(); + } catch (SymfonyProcessTimedOutException) { + // A timeout shorter than the start of git. + throw $this->timedOut($command); } catch (SymfonyRuntimeException $e) { // Too many arguments (E2BIG), a working directory removed since the // repository was opened... @@ -88,7 +91,7 @@ public function run(string $command, #[\SensitiveParameter] array $args, array $ try { $process->wait(); } catch (SymfonyProcessTimedOutException) { - throw new ProcessTimedOutException(\sprintf('The git command "%s" ran longer than %s seconds.', $command, $this->timeout)); + throw $this->timedOut($command); } catch (SymfonyRuntimeException $e) { // Killed by a signal... throw new DriverException(Credentials::redact(\sprintf('Unable to run the git command "%s": %s', $command, $e->getMessage()))); @@ -125,4 +128,9 @@ private static function notStarted(string $command, string $error): DriverExcept return new DriverException(Credentials::redact(1 === preg_match('/[.!?]$/', $message) ? $message : $message.'.')); } + + private function timedOut(string $command): ProcessTimedOutException + { + return new ProcessTimedOutException(\sprintf('The git command "%s" ran longer than %s %s.', $command, $this->timeout, 1.0 === $this->timeout ? 'second' : 'seconds')); + } } diff --git a/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php b/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php index 5c9bb75d..d4effff3 100644 --- a/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php +++ b/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php @@ -52,7 +52,7 @@ public function __construct(array $options) $unknownOptions = array_diff(array_keys($options), [...self::DRIVER_OPTIONS, 'debug', 'working_dir']); if ([] !== $unknownOptions) { - throw new InvalidArgumentException(\sprintf('Unknown options "%s".', implode('", "', $unknownOptions))); + throw new InvalidArgumentException(\sprintf(1 === \count($unknownOptions) ? 'Unknown option "%s".' : 'Unknown options "%s".', implode('", "', $unknownOptions))); } // PHP doesn't check the types of the options, unlike PHPStan. diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index b7668463..44992fd7 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -36,6 +36,11 @@ public function __construct( // local changes ... would be overwritten by checkout:". $errorOutput = $process->getErrorOutput(); $error = 1 === preg_match('/^(?:fatal|error):.*$/m', $errorOutput, $matches) ? $matches[0] : explode("\n", trim($errorOutput), 2)[0]; + // A merge writes its conflicts on the standard output, like + // "CONFLICT (content): Merge conflict in README.md". + if ('' === trim($errorOutput) && 1 === preg_match('/^CONFLICT .*$/m', $process->getOutput(), $matches)) { + $error = $matches[0]; + } $error = rtrim(trim($error), ':'); if ('' !== $error) { $message .= ': '.$error; diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index 3e131205..1fe5b36b 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -14,6 +14,7 @@ use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Util\LeadingDash; /** * A name git resolves to a commit: a branch, a tag, a hash, "HEAD~2"... @@ -52,6 +53,8 @@ public function getLog(array|string|null $paths = null, ?int $offset = null, ?in public function getCommit(): Commit { $driver = $this->repository->getRepositoryDriver(); + // Before the driver gets "^{commit}", which its message would quote. + LeadingDash::assertNone($this->revision); try { // "^{commit}" would be part of the text of ":/text", the commit diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php index dda24342..b29b3c82 100644 --- a/src/Gitonomy/Git/Util/RemoteUrl.php +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -55,7 +55,7 @@ public static function assertSafe(#[\SensitiveParameter] string $url): void // the host of a git URL the same way. A valid host never starts with a dash. $authority = self::authority($url); if (null !== $authority && 1 === preg_match('/(?:^|@)\[?-/', $authority)) { - throw new InvalidArgumentException(\sprintf('"%s" has a host or a user starting with a dash, which ssh would read as an option.', Credentials::redact($url))); + throw new InvalidArgumentException(\sprintf('"%s" has a host or a user starting with a dash, which ssh, or the command of core.gitProxy, would read as an option.', Credentials::redact($url))); } } diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 5e1800c0..0fe8ec00 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -376,12 +376,21 @@ public function testGetIncludingBranchesException(bool $bare): void { $repository = self::createFoobarRepository($bare); $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Keep the local branches, the remote ones, or both: $local and $remote can\'t both be false.'); $commit = $repository->getCommit(self::INITIAL_COMMIT); $commit->getIncludingBranches(false, false); } + public function testTheLastModificationOfAPathNoCommitChanges(): void + { + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage(\sprintf('No commit up to "%s" changes "nope".', self::LONGFILE_COMMIT)); + + self::createFoobarRepository()->getCommit(self::LONGFILE_COMMIT)->getLastModification('nope'); + } + #[DataProvider('provideBare')] public function testGetIncludingBranches(bool $bare): void { diff --git a/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php b/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php index 512d2c08..652319bd 100644 --- a/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php +++ b/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php @@ -89,6 +89,31 @@ public static function provideErrorOutputs(): iterable yield 'no error' => ["\nwarning: something\nmerge: nope - not something we can merge\n", 'The git command "merge" failed (exit 128): warning: something.']; } + /** + * git writes the conflicts of a merge on its standard output. + */ + public function testTheMessageOfAConflictHasIt(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + file_put_contents($repository->getWorkingDir().'/README.md', "mine\n"); + self::git($repository, 'commit', ['-q', '-a', '-m', 'Mine']); + self::git($repository, 'checkout', ['-q', '-b', 'theirs', 'HEAD~1']); + file_put_contents($repository->getWorkingDir().'/README.md', "theirs\n"); + self::git($repository, 'commit', ['-q', '-a', '-m', 'Theirs']); + self::git($repository, 'checkout', ['-q', 'master']); + + try { + $repository->getWorkingCopy()->merge('theirs'); + $this->fail('Both sides change README.md.'); + } catch (ProcessException $e) { + $this->assertSame('The git command "merge" failed (exit 1): CONFLICT (content): Merge conflict in README.md.', $e->getMessage()); + } + } + public function testTheMessageOfAFailureWithoutErrorOutputEndsWithTheExitCode(): void { $this->skipUnlessCliDriver(); diff --git a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php index 5df1f4a5..4c05b7e3 100644 --- a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php +++ b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php @@ -111,7 +111,7 @@ public function testAHostStartingWithADashIsRefused(\Closure $call, string $url) $call($url, $dir.'/clone'); $this->fail('A host starting with a dash is refused.'); } catch (InvalidArgumentException $e) { - $this->assertSame(\sprintf('"%s" has a host or a user starting with a dash, which ssh would read as an option.', Credentials::redact($url)), $e->getMessage()); + $this->assertSame(\sprintf('"%s" has a host or a user starting with a dash, which ssh, or the command of core.gitProxy, would read as an option.', Credentials::redact($url)), $e->getMessage()); } finally { chdir($previousCwd); } diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index a03f2716..29d048a4 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -311,6 +311,34 @@ public function testTimeout(): void $repository->run('commit', ['--allow-empty', '-m', 'Waits for its hook']); } + public function testATimeoutOfOneSecond(): void + { + $repository = self::createFoobarRepository(false); + $repository->getHooks()->set('pre-commit', "#!/bin/sh\nsleep 10\n"); + $repository = new Repository($repository->getPath(), driver: self::getCliDriver(['GIT_AUTHOR_NAME' => 'A', 'GIT_AUTHOR_EMAIL' => 'a@example.com', 'GIT_COMMITTER_NAME' => 'A', 'GIT_COMMITTER_EMAIL' => 'a@example.com'], processTimeout: 1)); + + $this->expectException(ProcessTimedOutException::class); + $this->expectExceptionMessage('The git command "commit" ran longer than 1 second.'); + + $repository->run('commit', ['--allow-empty', '-m', 'Waits for its hook']); + } + + /** + * Symfony Process checks the timeout when git starts too: its exception + * held the command line. + */ + public function testATimeoutWhileGitStarts(): void + { + $repository = new Repository(self::createFoobarRepository()->getPath(), driver: self::getCliDriver(processTimeout: 0.000001)); + + try { + $repository->run('log', ['-n', '1']); + $this->markTestSkipped('git started within the timeout.'); + } catch (ProcessTimedOutException $e) { + $this->assertSame('The git command "log" ran longer than 1.0E-6 seconds.', $e->getMessage()); + } + } + public function testIsValidRepositoryAndBranchMatchesTheNameExactly(): void { $repository = self::createFoobarRepository(); @@ -951,6 +979,16 @@ public function testLegacySetLoggerRequiresCliDriver(): void new Repository(self::createTempDir(), driver: $this->createStub(DriverInterface::class))->setLogger($this->createStub(LoggerInterface::class)); } + #[Group('legacy')] + #[IgnoreDeprecations] + public function testAnUnknownOptionIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown option "foo".'); + + new Repository(self::createTempDir(), ['foo' => 1]); + } + #[Group('legacy')] #[IgnoreDeprecations] public function testUnknownOptionsAreRefused(): void diff --git a/tests/Gitonomy/Git/Tests/RevisionTest.php b/tests/Gitonomy/Git/Tests/RevisionTest.php index b0b267dd..05532b36 100644 --- a/tests/Gitonomy/Git/Tests/RevisionTest.php +++ b/tests/Gitonomy/Git/Tests/RevisionTest.php @@ -14,6 +14,7 @@ use Gitonomy\Git\Commit; use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use PHPUnit\Framework\Attributes\DataProvider; @@ -81,6 +82,17 @@ public function testALogOfARangeFromAt(): void $this->assertSame($hashes('HEAD~2..HEAD'), $hashes('@~2..@')); } + /** + * The message names the revision, without the "^{commit}" gitlib adds. + */ + public function testARevisionStartingWithADashIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"--output=/x" starts with a dash, which git would read as an option.'); + + self::createFoobarRepository()->getRevision('--output=/x')->getCommit(); + } + #[DataProvider('provideBare')] public function testGetLog(bool $bare): void { From 2bd07b2f67f1c23573e6f2882c2e8594b417a899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 19:46:42 +0200 Subject: [PATCH 140/150] Test every deprecation Eleven deprecated methods ran in no test: File::addChange(), isChangeMode(), isDelete() and getAnchor(), FileChange::getCount(), Diff::setRepository(), PushReference::getReference() and getRevision(), Commit::getFixedShortHash(), Log::setOffset() and Repository::shell(). Nothing checked the message of the deprecation of CommitReference either. Nothing fixed their behavior until 3.0: each gets a legacy test, CommitReference in a process of its own, as Submodule declares its alias once loaded, and shell() through a command of ~/git-shell-commands. --- tests/Gitonomy/Git/Tests/CommitTest.php | 9 +++ tests/Gitonomy/Git/Tests/DiffTest.php | 64 +++++++++++++++++++ tests/Gitonomy/Git/Tests/LogTest.php | 12 ++++ .../Gitonomy/Git/Tests/PushReferenceTest.php | 18 ++++++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 26 ++++++++ tests/Gitonomy/Git/Tests/TreeTest.php | 15 +++++ 6 files changed, 144 insertions(+) diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 0fe8ec00..a3dba6e3 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -321,6 +321,15 @@ public function testGetSignedMessage(bool $bare): void $this->assertSame('signed commit'."\n", $commit->getMessage()); } + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetFixedShortHash(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Commit::getFixedShortHash()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.'); + + $this->assertSame(substr(self::LONGFILE_COMMIT, 0, 6), self::createFoobarRepository()->getCommit(self::LONGFILE_COMMIT)->getFixedShortHash()); + } + #[DataProvider('provideBare')] #[Group('legacy')] #[IgnoreDeprecations] diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 05b95bd9..4ce9a8a3 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -515,6 +515,70 @@ public function testLegacySetRepository(): void $this->assertSame($repository, $file->getRepository()); } + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyDiffSetRepository(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\Diff::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); + + $repository = self::createFoobarRepository(); + $diff = Diff::parse(":100644 100644 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 M\tREADME.md\n"); + $diff->setRepository($repository); + + $this->assertSame($repository, $diff->getFiles()[0]->getRepository()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyAddChange(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\File::addChange()" method is deprecated, give the changes to the constructor instead.'); + + $file = new File('README.md', 'README.md', '100644', '100644', null, null, false); + $change = new FileChange(1, 1, 1, 1, [new DiffLine(DiffLineType::Addition, 'added')]); + $file->addChange($change); + + $this->assertSame([$change], $file->getChanges()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyIsChangeMode(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\File::isChangeMode()" method is deprecated, use "isModeChange()" instead.'); + + $this->assertTrue(new File('run.sh', 'run.sh', '100644', '100755', null, null, false)->isChangeMode()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyFileIsDelete(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\File::isDelete()" method is deprecated, use "isDeletion()" instead.'); + + $this->assertTrue(new File('README.md', null, '100644', null, null, null, false)->isDelete()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetAnchor(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\File::getAnchor()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.'); + + $this->assertSame('222222222222', new File('README.md', 'README.md', '100644', '100644', str_repeat('1', 40), str_repeat('2', 40), false)->getAnchor()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetCount(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\FileChange::getCount()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.'); + + $change = new FileChange(1, 1, 1, 2, [new DiffLine(DiffLineType::Context, 'kept'), new DiffLine(DiffLineType::Addition, 'added')]); + + $this->assertSame(1, $change->getCount(FileChange::LINE_ADD)); + } + public function testMnemonicPrefix(): void { // With `diff.mnemonicPrefix` enabled, git replaces the default "a/" and "b/" diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 2c7e6841..ebca07c0 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -335,6 +335,18 @@ public function testWithersReturnAnotherLog(bool $bare): void $this->assertSame(\array_slice(self::hashes($log), 0, 2), self::hashes($limited)); } + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacySetOffset(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Log::setOffset()" method is deprecated, use "withOffset()" instead.'); + + $log = self::createFoobarRepository()->getLog(); + $log->setOffset(2); + + $this->assertSame(2, $log->getOffset()); + } + #[Group('legacy')] #[IgnoreDeprecations] public function testLegacySetLimit(): void diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index 0f4c6235..191bbfc8 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -67,6 +67,24 @@ public function testLegacyIsDelete(): void $this->assertTrue(new PushReference(self::createFoobarRepository(), 'foo', self::LONGFILE_COMMIT, PushReference::ZERO)->isDelete()); } + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetReference(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\PushReference::getReference()" method is deprecated, use "getFullname()" instead.'); + + $this->assertSame('refs/heads/foo', new PushReference(self::createFoobarRepository(), 'refs/heads/foo', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->getReference()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetRevision(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\PushReference::getRevision()" method is deprecated, use "getRange()" instead.'); + + $this->assertSame(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT, new PushReference(self::createFoobarRepository(), 'refs/heads/foo', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->getRevision()); + } + public function testUnknownBeforeIsAnErrorNotAForcePush(): void { $reference = new PushReference(self::createFoobarRepository(), 'foo', str_repeat('1', 40), self::LONGFILE_COMMIT); diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 29d048a4..7a907b17 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -979,6 +979,32 @@ public function testLegacySetLoggerRequiresCliDriver(): void new Repository(self::createTempDir(), driver: $this->createStub(DriverInterface::class))->setLogger($this->createStub(LoggerInterface::class)); } + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyShell(): void + { + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('The command of git shell is a shell script.'); + } + + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::shell()" method is deprecated, run "git shell" with Symfony Process instead: it ignores the driver.'); + + // git shell runs the commands of ~/git-shell-commands. + $home = self::createTempDir(); + mkdir($home.'/git-shell-commands'); + file_put_contents($home.'/git-shell-commands/record', "#!/bin/sh\n".'echo "$1" > "$HOME/recorded"'."\n"); + chmod($home.'/git-shell-commands/record', 0o700); + $repository = self::createFoobarRepository(); + + $repository->shell('record', ['HOME' => $home]); + + // shell() doesn't wait for git. + for ($i = 0; $i < 100 && !is_file($home.'/recorded'); ++$i) { + usleep(50_000); + } + $this->assertStringEqualsFile($home.'/recorded', $repository->getGitDir()."\n"); + } + #[Group('legacy')] #[IgnoreDeprecations] public function testAnUnknownOptionIsRefused(): void diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index ca21b858..e702aaff 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -20,6 +20,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; class TreeTest extends AbstractTestCase { @@ -75,6 +76,20 @@ public function testGetSubmoduleEntries(bool $bare): void $this->assertSame('160000', $submodules['barbaz']->getMode()); } + /** + * In a process of its own: once Submodule is loaded, it declares the alias, + * whose file isn't loaded anymore. + */ + #[Group('legacy')] + #[IgnoreDeprecations] + #[RunInSeparateProcess] + public function testLegacyCommitReference(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\CommitReference" class is deprecated, use "Gitonomy\Git\Submodule" instead.'); + + $this->assertTrue(class_exists(CommitReference::class)); + } + #[DataProvider('provideBare')] #[Group('legacy')] #[IgnoreDeprecations] From 15ff664caceda63e699616dfa746d3788464ea53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Thu, 15 Oct 2026 15:23:35 +0200 Subject: [PATCH 141/150] Prepare the upgrade to 2.0 1.7 has the API of 2.0 that can be added without breaking 1.x, and deprecates what 2.0 replaces or breaks, with the messages of 2.0: code without deprecations with 1.7 runs with 2.0 too, so a library can require "^1.7 || ^2.0" with a single code path. 1.7 breaks nothing of 1.6: the parameters keep their names, the arguments added to the methods of classes that aren't final are read with func_get_arg(), and gitlib keeps calling the deprecated methods a subclass overrides. It requires PHP 8.1, for the DiffLineType enum, and symfony/deprecation-contracts. --- .github/workflows/tests.yml | 6 +- CHANGELOG.md | 104 +++++ composer.json | 8 +- doc/admin.md | 70 +-- doc/blame.md | 36 +- doc/branch.md | 14 + doc/commit.md | 32 +- doc/diff.md | 70 ++- doc/log.md | 19 +- doc/references.md | 17 +- doc/repository.md | 124 ++---- doc/tree.md | 29 +- doc/workingcopy.md | 24 +- phpunit.xml.dist | 3 + src/Gitonomy/Git/Admin.php | 89 ++-- src/Gitonomy/Git/Blame.php | 59 ++- src/Gitonomy/Git/Blame/Hunk.php | 45 ++ src/Gitonomy/Git/Blame/Line.php | 23 + src/Gitonomy/Git/Blob.php | 4 + src/Gitonomy/Git/Commit.php | 48 +- src/Gitonomy/Git/CommitReference.php | 16 +- src/Gitonomy/Git/Diff/Diff.php | 61 ++- src/Gitonomy/Git/Diff/DiffLine.php | 40 ++ src/Gitonomy/Git/Diff/DiffLineType.php | 26 ++ src/Gitonomy/Git/Diff/File.php | 91 +++- src/Gitonomy/Git/Diff/FileChange.php | 98 ++++- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 238 ++++++++++ src/Gitonomy/Git/Exception/LogicException.php | 3 + .../Git/Exception/ProcessException.php | 3 + .../Exception/ReferenceNotFoundException.php | 3 + .../Exception/UnexpectedValueException.php | 3 + src/Gitonomy/Git/Hooks.php | 2 + src/Gitonomy/Git/Log.php | 98 ++++- src/Gitonomy/Git/Parser/BlameParser.php | 3 + src/Gitonomy/Git/Parser/CommitParser.php | 3 + src/Gitonomy/Git/Parser/DiffParser.php | 20 +- src/Gitonomy/Git/Parser/LogParser.php | 3 + src/Gitonomy/Git/Parser/ReferenceParser.php | 3 + src/Gitonomy/Git/Parser/TagParser.php | 3 + src/Gitonomy/Git/Parser/TreeParser.php | 3 + src/Gitonomy/Git/PushReference.php | 88 +++- src/Gitonomy/Git/Reference.php | 3 + src/Gitonomy/Git/Reference/Branch.php | 38 +- src/Gitonomy/Git/Reference/Stash.php | 2 + src/Gitonomy/Git/Reference/Tag.php | 2 + src/Gitonomy/Git/ReferenceBag.php | 96 +++- src/Gitonomy/Git/Repository.php | 411 ++++++++++++++++-- src/Gitonomy/Git/Revision.php | 3 + src/Gitonomy/Git/RevisionList.php | 2 + src/Gitonomy/Git/Submodule.php | 41 ++ src/Gitonomy/Git/Tree.php | 82 +++- src/Gitonomy/Git/TreeEntry.php | 46 ++ src/Gitonomy/Git/Util/LegacyOverride.php | 33 ++ src/Gitonomy/Git/Util/StringHelper.php | 4 + src/Gitonomy/Git/WorkingCopy.php | 55 ++- tests/Gitonomy/Git/Tests/AbstractTest.php | 27 +- tests/Gitonomy/Git/Tests/AdminTest.php | 30 ++ .../Git/Tests/BackwardCompatibilityTest.php | 259 +++++++++++ tests/Gitonomy/Git/Tests/BlameTest.php | 102 +++++ tests/Gitonomy/Git/Tests/BlobTest.php | 2 +- tests/Gitonomy/Git/Tests/CommitTest.php | 68 +++ tests/Gitonomy/Git/Tests/DiffTest.php | 296 +++++++++++-- tests/Gitonomy/Git/Tests/LogTest.php | 108 +++++ .../Gitonomy/Git/Tests/PushReferenceTest.php | 53 ++- tests/Gitonomy/Git/Tests/ReferenceTest.php | 129 ++++++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 334 +++++++++++++- tests/Gitonomy/Git/Tests/TreeTest.php | 120 ++++- tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 97 ++++- 68 files changed, 3629 insertions(+), 446 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/Gitonomy/Git/Blame/Hunk.php create mode 100644 src/Gitonomy/Git/Diff/DiffLine.php create mode 100644 src/Gitonomy/Git/Diff/DiffLineType.php create mode 100644 src/Gitonomy/Git/Driver/Cli/CliDriver.php create mode 100644 src/Gitonomy/Git/Submodule.php create mode 100644 src/Gitonomy/Git/TreeEntry.php create mode 100644 src/Gitonomy/Git/Util/LegacyOverride.php create mode 100644 tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4e611ce8..b691d7de 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,7 @@ name: Tests on: push: - branches: [ main ] + branches: [ main, 1.x ] pull_request: jobs: @@ -12,11 +12,11 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.1', '8.2', '8.3', '8.4'] + php: ['8.1', '8.2', '8.3', '8.4', '8.5'] composer-flags: [''] name: [''] include: - - php: '8.0' + - php: '8.1' composer-flags: '--prefer-lowest' name: '(prefer lowest dependencies)' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..061e75e0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,104 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [Unreleased] + +Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). + +1.7 prepares the upgrade to 2.0: it has the API of 2.0 that can be added without breaking 1.x, +and deprecates what 2.0 replaces or breaks. Code without deprecations with 1.7 runs with 2.0 +too, so a library can require `^1.7 || ^2.0` with a single code path. The exceptions, the +changes of 2.0 1.7 can't bring, are listed in the `UPGRADE-2.0.md` of 2.0. + +1.7 breaks nothing of 1.6: the parameters keep their names, the arguments added to the methods +of classes that aren't final are read with `func_get_arg()`, commented out in their signature +(`@psalm-variadic` for Psalm), and gitlib keeps calling the deprecated methods that a subclass +overrides. Deprecations are triggered through `symfony/deprecation-contracts`, a new +dependency. + +1.7 requires PHP 8.1, for the `DiffLineType` enum: on PHP 8.0, Composer keeps 1.6. + +### Features + +- `Gitonomy\Git\Driver\Cli\CliDriver` configures git: the binary, the environment variables, + the timeout and the logger. It takes the arguments of the `CliDriver` of 2.0. +- `new Repository($dir, $workingDir, $driver)`: the second argument may be the working + directory, the third one is a `CliDriver`. +- `Repository::init()`, `initBare()`, `cloneFrom()`, `cloneBareFrom()`, `mirrorFrom()`, + `isValidRepository()` and `isValidRepositoryAndBranch()` take the URL before the path and a + `CliDriver` as last argument. Like in 2.0, `isValidRepositoryAndBranch()` checks the exact + name of the branch, and both read a relative path as a path, never as the name of a remote + of the working directory of PHP. +- `Repository::getDiff($from, $to)`, with two arguments, compares two revisions, `Repository::getMergeBase()` + returns their best common ancestor, `WorkingCopy::getDiff()` compares the working tree with + a revision. Like in 2.0, they throw a `ReferenceNotFoundException` for an unknown revision, + as `WorkingCopy::checkoutNewBranch()`. +- `Diff::parse()`, `Diff::fromArray()` and `File::fromArray()` take the repository to read the + blobs from, the constructor of `Diff\File` the changes and the repository. + `File::isModeChange()`, `File::isCopy()`, `FileChange::getAdditions()` and + `FileChange::getDeletions()`. +- `FileChange::getDiffLines()` returns the lines of a hunk as `Diff\DiffLine` objects, with + `getType()`, a `Diff\DiffLineType`, and `getContent()`. The constructor of `FileChange` + takes `DiffLine` objects. +- `Repository::getBlame()` and `Blame` take the first and the last lines to blame, as + integers. + `Blame::getHunks()` returns `Blame\Hunk` objects, numbered like the file; + `Blame\Line::getNumber()` and `getOriginalNumber()`. +- `Log::withOffset()` and `withLimit()` return another log. +- `Branch::isMergedInto()` and `Branch::moveTo()`. `ReferenceBag::createBranch()` and + `createTag()` take a `Revision` too. +- `PushReference::getFullname()`, `getRange()`, `isCreation()` and `isDeletion()`. +- Iterating a `Tree` gives its entries, `TreeEntry` objects with `getName()`, `getMode()` and + `getObject()`, by name; `Tree::getBlobs()`, `getTrees()` and `getSubmodules()` return those + of one type. +- `Submodule`, with `getHash()`. The submodules of trees are still `CommitReference` + objects, which extend `Submodule`. +- `WorkingCopy::checkoutNewBranch()`. `WorkingCopy::checkout()` takes any `Revision`. +- `Repository::getSizeInBytes()`, which doesn't follow links. + +### Deprecations + +- An array of options as second argument of `Repository`, the `debug` option set to `false`, + `Repository::setLogger()` and `getLogger()`: use the `workingDir` and `driver` arguments. +- `Admin` and `Repository::cloneTo()`: use the static methods of `Repository`. +- `Repository::getDiff()` with one argument and `Log::getDiff()`: use + `Repository::getDiff($from, $to)` or `WorkingCopy::getDiff()`. +- `Diff::setRepository()`, `File::setRepository()`, `File::addChange()`, + `File::isChangeMode()`, `File::isDelete()`, `File::getAnchor()` and `FileChange::getCount()`. +- `FileChange::getLines()`, which returns the lines as arrays, the `FileChange::LINE_*` + constants, and passing the lines to the constructor of `FileChange` as arrays: use + `getDiffLines()`, `DiffLineType` and `DiffLine` objects. +- A range of lines as a string in `Repository::getBlame()` and `Blame`, + `Blame::getGroupedLines()` and `Blame\Line::getLine()`. +- `Log::setOffset()`, `setLimit()`, a negative offset or limit, an option of `git log` but + `--all` as revision, and `count()` of a log whose offset or limit leaves commits out: it + counts them all, where 2.0 counts the commits the log iterates. Use `countCommits()`. +- `ReferenceBag::update()`, `Branch::isMergedTo()`, arguments of + `Commit::getIncludingBranches()`, creating a branch or a tag that exists already, deleting + a reference by another name than its full name under `refs/`. +- Passing an argument to `Repository::getReferences()`, to read the references again: open + a new `Repository`. +- `PushReference::getReference()`, `getRevision()`, `isCreate()` and `isDelete()`. +- `CommitReference`. `Tree::getEntries()`, `getBlobEntries()`, `getTreeEntries()` and + `getCommitReferenceEntries()`, which return the entries as arrays: iterate the tree, or use + `getBlobs()`, `getTrees()` and `getSubmodules()`. +- The second argument of `WorkingCopy::checkout()`: use `checkoutNewBranch()`. A local + `Branch` object, which `checkout()` detaches HEAD on, where 2.0 attaches HEAD to the branch: + pass its name, or its commit. +- Passing a revision name to `Repository::getTree()` or `getBlob()` instead of a hash, and + passing data to the constructor of `Commit`, internal in 2.0. +- `Repository::getSize()`, `Repository::shell()`, `Commit::getShortMessage()`, + `Commit::getFixedShortHash()` and `Util\StringHelper`. +- The parsers that 2.0 moves to its `CliDriver`, all but `DiffParser` and `ParserBase`. +- The classes `final` in 2.0 are `@final since gitonomy/gitlib 1.7`. The constructors + internal in 2.0 (`Commit`, `Tree`, `Blob`, `Revision`, `Reference`, `ReferenceBag`) say so. + +## [v1.6.0] - 2025-12-09 + +_Earlier versions are not documented here. See the +[GitHub releases](https://github.com/gitonomy/gitlib/releases) and +[commit history](https://github.com/gitonomy/gitlib/commits/v1.6.0) for prior changes._ + +[Unreleased]: https://github.com/gitonomy/gitlib/compare/v1.6.0...1.x +[v1.6.0]: https://github.com/gitonomy/gitlib/releases/tag/v1.6.0 diff --git a/composer.json b/composer.json index 6979e26f..4b9a2fca 100644 --- a/composer.json +++ b/composer.json @@ -35,16 +35,18 @@ } }, "require": { - "php": "^8.0", + "php": "^8.1", "ext-pcre": "*", + "symfony/deprecation-contracts": "^2.5 || ^3", "symfony/polyfill-mbstring": "^1.7", "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "ext-fileinfo": "*", - "phpspec/prophecy-phpunit": "^2.0", + "phpspec/prophecy-phpunit": "^2.1", "phpunit/phpunit": "^7.5.20 || ^8.5.20 || ^9.5.9", - "psr/log": "^1.0" + "psr/log": "^1.0", + "symfony/phpunit-bridge": "^6.4 || ^7.0" }, "config": { "preferred-install": "dist", diff --git a/doc/admin.md b/doc/admin.md index d4776804..bd774e5a 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -1,24 +1,25 @@ Create and access git repositories ================================== -gitlib provides methods to initialize new repositories. +gitlib provides static methods of `Repository` to initialize and clone +repositories. Each of them takes a `CliDriver` as last argument, to +configure git (see [Repository](repository.md)). + +`Admin` and `Repository::cloneTo()`, which did it before 1.7, are +deprecated. Create a repository ------------------- -To initialize a new repository, use method `Admin::init`. - ```php -// Initialize a bare repository -$repository = Gitonomy\Git\Admin::init('/path/to/repository'); +use Gitonomy\Git\Repository; -// Initialize a non-bare repository -$repository = Gitonomy\Git\Admin::init('/path/to/repository', false); -``` +// Initialize a repository with a working directory +$repository = Repository::init('/path/to/repository'); -Default behavior is to create a bare repository. If you want to -initialize a repository with a working copy,pass `false` as third -argument of Repository constructor. +// Initialize a bare repository +$repository = Repository::initBare('/path/to/repository'); +``` Cloning repositories -------------------- @@ -26,49 +27,48 @@ Cloning repositories You can clone a repository from an URL by doing: ```php -// Clone to a bare repository -$repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git'); +// Clone to a repository with a working directory +$repository = Repository::cloneFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib'); -// Clone to a non-bare repository -$repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', false); +// Clone to a bare repository +$repository = Repository::cloneBareFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib'); ``` -Default behavior is to clone in a bare repository. - You can also clone a repository and point it to a specific branch. In a -non-bare repository, this branch will be checked out: +repository with a working directory, this branch is checked out: ```php -// Clone to a bare repository -$repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch'); +$repository = Repository::cloneFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib', 'a-branch'); -// Clone to a non-bare repository -$repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch', false); +$repository = Repository::cloneBareFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib', 'a-branch'); ``` -Clone a Repository object -------------------------- - -If you already have a Repository instance and want to clone it, you can -use this shortcut: +To clone a `Repository` object, clone its git directory: ```php -$new = $repository->cloneTo('/tmp/clone'); +$new = Repository::cloneBareFrom($repository->getGitDir(), '/tmp/clone'); ``` Mirror a repository ------------------- -If you want to mirror fully a repository and all references, use the -`mirrorTo` method. This method takes only two arguments, where to mirror -and what to mirror: +To mirror a repository and all its references into a bare repository, +use the `mirrorFrom` method: + +```php +$mirror = Repository::mirrorFrom('https://github.com/gitonomy/gitlib.git', '/tmp/mirror'); +``` + +Check a remote repository +------------------------- + +To check that git can read a repository, without cloning it: ```php -// Mirror to a bare repository -$mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git'); +Repository::isValidRepository('https://github.com/gitonomy/gitlib.git'); -// Mirror to a non-bare repository -$mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git', false); +// And that it has the branch "main", with this exact name +Repository::isValidRepositoryAndBranch('https://github.com/gitonomy/gitlib.git', 'main'); ``` ### References diff --git a/doc/blame.md b/doc/blame.md index 80816c9b..bba94cd8 100644 --- a/doc/blame.md +++ b/doc/blame.md @@ -26,6 +26,17 @@ If you want to access directly a line: $line = $blame->getLine(32); ``` +To blame some lines only, pass the first and the last ones: + +```php +$blame = $repository->getBlame('master', 'README.md', 10, 20); +``` + +`getLines()` and `getLine()` number these lines from 1, where gitlib 2.0 +numbers them like the file: use `Line::getNumber()`. A range of lines as +a string (`'10,20'`, `'10,+11'`, a regular expression), passed to +`git blame -L`, is deprecated. + The Line object --------------- @@ -33,8 +44,10 @@ LineObject represents an item of the blame file. It is composed of those informations: ```php -$line->getCommit(); // returns a Commit -$line->getContent(); // returns text +$line->getCommit(); // returns a Commit +$line->getContent(); // returns text +$line->getNumber(); // the number of the line in the file +$line->getOriginalNumber(); // its number in the file of the commit // you can access author from commmit: $author = $line->getCommit()->getAuthorName(); @@ -46,13 +59,18 @@ Group reading by commit If you plan to display it, you'll probably need a version where lines from same commit are grouped. -To do so, use the *getGroupedLines* method that will return an array -like this: +To do so, use the *getHunks* method, which returns `Blame\Hunk` objects, +the consecutive lines last changed by the same commit, by number in the +file: ```php -$blame = array( - array(Commit, array(1 => Line, 2 => Line, 3 => Line)), - array(Commit, array(4 => Line)), - array(Commit, array(5 => Line, 6 => Line)) -) +foreach ($blame->getHunks() as $hunk) { + echo $hunk->getCommit()->getHash().PHP_EOL; + + foreach ($hunk->getLines() as $number => $line) { + echo $number.': '.$line->getContent().PHP_EOL; + } +} ``` + +`getGroupedLines()` and `Line::getLine()` are deprecated. diff --git a/doc/branch.md b/doc/branch.md index d6e0872c..188fc3a8 100644 --- a/doc/branch.md +++ b/doc/branch.md @@ -14,3 +14,17 @@ You can check is the branch is a local or remote one: $branch->isLocal(); $branch->isRemote(); ``` + +To check that the history of a revision contains the branch: + +```php +$branch->isMergedInto('master'); +``` + +`isMergedTo()` is deprecated. + +To point the branch to another revision: + +```php +$branch = $branch->moveTo('a8b7e4...'); +``` diff --git a/doc/commit.md b/doc/commit.md index 08e9b1e3..08d546ac 100644 --- a/doc/commit.md +++ b/doc/commit.md @@ -31,7 +31,7 @@ For example, if you want to display all parents, starting from a commit: ```php function displayLog(Gitonomy\Git\Commit $commit) { - echo '- '.$commit->getShortMessage().PHP_EOL; + echo '- '.$commit->getSubjectMessage().PHP_EOL; foreach ($commit->getParents() as $parent) { displayLog($parent); } @@ -94,24 +94,20 @@ To access the message, you can use the *getMessage* method: $commit->getMessage(); ``` -For your convenience, this library provides a shortcut method to keep -only the first line or first 50 characters if the first line is too -long: +To keep only the first line, or its first 50 characters if it is too +long, use [symfony/string](https://symfony.com/doc/current/string.html): ```php -$commit->getShortMessage(); -``` +use function Symfony\Component\String\u; -You can customize it like this: +u($commit->getSubjectMessage())->truncate(50, '...'); -```php -$commit->getShortMessage(45, true, '.'); +// Without cutting the last word +u($commit->getSubjectMessage())->truncate(45, '.', false); ``` -- The first parameter is the max length of the message. -- The second parameter determine if the last word should be cut or - preserved -- The third parameter is the separator +`getShortMessage()`, which did it, is deprecated, as `getFixedShortHash()`: +use `substr($commit->getHash(), 0, 6)`. There are also two other methods for your convenience: @@ -165,7 +161,11 @@ Find every branches containing a commit --------------------------------------- ```php -$branches = $commit->getIncludingBranches($includeLocalBranches, $includeRemoteBranches); -$localBranches = $commit->getIncludingBranches(true, false); -$remoteBranches = $commit->getIncludingBranches(false, true); +use Gitonomy\Git\Reference\Branch; + +$branches = $commit->getIncludingBranches(); +$localBranches = array_filter($branches, static fn (Branch $branch): bool => $branch->isLocal()); +$remoteBranches = array_filter($branches, static fn (Branch $branch): bool => $branch->isRemote()); ``` + +Passing arguments to `getIncludingBranches()` is deprecated. diff --git a/doc/diff.md b/doc/diff.md index 2ce7870a..a243d5df 100644 --- a/doc/diff.md +++ b/doc/diff.md @@ -4,25 +4,41 @@ Computing diff Even if git is a diff-less storage engine, it's possible to compute them. -To compute a diff in git, you need to specify a *revision*. This -revision can be a commit (*2bc7a8*) or a range (*2bc7a8..ff4c21b*). +To compute a diff, pass the two revisions to compare to the *getDiff* +method of the repository: + +```php +$diff = $repository->getDiff('master@{2 days ago}', 'master'); +``` For more informations about git revisions: *man gitrevisions*. -When you have decided the revision you want and have your *Repository* -object, you can call the *getDiff* method on the repository: +To compare with the best common ancestor, like `git diff a...b`: + +```php +$diff = $repository->getDiff($repository->getMergeBase('a', 'b'), 'b'); +``` + +To compare the working tree, staged or not, with a revision: ```php -$diff = $repository->getDiff('master@{2 days ago}..master'); +$diff = $repository->getWorkingCopy()->getDiff('master'); ``` -You can also access it from a *Log* object: +Calling `getDiff()` with one argument, a range or a list of revisions, is +deprecated, as `Log::getDiff()`. + +To parse the output of `git diff --raw --patch` yourself, give the +repository to read the blobs from: ```php -$log = $repository->getLog('master@{2 days ago}..master'); -$diff = $log->getDiff(); +use Gitonomy\Git\Diff\Diff; + +$diff = Diff::parse($output, $repository); ``` +`Diff::setRepository()` and `File::setRepository()` are deprecated. + Iterating a diff ---------------- @@ -56,7 +72,7 @@ $file->isDeletion(); $file->isModification(); $file->isRename(); -$file->isChangeMode(); +$file->isModeChange(); $file->getAdditions(); // Number of added lines $file->getDeletions(); // Number of deleted lines @@ -78,27 +94,35 @@ The FileChange object A *File* object is composed of many changes. For each of those changes, a *FileChange* object is associated. -To access changes from a file, use the *getChanges* method: +To access changes from a file, use the *getChanges* method. The +*getDiffLines* method of a change returns its lines, `DiffLine` objects: ```php -$changes = $file->getChanges(); -foreach ($changes as $change) { - foreach ($lines as $data) { - list ($type, $line) = $data; - if ($type === FileChange::LINE_CONTEXT) { - echo ' '.$line.PHP_EOL; - } elseif ($type === FileChange::LINE_ADD) { - echo '+'.$line.PHP_EOL; - } else { - echo '-'.$line.PHP_EOL; - } +use Gitonomy\Git\Diff\DiffLineType; + +foreach ($file->getChanges() as $change) { + foreach ($change->getDiffLines() as $line) { + echo match ($line->getType()) { + DiffLineType::Addition => '+', + DiffLineType::Context => ' ', + DiffLineType::Deletion => '-', + }.$line->getContent().PHP_EOL; } } ``` +`DiffLine::getContent()` returns the line, without its prefix (`+`, `-` +or a space) nor its line break. + +`FileChange::getLines()`, which returns the lines as arrays +(`[$type, $content]`), the `FileChange::LINE_ADD`, `LINE_CONTEXT` and +`LINE_REMOVE` constants, and passing the lines to the constructor of +`FileChange` as arrays are deprecated: use `getDiffLines()`, `DiffLineType` +and `DiffLine` objects. + To get line numbers, use the range methods: ```php -echo sprintf('Previously from line %s to %s%s', $change->getOldRangeStart(), $change->getOldRangeEnd(), PHP_EOL); -echo sprintf('Now from line %s to %s%s', $change->getNewRangeStart(), $change->getNewRangeEnd(), PHP_EOL); +echo sprintf('Previously %s lines from line %s%s', $change->getRangeOldCount(), $change->getRangeOldStart(), PHP_EOL); +echo sprintf('Now %s lines from line %s%s', $change->getRangeNewCount(), $change->getRangeNewStart(), PHP_EOL); ``` diff --git a/doc/log.md b/doc/log.md index 4dc5a465..6ed4d67b 100644 --- a/doc/log.md +++ b/doc/log.md @@ -35,21 +35,24 @@ If you want to count overall commits, without offset or limit, use the ```php echo sprintf('This log contains %s commits%s', $log->countCommits(), PHP_EOL); - -// Countable interface -echo sprintf('This log contains %s commits%s', count($log), PHP_EOL); ``` +`count($log)` counts them too, which is deprecated when the offset or +the limit of the log leaves commits out: gitlib 2.0 counts the commits +the log iterates. + Offset and limit ---------------- -Use those methods: +Use those methods, which return another log: ```php -$log->setOffset(32); -$log->setLimit(40); +$page = $log->withOffset(32)->withLimit(40); // or read it: -$log->getOffset(); -$log->getLimit(); +$page->getOffset(); +$page->getLimit(); ``` + +`setOffset()` and `setLimit()`, which changed the log, are deprecated, as +a negative offset or limit. diff --git a/doc/references.md b/doc/references.md index 64430929..6a9a63b2 100644 --- a/doc/references.md +++ b/doc/references.md @@ -11,6 +11,11 @@ this object from a *Repository*, use the *getReferences* method: $references = $repository->getReferences(); ``` +The bag reads the references once. To see the writes of +`Repository::run()` or of other processes, open a new `Repository`: +passing an argument to `getReferences()`, to read them again, is +deprecated. + First, you can test existence of tags and branches like this: ```php @@ -64,16 +69,24 @@ on ReferenceBag object: ```php // create a branch $references = $repository->getReferences(); -$branch = $references->createBranch('foobar', 'a8b7e4...'); // commit to reference +$branch = $references->createBranch('foobar', 'a8b7e4...'); // commit to reference, or a Revision // create a tag $references = $repository->getReferences(); -$tag = $references->createTag('0.3', 'a8b7e4...'); // commit to reference +$tag = $references->createTag('0.3', 'a8b7e4...'); // commit to reference, or a Revision + +// move a branch, to a revision or its name +$branch = $branch->moveTo('master'); // delete a branch or a tag $branch->delete(); ``` +Creating a branch or a tag that exists already, which moves it, is +deprecated, as `ReferenceBag::update()`: use `Branch::moveTo()`. Deleting +a reference by another name than its full name, like `HEAD` or `master`, +is deprecated. + Resolution from a commit ------------------------ diff --git a/doc/repository.md b/doc/repository.md index 05b4a4e7..626a8af2 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -8,32 +8,47 @@ to the constructor: $repository = new Repository('/path/to/repo'); ``` -Repository options ------------------- +When the git directory is kept apart from the working directory, pass the +working directory as second argument: -The constructor of Repository takes an additional parameter: `$options`. -This parameter can be used used to tune behavior of library. +```php +$repository = new Repository('/path/to/repo.git', '/path/to/working-dir'); +``` -Available options are: +Configure git +------------- -- **debug** (default: true): Enables exception when edge cases are met -- **environment\_variables**: (default: none) An array of environment - variables to be set in sub-process -- **logger**: (default: none) Logger to use for reporting of execution - (a `Psr\Log\LoggerInterface`) -- **command**: (default: `git`) Specify command to execute to run git -- **working\_dir**: If you are using multiple working directories, - this option is for you +The last argument of the constructor is a `Gitonomy\Git\Driver\Cli\CliDriver`, +which configures how gitlib runs git: -An example: +- **command** (default: `git`): the git binary; +- **environmentVariables** (default: none): environment variables set on + every git process; +- **processTimeout** (default: 3600): in seconds, `null` for none; +- **logger** (default: none): a `Psr\Log\LoggerInterface` receiving every + git command run. ```php -$repository = new Repository('/path/to/repo', [ - 'debug' => true, - 'logger' => new Monolog\Logger(), -]); +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repo', driver: new CliDriver( + command: '/home/alice/bin/git', + environmentVariables: ['GIT_SSH_COMMAND' => 'ssh -i /path/to/key'], + processTimeout: 60, + logger: new Monolog\Logger('repository'), +)); ``` +`CliDriver` has the arguments of the one of gitlib 2.0, where it runs git +itself: code using it works with both versions. + +An array of options as second argument is deprecated: its `working_dir` +option is the second argument, and its `logger`, `command`, +`environment_variables` and `process_timeout` options configure a +`CliDriver`. Setting its `debug` option to `false` is deprecated: gitlib +2.0 always throws when git fails. + Test if a repository is bare ---------------------------- @@ -47,21 +62,18 @@ $repository->isBare(); Compute size of a repository ---------------------------- -To know how much size a repository is using on your drive, you can use -`getSize` method on a *Repository* object. - -> **warning** -> -> This command was only tested with linux. - -The returned size is in kilobytes: +To know how much size the git directory of a repository is using on your +drive, you can use `getSizeInBytes` method on a *Repository* object. It +doesn't follow the links. ```php -$size = $repository->getSize(); +$size = $repository->getSizeInBytes(); -echo 'Your repository size is '.$size.'KB'; +echo 'Your repository size is '.$size.' bytes'; ``` +`getSize()`, in kilobytes, is deprecated. + Access HEAD ----------- @@ -78,27 +90,14 @@ if ($repository->isHeadDetached()) { } ``` -Options for repository ----------------------- - -### Logger +Logger +------ If you are developing, you may appreciate to have a logger inside -repository, telling you every executed command. - -You call method `setLogger` as an option on repository creation: - -```php -$repository->setLogger(new Monolog\Logger('repository')); - -$repository->run('fetch', ['--all']); -``` - -You can also specify as an option on repository creation: +repository, telling you every executed command: ```php -$logger = new MonologLogger('repository'); -$repository = new Repository('/path/foo', ['logger' => $logger]); +$repository = new Repository('/path/foo', driver: new CliDriver(logger: new Monolog\Logger('repository'))); $repository->run('fetch', ['--all']); ``` @@ -111,37 +110,4 @@ debug last command (fetch) return code: 0 debug last command (fetch) output: Fetching origin ``` -### Disable debug-mode - -Gitlib throws an exception when something seems wrong. If a `git` command exits -with a non-zero code, then execution will be stopped, and a `RuntimeException` -will be thrown. If you want to prevent this, set the `debug` option to` false`. -This will make `Repository` log errors and return empty data instead of -throwing exceptions. - -```php -$repository = new Repository('/tmp/foo', ['debug' => false, 'logger' => $logger]); -``` - -> **note** -> -> If you plan to disable debug, you should rely on the logger to keep a trace -> of the failing cases. - -### Specify git command to use - -You can pass the option `command` to specify which command to use to run git -calls. If you have a git binary located somewhere else, use this option to -specify to gitlib path to your git binary: - -```php -$repository = new Gitonomy\Git\Repository('/tmp/foo', ['command' => '/home/alice/bin/git']); -``` - -### Environment variables - -It is possible to send environment variables to the `git` commands. - -```php -$repository = new Gitonomy\Git\Repository('/tmp/foo', ['environment_variables' => ['GIT_']]) -``` +`setLogger()` and `getLogger()` are deprecated. diff --git a/doc/tree.md b/doc/tree.md index 0c5c8ef7..d26bce65 100644 --- a/doc/tree.md +++ b/doc/tree.md @@ -13,23 +13,21 @@ $tree = $commit->getTree(); This tree is the entry point of all of your files. -The main method for a tree is the *getEntries* method. This method will -return an array, indexed by name. Each of those elements will be the -entry mode and the entry object. +Iterating a tree gives its entries, `TreeEntry` objects, by name. An +entry has a name, a mode and an object: a `Blob`, a `Tree` or a +`Submodule`. Let's understand how it works with a concrete example: ```php function displayTree(Tree $tree, $indent = 0) { - $indent = str_repeat(' ', $indent); - foreach ($tree->getEntries() as $name => $data) { - list($mode, $entry) = $data; - if ($entry instanceof Tree) { - echo $indent.$name.'/'.PHP_EOL; - displayTree($tree, $indent + 1); + foreach ($tree as $name => $entry) { + if ($entry->getObject() instanceof Tree) { + echo str_repeat(' ', $indent).$name.'/'.PHP_EOL; + displayTree($entry->getObject(), $indent + 1); } else { - echo $indent.$name.PHP_EOL; + echo str_repeat(' ', $indent).$name.PHP_EOL; } } } @@ -39,6 +37,17 @@ displayTree($commit->getTree()); This method will recursively display all entries of a tree. +`getBlobs()`, `getTrees()` and `getSubmodules()` return the entries of +one type, by name. A submodule is a `Submodule`, with the hash of the +commit it points to: check it with `instanceof Submodule`. The +`CommitReference` class of its objects, which extends `Submodule`, is +deprecated. + +`getEntries()`, `getBlobEntries()`, `getTreeEntries()` and +`getCommitReferenceEntries()`, which return the entries as arrays +(`[$mode, $object]`), are deprecated: iterate the tree, or use +`getBlobs()`, `getTrees()` and `getSubmodules()`. + Resolve a path -------------- diff --git a/doc/workingcopy.md b/doc/workingcopy.md index 4ecea6c1..8ae81272 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -13,18 +13,21 @@ $wc = $repo->getWorkingCopy(); Checkout a revision ------------------- -You can checkout any revision using *checkout* method. You can also pass -a second argument, which will be passed as argument with `-b`: +You can checkout any revision using *checkout* method, and create a +branch with *checkoutNewBranch*: ```php // git checkout master $wc->checkout('master'); -// git checkout origin/master -b master -$wc->checkout('origin/master', 'master'); +// git checkout -b master origin/master +$wc->checkoutNewBranch('master', 'origin/master'); ``` -You can also pass a *Reference* or a *Commit*. +You can also pass a *Revision*, like a *Reference* or a *Commit*. Passing +the branch to create as second argument of `checkout()` is deprecated, as +a local *Branch* object, which detaches HEAD: pass its name to attach HEAD +to it, like gitlib 2.0 does with the object. Staged modifications -------------------- @@ -45,3 +48,14 @@ You can get pending modifications on tracked files by calling method ```php $diff = $wc->getDiffPending(); ``` + +All modifications +----------------- + +You can get the modifications of the working tree, staged or not, since +a revision, `HEAD` by default: + +```php +$diff = $wc->getDiff(); +$diff = $wc->getDiff('master'); +``` diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 77fe6238..afa6bbc8 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,6 +21,9 @@ ./tests/Gitonomy/Git/Tests + + + ./src/Gitonomy/Git diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 7b32794c..9d561284 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -12,13 +12,18 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\RuntimeException; -use Symfony\Component\Process\Process; +use Gitonomy\Git\Util\LegacyOverride; /** * Administration class for Git repositories. * * @author Alexandre Salomé + * + * @deprecated since gitonomy/gitlib 1.7, use the static methods of Repository instead + * + * @final since gitonomy/gitlib 1.7 */ class Admin { @@ -32,15 +37,18 @@ class Admin * @throws RuntimeException Directory exists or not writable (only if debug=true) * * @return Repository + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::initBare() or Repository::init() instead */ public static function init($path, $bare = true, array $options = []) { - $process = static::getProcess('init', array_merge(['-q'], $bare ? ['--bare'] : [], [$path]), $options); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" instead.', __METHOD__, Repository::class, $bare ? 'initBare' : 'init'); - $process->run(); - - if (!$process->isSuccessFul()) { - throw new RuntimeException(sprintf("Error on repository initialization, command wasn't successful (%s). Error output:\n%s", $process->getCommandLine(), $process->getErrorOutput())); + $driver = CliDriver::fromOptions($options); + if ($bare) { + $driver->initBare($path); + } else { + $driver->init($path); } return new Repository($path, $options); @@ -57,10 +65,15 @@ public static function init($path, $bare = true, array $options = []) * @param array $options options for Repository creation * * @return bool true if url is valid + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepository() instead */ public static function isValidRepository($url, array $options = []) { - $process = static::getProcess('ls-remote', [$url], $options); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class); + + // Unlike Repository::isValidRepository(), in the working directory of PHP, where a relative path may name a remote. + $process = CliDriver::fromOptions($options)->createProcess('ls-remote', [$url]); $process->run(); @@ -80,10 +93,16 @@ public static function isValidRepository($url, array $options = []) * @param array $options options for Repository creation * * @return bool true if url is valid and branch exists + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepositoryAndBranch() instead */ public static function isValidRepositoryAndBranch($url, $branchName, array $options = []) { - $process = static::getProcess('ls-remote', ['--heads', $url, $branchName], $options); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class); + + // Unlike Repository::isValidRepositoryAndBranch(), "release" matches "feature/release", and "rel" "release", + // in the working directory of PHP. + $process = CliDriver::fromOptions($options)->createProcess('ls-remote', ['--heads', $url, $branchName]); $process->run(); $processOutput = $process->getOutput(); @@ -100,12 +119,16 @@ public static function isValidRepositoryAndBranch($url, $branchName, array $opti * @param array $options options for Repository creation * * @return Repository + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() instead */ public static function cloneTo($path, $url, $bare = true, array $options = []) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + $args = $bare ? ['--bare'] : []; - return static::cloneRepository($path, $url, $args, $options); + return self::cloneWith($path, $url, $args, $options); } /** @@ -118,15 +141,19 @@ public static function cloneTo($path, $url, $bare = true, array $options = []) * @param array $options options for Repository creation * * @return Repository + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() with their "branch" argument instead */ public static function cloneBranchTo($path, $url, $branch, $bare = true, $options = []) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + $args = ['--branch', $branch]; if ($bare) { $args[] = '--bare'; } - return static::cloneRepository($path, $url, $args, $options); + return self::cloneWith($path, $url, $args, $options); } /** @@ -137,10 +164,14 @@ public static function cloneBranchTo($path, $url, $branch, $bare = true, $option * @param array $options options for Repository creation * * @return Repository + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::mirrorFrom() instead */ public static function mirrorTo($path, $url, array $options = []) { - return static::cloneRepository($path, $url, ['--mirror'], $options); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class); + + return self::cloneWith($path, $url, ['--mirror'], $options); } /** @@ -152,37 +183,27 @@ public static function mirrorTo($path, $url, array $options = []) * @param array $options options for Repository creation * * @return Repository + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneFrom(), Repository::cloneBareFrom() or Repository::mirrorFrom() instead */ public static function cloneRepository($path, $url, array $args = [], array $options = []) { - $process = static::getProcess('clone', array_merge(['-q'], $args, [$url, $path]), $options); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class); - $process->run(); - - if (!$process->isSuccessFul()) { - throw new RuntimeException(sprintf('Error while initializing repository: %s', $process->getErrorOutput())); - } + CliDriver::fromOptions($options)->cloneRepository($url, $path, $args); return new Repository($path, $options); } - /** - * This internal method is used to create a process object. - */ - private static function getProcess($command, array $args = [], array $options = []) + private static function cloneWith($path, $url, array $args, array $options) { - $is_windows = defined('PHP_WINDOWS_VERSION_BUILD'); - $options = array_merge([ - 'environment_variables' => $is_windows ? ['PATH' => getenv('PATH')] : [], - 'command' => 'git', - 'process_timeout' => 3600, - ], $options); - - $process = new Process(array_merge([$options['command'], $command], $args)); - $process->setEnv($options['environment_variables']); - $process->setTimeout($options['process_timeout']); - $process->setIdleTimeout($options['process_timeout']); - - return $process; + // Like in 1.6, a subclass overriding cloneRepository() clones the repository. + if (LegacyOverride::isOverridden(static::class, self::class, 'cloneRepository')) { + return static::cloneRepository($path, $url, $args, $options); + } + + CliDriver::fromOptions($options)->cloneRepository($url, $path, $args); + + return new Repository($path, $options); } } diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index dec0773b..e9d3e407 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -12,12 +12,15 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Blame\Hunk; use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Parser\BlameParser; /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Blame implements \Countable { @@ -47,14 +50,33 @@ class Blame implements \Countable protected $lines; /** - * @param string $lineRange Argument to pass to git blame (-L). - * Can be a line range (40,60 or 40,+21) - * or a regexp ('/^function$/') + * The fourth argument is the first line to blame, and a fifth one the last + * line, like in 2.0: like "git blame -L 5,3", a last line before the first + * one blames the lines 3 to 5. + * + * A string as fourth argument, a range of lines passed to "git blame -L", + * like "40,60", "40,+21" or a regexp ("/^function$/"), is deprecated. + * + * @param int|string|null $lineRange The first line to blame, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null + * + * @throws InvalidArgumentException An invalid range of lines */ - public function __construct(Repository $repository, Revision $revision, $file, $lineRange = null) + public function __construct(Repository $repository, Revision $revision, $file, $lineRange = null, ?int $lastLine = null) { + if (is_string($lineRange)) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a range of lines as a string to "%s::getBlame()" or "%s()" is deprecated, pass the first and the last lines instead.', Repository::class, __METHOD__); + } elseif (null !== $lastLine) { + if ((null !== $lineRange && $lineRange < 1) || $lastLine < 1) { + throw new InvalidArgumentException(sprintf('Invalid range of lines "%s,%s": the lines are numbered from 1.', $lineRange, $lastLine)); + } + + $lineRange = ($lineRange ?? 1).','.$lastLine; + } + $this->repository = $repository; $this->revision = $revision; + // A first line alone, "-L 5" like in 1.6, blames from this line to the end of the file. $this->lineRange = $lineRange; $this->file = $file; } @@ -77,13 +99,42 @@ public function getLine($number) return $lines[$number]; } + /** + * @return list The consecutive lines last changed by the same commit + */ + public function getHunks(): array + { + $hunks = []; + $commit = null; + $lines = []; + foreach ($this->getLines() as $line) { + if ($commit !== $line->getCommit() && [] !== $lines) { + $hunks[] = new Hunk($commit, $lines); + $lines = []; + } + $commit = $line->getCommit(); + // By number in the file, like in gitlib 2.0, where getLines() numbers them so too. + $lines[$line->getNumber()] = $line; + } + + if (null !== $commit) { + $hunks[] = new Hunk($commit, $lines); + } + + return $hunks; + } + /** * Returns lines grouped by commit. * * @return array a list of two-elements array (commit, lines) + * + * @deprecated since gitonomy/gitlib 1.7, use getHunks() instead */ public function getGroupedLines() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getHunks()" instead.', __METHOD__); + $result = []; $commit = null; $current = []; diff --git a/src/Gitonomy/Git/Blame/Hunk.php b/src/Gitonomy/Git/Blame/Hunk.php new file mode 100644 index 00000000..7919cf25 --- /dev/null +++ b/src/Gitonomy/Git/Blame/Hunk.php @@ -0,0 +1,45 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Blame; + +use Gitonomy\Git\Commit; + +/** + * Consecutive lines of a blame, last changed by the same commit. + * + * @author Grégoire Pineau + */ +final class Hunk +{ + /** + * @param array $lines By number + */ + public function __construct( + private Commit $commit, + private array $lines, + ) { + } + + public function getCommit(): Commit + { + return $this->commit; + } + + /** + * @return array By number + */ + public function getLines(): array + { + return $this->lines; + } +} diff --git a/src/Gitonomy/Git/Blame/Line.php b/src/Gitonomy/Git/Blame/Line.php index 83a7a912..1b10a2f3 100644 --- a/src/Gitonomy/Git/Blame/Line.php +++ b/src/Gitonomy/Git/Blame/Line.php @@ -16,6 +16,8 @@ /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Line { @@ -45,8 +47,29 @@ public function getContent() return $this->content; } + /** + * @return int The number of the line in the blamed file + */ + public function getNumber(): int + { + return (int) $this->targetLine; + } + + /** + * @return int The number of the line in the file of the commit that last changed it + */ + public function getOriginalNumber(): int + { + return (int) $this->sourceLine; + } + + /** + * @deprecated since gitonomy/gitlib 1.7, use getOriginalNumber() instead + */ public function getLine() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getOriginalNumber()" instead.', __METHOD__); + return $this->sourceLine; } diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index dfe885fe..990773ff 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -16,6 +16,8 @@ * Representation of a Blob commit. * * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Blob { @@ -52,6 +54,8 @@ class Blob /** * @param Repository $repository Repository where the blob is located * @param string $hash Hash of the blob + * + * In gitlib 2.0, the constructor is internal: get it from Repository::getBlob(). */ public function __construct(Repository $repository, $hash) { diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 573d3b52..38859bf9 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -23,6 +23,8 @@ * Representation of a Git commit. * * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Commit extends Revision { @@ -38,9 +40,17 @@ class Commit extends Revision * * @param Repository $repository Repository of the commit * @param string $hash Hash of the commit + * + * In gitlib 2.0, the constructor is internal, and takes a CommitData + * instead of an array: get it from Repository::getCommit(). Passing data + * is deprecated. */ public function __construct(Repository $repository, $hash, array $data = []) { + if ([] !== $data) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing data to "%s()" is deprecated, the constructor is internal in 2.0, and takes a "CommitData" instead of an array: get the commit from "%s::getCommit()".', __METHOD__, Repository::class); + } + if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { throw new ReferenceNotFoundException($hash); } @@ -50,6 +60,9 @@ public function __construct(Repository $repository, $hash, array $data = []) $this->setData($data); } + /** + * In gitlib 2.0, it is internal, and takes a CommitData instead of an array. + */ public function setData(array $data) { foreach ($data as $name => $value) { @@ -64,10 +77,7 @@ public function getDiff() { $args = ['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index', $this->revision]; - $diff = Diff::parse($this->repository->run('diff-tree', $args)); - $diff->setRepository($this->repository); - - return $diff; + return Diff::parse($this->repository->run('diff-tree', $args), $this->repository); } /** @@ -94,10 +104,14 @@ public function getShortHash() * Returns a fixed-with short hash. * * @return string Short hash + * + * @deprecated since gitonomy/gitlib 1.7, use substr($commit->getHash(), 0, $length) instead */ public function getFixedShortHash($length = 6) { - return StringHelper::substr($this->revision, 0, $length); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.', __METHOD__); + + return substr($this->revision, 0, $length); } /** @@ -149,7 +163,7 @@ public function getTree() public function getLastModification($path = null) { if (0 === strpos($path, '/')) { - $path = StringHelper::substr($path, 1); + $path = substr($path, 1); } if ($getWorkingDir = $this->repository->getWorkingDir()) { @@ -171,17 +185,23 @@ public function getLastModification($path = null) * @param string $separator * * @return string + * + * @deprecated since gitonomy/gitlib 1.7, use symfony/string instead: u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve) */ public function getShortMessage($length = 50, $preserve = false, $separator = '...') { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".', __METHOD__); + $message = $this->getData('subjectMessage'); + // The encoding StringHelper::setEncoding() set, without loading this deprecated class. + $encoding = class_exists(StringHelper::class, false) ? StringHelper::getEncoding() : 'utf-8'; - if (StringHelper::strlen($message) > $length) { - if ($preserve && false !== ($breakpoint = StringHelper::strpos($message, ' ', $length))) { + if (mb_strlen($message, $encoding) > $length) { + if ($preserve && false !== ($breakpoint = mb_strpos($message, ' ', $length, $encoding))) { $length = $breakpoint; } - return rtrim(StringHelper::substr($message, 0, $length)).$separator; + return rtrim(mb_substr($message, 0, $length, $encoding)).$separator; } return $message; @@ -200,6 +220,8 @@ public function resolveReferences() /** * Find branch containing the commit. * + * Passing arguments, to keep local or remote branches only, is deprecated. + * * @param bool $local set true to try to locate a commit on local repository * @param bool $remote set true to try to locate a commit on remote repository * @@ -207,6 +229,10 @@ public function resolveReferences() */ public function getIncludingBranches($local = true, $remote = true) { + if (func_num_args() > 0) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing arguments to "%s()" is deprecated, filter the branches it returns with "%s::isLocal()" or "isRemote()" instead.', __METHOD__, Branch::class); + } + $arguments = ['--contains', $this->revision]; if ($local && $remote) { @@ -229,7 +255,7 @@ public function getIncludingBranches($local = true, $remote = true) $branchesName = explode("\n", trim(str_replace('*', '', $result))); $branchesName = array_filter($branchesName, function ($v) { - return false === StringHelper::strpos($v, '->'); + return false === strpos($v, '->'); }); $branchesName = array_map('trim', $branchesName); @@ -239,7 +265,7 @@ public function getIncludingBranches($local = true, $remote = true) foreach ($branchesName as $branchName) { if (false === $local) { $branches[] = $references->getRemoteBranch($branchName); - } elseif (0 === StringHelper::strrpos($branchName, 'remotes/')) { + } elseif (0 === strrpos($branchName, 'remotes/')) { $branches[] = $references->getRemoteBranch(str_replace('remotes/', '', $branchName)); } else { $branches[] = $references->getBranch($branchName); diff --git a/src/Gitonomy/Git/CommitReference.php b/src/Gitonomy/Git/CommitReference.php index 7be3d89c..7afbebd4 100644 --- a/src/Gitonomy/Git/CommitReference.php +++ b/src/Gitonomy/Git/CommitReference.php @@ -12,7 +12,13 @@ namespace Gitonomy\Git; -class CommitReference +/** + * The submodules of trees, a Submodule: 2.0 returns Submodule objects, and + * makes this class its alias. + * + * @deprecated since gitonomy/gitlib 1.7, use Submodule instead + */ +class CommitReference extends Submodule { /** * @var string @@ -23,4 +29,12 @@ public function __construct($hash) { $this->hash = $hash; } + + /** + * The hash of this class, which the objects 1.6 serialized have too, unlike the one of Submodule. + */ + public function getHash(): string + { + return $this->hash; + } } diff --git a/src/Gitonomy/Git/Diff/Diff.php b/src/Gitonomy/Git/Diff/Diff.php index 7737ee8f..87d3a521 100644 --- a/src/Gitonomy/Git/Diff/Diff.php +++ b/src/Gitonomy/Git/Diff/Diff.php @@ -14,11 +14,14 @@ use Gitonomy\Git\Parser\DiffParser; use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\LegacyOverride; /** * Representation of a diff. * * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Diff { @@ -45,21 +48,38 @@ public function __construct(array $files, $rawDiff) } /** + * Parses the output of ``git diff --raw --patch``. + * + * @param Repository|null $repository The repository to read the blobs of the files from + * * @return Diff + * + * @psalm-variadic The arguments this method gets with func_get_arg(), commented out in its signature */ - public static function parse($rawDiff) + public static function parse($rawDiff /* , ?Repository $repository = null */) { + // A second argument, without breaking the classes overriding this method. + $repository = 1 < \func_num_args() ? func_get_arg(1) : null; + $parser = new DiffParser(); $parser->parse($rawDiff); - return new self($parser->files, $rawDiff); + $diff = new self($parser->files, $rawDiff); + if (null !== $repository) { + $diff->attachRepository($repository); + } + + return $diff; } + /** + * @deprecated since gitonomy/gitlib 1.7, the diffs gitlib returns have their repository already + */ public function setRepository(Repository $repository) { - foreach ($this->files as $file) { - $file->setRepository($repository); - } + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); + + $this->attachRepository($repository); } /** @@ -103,20 +123,43 @@ function (File $file) { /** * Create a new instance of Diff from an array. * - * @param array $array The array + * @param array $array The array + * @param Repository|null $repository The repository to read the blobs of the files from * * @return Diff The new instance + * + * @psalm-variadic The arguments this method gets with func_get_arg(), commented out in its signature */ - public static function fromArray(array $array) + public static function fromArray(array $array /* , ?Repository $repository = null */) { + // A second argument, without breaking the classes overriding this method. + $repository = 1 < \func_num_args() ? func_get_arg(1) : null; + return new static( array_map( - function ($array) { - return File::fromArray($array); + function ($array) use ($repository) { + return File::fromArray($array, $repository); }, $array['files'] ), $array['rawDiff'] ); } + + private function attachRepository(Repository $repository): void + { + foreach ($this->files as $file) { + // Like in 1.6, a subclass overriding File::setRepository() gets the repository. + if (LegacyOverride::isOverridden($file, File::class, 'setRepository')) { + $file->setRepository($repository); + + continue; + } + + // Without the deprecation of File::setRepository(). + \Closure::bind(static function () use ($file, $repository) { + $file->repository = $repository; + }, null, File::class)(); + } + } } diff --git a/src/Gitonomy/Git/Diff/DiffLine.php b/src/Gitonomy/Git/Diff/DiffLine.php new file mode 100644 index 00000000..cabd2429 --- /dev/null +++ b/src/Gitonomy/Git/Diff/DiffLine.php @@ -0,0 +1,40 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Diff; + +/** + * A line of a hunk. + * + * @author Grégoire Pineau + */ +final class DiffLine +{ + public function __construct( + private readonly DiffLineType $type, + private readonly string $content, + ) { + } + + public function getType(): DiffLineType + { + return $this->type; + } + + /** + * @return string The line, without its "+", "-" or " " prefix, nor its line break + */ + public function getContent(): string + { + return $this->content; + } +} diff --git a/src/Gitonomy/Git/Diff/DiffLineType.php b/src/Gitonomy/Git/Diff/DiffLineType.php new file mode 100644 index 00000000..a00cc0f8 --- /dev/null +++ b/src/Gitonomy/Git/Diff/DiffLineType.php @@ -0,0 +1,26 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Diff; + +/** + * The kind of a line of a hunk, its values those of the deprecated + * FileChange::LINE_* constants. + * + * @author Grégoire Pineau + */ +enum DiffLineType: int +{ + case Addition = 1; + case Context = 0; + case Deletion = -1; +} diff --git a/src/Gitonomy/Git/Diff/File.php b/src/Gitonomy/Git/Diff/File.php index bfca155c..1b72d86f 100644 --- a/src/Gitonomy/Git/Diff/File.php +++ b/src/Gitonomy/Git/Diff/File.php @@ -13,9 +13,12 @@ namespace Gitonomy\Git\Diff; use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\LegacyOverride; /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class File { @@ -54,6 +57,11 @@ class File */ protected $isBinary; + /** + * @var bool + */ + private $isCopy; + /** * @var FileChange[] An array of FileChange objects */ @@ -66,8 +74,11 @@ class File /** * Instanciates a new File object. + * + * @param FileChange[] $changes + * @param Repository|null $repository The repository to read the blobs from */ - public function __construct($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary) + public function __construct($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary, $isCopy = false, array $changes = [], ?Repository $repository = null) { $this->oldName = $oldName; $this->newName = $newName; @@ -76,12 +87,18 @@ public function __construct($oldName, $newName, $oldMode, $newMode, $oldIndex, $ $this->oldIndex = $oldIndex; $this->newIndex = $newIndex; $this->isBinary = $isBinary; - - $this->changes = []; + $this->isCopy = $isCopy; + $this->changes = $changes; + $this->repository = $repository; } + /** + * @deprecated since gitonomy/gitlib 1.7, give the changes to the constructor instead + */ public function addChange(FileChange $change) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, give the changes to the constructor instead.', __METHOD__); + $this->changes[] = $change; } @@ -108,23 +125,46 @@ public function isModification() /** * Indicates if it's a rename. * - * A rename can only occurs if it's a modification (not a creation or a deletion). + * A rename can only occurs if it's a modification (not a creation or a deletion), and is not a copy. * * @return bool */ public function isRename() { - return $this->isModification() && $this->oldName !== $this->newName; + return $this->isModification() && $this->oldName !== $this->newName && !$this->isCopy; + } + + /** + * Indicates if it's a copy. + * + * Unlike a rename, the source file of a copy still exists after the change. + * The diffs gitlib reads have no copies: git detects them with -C only. + */ + public function isCopy(): bool + { + return $this->isCopy; } /** * Indicates if the file mode has changed. */ - public function isChangeMode() + public function isModeChange(): bool { return $this->isModification() && $this->oldMode !== $this->newMode; } + /** + * Indicates if the file mode has changed. + * + * @deprecated since gitonomy/gitlib 1.7, use isModeChange() instead + */ + public function isChangeMode() + { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isModeChange()" instead.', __METHOD__); + + return $this->isModeChange(); + } + /** * Indicates if this diff file is a deletion. * @@ -139,10 +179,14 @@ public function isDeletion() * Indicates if this diff file is a deletion. * * @return bool + * + * @deprecated since gitonomy/gitlib 1.7, use isDeletion() instead */ public function isDelete() { - return null === $this->newName; + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); + + return $this->isDeletion(); } /** @@ -152,7 +196,8 @@ public function getAdditions() { $result = 0; foreach ($this->changes as $change) { - $result += $change->getCount(FileChange::LINE_ADD); + // Like in 1.6, a subclass overriding FileChange::getCount() counts the lines. + $result += LegacyOverride::isOverridden($change, FileChange::class, 'getCount') ? $change->getCount(DiffLineType::Addition->value) : $change->getAdditions(); } return $result; @@ -165,7 +210,8 @@ public function getDeletions() { $result = 0; foreach ($this->changes as $change) { - $result += $change->getCount(FileChange::LINE_REMOVE); + // Like in 1.6, a subclass overriding FileChange::getCount() counts the lines. + $result += LegacyOverride::isOverridden($change, FileChange::class, 'getCount') ? $change->getCount(DiffLineType::Deletion->value) : $change->getDeletions(); } return $result; @@ -240,21 +286,31 @@ public function toArray() } /** + * @param Repository|null $repository The repository to read the blobs from + * * @return File + * + * @psalm-variadic The arguments this method gets with func_get_arg(), commented out in its signature */ - public static function fromArray(array $array) + public static function fromArray(array $array /* , ?Repository $repository = null */) { - $file = new self($array['old_name'], $array['new_name'], $array['old_mode'], $array['new_mode'], $array['old_index'], $array['new_index'], $array['is_binary']); + // A second argument, without breaking the classes overriding this method. + $repository = 1 < \func_num_args() ? func_get_arg(1) : null; - foreach ($array['changes'] as $change) { - $file->addChange(FileChange::fromArray($change)); - } + $changes = array_map(function (array $change) { + return FileChange::fromArray($change); + }, $array['changes']); - return $file; + return new self($array['old_name'], $array['new_name'], $array['old_mode'], $array['new_mode'], $array['old_index'], $array['new_index'], $array['is_binary'], $array['is_copy'] ?? false, $changes, $repository); } + /** + * @deprecated since gitonomy/gitlib 1.7, a presentation concern: use substr($file->getNewIndex(), 0, 12) instead + */ public function getAnchor() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.', __METHOD__); + return substr($this->newIndex, 0, 12); } @@ -263,8 +319,13 @@ public function getRepository() return $this->repository; } + /** + * @deprecated since gitonomy/gitlib 1.7, the diffs gitlib returns have their repository already + */ public function setRepository(Repository $repository) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); + $this->repository = $repository; } diff --git a/src/Gitonomy/Git/Diff/FileChange.php b/src/Gitonomy/Git/Diff/FileChange.php index cca18dc6..2da0f01a 100644 --- a/src/Gitonomy/Git/Diff/FileChange.php +++ b/src/Gitonomy/Git/Diff/FileChange.php @@ -12,10 +12,26 @@ namespace Gitonomy\Git\Diff; +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * @final since gitonomy/gitlib 1.7 + */ class FileChange { + /** + * @deprecated since gitonomy/gitlib 1.7, use DiffLineType::Context instead + */ const LINE_CONTEXT = 0; + + /** + * @deprecated since gitonomy/gitlib 1.7, use DiffLineType::Deletion instead + */ const LINE_REMOVE = -1; + + /** + * @deprecated since gitonomy/gitlib 1.7, use DiffLineType::Addition instead + */ const LINE_ADD = 1; protected $rangeOldStart; @@ -25,36 +41,52 @@ class FileChange protected $lines; /** - * @param int $rangeOldStart - * @param int $rangeOldCount - * @param int $rangeNewStart - * @param int $rangeNewCount - * @param array $lines + * @param int $rangeOldStart + * @param int $rangeOldCount + * @param int $rangeNewStart + * @param int $rangeNewCount + * @param list $lines The lines as arrays, [$type, $content], are deprecated * * @return void */ public function __construct($rangeOldStart, $rangeOldCount, $rangeNewStart, $rangeNewCount, $lines) { + foreach ($lines as $line) { + if (!$line instanceof DiffLine) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing the lines to "%s()" as arrays is deprecated, pass "%s" objects instead.', __METHOD__, DiffLine::class); + + break; + } + } + $this->rangeOldStart = $rangeOldStart; $this->rangeOldCount = $rangeOldCount; $this->rangeNewStart = $rangeNewStart; $this->rangeNewCount = $rangeNewCount; - $this->lines = $lines; + // As arrays, like in 1.6: the subclasses may read them. + $this->lines = array_map(static fn ($line) => $line instanceof DiffLine ? [$line->getType()->value, $line->getContent()] : $line, $lines); } /** * @return int + * + * @deprecated since gitonomy/gitlib 1.7, use getAdditions() or getDeletions() instead */ public function getCount($type) { - $result = 0; - foreach ($this->lines as $line) { - if ($line[0] === $type) { - $result++; - } - } + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.', __METHOD__); - return $result; + return $this->countLines($type); + } + + public function getAdditions(): int + { + return $this->countLines(DiffLineType::Addition->value); + } + + public function getDeletions(): int + { + return $this->countLines(DiffLineType::Deletion->value); } /** @@ -90,10 +122,22 @@ public function getRangeNewCount() } /** - * @return array + * @return list + */ + public function getDiffLines(): array + { + return array_map(static fn (array $line): DiffLine => new DiffLine(self::lineType($line[0]), $line[1]), array_values($this->lines)); + } + + /** + * @return array The lines, as arrays: [$type, $content] + * + * @deprecated since gitonomy/gitlib 1.7, use getDiffLines() instead */ public function getLines() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getDiffLines()" instead.', __METHOD__); + return $this->lines; } @@ -118,6 +162,30 @@ public function toArray() */ public static function fromArray(array $array) { - return new self($array['range_old_start'], $array['range_old_count'], $array['range_new_start'], $array['range_new_count'], $array['lines']); + $change = new self($array['range_old_start'], $array['range_old_count'], $array['range_new_start'], $array['range_new_count'], []); + // The lines as they are, like in 1.6. + $change->lines = $array['lines']; + + return $change; + } + + /** + * @throws InvalidArgumentException $type is none of the values of DiffLineType + */ + private static function lineType(int $type): DiffLineType + { + return DiffLineType::tryFrom($type) ?? throw new InvalidArgumentException(sprintf('Unknown type of line %d: the types are 1 (an addition), 0 (a context line) and -1 (a deletion).', $type)); + } + + private function countLines($type): int + { + $result = 0; + foreach ($this->lines as $line) { + if ($line[0] === $type) { + $result++; + } + } + + return $result; } } diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php new file mode 100644 index 00000000..c999bd2e --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -0,0 +1,238 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli; + +use Gitonomy\Git\Exception\RuntimeException; +use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Process; + +/** + * Configures how gitlib runs git: pass it as the "driver" argument of + * Repository, and of its static methods creating a repository. + * + * Its constructor is the one of the CliDriver of gitlib 2.0, to write code + * running with both versions. + * + * @author Grégoire Pineau + */ +final class CliDriver +{ + /** + * @param string $command The git binary + * @param array $environmentVariables Set on every git process, false removing an inherited one + * @param float|null $processTimeout In seconds, for the whole process and without output; null for none + * @param LoggerInterface|null $logger Receives every git command a repository runs, with its output + */ + public function __construct( + private string $command = 'git', + private array $environmentVariables = [], + private ?float $processTimeout = 3600, + private ?LoggerInterface $logger = null, + ) { + } + + /** + * @internal For the deprecated options of Admin and Repository::cloneTo(), to be removed in 2.0 + */ + public static function fromOptions(array $options): self + { + $options = array_merge([ + 'environment_variables' => defined('PHP_WINDOWS_VERSION_BUILD') ? ['PATH' => getenv('PATH')] : [], + 'command' => 'git', + 'process_timeout' => 3600, + ], $options); + + return new self($options['command'], $options['environment_variables'], $options['process_timeout']); + } + + /** + * @internal For Repository, which takes options in 1.x + * + * @return array{command: string, environment_variables: array, process_timeout: float|null, logger: LoggerInterface|null} + */ + public function getOptions(): array + { + return [ + 'command' => $this->command, + 'environment_variables' => $this->environmentVariables, + 'process_timeout' => $this->processTimeout, + 'logger' => $this->logger, + ]; + } + + /** + * @internal For Repository::init() + * + * @throws RuntimeException + */ + public function init(string $path): void + { + $this->initRepository(['-q', $path]); + } + + /** + * @internal For Repository::initBare() + * + * @throws RuntimeException + */ + public function initBare(string $path): void + { + $this->initRepository(['-q', '--bare', $path]); + } + + /** + * @internal For Repository::cloneFrom() + * + * @throws RuntimeException + */ + public function cloneFrom(string $url, string $path, ?string $branch): void + { + $this->cloneRepository($url, $path, null === $branch ? [] : ['--branch', $branch]); + } + + /** + * @internal For Repository::cloneBareFrom() + * + * @throws RuntimeException + */ + public function cloneBareFrom(string $url, string $path, ?string $branch): void + { + $this->cloneRepository($url, $path, null === $branch ? ['--bare'] : ['--branch', $branch, '--bare']); + } + + /** + * @internal For Repository::mirrorFrom() + * + * @throws RuntimeException + */ + public function mirrorFrom(string $url, string $path): void + { + $this->cloneRepository($url, $path, ['--mirror']); + } + + /** + * @internal For Repository::isValidRepository() + */ + public function isValidRepository(string $url): bool + { + $process = $this->createLsRemoteProcess([], $url, []); + $process->run(); + + return $process->isSuccessful(); + } + + /** + * @internal For Repository::isValidRepositoryAndBranch() + */ + public function isValidRepositoryAndBranch(string $url, string $branch): bool + { + // git lists the branches whose name ends with the pattern: "release" matches "feature/release". + $process = $this->createLsRemoteProcess(['--heads'], $url, ['refs/heads/'.$branch]); + $process->run(); + if (!$process->isSuccessful()) { + return false; + } + + foreach (explode("\n", $process->getOutput()) as $line) { + if (str_ends_with($line, "\trefs/heads/".$branch)) { + return true; + } + } + + return false; + } + + /** + * @internal For the deprecated Admin, to be removed in 2.0 + * + * @param string[] $args Arguments of ``git clone`` + * + * @throws RuntimeException + */ + public function cloneRepository(string $url, string $path, array $args): void + { + $process = $this->createProcess('clone', array_merge(['-q'], $args, [$url, $path])); + $process->run(); + + if (!$process->isSuccessful()) { + throw new RuntimeException(sprintf('Error while initializing repository: %s', $process->getErrorOutput())); + } + } + + /** + * @internal For the deprecated Admin, to be removed in 2.0 + * + * @param string[] $args + */ + public function createProcess(string $command, array $args): Process + { + $process = new Process(array_merge([$this->command, $command], $args)); + $process->setEnv($this->environmentVariables); + $process->setTimeout($this->processTimeout); + $process->setIdleTimeout($this->processTimeout); + + return $process; + } + + /** + * Like in 2.0, out of the working directory of PHP, which may be a + * repository, like the checkout of an application: ls-remote would read + * its configuration, and a relative path as the name of one of its remotes. + * + * @param string[] $options + * @param string[] $patterns + */ + private function createLsRemoteProcess(array $options, string $url, array $patterns): Process + { + $process = $this->createProcess('ls-remote', array_merge($options, [self::absoluteUrl($url)], $patterns)); + $process->setWorkingDirectory(sys_get_temp_dir()); + + return $process; + } + + /** + * A relative path, relative to the working directory of PHP. + */ + private static function absoluteUrl(string $url): string + { + if (str_starts_with($url, '/') || 1 === preg_match('#^[a-zA-Z]:[\\\\/]#', $url)) { + return $url; + } + + // Like git: a URL, or an address like "host:path", has a colon before any slash. + $colon = strpos($url, ':'); + $slash = strpos($url, '/'); + if (false !== $colon && (false === $slash || $colon < $slash)) { + return $url; + } + + $cwd = getcwd(); + + return false === $cwd ? $url : $cwd.'/'.$url; + } + + /** + * @param string[] $args + * + * @throws RuntimeException + */ + private function initRepository(array $args): void + { + $process = $this->createProcess('init', $args); + $process->run(); + + if (!$process->isSuccessful()) { + throw new RuntimeException(sprintf("Error on repository initialization, command wasn't successful (%s). Error output:\n%s", $process->getCommandLine(), $process->getErrorOutput())); + } + } +} diff --git a/src/Gitonomy/Git/Exception/LogicException.php b/src/Gitonomy/Git/Exception/LogicException.php index 8d299890..3d5866fe 100644 --- a/src/Gitonomy/Git/Exception/LogicException.php +++ b/src/Gitonomy/Git/Exception/LogicException.php @@ -2,6 +2,9 @@ namespace Gitonomy\Git\Exception; +/** + * @final since gitonomy/gitlib 1.7 + */ class LogicException extends \LogicException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index 6d6b6912..c4e20028 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -4,6 +4,9 @@ use Symfony\Component\Process\Process; +/** + * @final since gitonomy/gitlib 1.7 + */ class ProcessException extends RuntimeException implements GitExceptionInterface { protected $process; diff --git a/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php b/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php index dd9ae98d..1de3908c 100644 --- a/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php +++ b/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Exception; +/** + * @final since gitonomy/gitlib 1.7 + */ class ReferenceNotFoundException extends \InvalidArgumentException implements GitExceptionInterface { public function __construct($reference) diff --git a/src/Gitonomy/Git/Exception/UnexpectedValueException.php b/src/Gitonomy/Git/Exception/UnexpectedValueException.php index 83b3bebb..00d26bf5 100644 --- a/src/Gitonomy/Git/Exception/UnexpectedValueException.php +++ b/src/Gitonomy/Git/Exception/UnexpectedValueException.php @@ -2,6 +2,9 @@ namespace Gitonomy\Git\Exception; +/** + * @final since gitonomy/gitlib 1.7 + */ class UnexpectedValueException extends \UnexpectedValueException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index dd0adbe8..d5fdd0a6 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -20,6 +20,8 @@ * Hooks collection, aggregated by repository. * * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Hooks { diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index 47665359..fae16366 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -13,12 +13,16 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Util\LegacyOverride; use Gitonomy\Git\Util\StringHelper; /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Log implements \Countable, \IteratorAggregate { @@ -62,6 +66,17 @@ public function __construct(Repository $repository, $revisions = null, $paths = $revisions = new RevisionList($repository, $revisions); } + foreach (null === $revisions ? [] : $revisions->getAll() as $revision) { + if ($revision instanceof Commit || $revision instanceof Reference) { + continue; + } + + $name = $revision->getRevision(); + if (str_starts_with($name, '-') && '--all' !== $name) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing "%s", an option of "git log", as revision to "%s()" is deprecated, it throws in 2.0: pass revisions, ranges, names to exclude or "--all".', $name, __METHOD__); + } + } + if (null === $paths) { $paths = []; } elseif (is_string($paths)) { @@ -70,6 +85,14 @@ public function __construct(Repository $repository, $revisions = null, $paths = throw new \InvalidArgumentException(sprintf('Expected a string or an array, got a "%s".', is_object($paths) ? get_class($paths) : gettype($paths))); } + if (null !== $offset && $offset < 0) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a negative offset to "%s()" is deprecated, it throws in 2.0.', __METHOD__); + } + + if (null !== $limit && $limit < 0) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a negative limit to "%s()" is deprecated, it throws in 2.0: pass null for no limit.', __METHOD__); + } + $this->repository = $repository; $this->revisions = $revisions; $this->paths = $paths; @@ -79,10 +102,14 @@ public function __construct(Repository $repository, $revisions = null, $paths = /** * @return Diff + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::getDiff() instead: it ignored the paths of the log */ public function getDiff() { - return $this->repository->getDiff($this->revisions); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::getDiff()" instead.', __METHOD__, Repository::class); + + return $this->repository->getLegacyDiff($this->revisions); } /** @@ -109,11 +136,32 @@ public function getOffset() return $this->offset; } + /** + * @return self The log, starting from the commit $offset + * + * @throws InvalidArgumentException $offset is negative + */ + public function withOffset(?int $offset): self + { + if (null !== $offset && $offset < 0) { + throw new InvalidArgumentException(sprintf('The offset of a log can\'t be negative, %d given.', $offset)); + } + + $log = clone $this; + $log->offset = $offset; + + return $log; + } + /** * @param int $offset + * + * @deprecated since gitonomy/gitlib 1.7, use withOffset() instead */ public function setOffset($offset) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "withOffset()" instead.', __METHOD__); + $this->offset = $offset; return $this; @@ -127,11 +175,32 @@ public function getLimit() return $this->limit; } + /** + * @return self The log, of $limit commits at most + * + * @throws InvalidArgumentException $limit is negative + */ + public function withLimit(?int $limit): self + { + if (null !== $limit && $limit < 0) { + throw new InvalidArgumentException(sprintf('The limit of a log can\'t be negative, %d given.', $limit)); + } + + $log = clone $this; + $log->limit = $limit; + + return $log; + } + /** * @param int $limit + * + * @deprecated since gitonomy/gitlib 1.7, use withLimit() instead */ public function setLimit($limit) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "withLimit()" instead.', __METHOD__); + $this->limit = $limit; return $this; @@ -145,7 +214,12 @@ public function getSingleCommit() $limit = $this->limit; $this->limit = 1; $commits = $this->getCommits(); - $this->setLimit($limit); + // Like in 1.6, a subclass overriding setLimit() sets the limit back. + if (LegacyOverride::isOverridden($this, self::class, 'setLimit')) { + $this->setLimit($limit); + } else { + $this->limit = $limit; + } if (count($commits) === 0) { throw new ReferenceNotFoundException('The log is empty'); @@ -159,7 +233,9 @@ public function getSingleCommit() */ public function getCommits() { - $args = ['--encoding='.StringHelper::getEncoding(), '--format=raw']; + // The encoding StringHelper::setEncoding() set, without loading this deprecated class. + $encoding = class_exists(StringHelper::class, false) ? StringHelper::getEncoding() : 'utf-8'; + $args = ['--encoding='.$encoding, '--format=raw']; if (null !== $this->offset) { $args[] = '--skip='.((int) $this->offset); @@ -204,12 +280,26 @@ public function getCommits() } /** + * Counting a log whose offset or limit leaves commits out is deprecated: + * this count includes them, where 2.0 counts the commits the log iterates. + * * @see Countable */ #[\ReturnTypeWillChange] public function count() { - return $this->countCommits(); + $count = $this->countCommits(); + + $iterated = max(0, $count - (int) $this->offset); + if (null !== $this->limit) { + $iterated = min($iterated, (int) $this->limit); + } + + if ($iterated !== $count) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Counting a log whose offset or limit leaves commits out with "%s()" is deprecated, it counts only the commits the log iterates in 2.0: use "countCommits()" to count them all.', __METHOD__); + } + + return $count; } /** diff --git a/src/Gitonomy/Git/Parser/BlameParser.php b/src/Gitonomy/Git/Parser/BlameParser.php index 8dd6c570..377cc6c0 100644 --- a/src/Gitonomy/Git/Parser/BlameParser.php +++ b/src/Gitonomy/Git/Parser/BlameParser.php @@ -15,6 +15,9 @@ use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Repository; +/** + * @deprecated since gitonomy/gitlib 1.7, gitlib 2.0 moves it to the internal parsers of its CliDriver + */ class BlameParser extends ParserBase { public $lines; diff --git a/src/Gitonomy/Git/Parser/CommitParser.php b/src/Gitonomy/Git/Parser/CommitParser.php index 6fff1ea4..bfcb64ed 100644 --- a/src/Gitonomy/Git/Parser/CommitParser.php +++ b/src/Gitonomy/Git/Parser/CommitParser.php @@ -14,6 +14,9 @@ use Gitonomy\Git\Exception\RuntimeException; +/** + * @deprecated since gitonomy/gitlib 1.7, gitlib 2.0 merges it into the internal LogParser of its CliDriver + */ class CommitParser extends ParserBase { public $tree; diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index b6386ee9..cd741015 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -12,9 +12,14 @@ namespace Gitonomy\Git\Parser; +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\DiffLineType; use Gitonomy\Git\Diff\File; use Gitonomy\Git\Diff\FileChange; +/** + * @final since gitonomy/gitlib 1.7 + */ class DiffParser extends ParserBase { public $files; @@ -117,9 +122,8 @@ protected function doParse() $newIndex = $newIndex === null ? '' : $newIndex; $oldIndex = preg_match('/^0+$/', $oldIndex) ? null : $oldIndex; $newIndex = preg_match('/^0+$/', $newIndex) ? null : $newIndex; - $file = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary); - // 5. Diff + $changes = []; while ($this->expects('@@ ')) { $vars = $this->consumeRegexp('/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?/'); $rangeOldStart = (int) $vars[1]; @@ -134,11 +138,11 @@ protected function doParse() $lines = []; while (true) { if ($this->expects(' ')) { - $lines[] = [FileChange::LINE_CONTEXT, $this->consumeTo("\n")]; + $lines[] = new DiffLine(DiffLineType::Context, $this->consumeTo("\n")); } elseif ($this->expects('+')) { - $lines[] = [FileChange::LINE_ADD, $this->consumeTo("\n")]; + $lines[] = new DiffLine(DiffLineType::Addition, $this->consumeTo("\n")); } elseif ($this->expects('-')) { - $lines[] = [FileChange::LINE_REMOVE, $this->consumeTo("\n")]; + $lines[] = new DiffLine(DiffLineType::Deletion, $this->consumeTo("\n")); } elseif ($this->expects("\ No newline at end of file")) { // Ignore this case... } else { @@ -148,12 +152,10 @@ protected function doParse() $this->consumeNewLine(); } - $change = new FileChange($rangeOldStart, $rangeOldCount, $rangeNewStart, $rangeNewCount, $lines); - - $file->addChange($change); + $changes[] = new FileChange($rangeOldStart, $rangeOldCount, $rangeNewStart, $rangeNewCount, $lines); } - $this->files[] = $file; + $this->files[] = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary, false, $changes); } } } diff --git a/src/Gitonomy/Git/Parser/LogParser.php b/src/Gitonomy/Git/Parser/LogParser.php index 67b86687..c92df2f0 100644 --- a/src/Gitonomy/Git/Parser/LogParser.php +++ b/src/Gitonomy/Git/Parser/LogParser.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Parser; +/** + * @deprecated since gitonomy/gitlib 1.7, gitlib 2.0 moves it to the internal parsers of its CliDriver + */ class LogParser extends CommitParser { public $log = []; diff --git a/src/Gitonomy/Git/Parser/ReferenceParser.php b/src/Gitonomy/Git/Parser/ReferenceParser.php index 82e3a8b5..4091bade 100644 --- a/src/Gitonomy/Git/Parser/ReferenceParser.php +++ b/src/Gitonomy/Git/Parser/ReferenceParser.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Parser; +/** + * @deprecated since gitonomy/gitlib 1.7, gitlib 2.0 moves it to the internal parsers of its CliDriver + */ class ReferenceParser extends ParserBase { public $references; diff --git a/src/Gitonomy/Git/Parser/TagParser.php b/src/Gitonomy/Git/Parser/TagParser.php index 1a1c084c..bfe8b6bc 100644 --- a/src/Gitonomy/Git/Parser/TagParser.php +++ b/src/Gitonomy/Git/Parser/TagParser.php @@ -14,6 +14,9 @@ use Gitonomy\Git\Exception\RuntimeException; +/** + * @deprecated since gitonomy/gitlib 1.7, gitlib 2.0 moves it to the internal parsers of its CliDriver + */ class TagParser extends ParserBase { public $object; diff --git a/src/Gitonomy/Git/Parser/TreeParser.php b/src/Gitonomy/Git/Parser/TreeParser.php index d01f4b28..2636a565 100644 --- a/src/Gitonomy/Git/Parser/TreeParser.php +++ b/src/Gitonomy/Git/Parser/TreeParser.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Parser; +/** + * @deprecated since gitonomy/gitlib 1.7, gitlib 2.0 moves it to the internal parsers of its CliDriver + */ class TreeParser extends ParserBase { public $entries = []; diff --git a/src/Gitonomy/Git/PushReference.php b/src/Gitonomy/Git/PushReference.php index dea10823..ce851642 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -13,12 +13,15 @@ namespace Gitonomy\Git; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Util\LegacyOverride; /** * Push reference contains a commit interval. This object aggregates methods * for this interval. * * @author Julien DIDIER + * + * @final since gitonomy/gitlib 1.7 */ class PushReference { @@ -65,11 +68,23 @@ public function getRepository() return $this->repository; } + /** + * @return string The full name of the pushed reference + */ + public function getFullname(): string + { + return $this->reference; + } + /** * @return string + * + * @deprecated since gitonomy/gitlib 1.7, use getFullname() instead */ public function getReference() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getFullname()" instead.', __METHOD__); + return $this->reference; } @@ -95,7 +110,8 @@ public function getAfter() public function getLog($excludes = []) { return $this->repository->getLog(array_merge( - [$this->getRevision()], + // Like in 1.6, a subclass overriding getRevision() gives the range. + [LegacyOverride::isOverridden($this, self::class, 'getRevision') ? $this->getRevision() : $this->getRange()], array_map(function ($e) { return '^'.$e; }, $excludes) @@ -103,35 +119,73 @@ public function getLog($excludes = []) } /** - * @return string + * @return string The pushed commits: "before..after", or "after" for a creation + * + * @throws LogicException The push deletes the reference */ - public function getRevision() + public function getRange(): string { - if ($this->isDelete()) { + if ($this->deletes()) { throw new LogicException('No revision for deletion'); } - if ($this->isCreate()) { + if ($this->creates()) { return $this->getAfter(); } return $this->getBefore().'..'.$this->getAfter(); } + /** + * @return string + * + * @deprecated since gitonomy/gitlib 1.7, use getRange() instead + */ + public function getRevision() + { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getRange()" instead.', __METHOD__); + + return $this->getRange(); + } + + /** + * @return bool Whether the push creates the reference, like File::isCreation() for a file + */ + public function isCreation(): bool + { + return $this->isZero($this->before); + } + /** * @return bool + * + * @deprecated since gitonomy/gitlib 1.7, use isCreation() instead */ public function isCreate() { - return $this->isZero($this->before); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isCreation()" instead.', __METHOD__); + + return $this->isCreation(); + } + + /** + * @return bool Whether the push deletes the reference, like File::isDeletion() for a file + */ + public function isDeletion(): bool + { + return $this->isZero($this->after); } /** * @return bool + * + * @deprecated since gitonomy/gitlib 1.7, use isDeletion() instead */ public function isDelete() { - return $this->isZero($this->after); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); + + return $this->isDeletion(); } /** @@ -147,7 +201,7 @@ public function isForce() */ public function isFastForward() { - return !$this->isDelete() && !$this->isCreate() && !$this->isForce(); + return !$this->deletes() && !$this->creates() && !$this->isForce(); } /** @@ -163,7 +217,7 @@ protected function isZero($reference) */ protected function getForce() { - if ($this->isDelete() || $this->isCreate()) { + if ($this->deletes() || $this->creates()) { return false; } @@ -174,4 +228,20 @@ protected function getForce() return $this->before !== trim($result); } + + /** + * Like in 1.6, a subclass overriding isCreate() tells whether the push creates the reference. + */ + private function creates(): bool + { + return LegacyOverride::isOverridden($this, self::class, 'isCreate') ? $this->isCreate() : $this->isCreation(); + } + + /** + * Like in 1.6, a subclass overriding isDelete() tells whether the push deletes the reference. + */ + private function deletes(): bool + { + return LegacyOverride::isOverridden($this, self::class, 'isDelete') ? $this->isDelete() : $this->isDeletion(); + } } diff --git a/src/Gitonomy/Git/Reference.php b/src/Gitonomy/Git/Reference.php index 96b8fa12..f5f430cb 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -25,6 +25,9 @@ abstract class Reference extends Revision { protected $commitHash; + /** + * In gitlib 2.0, the constructor is internal, and requires the commit hash: get it from Repository::getReferences(). + */ public function __construct(Repository $repository, $revision, $commitHash = null) { $this->repository = $repository; diff --git a/src/Gitonomy/Git/Reference/Branch.php b/src/Gitonomy/Git/Reference/Branch.php index 0d27fb88..ab5799df 100644 --- a/src/Gitonomy/Git/Reference/Branch.php +++ b/src/Gitonomy/Git/Reference/Branch.php @@ -15,12 +15,14 @@ use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference; -use Gitonomy\Git\Util\StringHelper; +use Gitonomy\Git\Revision; /** * Representation of a branch reference. * * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Branch extends Reference { @@ -55,6 +57,18 @@ public function isLocal() return $this->local; } + /** + * Checks if the history of $revision contains this branch. + */ + public function isMergedInto(Revision|string $revision): bool + { + // ReferenceBag::createBranch() keeps the name it got instead of a hash, like in 1.6. + $commit = $this->repository->getRevision($this->getCommitHash())->getCommit(); + $mergeBase = $this->repository->getMergeBase($commit, $revision); + + return null !== $mergeBase && $mergeBase->getHash() === $commit->getHash(); + } + /** * Check if this branch is merged to a destination branch * Optionally, check only with remote branches. @@ -63,9 +77,13 @@ public function isLocal() * @param bool $compareOnlyWithRemote * * @return null|bool + * + * @deprecated since gitonomy/gitlib 1.7, use isMergedInto() instead */ public function isMergedTo($destinationBranchName = 'master', $compareOnlyWithRemote = false) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isMergedInto()" instead.', __METHOD__, self::class); + $arguments = ['-a']; if ($compareOnlyWithRemote) { @@ -91,13 +109,29 @@ public function isMergedTo($destinationBranchName = 'master', $compareOnlyWithRe $output = explode("\n", trim(str_replace(['*', 'remotes/'], '', $result))); $filtered_output = array_filter($output, static function ($v) { - return false === StringHelper::strpos($v, '->'); + return false === strpos($v, '->'); }); $trimmed_output = array_map('trim', $filtered_output); return in_array($this->getName(), $trimmed_output, true); } + /** + * Points the branch to $revision. + * + * @return self The branch, pointing to $revision + */ + public function moveTo(Revision|string $revision): self + { + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); + $this->repository->run('update-ref', [$this->getFullname(), $hash]); + + $references = $this->repository->getReferences(); + $references->invalidate(); + + return $this->isLocal() ? $references->getBranch($this->getName()) : $references->getRemoteBranch($this->getName()); + } + private function detectBranchType() { if (null === $this->local) { diff --git a/src/Gitonomy/Git/Reference/Stash.php b/src/Gitonomy/Git/Reference/Stash.php index 9f26802f..fb8ae331 100644 --- a/src/Gitonomy/Git/Reference/Stash.php +++ b/src/Gitonomy/Git/Reference/Stash.php @@ -16,6 +16,8 @@ /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Stash extends Reference { diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index 78c43b25..cae854ce 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -24,6 +24,8 @@ * * @author Alexandre Salomé * @author Bruce Wells + * + * @final since gitonomy/gitlib 1.7 */ class Tag extends Reference { diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 71ef9ae4..2358a46b 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -17,12 +17,15 @@ use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Stash; use Gitonomy\Git\Reference\Tag; +use Gitonomy\Git\Util\LegacyOverride; /** * Reference set associated to a repository. * * @author Alexandre Salomé * @author Julien DIDIER + * + * @final since gitonomy/gitlib 1.7 */ class ReferenceBag implements \Countable, \IteratorAggregate { @@ -65,6 +68,8 @@ class ReferenceBag implements \Countable, \IteratorAggregate * Constructor. * * @param Repository $repository The repository + * + * In gitlib 2.0, the constructor is internal: get it from Repository::getReferences(). */ public function __construct($repository) { @@ -104,44 +109,63 @@ public function has($fullname) /** * @return Reference + * + * @deprecated since gitonomy/gitlib 1.7, use Branch::moveTo() instead */ public function update(Reference $reference) { - $fullname = $reference->getFullname(); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::moveTo()" instead.', __METHOD__, Branch::class); - $this->initialize(); - $this->repository->run('update-ref', [$fullname, $reference->getCommitHash()]); - - $this->references[$fullname] = $reference; - - return $reference; + return $this->updateReference($reference); } /** - * @return Reference + * Creating a branch that exists already, which moves it, is deprecated. + * + * @param Revision|string $commitHash The hash of a commit, or a Revision + * + * @return Branch */ public function createBranch($name, $commitHash) { - $branch = new Branch($this->repository, 'refs/heads/'.$name, $commitHash); + if ($this->has('refs/heads/'.$name)) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Creating the branch "%s", which exists already, is deprecated, it throws in 2.0: use "%s::moveTo()" instead.', $name, Branch::class); + } - return $this->update($branch); + $branch = new Branch($this->repository, 'refs/heads/'.$name, self::commitHash($commitHash)); + + return $this->write($branch); } /** - * @return Reference + * Creating a tag that exists already, which moves it, is deprecated. + * + * @param Revision|string $commitHash The hash of a commit, or a Revision + * + * @return Tag */ public function createTag($name, $commitHash) { - $tag = new Tag($this->repository, 'refs/tags/'.$name, $commitHash); + if ($this->has('refs/tags/'.$name)) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Creating the tag "%s", which exists already, is deprecated, it throws in 2.0: delete it first.', $name); + } - return $this->update($tag); + $tag = new Tag($this->repository, 'refs/tags/'.$name, self::commitHash($commitHash)); + + return $this->write($tag); } /** + * Deleting a reference by another name than its full name under "refs/", like "HEAD" or "master", is deprecated. + * * @return void */ public function delete($fullname) { + if (!str_starts_with($fullname, 'refs/')) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Deleting "%s", which is not the full name of a reference under "refs/", is deprecated, it throws in 2.0.', $fullname); + } + $this->repository->run('update-ref', ['-d', $fullname]); unset($this->references[$fullname]); @@ -379,6 +403,19 @@ protected function initialize() } } + /** + * Reads the references again when next needed. + * + * @internal + */ + public function invalidate(): void + { + $this->initialized = false; + $this->references = []; + $this->tags = []; + $this->branches = []; + } + /** * @return int * @@ -402,4 +439,37 @@ public function getIterator() return new \ArrayIterator($this->references); } + + /** + * @return Reference + */ + private function updateReference(Reference $reference) + { + $fullname = $reference->getFullname(); + + $this->initialize(); + $this->repository->run('update-ref', [$fullname, $reference->getCommitHash()]); + + $this->references[$fullname] = $reference; + + return $reference; + } + + /** + * @return Reference + */ + private function write(Reference $reference) + { + // Like in 1.6, a subclass overriding update() writes the reference. + return LegacyOverride::isOverridden($this, self::class, 'update') ? $this->update($reference) : $this->updateReference($reference); + } + + /** + * @param Revision|string $commitHash + */ + private static function commitHash($commitHash) + { + // A string as it is, like in 1.6: git resolves a name when writing the reference. + return $commitHash instanceof Revision ? $commitHash->getCommit()->getHash() : $commitHash; + } } diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 296496ae..fc78d09e 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -13,8 +13,10 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; @@ -25,6 +27,8 @@ * Main entry point for browsing a Git repository. * * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class Repository { @@ -101,7 +105,11 @@ class Repository /** * Constructs a new repository. * - * Available options are: + * The second argument is the working directory, when $dir is a git + * directory kept apart from it. An array of options there is deprecated: + * its "working_dir" option is this argument, and its "logger", "command", + * "environment_variables" and "process_timeout" options configure a + * CliDriver. Available options are: * * * working_dir : specify where working copy is located (option --work-tree) * @@ -111,13 +119,32 @@ class Repository * * * environment_variables : define environment variables for every ran process * - * @param string $dir path to git repository - * @param array $options array of options values + * @param string $dir path to git repository + * @param string|array|null $options The working tree, or an array of options, deprecated + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null * * @throws InvalidArgumentException The folder does not exists */ - public function __construct($dir, $options = []) + public function __construct($dir, $options = [], ?CliDriver $driver = null) { + if (!is_array($options)) { + $options = ['working_dir' => $options]; + } elseif ([] !== $options) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing an array of options to "%s()" is deprecated, pass the working directory as second argument, and configure the logger and git through the "driver" argument.', __METHOD__); + + if (null !== $driver && [] !== array_intersect(array_keys($options), ['command', 'environment_variables', 'inherit_environment_variables', 'logger', 'process_timeout'])) { + throw new InvalidArgumentException('The driver argument can\'t be combined with the "logger" or git options: configure the driver instead.'); + } + + if (array_key_exists('debug', $options) && !$options['debug']) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Setting the "debug" option of "%s()" to false is deprecated, a failing git operation always throws in 2.0: catch its exception instead.', __METHOD__); + } + } + + if (null !== $driver) { + $options = array_merge($options, $driver->getOptions()); + } + $options = array_merge([ 'working_dir' => null, 'debug' => true, @@ -152,6 +179,106 @@ public function __construct($dir, $options = []) } } + /** + * Initializes a repository with a working directory in $path. + * + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + * + * @throws RuntimeException The directory can't be written, or is already a file + */ + public static function init(string $path, ?CliDriver $driver = null): self + { + $driver = $driver ?? new CliDriver(); + $driver->init($path); + + return new self($path, null, $driver); + } + + /** + * Initializes a bare repository in $path. + * + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + * + * @throws RuntimeException The directory can't be written, or is already a file + */ + public static function initBare(string $path, ?CliDriver $driver = null): self + { + $driver = $driver ?? new CliDriver(); + $driver->initBare($path); + + return new self($path, null, $driver); + } + + /** + * Clones $url with a working directory in $path. + * + * @param string|null $branch The branch to check out, the default branch of $url when null + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + * + * @throws RuntimeException + */ + public static function cloneFrom(string $url, string $path, ?string $branch = null, ?CliDriver $driver = null): self + { + $driver = $driver ?? new CliDriver(); + $driver->cloneFrom($url, $path, $branch); + + return new self($path, null, $driver); + } + + /** + * Clones the branches and the tags of $url into a bare repository in $path. + * + * @param string|null $branch The branch HEAD points to, the default branch of $url when null + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + * + * @throws RuntimeException + */ + public static function cloneBareFrom(string $url, string $path, ?string $branch = null, ?CliDriver $driver = null): self + { + $driver = $driver ?? new CliDriver(); + $driver->cloneBareFrom($url, $path, $branch); + + return new self($path, null, $driver); + } + + /** + * Clones every reference of $url into a bare repository in $path, set up + * to mirror $url when fetching. + * + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + * + * @throws RuntimeException + */ + public static function mirrorFrom(string $url, string $path, ?CliDriver $driver = null): self + { + $driver = $driver ?? new CliDriver(); + $driver->mirrorFrom($url, $path); + + return new self($path, null, $driver); + } + + /** + * Checks, without cloning it, that $url is a repository git can read: + * false when it doesn't exist, can't be reached, or refuses access. + * + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + */ + public static function isValidRepository(string $url, ?CliDriver $driver = null): bool + { + return ($driver ?? new CliDriver())->isValidRepository($url); + } + + /** + * Checks, without cloning it, that $url is a repository git can read, + * and that it has the $branch branch. + * + * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null + */ + public static function isValidRepositoryAndBranch(string $url, string $branch, ?CliDriver $driver = null): bool + { + return ($driver ?? new CliDriver())->isValidRepositoryAndBranch($url, $branch); + } + /** * Initializes directory attributes on repository:. * @@ -315,12 +442,16 @@ public function getWorkingCopy() /** * Returns the reference list associated to the repository. * - * @param bool $reload Reload references from the filesystem + * @param bool $reload Reload references from the filesystem, deprecated: open a new Repository instead * * @return ReferenceBag */ public function getReferences($reload = false) { + if (\func_num_args() > 0) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing an argument to "%s()" is deprecated, open a new Repository to read the references again.', __METHOD__); + } + if (null === $this->referenceBag || $reload) { $this->referenceBag = new ReferenceBag($this); } @@ -347,12 +478,18 @@ public function getCommit($hash) /** * Instanciates a tree object or fetches one from the cache. * + * Passing anything but a hash, like a revision name, is deprecated. + * * @param string $hash A tree hash, with a length of 40 * * @return Tree */ public function getTree($hash) { + if (!preg_match('/^[0-9a-f]{4,40}$/', $hash)) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing "%s", which is not a hash, to "%s()" is deprecated, it throws in 2.0: use "getRevision($name)->getCommit()->getTree()" instead.', $hash, __METHOD__); + } + if (!isset($this->objects[$hash])) { $this->objects[$hash] = new Tree($this, $hash); } @@ -363,12 +500,18 @@ public function getTree($hash) /** * Instanciates a blob object or fetches one from the cache. * + * Passing anything but a hash, like "HEAD:README.md", is deprecated. + * * @param string $hash A blob hash, with a length of 40 * * @return Blob */ public function getBlob($hash) { + if (!preg_match('/^[0-9a-f]{4,40}$/', $hash)) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing "%s", which is not a hash, to "%s()" is deprecated, it throws in 2.0: use "Tree::resolvePath()" instead.', $hash, __METHOD__); + } + if (!isset($this->objects[$hash])) { $this->objects[$hash] = new Blob($this, $hash); } @@ -377,15 +520,29 @@ public function getBlob($hash) } /** + * Returns the commits that last changed the lines of $file. + * + * The third argument is the first line to blame, and a fourth one the last + * line, like in 2.0. A string as third argument, a range of lines like + * "git blame -L", is deprecated. + * + * @param int|string|null $lineRange The first line to blame, from 1, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null + * * @return Blame + * + * @psalm-variadic The arguments this method gets with func_get_arg(), commented out in its signature */ - public function getBlame($revision, $file, $lineRange = null) + public function getBlame($revision, $file, $lineRange = null /* , ?int $lastLine = null */) { + // A fourth argument, without breaking the classes overriding this method. + $lastLine = 3 < \func_num_args() ? func_get_arg(3) : null; + if (is_string($revision)) { $revision = $this->getRevision($revision); } - return new Blame($this, $revision, $file, $lineRange); + return new Blame($this, $revision, $file, $lineRange, $lastLine); } /** @@ -408,29 +565,104 @@ public function getLog($revisions = null, $paths = null, $offset = null, $limit } /** + * Returns the best common ancestor of two commits, the one "git diff a...b" compares to. + * + * @return Commit|null Null when they have no common history + * + * @throws ReferenceNotFoundException A revision names no commit + */ + public function getMergeBase(Revision|string $one, Revision|string $two): ?Commit + { + $process = $this->execute('merge-base', [$this->commitHash($one), $this->commitHash($two)]); + + // git exits with 1, writing nothing, when the commits have no common ancestor. + if (1 === $process->getExitCode() && '' === $process->getErrorOutput()) { + return null; + } + + if (!$process->isSuccessful()) { + // Returns with the "debug" option set to false. + $this->fail('merge-base', $process); + + return null; + } + + return $this->getCommit(trim($process->getOutput())); + } + + /** + * Returns the changes from the commit of the first argument to the commit $to. + * + * Passing one argument is deprecated: an "a..b" or "a...b" range, two + * revisions in an array or a RevisionList, or one revision to compare + * with the working tree, like WorkingCopy::getDiff() does. + * + * @param RevisionList|Revision|string|array $revisions The revision to compare from, when a second argument is given + * @param Revision|string|null $to + * * @return Diff + * + * @throws ReferenceNotFoundException A revision names no commit + * + * @psalm-variadic The arguments this method gets with func_get_arg(), commented out in its signature */ - public function getDiff($revisions) + public function getDiff($revisions /* , $to = null */) + { + // A second argument, without breaking the classes overriding this method. + $to = 1 < \func_num_args() ? func_get_arg(1) : null; + + if (null === $to) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Calling "%s()" with one argument is deprecated, pass the revisions to compare as first and second arguments, or use "%s::getDiff()" to compare a revision with the working tree.', __METHOD__, WorkingCopy::class); + + return $this->getLegacyDiff($revisions); + } + + if (!$revisions instanceof Revision && !is_string($revisions)) { + throw new InvalidArgumentException('The first argument of "Repository::getDiff()" must be a revision, when a second one is given.'); + } + + return $this->runDiff([$this->commitHash($revisions), $this->commitHash($to)]); + } + + /** + * For the deprecated forms of getDiff() and Log::getDiff(), to be removed in 2.0. + * + * @param RevisionList|Revision|string|array $revisions + * + * @internal + */ + public function getLegacyDiff($revisions): Diff { if (null !== $revisions && !$revisions instanceof RevisionList) { $revisions = new RevisionList($this, $revisions); } - $args = array_merge(['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index'], $revisions->getAsTextArray()); - - $diff = Diff::parse($this->run('diff', $args)); - $diff->setRepository($this); + return $this->runDiff($revisions->getAsTextArray()); + } - return $diff; + /** + * @return int The size of the files of the git directory, working tree excluded, in bytes + * + * @throws RuntimeException A file or a directory of the git directory can't be read + */ + public function getSizeInBytes(): int + { + return self::directorySize($this->gitDir); } /** * Returns the size of repository, in kilobytes. * + * Unlike getSizeInBytes(), it counts the files the links point to. + * * @return int A sum, in kilobytes + * + * @deprecated since gitonomy/gitlib 1.7, use getSizeInBytes() instead */ public function getSize() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getSizeInBytes()" instead.', __METHOD__); + $totalBytes = 0; $path = realpath($this->gitDir); if ($path && file_exists($path)) { @@ -446,9 +678,13 @@ public function getSize() * Executes a shell command on the repository, using PHP pipes. * * @param string $command The command to execute + * + * @deprecated since gitonomy/gitlib 1.7, run "git shell" with Symfony Process instead */ public function shell($command, array $env = []) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, run "git shell" with Symfony Process instead: it ignores the driver.', __METHOD__); + $argument = sprintf('%s \'%s\'', $command, $this->gitDir); $prefix = ''; @@ -534,39 +770,15 @@ public function setDescription($description) */ public function run($command, $args = []) { - $process = $this->getProcess($command, $args); - - if ($this->logger) { - $this->logger->info(sprintf('run command: %s "%s" ', $command, implode(' ', $args))); - $before = microtime(true); - } - - $process->run(); - - $output = $process->getOutput(); - - if ($this->logger && $this->debug) { - $duration = microtime(true) - $before; - $this->logger->debug(sprintf('last command (%s) duration: %sms', $command, sprintf('%.2f', $duration * 1000))); - $this->logger->debug(sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); - $this->logger->debug(sprintf('last command (%s) output: %s', $command, $output)); - } + $process = $this->execute($command, $args); if (!$process->isSuccessful()) { - $error = sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput()); - - if ($this->logger) { - $this->logger->error($error); - } - - if ($this->debug) { - throw new ProcessException($process); - } + $this->fail($command, $process); return; } - return $output; + return $process->getOutput(); } /** @@ -575,9 +787,13 @@ public function run($command, $args = []) * @param LoggerInterface $logger A logger * * @return Repository The current repository + * + * @deprecated since gitonomy/gitlib 1.7, pass a CliDriver configured with the logger as driver argument instead */ public function setLogger(LoggerInterface $logger) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, pass a "%s" configured with the logger as driver argument instead.', __METHOD__, CliDriver::class); + $this->logger = $logger; return $this; @@ -587,9 +803,13 @@ public function setLogger(LoggerInterface $logger) * Returns repository logger. * * @return LoggerInterface the logger or null + * + * @deprecated since gitonomy/gitlib 1.7, the logger is an argument of CliDriver */ public function getLogger() { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, the logger is an argument of "%s".', __METHOD__, CliDriver::class); + return $this->logger; } @@ -600,10 +820,74 @@ public function getLogger() * @param bool $bare flag indicating if repository is bare or has a working-copy * * @return Repository the newly created repository + * + * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() with getGitDir() as URL instead */ public function cloneTo($path, $bare = true, array $options = []) { - return Admin::cloneTo($path, $this->gitDir, $bare, $options); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" with "getGitDir()" as URL instead.', __METHOD__, self::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + + CliDriver::fromOptions($options)->cloneRepository($this->gitDir, $path, $bare ? ['--bare'] : []); + + return new self($path, $options); + } + + /** + * @param string[] $revisions + */ + private function runDiff(array $revisions): Diff + { + $args = array_merge(['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index'], $revisions); + + return Diff::parse($this->run('diff', $args), $this); + } + + /** + * @throws ReferenceNotFoundException $revision names no commit + */ + private function commitHash(Revision|string $revision): string + { + return ($revision instanceof Revision ? $revision : $this->getRevision($revision))->getCommit()->getHash(); + } + + /** + * Runs a git command, and logs it. + */ + private function execute($command, $args = []): Process + { + $process = $this->getProcess($command, $args); + + if ($this->logger) { + $this->logger->info(sprintf('run command: %s "%s" ', $command, implode(' ', $args))); + $before = microtime(true); + } + + $process->run(); + + if ($this->logger && $this->debug) { + $duration = microtime(true) - $before; + $this->logger->debug(sprintf('last command (%s) duration: %sms', $command, sprintf('%.2f', $duration * 1000))); + $this->logger->debug(sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); + $this->logger->debug(sprintf('last command (%s) output: %s', $command, $process->getOutput())); + } + + return $process; + } + + /** + * Logs the failure of a git command, and throws unless the "debug" option is false. + * + * @throws ProcessException + */ + private function fail($command, Process $process): void + { + if ($this->logger) { + $this->logger->error(sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput())); + } + + if ($this->debug) { + throw new ProcessException($process); + } } /** @@ -637,4 +921,49 @@ private function getProcess($command, $args = []) return $process; } + + /** + * git may delete a file or a directory while it is counted, during a gc + * or a push: a lock, a temporary pack, the quarantine of a push... It no + * longer counts. + * + * @throws RuntimeException A file or a directory of $dir can't be read + */ + private static function directorySize(string $dir): int + { + try { + $files = new \FilesystemIterator($dir, \FilesystemIterator::SKIP_DOTS); + } catch (\UnexpectedValueException $e) { + if (!file_exists($dir)) { + return 0; + } + + throw new RuntimeException(sprintf('Unable to read the directory "%s".', $dir), 0, $e); + } + + $size = 0; + /** @var \SplFileInfo $file */ + foreach ($files as $file) { + // A link isn't followed: it may point to a file not deployed yet, like a hook. + if ($file->isLink()) { + continue; + } + + if ($file->isDir()) { + $size += self::directorySize($file->getPathname()); + + continue; + } + + try { + $size += $file->getSize(); + } catch (\RuntimeException $e) { + if (file_exists($file->getPathname())) { + throw new RuntimeException(sprintf('Unable to read the size of "%s".', $file->getPathname()), 0, $e); + } + } + } + + return $size; + } } diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index 9e1921fb..146c1ad1 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -27,6 +27,9 @@ class Revision */ protected $revision; + /** + * In gitlib 2.0, the constructor is internal: get it from Repository::getRevision(). + */ public function __construct(Repository $repository, $revision) { $this->repository = $repository; diff --git a/src/Gitonomy/Git/RevisionList.php b/src/Gitonomy/Git/RevisionList.php index 1c1a1dcd..471b1574 100644 --- a/src/Gitonomy/Git/RevisionList.php +++ b/src/Gitonomy/Git/RevisionList.php @@ -14,6 +14,8 @@ /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class RevisionList implements \IteratorAggregate, \Countable { diff --git a/src/Gitonomy/Git/Submodule.php b/src/Gitonomy/Git/Submodule.php new file mode 100644 index 00000000..95326af6 --- /dev/null +++ b/src/Gitonomy/Git/Submodule.php @@ -0,0 +1,41 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git; + +/** + * A submodule in a tree: the commit of another repository it points to. + * + * @author Grégoire Pineau + * + * @final + */ +class Submodule +{ + /** + * @var string + */ + private $hash; + + public function __construct(string $hash) + { + $this->hash = $hash; + } + + /** + * @return string The commit of the submodule, in its own repository + */ + public function getHash(): string + { + return $this->hash; + } +} diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index 7830cfa4..87c2d867 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -17,8 +17,12 @@ /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 + * + * @implements \IteratorAggregate */ -class Tree +class Tree implements \IteratorAggregate { protected $repository; protected $hash; @@ -26,6 +30,9 @@ class Tree protected $entries; protected $entriesByType; + /** + * In gitlib 2.0, the constructor is internal: get it from Repository::getTree() or Commit::getTree(). + */ public function __construct(Repository $repository, $hash) { $this->repository = $repository; @@ -71,10 +78,54 @@ protected function initialize() } /** - * @return array An associative array name => $object + * @return \ArrayIterator The entries, by name + */ + public function getIterator(): \ArrayIterator + { + $this->initialize(); + + return new \ArrayIterator(self::treeEntries($this->entries)); + } + + /** + * @return array The blobs, by name + */ + public function getBlobs(): array + { + $this->initialize(); + + return self::treeEntries($this->entriesByType['blob']); + } + + /** + * @return array The submodules, by name + */ + public function getSubmodules(): array + { + $this->initialize(); + + return self::treeEntries($this->entriesByType['commit']); + } + + /** + * @return array The trees, by name + */ + public function getTrees(): array + { + $this->initialize(); + + return self::treeEntries($this->entriesByType['tree']); + } + + /** + * @return array An associative array name => [mode, object], a CommitReference being a Submodule + * + * @deprecated since gitonomy/gitlib 1.7, iterate the tree instead */ public function getEntries(): array { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, iterate the tree instead.', __METHOD__); + $this->initialize(); return $this->entries; @@ -82,9 +133,13 @@ public function getEntries(): array /** * @return array An associative array of name => [mode, commit reference] + * + * @deprecated since gitonomy/gitlib 1.7, use getSubmodules() instead */ public function getCommitReferenceEntries(): array { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getSubmodules()" instead.', __METHOD__); + $this->initialize(); return $this->entriesByType['commit']; @@ -92,9 +147,13 @@ public function getCommitReferenceEntries(): array /** * @return array An associative array of name => [mode, tree] + * + * @deprecated since gitonomy/gitlib 1.7, use getTrees() instead */ public function getTreeEntries(): array { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getTrees()" instead.', __METHOD__); + $this->initialize(); return $this->entriesByType['tree']; @@ -102,9 +161,13 @@ public function getTreeEntries(): array /** * @return array An associative array of name => [mode, blob] + * + * @deprecated since gitonomy/gitlib 1.7, use getBlobs() instead */ public function getBlobEntries(): array { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getBlobs()" instead.', __METHOD__); + $this->initialize(); return $this->entriesByType['blob']; @@ -143,4 +206,19 @@ public function resolvePath($path) return $element; } + + /** + * @param array $entries + * + * @return array + */ + private static function treeEntries(array $entries): array + { + $treeEntries = []; + foreach ($entries as $name => [$mode, $object]) { + $treeEntries[$name] = new TreeEntry((string) $name, $mode, $object); + } + + return $treeEntries; + } } diff --git a/src/Gitonomy/Git/TreeEntry.php b/src/Gitonomy/Git/TreeEntry.php new file mode 100644 index 00000000..0c183a34 --- /dev/null +++ b/src/Gitonomy/Git/TreeEntry.php @@ -0,0 +1,46 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git; + +/** + * An entry of a tree: a blob, a tree or a submodule, with its name and mode. + * + * @author Grégoire Pineau + */ +final class TreeEntry +{ + public function __construct( + private readonly string $name, + private readonly string $mode, + private readonly Blob|Tree|Submodule $object, + ) { + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return string The mode git stores, like "100644" for a file or "040000" for a tree + */ + public function getMode(): string + { + return $this->mode; + } + + public function getObject(): Blob|Tree|Submodule + { + return $this->object; + } +} diff --git a/src/Gitonomy/Git/Util/LegacyOverride.php b/src/Gitonomy/Git/Util/LegacyOverride.php new file mode 100644 index 00000000..0eb406d2 --- /dev/null +++ b/src/Gitonomy/Git/Util/LegacyOverride.php @@ -0,0 +1,33 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * Tells whether a subclass overrides a method that gitlib 1.x called itself, + * deprecated in 1.7: gitlib keeps calling the override, like before. + * + * @author Grégoire Pineau + * + * @internal To be removed in 2.0, with the deprecated methods + */ +final class LegacyOverride +{ + /** + * @param object|class-string $objectOrClass The instance, or the class of a static call + * @param class-string $class The class of gitlib declaring $method + */ + public static function isOverridden(object|string $objectOrClass, string $class, string $method): bool + { + return $class !== (new \ReflectionMethod($objectOrClass, $method))->getDeclaringClass()->getName(); + } +} diff --git a/src/Gitonomy/Git/Util/StringHelper.php b/src/Gitonomy/Git/Util/StringHelper.php index d784b66b..5e652699 100644 --- a/src/Gitonomy/Git/Util/StringHelper.php +++ b/src/Gitonomy/Git/Util/StringHelper.php @@ -12,10 +12,14 @@ namespace Gitonomy\Git\Util; +trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s" class is deprecated, use symfony/string instead: gitlib 2.0 reads the logs in UTF-8 only.', StringHelper::class); + /** * Helper class to support language particularity. * * @author Alexandre Salomé + * + * @deprecated since gitonomy/gitlib 1.7, use symfony/string instead: gitlib 2.0 reads the logs in UTF-8 only */ class StringHelper { diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index a94fbbb9..faf209ee 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -15,9 +15,13 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Reference\Branch; /** * @author Alexandre Salomé + * + * @final since gitonomy/gitlib 1.7 */ class WorkingCopy { @@ -48,32 +52,49 @@ public function getUntrackedFiles() return $lines; } - public function getDiffPending() + /** + * Returns the changes of the working tree, staged or not, since $revision. + * + * @throws ReferenceNotFoundException $revision names no commit + */ + public function getDiff(Revision|string $revision = 'HEAD'): Diff { - $diff = Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index'])); - $diff->setRepository($this->repository); + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); - return $diff; + return Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', $hash]), $this->repository); } - public function getDiffStaged() + public function getDiffPending() { - $diff = Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--staged'])); - $diff->setRepository($this->repository); + return Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index']), $this->repository); + } - return $diff; + public function getDiffStaged() + { + return Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--staged']), $this->repository); } /** + * Passing a branch to create as second argument is deprecated, as a local + * Branch object, which detaches HEAD, where 2.0 attaches HEAD to it. + * * @return WorkingCopy */ public function checkout($revision, $branch = null) { + if (null !== $branch) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a branch to create to "%s()" is deprecated, use "checkoutNewBranch()" instead.', __METHOD__); + } elseif ($revision instanceof Branch && $revision->isLocal()) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a local "%s" to "%s()", which detaches HEAD, is deprecated, 2.0 attaches HEAD to the branch: pass its name, or its commit to detach HEAD.', Branch::class, __METHOD__); + } + $args = []; if ($revision instanceof Commit) { $args[] = $revision->getHash(); } elseif ($revision instanceof Reference) { $args[] = $revision->getFullname(); + } elseif ($revision instanceof Revision) { + $args[] = $revision->getRevision(); } elseif (is_string($revision)) { $args[] = $revision; } else { @@ -89,6 +110,24 @@ public function checkout($revision, $branch = null) return $this; } + /** + * Creates the branch $branch at $startPoint, and checks it out, like + * `git checkout -b`. + * + * @return $this + * + * @throws ReferenceNotFoundException $startPoint names no commit + */ + public function checkoutNewBranch(string $branch, Revision|string $startPoint = 'HEAD'): self + { + // Like in 2.0. git gets the name, which tells a remote branch, to track it. + ($startPoint instanceof Revision ? $startPoint : $this->repository->getRevision($startPoint))->getCommit(); + + $this->run('checkout', ['-b', $branch, $startPoint instanceof Revision ? $startPoint->getRevision() : $startPoint]); + + return $this; + } + protected function run($command, array $args = []) { return $this->repository->run($command, $args); diff --git a/tests/Gitonomy/Git/Tests/AbstractTest.php b/tests/Gitonomy/Git/Tests/AbstractTest.php index 0b9a51b6..31379fd0 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTest.php +++ b/tests/Gitonomy/Git/Tests/AbstractTest.php @@ -12,7 +12,7 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Admin; +use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Repository; use PHPUnit\Framework\TestCase; @@ -42,7 +42,7 @@ abstract class AbstractTest extends TestCase public static function createEmptyRepository($bare = true) { $dir = self::createTempDir(); - $repository = Admin::init($dir, $bare, self::getOptions()); + $repository = $bare ? Repository::initBare($dir, self::getDriver()) : Repository::init($dir, self::getDriver()); self::registerDeletion($repository); return $repository; @@ -78,10 +78,11 @@ public static function provideEmpty() public static function createFoobarRepository($bare = true) { if (null === self::$localRepository) { - self::$localRepository = Admin::cloneTo(self::createTempDir(), self::REPOSITORY_URL, $bare, self::getOptions()); + self::$localRepository = Repository::cloneBareFrom(self::REPOSITORY_URL, self::createTempDir(), null, self::getDriver()); } - $repository = self::$localRepository->cloneTo(self::createTempDir(), $bare, self::getOptions()); + $url = self::$localRepository->getGitDir(); + $repository = $bare ? Repository::cloneBareFrom($url, self::createTempDir(), null, self::getDriver()) : Repository::cloneFrom($url, self::createTempDir(), null, self::getDriver()); self::registerDeletion($repository); return $repository; @@ -100,7 +101,7 @@ public static function registerDeletion(Repository $repository) } /** - * Created an empty directory and return path to it. + * Created an empty directory, deleted at the end of the tests, and return path to it. * * @return string a fullpath */ @@ -110,6 +111,10 @@ public static function createTempDir() unlink($tmpDir); mkdir($tmpDir); + register_shutdown_function(function () use ($tmpDir) { + self::deleteDir($tmpDir); + }); + return $tmpDir; } @@ -120,6 +125,11 @@ public static function createTempDir() */ protected static function deleteDir($dir) { + // Deleted already, by another shutdown function. + if (!file_exists($dir)) { + return; + } + $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS); $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::CHILD_FIRST); foreach ($iterator as $file) { @@ -148,4 +158,11 @@ protected static function getOptions() 'process_timeout' => 60, ]; } + + protected static function getDriver(): CliDriver + { + $options = self::getOptions(); + + return new CliDriver($options['command'], $options['environment_variables'], $options['process_timeout']); + } } diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index 0f7460fa..d0c1be34 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -16,9 +16,15 @@ use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +/** + * @group legacy + */ class AdminTest extends AbstractTest { + use ExpectDeprecationTrait; + private $tmpDir; /** @@ -39,6 +45,8 @@ public function tearDownTmpDir() public function testBare() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::initBare()" instead.'); + $repository = Admin::init($this->tmpDir, true, self::getOptions()); $objectDir = $this->tmpDir.'/objects'; @@ -52,6 +60,8 @@ public function testBare() public function testNotBare() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::init()" instead.'); + $repository = Admin::init($this->tmpDir, false, self::getOptions()); $objectDir = $this->tmpDir.'/.git/objects'; @@ -68,6 +78,8 @@ public function testNotBare() */ public function testClone($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::'.($repository->isBare() ? 'cloneBareFrom' : 'cloneFrom').'()" with "getGitDir()" as URL instead.'); + $newDir = self::createTempDir(); $new = $repository->cloneTo($newDir, $repository->isBare(), self::getOptions()); self::registerDeletion($new); @@ -88,6 +100,8 @@ public function testClone($repository) public function testCloneBranchBare() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with its "branch" argument instead, which takes the URL before the path.'); + //we can't use AbstractText::createFoobarRepository() //because it does not clone other branches than "master" //so we test it directly against the remote repository @@ -103,6 +117,8 @@ public function testCloneBranchBare() public function testCloneBranchNotBare() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with its "branch" argument instead, which takes the URL before the path.'); + //we can't use AbstractText::createFoobarRepository() //because it does not clone other branches than "master" //so we test it directly against remote repository @@ -121,6 +137,8 @@ public function testCloneBranchNotBare() */ public function testMirror($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::mirrorTo()" method is deprecated, use "Gitonomy\Git\Repository::mirrorFrom()" instead, which takes the URL before the path.'); + $newDir = self::createTempDir(); $new = Admin::mirrorTo($newDir, $repository->getGitDir(), self::getOptions()); self::registerDeletion($new); @@ -143,12 +161,16 @@ public function testMirror($repository) */ public function testCheckValidRepository($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::isValidRepository()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepository()" instead.'); + $url = $repository->getGitDir(); $this->assertTrue(Admin::isValidRepository($url)); } public function testCheckInvalidRepository() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::isValidRepository()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepository()" instead.'); + $url = $this->tmpDir.'/invalid.git'; mkdir($url); @@ -160,6 +182,8 @@ public function testCheckInvalidRepository() */ public function testCheckValidRepositoryAndBranch($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::isValidRepositoryAndBranch()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepositoryAndBranch()" instead.'); + $url = $repository->getGitDir(); $this->assertTrue(Admin::isValidRepositoryAndBranch($url, 'master')); } @@ -169,12 +193,16 @@ public function testCheckValidRepositoryAndBranch($repository) */ public function testCheckInvalidRepositoryAndBranch($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::isValidRepositoryAndBranch()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepositoryAndBranch()" instead.'); + $url = $repository->getGitDir(); $this->assertFalse(Admin::isValidRepositoryAndBranch($url, 'invalid-branch-name')); } public function testExistingFile() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::initBare()" instead.'); + $this->expectException(RuntimeException::class); $file = $this->tmpDir.'/test'; @@ -185,6 +213,8 @@ public function testExistingFile() public function testCloneRepository() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneRepository()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()", "Gitonomy\Git\Repository::cloneBareFrom()" or "Gitonomy\Git\Repository::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".'); + $newDir = self::createTempDir(); $args = []; diff --git a/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php b/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php new file mode 100644 index 00000000..6c4434bd --- /dev/null +++ b/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php @@ -0,0 +1,259 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Admin; +use Gitonomy\Git\Blame; +use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Log; +use Gitonomy\Git\PushReference; +use Gitonomy\Git\Reference; +use Gitonomy\Git\ReferenceBag; +use Gitonomy\Git\Repository; + +/** + * 1.7 keeps working what worked with 1.6: the named arguments, the classes + * extending the ones of gitlib, and the methods they override. + */ +class BackwardCompatibilityTest extends AbstractTest +{ + public function testEmptyOptions() + { + $source = self::createFoobarRepository(false); + + $repository = new Repository($source->getWorkingDir(), []); + + $this->assertSame($source->getWorkingDir(), $repository->getWorkingDir()); + } + + /** + * @group legacy + */ + public function testNamedArguments() + { + $source = self::createFoobarRepository(false); + $repository = new Repository($source->getGitDir(), options: ['working_dir' => $source->getWorkingDir()]); + + $this->assertSame($source->getWorkingDir(), $repository->getWorkingDir()); + $this->assertCount(1, $repository->getDiff(revisions: self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT)->getFiles()); + $this->assertCount(3, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', lineRange: 5)); + $this->assertCount(3, new Blame($repository, $repository->getRevision(self::LONGFILE_COMMIT), 'README.md', lineRange: 5)); + + $references = $repository->getReferences(); + $references->createBranch('foobar', commitHash: self::INITIAL_COMMIT); + $references->createTag('foobar', commitHash: self::INITIAL_COMMIT); + $this->assertCount(2, $references->resolve(hash: self::INITIAL_COMMIT)); + $this->assertCount(1, $references->resolveBranches(hash: self::INITIAL_COMMIT)); + $this->assertCount(1, $references->resolveTags(hash: self::INITIAL_COMMIT)); + + $this->assertSame('refs/heads/foobar', (new PushReference($repository, reference: 'refs/heads/foobar', before: self::INITIAL_COMMIT, after: self::LONGFILE_COMMIT))->getFullname()); + + $data = $repository->getCommit(self::LONGFILE_COMMIT)->getDiff()->toArray(); + $this->assertCount(1, Diff::fromArray(array: $data)->getFiles()); + $this->assertSame('long.php', File::fromArray(array: $data['files'][0])->getName()); + $this->assertCount(1, Diff::parse(rawDiff: $data['rawDiff'])->getFiles()); + } + + /** + * @group legacy + */ + public function testSubclassesOverridingMethodsWithTheSignatureOf16() + { + $repository = self::createFoobarRepository(); + $legacy = new LegacyRepository($repository->getGitDir()); + + $this->assertCount(1, $legacy->getDiff(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT)->getFiles()); + $this->assertCount(3, $legacy->getBlame(self::LONGFILE_COMMIT, 'README.md', 5)); + + $data = $repository->getCommit(self::LONGFILE_COMMIT)->getDiff()->toArray(); + $this->assertCount(1, LegacyDiff::parse($data['rawDiff'])->getFiles()); + $this->assertCount(1, LegacyDiff::fromArray($data)->getFiles()); + $this->assertSame('long.php', LegacyFile::fromArray($data['files'][0])->getName()); + } + + /** + * @group legacy + */ + public function testOverriddenAdminCloneRepository() + { + LegacyAdmin::$args = []; + + $repository = LegacyAdmin::cloneTo(self::createTempDir(), self::createFoobarRepository()->getGitDir(), false); + + $this->assertSame([[]], LegacyAdmin::$args); + $this->assertFalse($repository->isBare()); + } + + /** + * @group legacy + */ + public function testOverriddenReferenceBagUpdate() + { + $references = new LegacyReferenceBag(self::createFoobarRepository()); + + $references->createBranch('foobar', self::INITIAL_COMMIT); + $references->createTag('foobar', self::INITIAL_COMMIT); + + $this->assertSame(['refs/heads/foobar', 'refs/tags/foobar'], $references->updated); + } + + /** + * @group legacy + */ + public function testOverriddenLogSetLimit() + { + $log = new LegacyLog(self::createFoobarRepository(), self::LONGFILE_COMMIT, null, null, 5); + + $this->assertSame(self::LONGFILE_COMMIT, $log->getSingleCommit()->getHash()); + $this->assertSame([5], $log->limits); + $this->assertSame(5, $log->getLimit()); + } + + public function testOverriddenPushReferenceMethods() + { + $reference = new LegacyPushReference(self::createFoobarRepository(), 'refs/heads/foobar', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT); + + // The overrides say it creates the reference: neither forced, nor fast-forward. + $this->assertFalse($reference->isForce()); + $this->assertFalse($reference->isFastForward()); + $this->assertSame(self::LONGFILE_COMMIT, $reference->getRange()); + // getRevision() gives the commits of the log, from the first one. + $this->assertCount(8, $reference->getLog()->getCommits()); + } + + public function testOverriddenFileChangeGetCount() + { + $file = new File('a.txt', 'a.txt', '100644', '100644', 'abc', 'def', false, false, [new LegacyFileChange(1, 1, 1, 1, [])]); + + $this->assertSame(42, $file->getAdditions()); + $this->assertSame(42, $file->getDeletions()); + } + + /** + * @group legacy + */ + public function testOverriddenFileSetRepository() + { + $repository = self::createFoobarRepository(); + $file = new LegacyFile('a.txt', 'a.txt', '100644', '100644', 'abc', 'def', false); + + (new Diff([$file], ''))->setRepository($repository); + + $this->assertSame($repository, $file->repositorySet); + } +} + +class LegacyRepository extends Repository +{ + public function getDiff($revisions) + { + return parent::getDiff($revisions); + } + + public function getBlame($revision, $file, $lineRange = null) + { + return parent::getBlame($revision, $file, $lineRange); + } +} + +class LegacyDiff extends Diff +{ + public static function parse($rawDiff) + { + return parent::parse($rawDiff); + } + + public static function fromArray(array $array) + { + return parent::fromArray($array); + } +} + +class LegacyFile extends File +{ + public $repositorySet; + + public static function fromArray(array $array) + { + return parent::fromArray($array); + } + + public function setRepository(Repository $repository) + { + $this->repositorySet = $repository; + } +} + +class LegacyFileChange extends FileChange +{ + public function getCount($type) + { + return 42; + } +} + +class LegacyAdmin extends Admin +{ + public static $args = []; + + public static function cloneRepository($path, $url, array $args = [], array $options = []) + { + self::$args[] = $args; + + return parent::cloneRepository($path, $url, $args, $options); + } +} + +class LegacyReferenceBag extends ReferenceBag +{ + public $updated = []; + + public function update(Reference $reference) + { + $this->updated[] = $reference->getFullname(); + + return parent::update($reference); + } +} + +class LegacyLog extends Log +{ + public $limits = []; + + public function setLimit($limit) + { + $this->limits[] = $limit; + + return parent::setLimit($limit); + } +} + +class LegacyPushReference extends PushReference +{ + public function isCreate() + { + return true; + } + + public function isDelete() + { + return false; + } + + public function getRevision() + { + return $this->getAfter(); + } +} diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index 6d92547d..d4735e8d 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -12,8 +12,16 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Blame\Line; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; + class BlameTest extends AbstractTest { + use ExpectDeprecationTrait; + + const README_COMMIT = 'cfba9c69819a96df0635e2e7ddc86da20f954db6'; + /** * @dataProvider provideFoobar */ @@ -33,8 +41,102 @@ public function testBlame($repository) /** * @dataProvider provideFoobar */ + public function testHunks($repository) + { + $hunks = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getHunks(); + + $this->assertCount(3, $hunks); + $this->assertSame(self::INITIAL_COMMIT, $hunks[0]->getCommit()->getHash()); + $this->assertSame([1, 2, 3], array_keys($hunks[0]->getLines())); + $this->assertSame(self::README_COMMIT, $hunks[1]->getCommit()->getHash()); + $this->assertSame([4, 5, 6], array_keys($hunks[1]->getLines())); + $this->assertSame([7], array_keys($hunks[2]->getLines())); + + $line = $hunks[2]->getLines()[7]; + $this->assertSame(7, $line->getNumber()); + // The line 5 of the file in the initial commit. + $this->assertSame(5, $line->getOriginalNumber()); + } + + /** + * @dataProvider provideFoobar + */ + public function testRangeOfLines($repository) + { + $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 4, 6); + + $this->assertCount(3, $blame); + $this->assertSame([4, 5, 6], array_map(function (Line $line) { + return $line->getNumber(); + }, array_values($blame->getLines()))); + + $hunks = $blame->getHunks(); + $this->assertCount(1, $hunks); + // Numbered like the file, unlike getLines(), which numbers them from 1. + $this->assertSame([4, 5, 6], array_keys($hunks[0]->getLines())); + } + + /** + * @dataProvider provideFoobar + */ + public function testRangeOfLinesWithoutLastLine($repository) + { + $this->assertCount(3, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 5)); + $this->assertCount(2, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', null, 2)); + } + + /** + * @dataProvider provideFoobar + */ + public function testRangeOfLinesInReverse($repository) + { + $this->assertCount(3, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 6, 4)); + } + + /** + * @dataProvider provideFoobar + */ + public function testRangeOfLinesFromZero($repository) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid range of lines "0,3": the lines are numbered from 1.'); + + $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 0, 3); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testRangeOfLinesAsAString($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing a range of lines as a string to "Gitonomy\Git\Repository::getBlame()" or "Gitonomy\Git\Blame::__construct()" is deprecated, pass the first and the last lines instead.'); + + $this->assertCount(3, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', '4,+3')); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testLineGetLine($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Blame\Line::getLine()" method is deprecated, use "getOriginalNumber()" instead.'); + + $this->assertSame('5', $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getLine(7)->getLine()); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ public function testGroupedBlame($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Blame::getGroupedLines()" method is deprecated, use "getHunks()" instead.'); + $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getGroupedLines(); $this->assertCount(3, $blame); diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 431a0a46..5cb51a74 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -49,7 +49,7 @@ public function testNotExisting($repository) { $this->expectException(RuntimeException::class); - $blob = $repository->getBlob('foobar'); + $blob = $repository->getBlob('deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); $blob->getContent(); } diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 7d094648..ba74c7ac 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -16,11 +16,16 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use Gitonomy\Git\Tree; +use Gitonomy\Git\Util\StringHelper; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class CommitTest extends AbstractTest { + use ExpectDeprecationTrait; + /** * @dataProvider provideFoobar */ @@ -229,9 +234,13 @@ public function testGetSignedMessage($repository) /** * @dataProvider provideFoobar + * + * @group legacy */ public function testGetShortMessage($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Commit::getShortMessage()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".'); + // tests with a multi-line message $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); @@ -264,9 +273,12 @@ public function testGetBodyMessage($repository) /** * @dataProvider provideFoobar + * + * @group legacy */ public function testGetIncludingBranchesException($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing arguments to "Gitonomy\Git\Commit::getIncludingBranches()" is deprecated, filter the branches it returns with "Gitonomy\Git\Reference\Branch::isLocal()" or "isRemote()" instead.'); $this->expectException(InvalidArgumentException::class); $commit = $repository->getCommit(self::INITIAL_COMMIT); @@ -281,6 +293,24 @@ public function testGetIncludingBranches($repository) { $commit = $repository->getCommit(self::INITIAL_COMMIT); + $branches = $commit->getIncludingBranches(); + $this->assertCount(count($repository->getReferences()->getBranches()), $branches); + $this->assertCount(count($repository->getReferences()->getLocalBranches()), array_filter($branches, function (Branch $branch) { + return $branch->isLocal(); + })); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testGetIncludingBranchesWithArguments($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing arguments to "Gitonomy\Git\Commit::getIncludingBranches()" is deprecated, filter the branches it returns with "Gitonomy\Git\Reference\Branch::isLocal()" or "isRemote()" instead.'); + + $commit = $repository->getCommit(self::INITIAL_COMMIT); + $branches = $commit->getIncludingBranches(true, false); $this->assertCount(count($repository->getReferences()->getLocalBranches()), $branches); @@ -323,4 +353,42 @@ public function testEncoding($repository) $this->assertEquals('contribute to AUTHORS file', $commit->getSubjectMessage()); } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testGetFixedShortHash($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Commit::getFixedShortHash()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.'); + + $this->assertSame('74acd0', $repository->getCommit(self::INITIAL_COMMIT)->getFixedShortHash()); + } + + /** + * @runInSeparateProcess + * + * @group legacy + */ + public function testStringHelperIsDeprecated() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Util\StringHelper" class is deprecated, use symfony/string instead: gitlib 2.0 reads the logs in UTF-8 only.'); + + $this->assertSame(3, StringHelper::strlen('été')); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testConstructorWithData($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing data to "Gitonomy\\Git\\Commit::__construct()" is deprecated, the constructor is internal in 2.0, and takes a "CommitData" instead of an array: get the commit from "Gitonomy\\Git\\Repository::getCommit()".'); + + $commit = new Commit($repository, self::INITIAL_COMMIT, ['subjectMessage' => 'Hello']); + + $this->assertSame('Hello', $commit->getSubjectMessage()); + } } diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 9703bdde..2b9aac7b 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -13,11 +13,19 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\DiffLineType; use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Repository; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class DiffTest extends AbstractTest { + use ExpectDeprecationTrait; + const DELETE_COMMIT = '519d5693c72c925cd59205d9f11e9fa1d550028b'; const CREATE_COMMIT = 'e6fa3c792facc06faa049a6938c84c411954deb5'; const RENAME_COMMIT = '6640e0ef31518054847a1876328e26ee64083e0a'; @@ -109,7 +117,7 @@ public function testGetFiles_Rename($repository) $this->assertTrue($files[0]->isRename()); $this->assertFalse($files[0]->isDeletion()); $this->assertFalse($files[0]->isCreation()); - $this->assertFalse($files[0]->isChangeMode()); + $this->assertFalse($files[0]->isModeChange()); } /** @@ -122,7 +130,7 @@ public function testGetFiles_Changemode($repository) $this->assertCount(1, $files, '1 files modified'); $this->assertTrue($files[0]->isModification()); - $this->assertTrue($files[0]->isChangeMode()); + $this->assertTrue($files[0]->isModeChange()); $this->assertFalse($files[0]->isDeletion()); $this->assertFalse($files[0]->isCreation()); $this->assertFalse($files[0]->isRename()); @@ -153,14 +161,13 @@ public function testWorksWithUmlauts($repository) $this->assertSame('file_with_umlauts_\303\244\303\266\303\274', $files[0]->getNewName()); } + /** + * @group legacy + */ public function testDeleteFileWithoutRaw() { - $deprecationCalled = false; - $self = $this; - set_error_handler(function (int $errno, string $errstr) use ($self, &$deprecationCalled): void { - $deprecationCalled = true; - $self->assertSame('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', $errstr); - }, E_USER_DEPRECATED); + // Not silenced, like in 1.6. + $this->expectDeprecation('Unsilenced deprecation: Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); $diff = Diff::parse(<<<'DIFF' diff --git a/test b/test @@ -170,24 +177,20 @@ public function testDeleteFileWithoutRaw() DIFF); $firstFile = $diff->getFiles()[0]; - restore_exception_handler(); - - $this->assertTrue($deprecationCalled); $this->assertFalse($firstFile->isCreation()); $this->assertTrue($firstFile->isDeletion()); - $this->assertFalse($firstFile->isChangeMode()); + $this->assertFalse($firstFile->isModeChange()); $this->assertSame('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', $firstFile->getOldIndex()); $this->assertNull($firstFile->getNewIndex()); } + /** + * @group legacy + */ public function testModeChangeFileWithoutRaw() { - $deprecationCalled = false; - $self = $this; - set_error_handler(function (int $errno, string $errstr) use ($self, &$deprecationCalled): void { - $deprecationCalled = true; - $self->assertSame('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', $errstr); - }, E_USER_DEPRECATED); + // Not silenced, like in 1.6. + $this->expectDeprecation('Unsilenced deprecation: Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); $diff = Diff::parse(<<<'DIFF' diff --git a/a.out b/a.out @@ -197,23 +200,15 @@ public function testModeChangeFileWithoutRaw() DIFF); $firstFile = $diff->getFiles()[0]; - restore_exception_handler(); - - $this->assertTrue($deprecationCalled); $this->assertFalse($firstFile->isCreation()); $this->assertFalse($firstFile->isDeletion()); - $this->assertTrue($firstFile->isChangeMode()); + $this->assertTrue($firstFile->isModeChange()); $this->assertSame('', $firstFile->getOldIndex()); $this->assertSame('', $firstFile->getNewIndex()); } public function testModeChangeFileWithRaw() { - $deprecationCalled = false; - set_error_handler(function (int $errno, string $errstr) use (&$deprecationCalled): void { - $deprecationCalled = true; - }, E_USER_DEPRECATED); - $diff = Diff::parse(<<<'DIFF' :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M testfile :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 M testfile2 @@ -230,19 +225,15 @@ public function testModeChangeFileWithRaw() $firstFile = $files[0]; $secondFile = $files[1]; - restore_exception_handler(); - - $this->assertFalse($deprecationCalled); - $this->assertFalse($firstFile->isCreation()); $this->assertFalse($firstFile->isDeletion()); - $this->assertTrue($firstFile->isChangeMode()); + $this->assertTrue($firstFile->isModeChange()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getOldIndex()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getNewIndex()); $this->assertFalse($secondFile->isCreation()); $this->assertFalse($secondFile->isDeletion()); - $this->assertTrue($secondFile->isChangeMode()); + $this->assertTrue($secondFile->isModeChange()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getOldIndex()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getNewIndex()); } @@ -250,8 +241,7 @@ public function testModeChangeFileWithRaw() public function testThrowErrorOnBlobGetWithoutIndex() { $repository = $this->getMockBuilder(Repository::class)->disableOriginalConstructor()->getMock(); - $file = new File('oldName', 'newName', '100755', '100644', '', '', false); - $file->setRepository($repository); + $file = new File('oldName', 'newName', '100755', '100644', '', '', false, false, [], $repository); try { $file->getOldBlob(); @@ -267,13 +257,19 @@ public function testThrowErrorOnBlobGetWithoutIndex() $this->assertFalse($file->isCreation()); $this->assertFalse($file->isDeletion()); - $this->assertTrue($file->isChangeMode()); + $this->assertTrue($file->isModeChange()); $this->assertSame('', $file->getOldIndex()); $this->assertSame('', $file->getNewIndex()); } + /** + * @group legacy + */ public function testEmptyNewFile() { + // Not silenced, like in 1.6. + $this->expectDeprecation('Unsilenced deprecation: Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); + $diff = Diff::parse("diff --git a/test b/test\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n"); $firstFile = $diff->getFiles()[0]; @@ -283,8 +279,14 @@ public function testEmptyNewFile() $this->assertNull($firstFile->getOldName()); } + /** + * @group legacy + */ public function testEmptyOldFile() { + // Not silenced, like in 1.6. + $this->expectDeprecation('Unsilenced deprecation: Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); + $diff = Diff::parse("diff --git a/test b/test\ndeleted file mode 100644\nindex e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000\n"); $firstFile = $diff->getFiles()[0]; @@ -293,4 +295,226 @@ public function testEmptyOldFile() $this->assertNull($firstFile->getNewName()); $this->assertSame('test', $firstFile->getOldName()); } + + /** + * @dataProvider provideFoobar + */ + public function testParseWithTheRepository($repository) + { + $raw = $repository->run('diff-tree', ['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index', self::CREATE_COMMIT]); + + $file = Diff::parse($raw, $repository)->getFiles()[0]; + + $this->assertSame($repository, $file->getRepository()); + $this->assertSame("getNewBlob()->getContent()); + } + + /** + * @dataProvider provideFoobar + */ + public function testFromArrayWithTheRepository($repository) + { + $data = $repository->getCommit(self::CREATE_COMMIT)->getDiff()->toArray(); + + $diff = Diff::fromArray($data, $repository); + + $this->verifyCreateCommitDiff($diff); + $this->assertSame($repository, $diff->getFiles()[0]->getRepository()); + $this->assertSame($repository, File::fromArray($data['files'][0], $repository)->getRepository()); + } + + public function testConstructorTakesTheChangesAndTheRepository() + { + $repository = $this->getMockBuilder(Repository::class)->disableOriginalConstructor()->getMock(); + $change = new FileChange(1, 1, 1, 2, [new DiffLine(DiffLineType::Context, 'a'), new DiffLine(DiffLineType::Addition, 'b')]); + + $file = new File('a.txt', 'a.txt', '100644', '100644', 'abc', 'def', false, false, [$change], $repository); + + $this->assertSame([$change], $file->getChanges()); + $this->assertSame(1, $file->getAdditions()); + $this->assertSame(0, $file->getDeletions()); + $this->assertSame($repository, $file->getRepository()); + $this->assertFalse($file->isCopy()); + } + + public function testCopy() + { + $file = new File('a.txt', 'b.txt', '100644', '100644', 'abc', 'abc', false, true); + + $this->assertTrue($file->isCopy()); + $this->assertFalse($file->isRename()); + // Like in 1.6, toArray() has no "is_copy" key, which 2.0 adds: fromArray() reads it. + $this->assertArrayNotHasKey('is_copy', $file->toArray()); + $this->assertTrue(File::fromArray(['is_copy' => true] + $file->toArray())->isCopy()); + } + + public function testFileChangeCountsTheLines() + { + $change = new FileChange(1, 2, 1, 2, [new DiffLine(DiffLineType::Deletion, 'a'), new DiffLine(DiffLineType::Addition, 'b'), new DiffLine(DiffLineType::Addition, 'c'), new DiffLine(DiffLineType::Context, 'd')]); + + $this->assertSame(2, $change->getAdditions()); + $this->assertSame(1, $change->getDeletions()); + } + + public function testFileChangeTakesDiffLines() + { + $lines = [new DiffLine(DiffLineType::Deletion, 'a'), new DiffLine(DiffLineType::Addition, 'b'), new DiffLine(DiffLineType::Context, 'c')]; + + $change = new FileChange(1, 2, 1, 2, $lines); + + $this->assertEquals($lines, $change->getDiffLines()); + $this->assertSame([[-1, 'a'], [1, 'b'], [0, 'c']], $change->toArray()['lines']); + $this->assertEquals($lines, FileChange::fromArray($change->toArray())->getDiffLines()); + } + + /** + * @dataProvider provideFoobar + */ + public function testGetDiffLines($repository) + { + $change = $repository->getCommit(self::LONGFILE_COMMIT)->getDiff()->getFiles()[0]->getChanges()[0]; + + $lines = $change->getDiffLines(); + + $this->assertCount($change->getRangeNewCount(), $lines); + $this->assertSame(DiffLineType::Addition, $lines[0]->getType()); + $this->assertSame(array_map(static fn (DiffLine $line): array => [$line->getType()->value, $line->getContent()], $lines), $change->toArray()['lines']); + } + + public function testGetDiffLinesThrowsForAnUnknownType() + { + $change = FileChange::fromArray(['range_old_start' => 1, 'range_old_count' => 1, 'range_new_start' => 1, 'range_new_count' => 1, 'lines' => [[5, 'a']]]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown type of line 5: the types are 1 (an addition), 0 (a context line) and -1 (a deletion).'); + + $change->getDiffLines(); + } + + /** + * @group legacy + */ + public function testFileChangeLinesAsArrays() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing the lines to "Gitonomy\Git\Diff\FileChange::__construct()" as arrays is deprecated, pass "Gitonomy\Git\Diff\DiffLine" objects instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\FileChange::getLines()" method is deprecated, use "getDiffLines()" instead.'); + + $change = new FileChange(1, 1, 1, 2, [[FileChange::LINE_CONTEXT, 'a'], [FileChange::LINE_ADD, 'b']]); + + $this->assertSame([[0, 'a'], [1, 'b']], $change->getLines()); + $this->assertEquals([new DiffLine(DiffLineType::Context, 'a'), new DiffLine(DiffLineType::Addition, 'b')], $change->getDiffLines()); + $this->assertSame(1, $change->getAdditions()); + } + + /** + * @dataProvider provideFoobar + */ + public function testGetDiffBetweenTwoRevisions($repository) + { + $diff = $repository->getDiff(self::BEFORE_LONGFILE_COMMIT, $repository->getCommit(self::LONGFILE_COMMIT)); + + $this->assertSame($repository->getCommit(self::LONGFILE_COMMIT)->getDiff()->toArray()['files'], $diff->toArray()['files']); + $this->assertSame($repository, $diff->getFiles()[0]->getRepository()); + } + + /** + * @dataProvider provideFoobar + */ + public function testGetDiffRefusesAListOfRevisionsWithTo($repository) + { + $this->expectException(InvalidArgumentException::class); + + $repository->getDiff([self::BEFORE_LONGFILE_COMMIT], self::LONGFILE_COMMIT); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testGetDiffWithOneArgument($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Calling "Gitonomy\Git\Repository::getDiff()" with one argument is deprecated, pass the revisions to compare as first and second arguments, or use "Gitonomy\Git\WorkingCopy::getDiff()" to compare a revision with the working tree.'); + + $diff = $repository->getDiff(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT); + + $this->assertSame($repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(), $diff->toArray()); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testLogGetDiff($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Log::getDiff()" method is deprecated, use "Gitonomy\Git\Repository::getDiff()" instead.'); + + $diff = $repository->getLog(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT)->getDiff(); + + $this->assertSame($repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(), $diff->toArray()); + } + + /** + * @group legacy + */ + public function testSetRepository() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\Diff::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); + + $repository = $this->getMockBuilder(Repository::class)->disableOriginalConstructor()->getMock(); + $diff = new Diff([new File('a.txt', 'a.txt', '100644', '100644', 'abc', 'def', false)], ''); + $diff->setRepository($repository); + + $this->assertSame($repository, $diff->getFiles()[0]->getRepository()); + + $file = new File('a.txt', 'a.txt', '100644', '100644', 'abc', 'def', false); + $file->setRepository($repository); + + $this->assertSame($repository, $file->getRepository()); + } + + /** + * @group legacy + */ + public function testAddChange() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::addChange()" method is deprecated, give the changes to the constructor instead.'); + + $file = new File('a.txt', 'a.txt', '100644', '100644', 'abc', 'def', false); + $change = new FileChange(1, 1, 1, 1, [new DiffLine(DiffLineType::Addition, 'a')]); + $file->addChange($change); + + $this->assertSame([$change], $file->getChanges()); + } + + /** + * @group legacy + */ + public function testDeprecatedNames() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::isChangeMode()" method is deprecated, use "isModeChange()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::isDelete()" method is deprecated, use "isDeletion()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::getAnchor()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\FileChange::getCount()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.'); + + $file = new File('a.txt', null, '100755', null, 'abcdef0123456789', null, false); + + $this->assertFalse($file->isChangeMode()); + $this->assertTrue($file->isDelete()); + $this->assertSame('', $file->getAnchor()); + $this->assertSame('0123456789ab', (new File('a.txt', 'a.txt', '100644', '100644', 'abc', '0123456789abcdef', false))->getAnchor()); + $this->assertSame(1, (new FileChange(1, 1, 1, 1, [new DiffLine(DiffLineType::Addition, 'a')]))->getCount(DiffLineType::Addition->value)); + } + + /** + * @dataProvider provideFoobar + */ + public function testGetDiffThrowsForAnUnknownRevision($repository) + { + $this->expectException(ReferenceNotFoundException::class); + + $repository->getDiff(self::LONGFILE_COMMIT, 'unknown'); + } } diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index a02184dd..38ac22cf 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -12,10 +12,84 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Parser\LogParser; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class LogTest extends AbstractTest { + use ExpectDeprecationTrait; + + /** + * @dataProvider provideFoobar + */ + public function testWithOffsetAndLimit($repository) + { + $log = $repository->getLog(self::LONGFILE_COMMIT); + + $page = $log->withOffset(1)->withLimit(1); + + $this->assertSame([self::BEFORE_LONGFILE_COMMIT], array_map(function ($commit) { + return $commit->getHash(); + }, $page->getCommits())); + $this->assertSame(1, $page->getOffset()); + $this->assertSame(1, $page->getLimit()); + $this->assertNull($log->getOffset()); + $this->assertNull($log->getLimit()); + } + + /** + * @dataProvider provideFoobar + */ + public function testWithNegativeOffset($repository) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The offset of a log can\'t be negative, -1 given.'); + + $repository->getLog()->withOffset(-1); + } + + /** + * @dataProvider provideFoobar + */ + public function testWithNegativeLimit($repository) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The limit of a log can\'t be negative, -1 given.'); + + $repository->getLog()->withLimit(-1); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testSetOffsetAndLimit($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Log::setOffset()" method is deprecated, use "withOffset()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Log::setLimit()" method is deprecated, use "withLimit()" instead.'); + + $log = $repository->getLog(self::LONGFILE_COMMIT); + $log->setOffset(1)->setLimit(1); + + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $log->getSingleCommit()->getHash()); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testNegativeOffsetAndLimit($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing a negative offset to "Gitonomy\Git\Log::__construct()" is deprecated, it throws in 2.0.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing a negative limit to "Gitonomy\Git\Log::__construct()" is deprecated, it throws in 2.0: pass null for no limit.'); + + $log = $repository->getLog(self::LONGFILE_COMMIT, null, -1, -1); + + $this->assertCount(8, $log->getCommits()); + } /** * @dataProvider provideFoobar */ @@ -133,4 +207,38 @@ public function testParsesCommitsWithAndWithoutGitButlerHeaders(): void $this->assertEquals("Commit with GitButler headers\n", $log[1]['message']); $this->assertEquals("Another commit without GitButler headers\n", $log[2]['message']); } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testCountWithALimit($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Counting a log whose offset or limit leaves commits out with "Gitonomy\\Git\\Log::count()" is deprecated, it counts only the commits the log iterates in 2.0: use "countCommits()" to count them all.'); + + $log = $repository->getLog(self::LONGFILE_COMMIT)->withLimit(2); + + $this->assertCount(8, $log); + } + + /** + * @dataProvider provideFoobar + */ + public function testCountWithALimitLeavingNoCommitOut($repository) + { + $this->assertCount(8, $repository->getLog(self::LONGFILE_COMMIT)->withOffset(0)->withLimit(10)); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testOptionAsRevision($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing "--no-merges", an option of "git log", as revision to "Gitonomy\\Git\\Log::__construct()" is deprecated, it throws in 2.0: pass revisions, ranges, names to exclude or "--all".'); + + $this->assertCount(1, $repository->getLog(['--no-merges', self::MERGE_COMMIT])->withLimit(1)->getCommits()); + } } diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index 4490a3f9..8c638313 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -12,10 +12,14 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\LogicException; use Gitonomy\Git\PushReference; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class PushReferenceTest extends AbstractTest { + use ExpectDeprecationTrait; + const CREATE = 1; const DELETE = 2; const FORCE = 4; @@ -38,8 +42,8 @@ public function provideIsers() public function testIsers($reference, $before, $after, $mask) { $reference = new PushReference(self::createFoobarRepository(), $reference, $before, $after); - $this->assertEquals($mask & self::CREATE, $reference->isCreate(), 'Create value is correct.'); - $this->assertEquals($mask & self::DELETE, $reference->isDelete(), 'Delete value is correct.'); + $this->assertEquals($mask & self::CREATE, $reference->isCreation(), 'Create value is correct.'); + $this->assertEquals($mask & self::DELETE, $reference->isDeletion(), 'Delete value is correct.'); $this->assertEquals($mask & self::FORCE, $reference->isForce(), 'Force value is correct.'); $this->assertEquals($mask & self::FAST_FORWARD, $reference->isFastForward(), 'FastForward value is correct.'); } @@ -53,7 +57,7 @@ public function testLog($repository) $log = $ref->getLog()->getCommits(); $this->assertCount(7, $log, '7 commits in log'); - $this->assertEquals('add a long file', $log[0]->getShortMessage(), 'First commit is correct'); + $this->assertEquals('add a long file', $log[0]->getSubjectMessage(), 'First commit is correct'); } /** @@ -66,7 +70,7 @@ public function testSignedLog($repository) $ref = new PushReference($repository, 'foo', self::INITIAL_COMMIT, self::SIGNED_COMMIT); $log = $ref->getLog()->getCommits(); $this->assertCount(16, $log, '16 commits in log'); - $this->assertEquals('signed commit', $log[0]->getShortMessage(), 'Last commit is correct'); + $this->assertEquals('signed commit', $log[0]->getSubjectMessage(), 'Last commit is correct'); } /** @@ -78,6 +82,45 @@ public function testLogWithExclude($repository) $log = $ref->getLog([self::INITIAL_COMMIT])->getCommits(); $this->assertCount(7, $log, '7 commits in log'); - $this->assertEquals('add a long file', $log[0]->getShortMessage(), 'First commit is correct'); + $this->assertEquals('add a long file', $log[0]->getSubjectMessage(), 'First commit is correct'); + } + + public function testFullnameAndRange() + { + $repository = self::createFoobarRepository(); + + $reference = new PushReference($repository, 'refs/heads/foo', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT); + $this->assertSame('refs/heads/foo', $reference->getFullname()); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT, $reference->getRange()); + + $creation = new PushReference($repository, 'refs/heads/foo', PushReference::ZERO, self::LONGFILE_COMMIT); + $this->assertSame(self::LONGFILE_COMMIT, $creation->getRange()); + } + + public function testRangeOfADeletion() + { + $reference = new PushReference(self::createFoobarRepository(), 'refs/heads/foo', self::LONGFILE_COMMIT, PushReference::ZERO); + + $this->expectException(LogicException::class); + + $reference->getRange(); + } + + /** + * @group legacy + */ + public function testDeprecatedNames() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::getReference()" method is deprecated, use "getFullname()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::getRevision()" method is deprecated, use "getRange()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::isCreate()" method is deprecated, use "isCreation()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::isDelete()" method is deprecated, use "isDeletion()" instead.'); + + $reference = new PushReference(self::createFoobarRepository(), 'refs/heads/foo', PushReference::ZERO, self::LONGFILE_COMMIT); + + $this->assertSame('refs/heads/foo', $reference->getReference()); + $this->assertSame(self::LONGFILE_COMMIT, $reference->getRevision()); + $this->assertTrue($reference->isCreate()); + $this->assertFalse($reference->isDelete()); } } diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 0f975577..158b518c 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -12,12 +12,17 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Tag; +use Gitonomy\Git\Repository; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class ReferenceTest extends AbstractTest { + use ExpectDeprecationTrait; + /** * @dataProvider provideEmpty */ @@ -212,9 +217,13 @@ public function testCreateAndDeleteBranch($repository) /** * @dataProvider provideFoobar + * + * @group legacy */ public function testIsBranchMergedToMaster() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Reference\Branch::isMergedTo()" method is deprecated, use "Gitonomy\Git\Reference\Branch::isMergedInto()" instead.'); + $repository = self::createFoobarRepository(false); $repository->run('config', ['--local', 'user.name', '"Unit Test"']); @@ -237,4 +246,124 @@ public function testIsBranchMergedToMaster() $this->assertFalse($branch->isMergedTo('master')); } + + public function testIsMergedInto() + { + $repository = self::createFoobarRepository(); + $references = $repository->getReferences(); + + $this->assertTrue($references->getBranch('master')->isMergedInto('master')); + $this->assertFalse($references->getBranch('new-feature')->isMergedInto('master')); + $this->assertTrue($references->createBranch('initial', self::INITIAL_COMMIT)->isMergedInto($references->getBranch('new-feature'))); + } + + /** + * @dataProvider provideFoobar + */ + public function testMoveTo($repository) + { + $references = $repository->getReferences(); + $branch = $references->createBranch('foobar', self::INITIAL_COMMIT); + + $moved = $branch->moveTo(self::LONGFILE_COMMIT); + + $this->assertSame(self::LONGFILE_COMMIT, $moved->getCommitHash()); + // The bag read before sees the move, like in 2.0. + $this->assertSame(self::LONGFILE_COMMIT, $references->getBranch('foobar')->getCommitHash()); + $this->assertContains($moved, $references->getLocalBranches()); + $this->assertSame(self::LONGFILE_COMMIT."\n", $repository->run('rev-parse', ['refs/heads/foobar'])); + // The branch read before keeps its commit. + $this->assertSame(self::INITIAL_COMMIT, $branch->getCommitHash()); + } + + /** + * @dataProvider provideFoobar + */ + public function testMoveToAnAnnotatedTag($repository) + { + $branch = $repository->getReferences()->createBranch('foobar', self::INITIAL_COMMIT); + + $moved = $branch->moveTo($repository->getReferences()->getTag('annotated')); + + // The commit of the tag, not the tag object. + $this->assertSame($repository->getRevision('annotated^{commit}')->getCommit()->getHash(), $moved->getCommitHash()); + } + + /** + * @dataProvider provideFoobar + */ + public function testCreateBranchFromARevision($repository) + { + $branch = $repository->getReferences()->createBranch('foobar', $repository->getRevision('master')); + + $this->assertSame($repository->getReferences()->getBranch('master')->getCommitHash(), $branch->getCommitHash()); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testCreateExistingReferences($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Creating the branch "master", which exists already, is deprecated, it throws in 2.0: use "Gitonomy\Git\Reference\Branch::moveTo()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Creating the tag "0.1", which exists already, is deprecated, it throws in 2.0: delete it first.'); + + $references = $repository->getReferences(); + $references->createBranch('master', self::INITIAL_COMMIT); + $references->createTag('0.1', self::INITIAL_COMMIT); + + $this->assertSame(self::INITIAL_COMMIT."\n", $repository->run('rev-parse', ['refs/heads/master'])); + $this->assertSame(self::INITIAL_COMMIT."\n", $repository->run('rev-parse', ['refs/tags/0.1'])); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testDeleteOutsideRefs($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Deleting "HEAD", which is not the full name of a reference under "refs/", is deprecated, it throws in 2.0.'); + + $repository->getReferences()->delete('HEAD'); + + // git deletes the branch HEAD points to. + $this->assertFalse((new Repository($repository->getGitDir(), null, self::getDriver()))->getReferences()->hasBranch('master')); + } + + /** + * @dataProvider provideFoobar + * + * @group legacy + */ + public function testUpdate($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\ReferenceBag::update()" method is deprecated, use "Gitonomy\Git\Reference\Branch::moveTo()" instead.'); + + $repository->getReferences()->update(new Branch($repository, 'refs/heads/foobar', self::INITIAL_COMMIT)); + + $this->assertSame(self::INITIAL_COMMIT."\n", $repository->run('rev-parse', ['refs/heads/foobar'])); + } + + public function testCreateBranchFromAName() + { + $repository = self::createFoobarRepository(); + $references = $repository->getReferences(); + $master = $references->getBranch('master')->getCommitHash(); + + $branch = $references->createBranch('foobar', 'master'); + + // The name, like in 1.6, where 2.0 resolves it. + $this->assertSame('master', $branch->getCommitHash()); + $this->assertSame($master."\n", $repository->run('rev-parse', ['refs/heads/foobar'])); + $this->assertTrue($branch->isMergedInto('master')); + } + + public function testCreateBranchFromAnUnknownName() + { + $this->expectException(ProcessException::class); + + self::createFoobarRepository()->getReferences()->createBranch('foobar', 'unknown'); + } } diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index fc5c7deb..39285451 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -13,14 +13,239 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Blob; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Psr\Log\LoggerInterface; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class RepositoryTest extends AbstractTest { + use ExpectDeprecationTrait; use ProphecyTrait; + public function testInit() + { + $dir = self::createTempDir(); + $repository = Repository::init($dir, self::getDriver()); + self::registerDeletion($repository); + + $this->assertFalse($repository->isBare()); + $this->assertSame($dir.'/.git', $repository->getGitDir()); + $this->assertSame($dir, $repository->getWorkingDir()); + } + + public function testInitBare() + { + $dir = self::createTempDir(); + $repository = Repository::initBare($dir, self::getDriver()); + self::registerDeletion($repository); + + $this->assertTrue($repository->isBare()); + $this->assertSame($dir, $repository->getGitDir()); + $this->assertDirectoryExists($dir.'/objects'); + } + + public function testInitThrowsForAFile() + { + $file = self::createTempDir().'/file'; + touch($file); + + $this->expectException(RuntimeException::class); + + Repository::initBare($file, self::getDriver()); + } + + public function testCloneFrom() + { + $dir = self::createTempDir(); + $repository = Repository::cloneFrom(self::createFoobarRepository()->getGitDir(), $dir, null, self::getDriver()); + self::registerDeletion($repository); + + $this->assertFalse($repository->isBare()); + $this->assertSame($dir, $repository->getWorkingDir()); + $this->assertSame('master', $repository->getHead()->getName()); + $this->assertTrue($repository->getReferences()->hasRemoteBranch('origin/new-feature')); + } + + public function testCloneFromABranch() + { + $repository = Repository::cloneFrom(self::createFoobarRepository()->getGitDir(), self::createTempDir(), 'new-feature', self::getDriver()); + self::registerDeletion($repository); + + $this->assertSame('new-feature', $repository->getHead()->getName()); + } + + public function testCloneBareFrom() + { + $dir = self::createTempDir(); + $repository = Repository::cloneBareFrom(self::createFoobarRepository()->getGitDir(), $dir, null, self::getDriver()); + self::registerDeletion($repository); + + $this->assertTrue($repository->isBare()); + $this->assertSame($dir, $repository->getGitDir()); + $this->assertSame('master', $repository->getHead()->getName()); + $this->assertTrue($repository->getReferences()->hasBranch('new-feature')); + } + + public function testCloneBareFromABranch() + { + $repository = Repository::cloneBareFrom(self::createFoobarRepository()->getGitDir(), self::createTempDir(), 'new-feature', self::getDriver()); + self::registerDeletion($repository); + + $head = $repository->getHead(); + $this->assertInstanceOf(Branch::class, $head); + $this->assertSame('new-feature', $head->getName()); + } + + public function testCloneFromThrowsForAMissingRepository() + { + $this->expectException(RuntimeException::class); + + Repository::cloneFrom(self::createTempDir().'/missing', self::createTempDir(), null, self::getDriver()); + } + + public function testMirrorFrom() + { + $source = self::createFoobarRepository(false); + $dir = self::createTempDir(); + $repository = Repository::mirrorFrom($source->getGitDir(), $dir, self::getDriver()); + self::registerDeletion($repository); + + $this->assertTrue($repository->isBare()); + $this->assertSame($dir, $repository->getGitDir()); + // A mirror keeps the remote branches of its source, which a bare clone leaves out. + $this->assertTrue($repository->getReferences()->has('refs/remotes/origin/new-feature')); + } + + public function testIsValidRepository() + { + $this->assertTrue(Repository::isValidRepository(self::createFoobarRepository()->getGitDir(), self::getDriver())); + $this->assertFalse(Repository::isValidRepository(self::createTempDir(), self::getDriver())); + } + + public function testIsValidRepositoryAndBranch() + { + $url = self::createFoobarRepository()->getGitDir(); + + $this->assertTrue(Repository::isValidRepositoryAndBranch($url, 'new-feature', self::getDriver())); + // The exact name, where Admin::isValidRepositoryAndBranch() matches "new-feature" too. + $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'feature', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'unknown', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch(self::createTempDir(), 'master', self::getDriver())); + } + + public function testWorkingDirArgument() + { + $source = self::createFoobarRepository(false); + + $repository = new Repository($source->getGitDir(), $source->getWorkingDir(), self::getDriver()); + + $this->assertFalse($repository->isBare()); + $this->assertSame($source->getGitDir(), $repository->getGitDir()); + $this->assertSame($source->getWorkingDir(), $repository->getWorkingDir()); + } + + public function testDriverConfiguresGit() + { + $driver = new CliDriver('git', ['GIT_CONFIG_PARAMETERS' => "'gitlib.test=yes'"]); + $repository = new Repository(self::createFoobarRepository()->getGitDir(), null, $driver); + + $this->assertSame("yes\n", $repository->run('config', ['gitlib.test'])); + } + + public function testDriverCommand() + { + $repository = new Repository(self::createFoobarRepository()->getGitDir(), null, new CliDriver('not-git')); + + $this->expectException(ProcessException::class); + + $repository->run('status'); + } + + /** + * @group legacy + */ + public function testOptionsAreDeprecated() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing an array of options to "Gitonomy\Git\Repository::__construct()" is deprecated, pass the working directory as second argument, and configure the logger and git through the "driver" argument.'); + + $source = self::createFoobarRepository(false); + $repository = new Repository($source->getGitDir(), ['working_dir' => $source->getWorkingDir()] + self::getOptions()); + + $this->assertSame($source->getWorkingDir(), $repository->getWorkingDir()); + } + + /** + * @group legacy + */ + public function testDebugOptionSetToFalseIsDeprecated() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Setting the "debug" option of "Gitonomy\Git\Repository::__construct()" to false is deprecated, a failing git operation always throws in 2.0: catch its exception instead.'); + + $repository = new Repository(self::createFoobarRepository()->getGitDir(), ['debug' => false] + self::getOptions()); + + $this->assertNull($repository->run('not-a-command')); + } + + /** + * @group legacy + */ + public function testGitOptionsCannotBeCombinedWithTheDriver() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The driver argument can\'t be combined with the "logger" or git options: configure the driver instead.'); + + new Repository(self::createFoobarRepository()->getGitDir(), ['process_timeout' => 60], self::getDriver()); + } + + /** + * @group legacy + */ + public function testOtherOptionsCombineWithTheDriver() + { + $source = self::createFoobarRepository(false); + $driver = new CliDriver('git', ['GIT_CONFIG_PARAMETERS' => "'gitlib.test=yes'"]); + + $repository = new Repository($source->getGitDir(), ['working_dir' => $source->getWorkingDir()], $driver); + + $this->assertSame($source->getWorkingDir(), $repository->getWorkingDir()); + $this->assertSame("yes\n", $repository->run('config', ['gitlib.test'])); + } + + /** + * @dataProvider provideFoobar + */ + public function testGetMergeBase($repository) + { + $mergeBase = $repository->getMergeBase('master', $repository->getRevision(self::INITIAL_COMMIT)); + + $this->assertSame(self::INITIAL_COMMIT, $mergeBase->getHash()); + } + + public function testGetMergeBaseOfUnrelatedHistories() + { + $repository = self::createFoobarRepository(); + $driver = new CliDriver('git', ['GIT_AUTHOR_NAME' => 'Unit Test', 'GIT_AUTHOR_EMAIL' => 'unit@example.com', 'GIT_COMMITTER_NAME' => 'Unit Test', 'GIT_COMMITTER_EMAIL' => 'unit@example.com']); + // A root commit of the empty tree, which every repository has. + $unrelated = trim((new Repository($repository->getGitDir(), null, $driver))->run('commit-tree', ['4b825dc642cb6eb9a060e54bf8d69288fbee4904', '-m', 'Unrelated'])); + + $this->assertNull($repository->getMergeBase('master', $unrelated)); + } + + public function testGetMergeBaseThrowsForAnUnknownRevision() + { + $this->expectException(ReferenceNotFoundException::class); + + self::createFoobarRepository()->getMergeBase('master', 'unknown'); + } + /** * @dataProvider provideFoobar */ @@ -39,12 +264,30 @@ public function testGetBlobWithExistingWorks($repository) /** * @dataProvider provideFoobar + * + * @group legacy */ public function testGetSize($repository) { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::getSize()" method is deprecated, use "getSizeInBytes()" instead.'); + $size = $repository->getSize(); $this->assertGreaterThanOrEqual(57, $size, 'Repository is at least 57KB'); $this->assertLessThan(84, $size, 'Repository is less than 84KB'); + $this->assertSame((int) round($repository->getSizeInBytes() / 1000), $size); + } + + public function testGetSizeInBytesDoesNotFollowLinks() + { + $repository = self::createEmptyRepository(); + $size = $repository->getSizeInBytes(); + + $this->assertGreaterThan(0, $size); + + symlink($repository->getGitDir().'/missing', $repository->getGitDir().'/hooks/pre-commit'); + symlink($repository->getGitDir().'/config', $repository->getGitDir().'/hooks/post-commit'); + + $this->assertSame($size, $repository->getSizeInBytes()); } public function testIsBare() @@ -78,10 +321,10 @@ public function testLoggerOk($repository) ->info('run command: remote "" ') ->shouldBeCalledTimes(1); $loggerProphecy - ->debug(Argument::type('string')) // duration, return code and output - ->shouldBeCalledTimes(3); + ->debug(Argument::type('string')) // creation of the repository, duration, return code and output + ->shouldBeCalledTimes(4); - $repository->setLogger($loggerProphecy->reveal()); + $repository = new Repository($repository->getGitDir(), $repository->getWorkingDir(), new CliDriver('git', [], 60, $loggerProphecy->reveal())); $repository->run('remote'); } @@ -102,14 +345,93 @@ public function testLoggerNOk($repository) ->info(Argument::type('string')) ->shouldBeCalledTimes(1); $loggerProphecy - ->debug(Argument::type('string')) // duration, return code and output - ->shouldBeCalledTimes(3); + ->debug(Argument::type('string')) // creation of the repository, duration, return code and output + ->shouldBeCalledTimes(4); $loggerProphecy ->error(Argument::type('string')) ->shouldBeCalledTimes(1); - $repository->setLogger($loggerProphecy->reveal()); + $repository = new Repository($repository->getGitDir(), $repository->getWorkingDir(), new CliDriver('git', [], 60, $loggerProphecy->reveal())); $repository->run('not-work'); } + + /** + * @group legacy + */ + public function testSetLoggerIsDeprecated() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::setLogger()" method is deprecated, pass a "Gitonomy\Git\Driver\Cli\CliDriver" configured with the logger as driver argument instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::getLogger()" method is deprecated, the logger is an argument of "Gitonomy\Git\Driver\Cli\CliDriver".'); + + $repository = self::createFoobarRepository(); + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $repository->setLogger($logger); + + $this->assertSame($logger, $repository->getLogger()); + } + + public function testGetMergeBaseOfUnrelatedHistoriesLogsNoError() + { + $repository = self::createFoobarRepository(); + $driver = new CliDriver('git', ['GIT_AUTHOR_NAME' => 'Unit Test', 'GIT_AUTHOR_EMAIL' => 'unit@example.com', 'GIT_COMMITTER_NAME' => 'Unit Test', 'GIT_COMMITTER_EMAIL' => 'unit@example.com']); + $unrelated = trim((new Repository($repository->getGitDir(), null, $driver))->run('commit-tree', ['4b825dc642cb6eb9a060e54bf8d69288fbee4904', '-m', 'Unrelated'])); + + $loggerProphecy = $this->prophesize(LoggerInterface::class); + $loggerProphecy->error(Argument::any())->shouldNotBeCalled(); + $loggerProphecy->info(Argument::any())->shouldBeCalled(); + $loggerProphecy->debug(Argument::any())->shouldBeCalled(); + + $repository = new Repository($repository->getGitDir(), null, new CliDriver('git', [], 60, $loggerProphecy->reveal())); + + $this->assertNull($repository->getMergeBase('master', $unrelated)); + } + + public function testIsValidRepositoryReadsARelativePathAsAPath() + { + $repository = self::createFoobarRepository(false); + $cwd = getcwd(); + + try { + // A repository, whose remote "origin" git would read in its working directory. + chdir($repository->getWorkingDir()); + $this->assertFalse(Repository::isValidRepository('origin', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch('origin', 'master', self::getDriver())); + + chdir(dirname($repository->getWorkingDir())); + $this->assertTrue(Repository::isValidRepository(basename($repository->getWorkingDir()), self::getDriver())); + } finally { + chdir($cwd); + } + } + + /** + * @group legacy + */ + public function testGetObjectsByName() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing "master^{tree}", which is not a hash, to "Gitonomy\\Git\\Repository::getTree()" is deprecated, it throws in 2.0: use "getRevision($name)->getCommit()->getTree()" instead.'); + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing "master:README.md", which is not a hash, to "Gitonomy\\Git\\Repository::getBlob()" is deprecated, it throws in 2.0: use "Tree::resolvePath()" instead.'); + + $repository = self::createFoobarRepository(); + + $this->assertArrayHasKey('README.md', iterator_to_array($repository->getTree('master^{tree}'))); + $this->assertStringContainsString('Foo Bar project', $repository->getBlob('master:README.md')->getContent()); + } + + /** + * @group legacy + */ + public function testGetReferencesReadsThemAgain() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing an argument to "Gitonomy\\Git\\Repository::getReferences()" is deprecated, open a new Repository to read the references again.'); + + $repository = self::createFoobarRepository(); + $this->assertFalse($repository->getReferences()->hasBranch('foobar')); + + $repository->run('branch', ['foobar', self::INITIAL_COMMIT]); + + $this->assertFalse($repository->getReferences()->hasBranch('foobar')); + $this->assertTrue($repository->getReferences(true)->hasBranch('foobar')); + } } diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index b7ba7d32..d45c20e5 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -14,63 +14,76 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\CommitReference; +use Gitonomy\Git\Submodule; +use Gitonomy\Git\Tree; +use Gitonomy\Git\TreeEntry; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class TreeTest extends AbstractTest { + use ExpectDeprecationTrait; + const PATH_RESOLVING_COMMIT = 'cc06ac171d884282202dff88c1ded499a1f89420'; /** * @dataProvider provideFooBar */ - public function testGetEntries($repository) + public function testIterate($repository) { $tree = $repository->getCommit(self::LONGFILE_COMMIT)->getTree(); - $entries = $tree->getEntries(); - - $this->assertNotEmpty($entries['long.php'], 'long.php is present'); - $this->assertTrue($entries['long.php'][1] instanceof Blob, 'long.php is a Blob'); + $entries = iterator_to_array($tree); - $this->assertNotEmpty($entries['README.md'], 'README.md is present'); - $this->assertTrue($entries['README.md'][1] instanceof Blob, 'README.md is a Blob'); + $this->assertInstanceOf(TreeEntry::class, $entries['long.php']); + $this->assertSame('long.php', $entries['long.php']->getName()); + $this->assertSame('100644', $entries['long.php']->getMode()); + $this->assertInstanceOf(Blob::class, $entries['long.php']->getObject()); + $this->assertInstanceOf(Blob::class, $entries['README.md']->getObject()); } /** * @dataProvider provideFooBar */ - public function testGetCommitReferenceEntries($repository) + public function testGetSubmodules($repository) { $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $commits = $tree->getCommitReferenceEntries(); + $submodules = $tree->getSubmodules(); - $this->assertNotEmpty($commits['barbaz'], 'barbaz is present'); - $this->assertTrue($commits['barbaz'][1] instanceof CommitReference, 'barbaz is a Commit'); + $this->assertSame(['barbaz'], array_keys($submodules)); + $this->assertSame('barbaz', $submodules['barbaz']->getName()); + $this->assertSame('160000', $submodules['barbaz']->getMode()); + $this->assertInstanceOf(Submodule::class, $submodules['barbaz']->getObject()); + // A CommitReference, like in 1.6, which 2.0 makes an alias of Submodule. + $this->assertSame(CommitReference::class, get_class($submodules['barbaz']->getObject())); + $this->assertSame('5a519c47166f333e32091e166eb55f729106c964', $submodules['barbaz']->getObject()->getHash()); } /** * @dataProvider provideFooBar */ - public function testGetTreeEntries($repository) + public function testGetTrees($repository) { - $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + $this->assertSame([], $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree()->getTrees()); - $trees = $tree->getTreeEntries(); + $trees = $repository->getCommit(self::PATH_RESOLVING_COMMIT)->getTree()->getTrees(); - $this->assertEmpty($trees); + $this->assertSame('040000', $trees['test']->getMode()); + $this->assertInstanceOf(Tree::class, $trees['test']->getObject()); } /** * @dataProvider provideFooBar */ - public function testGetBlobEntries($repository) + public function testGetBlobs($repository) { $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $blobs = $tree->getBlobEntries(); + $blobs = $tree->getBlobs(); - $this->assertNotEmpty($blobs['README.md'], 'README.md is present'); - $this->assertTrue($blobs['README.md'][1] instanceof Blob, 'README.md is a blob'); + $this->assertSame('README.md', $blobs['README.md']->getName()); + $this->assertInstanceOf(Blob::class, $blobs['README.md']->getObject()); + $this->assertArrayNotHasKey('barbaz', $blobs); } /** @@ -82,8 +95,73 @@ public function testResolvePath($repository) $path = 'test/a/b/c'; $resolved = $tree->resolvePath($path); - $entries = $resolved->getEntries(); - $this->assertNotEmpty($entries['d'], 'Successfully resolved source folder'); + $this->assertArrayHasKey('d', iterator_to_array($resolved), 'Successfully resolved source folder'); + } + + /** + * @dataProvider provideFooBar + * + * @group legacy + */ + public function testGetEntries($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getEntries()" method is deprecated, iterate the tree instead.'); + + $tree = $repository->getCommit(self::LONGFILE_COMMIT)->getTree(); + + $entries = $tree->getEntries(); + + $this->assertSame('100644', $entries['long.php'][0]); + $this->assertInstanceOf(Blob::class, $entries['long.php'][1]); + $this->assertInstanceOf(Blob::class, $entries['README.md'][1]); + } + + /** + * @dataProvider provideFooBar + * + * @group legacy + */ + public function testGetCommitReferenceEntries($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getCommitReferenceEntries()" method is deprecated, use "getSubmodules()" instead.'); + + $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $commits = $tree->getCommitReferenceEntries(); + + $this->assertSame('160000', $commits['barbaz'][0]); + $this->assertInstanceOf(CommitReference::class, $commits['barbaz'][1]); + } + + /** + * @dataProvider provideFooBar + * + * @group legacy + */ + public function testGetTreeEntries($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getTreeEntries()" method is deprecated, use "getTrees()" instead.'); + + $trees = $repository->getCommit(self::PATH_RESOLVING_COMMIT)->getTree()->getTreeEntries(); + + $this->assertSame('040000', $trees['test'][0]); + $this->assertInstanceOf(Tree::class, $trees['test'][1]); + } + + /** + * @dataProvider provideFooBar + * + * @group legacy + */ + public function testGetBlobEntries($repository) + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getBlobEntries()" method is deprecated, use "getBlobs()" instead.'); + + $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $blobs = $tree->getBlobEntries(); + + $this->assertInstanceOf(Blob::class, $blobs['README.md'][1]); } } diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index dfa048f9..cdb1a4d3 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -12,25 +12,65 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Admin; +use Gitonomy\Git\Repository; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class WorkingCopyTest extends AbstractTest { + use ExpectDeprecationTrait; + public function testNoWorkingCopyInBare() { $this->expectException(LogicException::class); $path = self::createTempDir(); - $repo = Admin::init($path, true, self::getOptions()); + $repo = Repository::initBare($path, self::getDriver()); $repo->getWorkingCopy(); } + public function testCheckoutNewBranch() + { + $repository = self::createFoobarRepository(false); + $wc = $repository->getWorkingCopy(); + $wc->checkoutNewBranch('new-feature', 'origin/new-feature'); + + $head = $repository->getHead(); + $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); + $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + $this->assertSame($repository->getReferences()->getRemoteBranch('origin/new-feature')->getCommitHash(), $head->getCommitHash()); + } + + public function testCheckoutNewBranchFromHead() + { + $repository = self::createFoobarRepository(false); + $repository->getWorkingCopy()->checkoutNewBranch('foobar'); + + $head = $repository->getHead(); + $this->assertSame('foobar', $head->getName()); + $this->assertSame($repository->getReferences()->getBranch('master')->getCommitHash(), $head->getCommitHash()); + } + + public function testCheckoutARevision() + { + $repository = self::createFoobarRepository(false); + $repository->getWorkingCopy()->checkout($repository->getRevision('0.1')); + + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame($repository->getRevision('0.1')->getCommit()->getHash(), $repository->getHeadCommit()->getHash()); + } + + /** + * @group legacy + */ public function testCheckout() { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing a branch to create to "Gitonomy\Git\WorkingCopy::checkout()" is deprecated, use "checkoutNewBranch()" instead.'); + $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); $wc->checkout('origin/new-feature', 'new-feature'); @@ -40,6 +80,27 @@ public function testCheckout() $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); } + public function testDiff() + { + $repository = self::createFoobarRepository(false); + $wc = $repository->getWorkingCopy(); + + $this->assertCount(0, $wc->getDiff()->getFiles()); + + file_put_contents($repository->getWorkingDir().'/staged.txt', 'staged'); + $repository->run('add', ['staged.txt']); + file_put_contents($repository->getWorkingDir().'/README.md', 'pending'); + + $diff = $wc->getDiff(); + $this->assertSame(['README.md', 'staged.txt'], array_map(function ($file) { + return $file->getName(); + }, $diff->getFiles())); + $this->assertSame($repository, $diff->getFiles()[0]->getRepository()); + + // Against another revision, the changes of the commits since. + $this->assertGreaterThan(2, count($wc->getDiff($repository->getRevision(self::LONGFILE_COMMIT))->getFiles())); + } + public function testDiffStaged() { $repository = self::createFoobarRepository(false); @@ -110,4 +171,36 @@ public function testGetUntracked() $this->assertContains('untracked.txt', $wc->getUntrackedFiles()); } + + public function testDiffThrowsForAnUnknownRevision() + { + $this->expectException(ReferenceNotFoundException::class); + + self::createFoobarRepository(false)->getWorkingCopy()->getDiff('unknown'); + } + + public function testCheckoutNewBranchThrowsForAnUnknownStartPoint() + { + $repository = self::createFoobarRepository(false); + + try { + $repository->getWorkingCopy()->checkoutNewBranch('foobar', 'unknown'); + $this->fail('An unknown start point throws.'); + } catch (ReferenceNotFoundException $e) { + $this->assertFalse((new Repository($repository->getGitDir(), null, self::getDriver()))->getReferences()->hasBranch('foobar')); + } + } + + /** + * @group legacy + */ + public function testCheckoutALocalBranchObject() + { + $this->expectDeprecation('Since gitonomy/gitlib 1.7: Passing a local "Gitonomy\\Git\\Reference\\Branch" to "Gitonomy\\Git\\WorkingCopy::checkout()", which detaches HEAD, is deprecated, 2.0 attaches HEAD to the branch: pass its name, or its commit to detach HEAD.'); + + $repository = self::createFoobarRepository(false); + $repository->getWorkingCopy()->checkout($repository->getReferences()->getBranch('master')); + + $this->assertTrue($repository->isHeadDetached()); + } } From 02f796eedb1e436429afa1b2730638703480ffa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Thu, 15 Oct 2026 15:42:18 +0200 Subject: [PATCH 142/150] Release v1.7.0 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 061e75e0..cc0535fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project are documented in this file. -## [Unreleased] +## [v1.7.0] - 2026-10-15 Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). @@ -100,5 +100,5 @@ _Earlier versions are not documented here. See the [GitHub releases](https://github.com/gitonomy/gitlib/releases) and [commit history](https://github.com/gitonomy/gitlib/commits/v1.6.0) for prior changes._ -[Unreleased]: https://github.com/gitonomy/gitlib/compare/v1.6.0...1.x +[v1.7.0]: https://github.com/gitonomy/gitlib/compare/v1.6.0...v1.7.0 [v1.6.0]: https://github.com/gitonomy/gitlib/releases/tag/v1.6.0 From 8743ffb59e40c68302345580dc0b50dcc37e68dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Thu, 15 Oct 2026 15:52:22 +0200 Subject: [PATCH 143/150] Smooth the upgrade from 1.7 1.7 has the API of 2.0 that can be added without breaking 1.x, and deprecates what 2.0 replaces: 2.0 introduces no deprecation of its own, they are all deprecated since 1.7. So the lines of a hunk and the entries of a tree get the methods 1.7 has: FileChange::getDiffLines(), iterating a Tree, Tree::getBlobs(), getTrees() and getSubmodules(). The methods returning arrays are deprecated, and keep returning the arrays of 1.x until 3.0, instead of objects readable as arrays. UnexpectedValueException, which 2.0 no longer throws, has nothing to replace it in 1.7: it isn't deprecated. UPGRADE-2.0.md explains how to support 1.x and 2.x with a single code path, and the branch alias lets "^2.0@dev" install main. --- CHANGELOG.md | 24 ++- UPGRADE-2.0.md | 171 ++++++++++++++++-- composer.json | 5 + doc/admin.md | 2 +- doc/blame.md | 6 +- doc/branch.md | 4 +- doc/commit.md | 6 +- doc/diff.md | 22 ++- doc/log.md | 2 +- doc/references.md | 4 +- doc/repository.md | 10 +- doc/tree.md | 29 +-- doc/workingcopy.md | 4 +- src/Gitonomy/Git/Admin.php | 30 +-- src/Gitonomy/Git/Blame.php | 4 +- src/Gitonomy/Git/Blame/Line.php | 4 +- src/Gitonomy/Git/Commit.php | 10 +- src/Gitonomy/Git/CommitReference.php | 4 +- src/Gitonomy/Git/Diff/Diff.php | 4 +- src/Gitonomy/Git/Diff/DiffLine.php | 39 +--- src/Gitonomy/Git/Diff/File.php | 20 +- src/Gitonomy/Git/Diff/FileChange.php | 56 ++++-- .../Exception/UnexpectedValueException.php | 3 +- src/Gitonomy/Git/Log.php | 12 +- src/Gitonomy/Git/PushReference.php | 16 +- src/Gitonomy/Git/Reference/Branch.php | 4 +- src/Gitonomy/Git/ReferenceBag.php | 4 +- src/Gitonomy/Git/Repository.php | 28 +-- src/Gitonomy/Git/Tree.php | 129 +++++++++---- src/Gitonomy/Git/TreeEntry.php | 38 +--- src/Gitonomy/Git/WorkingCopy.php | 2 +- tests/Gitonomy/Git/Tests/AdminTest.php | 24 +-- tests/Gitonomy/Git/Tests/BlameTest.php | 2 +- tests/Gitonomy/Git/Tests/BlobTest.php | 2 +- tests/Gitonomy/Git/Tests/CommitTest.php | 6 +- tests/Gitonomy/Git/Tests/DiffTest.php | 58 ++++-- tests/Gitonomy/Git/Tests/LogTest.php | 6 +- .../Gitonomy/Git/Tests/PushReferenceTest.php | 8 +- tests/Gitonomy/Git/Tests/ReferenceTest.php | 18 +- tests/Gitonomy/Git/Tests/RepositoryTest.php | 12 +- tests/Gitonomy/Git/Tests/TreeTest.php | 79 ++++++-- tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 2 +- 42 files changed, 582 insertions(+), 331 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51456c44..8d6c9876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,8 @@ deprecated until 3.0. missing revision or object a `ReferenceNotFoundException`, a timeout a `ProcessTimedOutException`; dates are `\DateTimeImmutable`; names in diffs and trees are UTF-8; references are read again after every write gitlib makes, and peel annotated tags; - the diff of a merge commit is against its first parent; blame lines, tree entries, diff - lines and logs are typed objects; the subject of a message is its first paragraph. See + the diff of a merge commit is against its first parent; blame lines and logs are typed + objects; the subject of a message is its first paragraph. See [UPGRADE-2.0.md](UPGRADE-2.0.md) for every change. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Return the dates of commits and tags at the UTC offset of their author, committer or tagger, @@ -43,6 +43,9 @@ deprecated until 3.0. `The git command "clone" failed (exit 128): fatal: ...`. The command line, the rest of the error output and the output are no longer in the message: `getErrorOutput()` and `getOutput()` return the outputs of git. ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- `Tree::resolvePath()` throws an `InvalidArgumentException` for a path through a submodule, + instead of an `Exception\UnexpectedValueException`, which gitlib no longer throws. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) - When git can't start, a missing binary for instance, `CliDriver` throws a `DriverException` instead of a `ProcessException`, with a message of one line, without the command line: `The git command "clone" could not start: ...`. @@ -78,7 +81,9 @@ deprecated until 3.0. `Repository::getSizeInBytes()` and `WorkingCopy::getDiff()`, `WorkingCopy::checkoutNewBranch()`, `Branch::moveTo()` and `Branch::isMergedInto()`, `Log::withOffset()` and `withLimit()`, `Blame::getHunks()` and the line numbers of - `Blame\Line`, `TreeEntry` and `Submodule`, `Diff\DiffLine` and `Diff\DiffLineType`, + `Blame\Line`, `FileChange::getDiffLines()`, which returns `Diff\DiffLine` objects typed by + `Diff\DiffLineType`, iterating a `Tree`, which gives `TreeEntry` objects, + `Tree::getBlobs()`, `getTrees()` and `getSubmodules()`, `Submodule`, `File::isModeChange()`, `FileChange::getAdditions()` and `getDeletions()`, `PushReference::getFullname()`, `getRange()`, `isCreation()` and `isDeletion()`, and the SSH and X.509 signatures of tags. ([#253](https://github.com/gitonomy/gitlib/pull/253), @@ -103,7 +108,8 @@ deprecated until 3.0. ### Deprecations All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). -([#253](https://github.com/gitonomy/gitlib/pull/253)) +([#253](https://github.com/gitonomy/gitlib/pull/253)) 1.7 deprecates them already: they are +all deprecated since 1.7. - `Admin`, replaced by the static methods of `Repository`, and `Repository::cloneTo()`, replaced by `cloneFrom()` and `cloneBareFrom()`. @@ -116,18 +122,20 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). replaced by `Branch::moveTo()` and `isMergedInto()`. - Calling `Repository::getDiff()` with one argument, `Log::getDiff()`, `Diff::setRepository()`, `File::setRepository()`, `addChange()`, `isChangeMode()`, `isDelete()` and `getAnchor()`, - `FileChange::getCount()` and its `LINE_*` constants, reading diff lines as arrays. + `FileChange::getCount()`, `getLines()`, replaced by `getDiffLines()`, and the `LINE_*` + constants, replaced by `DiffLineType`, lines passed to the constructor of `FileChange` as + arrays. - `Log::setOffset()` and `setLimit()`. - A range of lines as a string in `Repository::getBlame()`, `Blame::getGroupedLines()` and `Blame\Line::getLine()`. -- `CommitReference`, `Tree::getCommitReferenceEntries()`, reading tree entries as arrays. +- `CommitReference`, `Tree::getCommitReferenceEntries()`, `getEntries()`, `getBlobEntries()` + and `getTreeEntries()`: iterate the tree, or use `getBlobs()`, `getTrees()` and + `getSubmodules()`. - The branch to create of `WorkingCopy::checkout()`. - `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, `PushReference::getReference()`, `getRevision()`, `isCreate()` and `isDelete()`. - `Repository::getSize()`, in kilobytes: use `getSizeInBytes()`. ([#254](https://github.com/gitonomy/gitlib/pull/254)) -- `Exception\UnexpectedValueException`, which gitlib no longer throws. - ([#254](https://github.com/gitonomy/gitlib/pull/254)) ### Security diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 312308f5..8b3b3e0b 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -12,6 +12,10 @@ call triggers a deprecation (`E_USER_DEPRECATED`, through them. Log the deprecations of your application to find them. The other changes, listed below too, break code that relies on them. +Upgrade to 1.7 first, which requires PHP 8.1: it has most of the +replacements, and triggers the deprecations of the API they replace (see +[Supporting 1.x and 2.x](#supporting-1x-and-2x)). + Requirements ------------ @@ -28,6 +32,65 @@ Requirements * Repositories using SHA-256 hashes are refused, with an `UnsupportedOperationException`, by both drivers. +Supporting 1.x and 2.x +---------------------- + +gitlib 1.7 has the API of 2.0 that can be added without breaking 1.x, and +deprecates what 2.0 replaces or breaks: `new Repository($dir, $workingDir, +$driver)` with a `CliDriver` of the same arguments, the static methods of +`Repository` creating repositories, `Diff::parse($rawDiff, $repository)`, +`Repository::getDiff($from, $to)`, `FileChange::getDiffLines()`, iterating +a `Tree`, `Log::withLimit()`, `Branch::isMergedInto()`, +`Blame::getHunks()`... Fix the deprecations of 1.7, then require +`^1.7 || ^2.0`: a library supporting PHP 8.1 to 8.3 gets 1.7 there, and 2.0 +from PHP 8.4, with the same code. + +Before: + +```php +$repository = new Repository($gitDir, ['working_dir' => $workingDir, 'logger' => $logger]); +$diff = Diff::parse($rawDiff); +$diff->setRepository($repository); +``` + +After, with 1.7 and 2.0: + +```php +use Gitonomy\Git\Driver\Cli\CliDriver; + +$repository = new Repository($gitDir, $workingDir, new CliDriver(logger: $logger)); +$diff = Diff::parse($rawDiff, $repository); +``` + +1.7 breaks nothing of 1.x, so: + +* Pass by position the arguments whose name changes in 2.0 (see + [Extending gitlib](#extending-gitlib)): 1.7 keeps the names of 1.x. So + do the arguments 1.7 adds to `Repository::getDiff()`, `getBlame()`, + `Diff::parse()`, `Diff::fromArray()` and `File::fromArray()`: their + signature leaves them out, not to break the classes overriding these + methods. +* `ReferenceBag::createBranch()` and `createTag()` keep a revision name as + the hash of the reference they return, like 1.x: pass a `Revision` to + get the hash of its commit with both versions. +* The submodules of trees are `CommitReference` objects, which extend + `Submodule` in 1.7: check them with `instanceof Submodule`. +* `File::toArray()` has no `is_copy` key in 1.7. + +What 1.7 can't bring, as it would break 1.x, behaves differently with +both versions: + +* With a range of lines, `Blame::getLines()` and `getLine()` number the + lines from 1 in 1.7, like the file in 2.0: use `Line::getNumber()`, or + `Blame::getHunks()`, numbered like the file in 1.7 too. +* 1.7 doesn't read the references again after a write: open a new + `Repository` to read them, with both versions. `getReferences(true)` is + deprecated in 1.7 too. +* The classes `final` in 2.0 are only `@final` in 1.7: see + [Testing code using gitlib](#testing-code-using-gitlib). +* The other changes listed below: dates, the stricter parser of diffs, + the directory git runs in, the exceptions... + Opening a repository -------------------- @@ -43,6 +106,16 @@ Opening a repository request. Pass the variables git needs to the `environmentVariables` argument instead. + git inherits `$_ENV`, and the variables of `getenv()` that `$_SERVER` + holds too, like Symfony Process passes them: a variable an application + only writes in `$_SERVER`, without `putenv()` nor `$_ENV`, which the + option passed, no longer reaches git. A command line application + relying on it passes `$_SERVER` itself: + + ```php + $driver = new CliDriver(environmentVariables: array_filter($_SERVER, is_string(...))); + ``` + Before: ```php @@ -233,11 +306,13 @@ Diffs changes when constructed: `Diff::setRepository()`, `File::setRepository()` and `File::addChange()` are deprecated. `Diff::parse()` and `fromArray()` take the repository to read blobs from. -* The lines of a hunk, `FileChange::getLines()`, are `Diff\DiffLine` - objects, with `getType()`, a `Diff\DiffLineType`, and `getContent()`: - reading them as arrays is deprecated, as the `FileChange::LINE_*` - constants and `FileChange::getCount()`, replaced by `getAdditions()` and - `getDeletions()`. +* `FileChange::getDiffLines()` returns the lines of a hunk as + `Diff\DiffLine` objects, with `getType()`, a `Diff\DiffLineType`, and + `getContent()`: `FileChange::getLines()`, which returns them as arrays, + is deprecated, as the `FileChange::LINE_*` constants and + `FileChange::getCount()`, replaced by `getAdditions()` and + `getDeletions()`. The constructor of `FileChange` takes `DiffLine` + objects: arrays are deprecated. Before: @@ -249,12 +324,12 @@ Diffs } ``` - After: + After, with 1.7 and 2.0: ```php use Gitonomy\Git\Diff\DiffLineType; - foreach ($change->getLines() as $line) { + foreach ($change->getDiffLines() as $line) { if (DiffLineType::Addition === $line->getType()) { // ... $line->getContent() } @@ -273,6 +348,13 @@ Diffs `git diff --raw --patch`, with `-z` or not. It reads the names of the files from the raw information: names with spaces are no longer split at the wrong place. +* It reads the status of a file, a creation, a modification, a deletion, + a rename..., from its raw line (`A`, `M`, `D`, `R100`...), instead of + the headers of the patch (`new file mode`...): a diff written by hand + where they disagree gives another result. Like git, the raw line + separates the status and the name with a TAB: spaces throw a + `RuntimeException`. Generate the diffs of your tests with git, with + `git diff --raw --patch --full-index`, instead of writing them. * During a merge conflict, `WorkingCopy::getDiffPending()` and `getDiffStaged()` leave the unmerged files out, where they threw, and `WorkingCopy::getDiff()` compares them, conflict markers included, to the @@ -295,12 +377,30 @@ Blame Trees ----- -* `Tree::getEntries()`, `getTreeEntries()`, `getBlobEntries()` and - `getSubmoduleEntries()` return `TreeEntry` objects, with `getName()`, - `getMode()` and `getObject()`: reading them as arrays - (`[$mode, $object]`) is deprecated. +* Iterating a `Tree` gives its entries by name, `TreeEntry` objects with + `getName()`, `getMode()` and `getObject()`; `getBlobs()`, `getTrees()` + and `getSubmodules()` return them by kind. `getEntries()`, + `getBlobEntries()` and `getTreeEntries()`, which return them as arrays + (`[$mode, $object]`), are deprecated. + + Before: + + ```php + foreach ($tree->getEntries() as $name => [$mode, $object]) { + // ... + } + ``` + + After, with 1.7 and 2.0: + + ```php + foreach ($tree as $name => $entry) { + // ... $entry->getMode(), $entry->getObject() + } + ``` + * `CommitReference` is deprecated: it is `Submodule`. So is - `Tree::getCommitReferenceEntries()`: use `getSubmoduleEntries()`. + `Tree::getCommitReferenceEntries()`: use `getSubmodules()`. * `Tree::resolvePath()` throws an `InvalidArgumentException` explaining why a path doesn't resolve, a path through a submodule included, which threw an `UnexpectedValueException`, and resolves `/` to the tree. @@ -409,7 +509,9 @@ Errors writes on its output (`CONFLICT (content): ...`). The command line, the rest of the error output and the output are no longer in the message: `getErrorOutput()` and `getOutput()` return the outputs of git. Its - constructor takes the git command as second argument, optional. + constructor takes the git command as second argument, optional, and + reads the exit code and the outputs of the `Process`: a test double of + `Process` must return them. * When git can't start, a missing binary for instance, `CliDriver` throws a `DriverException` instead of a `ProcessException`, with a message of one line, without the command line: `The git command "clone" could not @@ -419,9 +521,8 @@ Errors failed; * `ReferenceBag` (`Repository::getReferences()`) only returns an empty bag for a repository without references, and throws for any other failure. -* `Gitonomy\Git\Exception\UnexpectedValueException` is deprecated: gitlib - no longer throws it. Catch `InvalidArgumentException` for - `Tree::resolvePath()`. +* gitlib no longer throws `Gitonomy\Git\Exception\UnexpectedValueException`: + catch `InvalidArgumentException` for `Tree::resolvePath()`. * `Commit::getShortHash()` throws a `ReferenceNotFoundException` for an unknown commit, instead of a `ProcessException`. * `Hooks` throws a `RuntimeException` when a hook exists already, or can't @@ -555,6 +656,27 @@ Extending gitlib be unserialized: clear such caches. `Diff::toArray()` and `Diff::fromArray()` remain compatible. +Testing code using gitlib +------------------------- + +The classes `final` in 2.0 can't be doubled: `createMock()` of PHPUnit, +Prophecy and Mockery refuse them ("Class "Gitonomy\Git\WorkingCopy" is +declared "final" and cannot be doubled", "could not reflect class +Gitonomy\Git\WorkingCopy as it is marked final"). Instead, from the +simplest: + +1. Construct the value objects: `Diff`, `Diff\File`, `Diff\FileChange` + and `Diff\DiffLine` have public constructors. `new Diff([new File(...)], + '')` works with 1.x too. +2. Parse the real output of git: `Diff::parse()` of + `git diff --raw --patch --full-index` (see [Diffs](#diffs)). +3. Use a real repository, in a temporary directory: + `Repository::init($path)`, or `git init` with 1.x before 1.7. A + `WorkingCopy` needs one. +4. Implement `Gitonomy\Git\Driver\DriverInterface`, and pass it as the + driver of `Repository`: see + ["Using your own driver"](doc/repository.md#using-your-own-driver). + Data returned ------------- @@ -590,9 +712,20 @@ Data returned Other changes ------------- -* Relative paths, in the arguments of `Repository::run()` and in the paths - of a log, are relative to the repository instead of the current directory - of PHP. +* `Repository::run()` and `runProcess()` run git in the working directory + of the repository, or its git directory when it is bare, instead of the + current directory of PHP. Relative paths, in the arguments of `run()` + and in the paths of a log, are relative to the repository. When PHP runs + in a subdirectory of the repository, the output of the commands relative + to the current directory changes too, and so does their default scope: + + | `run()` in `repository/sub` | 1.x | 2.0 | + |------------------------------|-------------------|-------------------------------| + | `ls-files` | `in-sub.txt` | `root.txt`, `sub/in-sub.txt` | + | `status --short` | ` M ../root.txt` | ` M root.txt` | + | `rev-parse --show-prefix` | `sub/` | (empty) | + + Nothing changes when PHP runs out of the repository. * `Hooks::set()` makes hooks executable by their owner only (`0700`, instead of `0777`): change their permissions if another user runs them. * `Repository::shell()` is deprecated: it ignored the driver. Run diff --git a/composer.json b/composer.json index 38d97964..42bdc86b 100644 --- a/composer.json +++ b/composer.json @@ -55,5 +55,10 @@ "config": { "preferred-install": "dist", "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } } } diff --git a/doc/admin.md b/doc/admin.md index 3159199b..5ba076fe 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -2,7 +2,7 @@ Create and access git repositories ================================== The static methods of `Repository` create new repositories, and return them. -They replace `Admin`, deprecated since 2.0: see +They replace `Admin`, deprecated since 1.7: see [UPGRADE-2.0.md](../UPGRADE-2.0.md#creating-repositories). To open a repository that exists already, use `new Repository($path)`: see diff --git a/doc/blame.md b/doc/blame.md index 37556af0..9b24da4e 100644 --- a/doc/blame.md +++ b/doc/blame.md @@ -26,7 +26,7 @@ throws a `DriverException`. A last line before the first one blames the lines in order, like `git blame -L 5,3`. Passing a range as a string (`'2,3'` or `'2,+2'`, like `git blame -L`) is -deprecated since 2.0: pass the first and the last lines instead. +deprecated since 1.7: pass the first and the last lines instead. Line-per-line iteration ----------------------- @@ -66,7 +66,7 @@ $line->getCommit(); // The commit that last changed it $author = $line->getCommit()->getAuthorName(); ``` -`Line::getLine()` is deprecated since 2.0: use `getOriginalNumber()`, which +`Line::getLine()` is deprecated since 1.7: use `getOriginalNumber()`, which returns an integer. Group reading by commit @@ -87,4 +87,4 @@ foreach ($blame->getHunks() as $hunk) { } ``` -`getGroupedLines()` is deprecated since 2.0: use `getHunks()`. +`getGroupedLines()` is deprecated since 1.7: use `getHunks()`. diff --git a/doc/branch.md b/doc/branch.md index 7752e2c8..42879169 100644 --- a/doc/branch.md +++ b/doc/branch.md @@ -41,7 +41,7 @@ $branch = $branch->moveTo('master~2'); ``` Like `git update-ref`, it doesn't touch the working tree, even for the -branch checked out. `ReferenceBag::update()` is deprecated since 2.0: use +branch checked out. `ReferenceBag::update()` is deprecated since 1.7: use `moveTo()`. Merged branches @@ -56,7 +56,7 @@ if ($branch->isMergedInto('master')) { } ``` -`isMergedTo()` is deprecated since 2.0: use `isMergedInto()`, which has no +`isMergedTo()` is deprecated since 1.7: use `isMergedInto()`, which has no default destination (`master`) nor flag to compare remote branches only. Branches containing a commit diff --git a/doc/commit.md b/doc/commit.md index 5b06c423..aac1dfca 100644 --- a/doc/commit.md +++ b/doc/commit.md @@ -30,7 +30,7 @@ $commit->getHash(); // "7e9ba14939b0ce67ab95d83e03bd64710db91ef4" $commit->getShortHash(); // "7e9ba14", abbreviated and unique in the repository ``` -`getFixedShortHash()` is deprecated since 2.0: use +`getFixedShortHash()` is deprecated since 1.7: use `substr($commit->getHash(), 0, 6)`. Browsing parents @@ -125,7 +125,7 @@ the subject is `"Fix the parser of dates"`, and the body `"It failed on leap years.\n"`. A message without an empty line has no body. -`getShortMessage()` is deprecated since 2.0: truncate the subject with +`getShortMessage()` is deprecated since 1.7: truncate the subject with `symfony/string`, a dependency of gitlib, instead: ```php @@ -197,4 +197,4 @@ $remoteBranches = array_filter($branches, static fn (Branch $branch): bool => $b ``` Passing arguments to `getIncludingBranches()`, to keep the local or the -remote branches only, is deprecated since 2.0. +remote branches only, is deprecated since 1.7. diff --git a/doc/diff.md b/doc/diff.md index 75fcd95a..e474f5f3 100644 --- a/doc/diff.md +++ b/doc/diff.md @@ -25,7 +25,7 @@ $diff = $repository->getDiff($repository->getMergeBase('master', 'feature'), 'fe Calling `getDiff()` with one argument (a range like `a..b` or `a...b`, two revisions in an array, or one revision to compare with the working tree) -is deprecated since 2.0: see [UPGRADE-2.0.md](../UPGRADE-2.0.md). +is deprecated since 1.7: see [UPGRADE-2.0.md](../UPGRADE-2.0.md). `Log::getDiff()` is deprecated too: it ignored the paths of the log. Other diffs @@ -126,15 +126,15 @@ $file->getDeletions(); $file->getChanges(); // FileChange objects: see below ``` -`isChangeMode()`, `isDelete()` and `getAnchor()` are deprecated since 2.0: +`isChangeMode()`, `isDelete()` and `getAnchor()` are deprecated since 1.7: use `isModeChange()`, `isDeletion()` and `substr($file->getNewIndex(), 0, 12)`. The FileChange object --------------------- The changes of a file are its hunks, the blocks starting with `@@` in a -patch. Each one is a `FileChange` object, with the lines of the hunk, -`DiffLine` objects: +patch. Each one is a `FileChange` object: `getDiffLines()` returns the lines +of the hunk, `DiffLine` objects: ```php use Gitonomy\Git\Diff\DiffLineType; @@ -142,7 +142,7 @@ use Gitonomy\Git\Diff\DiffLineType; foreach ($file->getChanges() as $change) { echo sprintf('@@ -%d,%d +%d,%d @@', $change->getRangeOldStart(), $change->getRangeOldCount(), $change->getRangeNewStart(), $change->getRangeNewCount()).PHP_EOL; - foreach ($change->getLines() as $line) { + foreach ($change->getDiffLines() as $line) { echo match ($line->getType()) { DiffLineType::Addition => '+', DiffLineType::Context => ' ', @@ -164,10 +164,12 @@ foreach ($file->getChanges() as $change) { `getAdditions()` and `getDeletions()` count the lines the hunk adds and deletes. -Reading a line as an array (`[$type, $content]`), the `FileChange::LINE_ADD`, -`LINE_CONTEXT` and `LINE_REMOVE` constants, and `FileChange::getCount()` are -deprecated since 2.0: use `getType()` and `getContent()`, `DiffLineType`, -and `getAdditions()` or `getDeletions()`. +`getLines()`, which returns each line as an array (`[$type, $content]`), the +`FileChange::LINE_ADD`, `LINE_CONTEXT` and `LINE_REMOVE` constants, and +`FileChange::getCount()` are deprecated since 1.7: use `getDiffLines()`, +`DiffLineType`, and `getAdditions()` or `getDeletions()`. So is passing the +lines to the constructor of `FileChange` as arrays: pass `DiffLine` +objects. Parsing a diff -------------- @@ -211,5 +213,5 @@ $diff = Diff::fromArray($array, $repository); The arrays of 1.x are read too. `Diff::setRepository()`, `File::setRepository()` and `File::addChange()` are -deprecated since 2.0: pass the repository to `Diff::parse()` or +deprecated since 1.7: pass the repository to `Diff::parse()` or `Diff::fromArray()`, and the changes to the constructor of `File`. diff --git a/doc/log.md b/doc/log.md index ceb9df81..1cd66947 100644 --- a/doc/log.md +++ b/doc/log.md @@ -138,5 +138,5 @@ $secondPage->getLimit(); // 10 Diff of a log ------------- -`Log::getDiff()` is deprecated since 2.0: it ignored the paths of the log. +`Log::getDiff()` is deprecated since 1.7: it ignored the paths of the log. Use `Repository::getDiff($from, $to)`, see [diff.md](diff.md). diff --git a/doc/references.md b/doc/references.md index 9feb1de7..adab71f6 100644 --- a/doc/references.md +++ b/doc/references.md @@ -14,7 +14,7 @@ $references = $repository->getReferences(); The bag reads the references again after every write gitlib makes: creating or deleting a reference, a checkout, a merge, `Repository::run()`... To see the writes of other processes, open a new `Repository`. Passing an argument -to `getReferences()`, to read them again, is deprecated since 2.0. +to `getReferences()`, to read them again, is deprecated since 1.7. First, you can test the existence of tags and branches like this: @@ -110,7 +110,7 @@ like `refs/heads/foobar`, and does nothing when it doesn't exist, like `createBranch()` and `createTag()` throw an `InvalidArgumentException` when the reference exists: they never move it. To move a branch, use `Branch::moveTo()`: see [branch.md](branch.md). `ReferenceBag::update()` is -deprecated since 2.0: use `Branch::moveTo()`. +deprecated since 1.7: use `Branch::moveTo()`. Tags ---- diff --git a/doc/repository.md b/doc/repository.md index 0864ed5f..a81e85de 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -47,7 +47,7 @@ $repository = new Repository('/srv/project.git', '/srv/project', new CliDriver(p ``` An array of options as second argument (`working_dir`, `logger`, `command`, -`process_timeout`...) is deprecated since 2.0: pass the working directory, +`process_timeout`...) is deprecated since 1.7: pass the working directory, and configure a `CliDriver` instead. See [UPGRADE-2.0.md](../UPGRADE-2.0.md#opening-a-repository). @@ -151,7 +151,7 @@ gitlib reads them again after every write it makes: creating or deleting a reference, a checkout, a merge, `run()`... It doesn't see the writes of other processes (a push to the repository, another worker...): open a new `Repository` to see them. The `$reload` argument of `getReferences()` is -deprecated since 2.0. +deprecated since 1.7. Description ----------- @@ -176,7 +176,7 @@ $size = $repository->getSizeInBytes(); echo 'Your repository size is '.round($size / 1000).' KB'; ``` -`getSize()`, which returns kilobytes (1,000 bytes), is deprecated since 2.0: +`getSize()`, which returns kilobytes (1,000 bytes), is deprecated since 1.7: use `getSizeInBytes()`. Running git commands @@ -208,7 +208,7 @@ your users starting with a dash is an option for git, unless `--` comes before it. gitlib reads the references again after the command, which may have written them. -`shell()` is deprecated since 2.0: run `git shell` with Symfony Process +`shell()` is deprecated since 1.7: run `git shell` with Symfony Process instead. Errors @@ -481,7 +481,7 @@ $repository = new Repository('/tmp/foo', driver: new CliDriver( ``` The `command`, `environment_variables` and `process_timeout` options of -`Repository` and `Admin`, from 1.x, are deprecated since 2.0 in favor of these +`Repository` and `Admin`, from 1.x, are deprecated since 1.7 in favor of these arguments. So is `inherit_environment_variables`, which passed the entries of `$_SERVER` to git, the headers and credentials of an HTTP request included: pass the variables git needs to `environmentVariables`. diff --git a/doc/tree.md b/doc/tree.md index 47abe374..21451d12 100644 --- a/doc/tree.md +++ b/doc/tree.md @@ -14,13 +14,13 @@ $tree = $commit->getTree(); Entries of a tree ----------------- -`getEntries()` returns the entries of a tree, keyed by their name. Each one -is a `TreeEntry` object, with the name of the entry, its mode, and its -object: a `Blob` for a file (see [blob.md](blob.md)), a `Tree` for a folder, -or a `Submodule`. +Iterating a tree gives its entries, keyed by their name. Each one is a +`TreeEntry` object, with the name of the entry, its mode, and its object: a +`Blob` for a file (see [blob.md](blob.md)), a `Tree` for a folder, or a +`Submodule`. ```php -foreach ($tree->getEntries() as $name => $entry) { +foreach ($tree as $name => $entry) { $entry->getName(); // The name of the file or the folder, the same as $name $entry->getMode(); // The mode git stores: "100644", "100755" (executable), "120000" (symbolic link), "040000" (tree), "160000" (submodule) $entry->getObject(); // A Blob, a Tree or a Submodule @@ -30,9 +30,9 @@ foreach ($tree->getEntries() as $name => $entry) { To keep one kind of entries only, also keyed by their name: ```php -$folders = $tree->getTreeEntries(); -$files = $tree->getBlobEntries(); -$submodules = $tree->getSubmoduleEntries(); +$files = $tree->getBlobs(); +$submodules = $tree->getSubmodules(); +$folders = $tree->getTrees(); ``` To get the object of an entry by its name: @@ -51,7 +51,7 @@ use Gitonomy\Git\Tree; function displayTree(Tree $tree, int $depth = 0): void { - foreach ($tree->getEntries() as $name => $entry) { + foreach ($tree as $name => $entry) { $object = $entry->getObject(); echo str_repeat(' ', $depth).match (true) { @@ -69,8 +69,9 @@ function displayTree(Tree $tree, int $depth = 0): void displayTree($commit->getTree()); ``` -Reading an entry as an array (`[$mode, $object]`) is deprecated since 2.0: -use `getMode()` and `getObject()`. +`getEntries()`, `getBlobEntries()` and `getTreeEntries()`, which return +the mode and the object of each entry as an array (`[$mode, $object]`), are +deprecated since 1.7: iterate the tree, or use `getBlobs()` and `getTrees()`. Submodules ---------- @@ -80,13 +81,13 @@ holds the hash of this commit: gitlib doesn't read the submodule. Open it as a repository, from its folder in the working tree, to read it. ```php -foreach ($tree->getSubmoduleEntries() as $name => $entry) { +foreach ($tree->getSubmodules() as $name => $entry) { echo $name.' is at '.$entry->getObject()->getHash().PHP_EOL; } ``` -`CommitReference` is deprecated since 2.0: it is `Submodule`. So is -`Tree::getCommitReferenceEntries()`: use `getSubmoduleEntries()`. +`CommitReference` is deprecated since 1.7: it is `Submodule`. So is +`Tree::getCommitReferenceEntries()`: use `getSubmodules()`. Resolve a path -------------- diff --git a/doc/workingcopy.md b/doc/workingcopy.md index 2e02ae64..462c7a46 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -72,7 +72,7 @@ overwrite the ones the checkout would change: they throw a `DriverException`. *checkoutNewBranch* then creates no branch. Passing the branch to create as second argument of *checkout* is deprecated -since 2.0: use *checkoutNewBranch*. +since 1.7: use *checkoutNewBranch*. Merge a revision ---------------- @@ -132,7 +132,7 @@ $diff = $wc->getDiff('master'); ``` Calling `Repository::getDiff()` with one revision, to compare it with the -working copy, is deprecated since 2.0: use `WorkingCopy::getDiff()`. +working copy, is deprecated since 1.7: use `WorkingCopy::getDiff()`. Staged modifications -------------------- diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 83e2d7b5..d29f7b57 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -25,7 +25,7 @@ * * @author Alexandre Salomé * - * @deprecated since gitonomy/gitlib 2.0, use the static methods of Repository instead + * @deprecated since gitonomy/gitlib 1.7, use the static methods of Repository instead * * @phpstan-import-type Options from LegacyOptions */ @@ -40,11 +40,11 @@ final class Admin * * @throws Exception\DriverException Directory exists or not writable * - * @deprecated since gitonomy/gitlib 2.0, use Repository::initBare() or Repository::init() instead + * @deprecated since gitonomy/gitlib 1.7, use Repository::initBare() or Repository::init() instead */ public static function init(string $path, bool $bare = true, array $options = []): Repository { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" instead.', __METHOD__, Repository::class, $bare ? 'initBare' : 'init'); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" instead.', __METHOD__, Repository::class, $bare ? 'initBare' : 'init'); $options = new LegacyOptions($options); $driver = $options->adminDriver() ?? new CliDriver(); @@ -69,11 +69,11 @@ public static function init(string $path, bool $bare = true, array $options = [] * * @return bool true if url is valid * - * @deprecated since gitonomy/gitlib 2.0, use Repository::isValidRepository() instead + * @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepository() instead */ public static function isValidRepository(#[\SensitiveParameter] string $url, array $options = []): bool { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class); return (new LegacyOptions($options)->adminDriver() ?? new CliDriver())->isValidRepository($url); } @@ -92,11 +92,11 @@ public static function isValidRepository(#[\SensitiveParameter] string $url, arr * * @return bool true if url is valid and branch exists * - * @deprecated since gitonomy/gitlib 2.0, use Repository::isValidRepositoryAndBranch() instead + * @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepositoryAndBranch() instead */ public static function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branchName, array $options = []): bool { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class); return (new LegacyOptions($options)->adminDriver() ?? new CliDriver())->isValidRepositoryAndBranch($url, $branchName); } @@ -109,11 +109,11 @@ public static function isValidRepositoryAndBranch(#[\SensitiveParameter] string * @param bool $bare indicates if repository should be bare or have a working copy * @param Options $options options for Repository creation * - * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() instead + * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() instead */ public static function cloneTo(string $path, #[\SensitiveParameter] string $url, bool $bare = true, array $options = []): Repository { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); $options = new LegacyOptions($options); $driver = $options->adminDriver() ?? new CliDriver(); @@ -135,11 +135,11 @@ public static function cloneTo(string $path, #[\SensitiveParameter] string $url, * @param bool $bare indicates if repository should be bare or have a working copy * @param Options $options options for Repository creation * - * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() with their "branch" argument instead + * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() with their "branch" argument instead */ public static function cloneBranchTo(string $path, #[\SensitiveParameter] string $url, string $branch, bool $bare = true, array $options = []): Repository { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); $options = new LegacyOptions($options); $driver = $options->adminDriver() ?? new CliDriver(); @@ -159,11 +159,11 @@ public static function cloneBranchTo(string $path, #[\SensitiveParameter] string * @param string $url url of repository to clone * @param Options $options options for Repository creation * - * @deprecated since gitonomy/gitlib 2.0, use Repository::mirrorFrom() instead + * @deprecated since gitonomy/gitlib 1.7, use Repository::mirrorFrom() instead */ public static function mirrorTo(string $path, #[\SensitiveParameter] string $url, array $options = []): Repository { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class); $options = new LegacyOptions($options); $driver = $options->adminDriver() ?? new CliDriver(); @@ -180,11 +180,11 @@ public static function mirrorTo(string $path, #[\SensitiveParameter] string $url * @param string[] $args arguments to be added to the command-line * @param Options $options options for Repository creation * - * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneFrom(), Repository::cloneBareFrom() or Repository::mirrorFrom() instead + * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneFrom(), Repository::cloneBareFrom() or Repository::mirrorFrom() instead */ public static function cloneRepository(string $path, #[\SensitiveParameter] string $url, array $args = [], array $options = []): Repository { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class); $options = new LegacyOptions($options); $driver = $options->adminDriver() ?? new CliDriver(); diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index 009ce6ee..d9b31acd 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -92,11 +92,11 @@ public function getHunks(): array * * @return list}> * - * @deprecated since gitonomy/gitlib 2.0, use getHunks() instead + * @deprecated since gitonomy/gitlib 1.7, use getHunks() instead */ public function getGroupedLines(): array { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getHunks()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getHunks()" instead.', __METHOD__); return array_map(static fn (Hunk $hunk): array => [$hunk->getCommit(), $hunk->getLines()], $this->getHunks()); } diff --git a/src/Gitonomy/Git/Blame/Line.php b/src/Gitonomy/Git/Blame/Line.php index 6254b2cd..6bc8b01b 100644 --- a/src/Gitonomy/Git/Blame/Line.php +++ b/src/Gitonomy/Git/Blame/Line.php @@ -53,11 +53,11 @@ public function getOriginalNumber(): int } /** - * @deprecated since gitonomy/gitlib 2.0, use getOriginalNumber() instead + * @deprecated since gitonomy/gitlib 1.7, use getOriginalNumber() instead */ public function getLine(): string { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getOriginalNumber()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getOriginalNumber()" instead.', __METHOD__); return (string) $this->originalNumber; } diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 22b1f29d..9b82f896 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -89,11 +89,11 @@ public function getShortHash(): string /** * Returns a fixed-with short hash. * - * @deprecated since gitonomy/gitlib 2.0, use substr($commit->getHash(), 0, $length) instead + * @deprecated since gitonomy/gitlib 1.7, use substr($commit->getHash(), 0, $length) instead */ public function getFixedShortHash(int $length = 6): string { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.', __METHOD__); return new CodePointString($this->revision)->slice(0, $length)->toString(); } @@ -157,11 +157,11 @@ public function getLastModification(?string $path = null): self * * Ported from https://github.com/fabpot/Twig-extensions/blob/d67bc7e69788795d7905b52d31188bbc1d390e01/lib/Twig/Extensions/Extension/Text.php#L52-L109 * - * @deprecated since gitonomy/gitlib 2.0, use symfony/string instead: u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve) + * @deprecated since gitonomy/gitlib 1.7, use symfony/string instead: u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve) */ public function getShortMessage(int $length = 50, bool $preserve = false, string $separator = '...'): string { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".', __METHOD__); $message = $this->getSubjectMessage(); @@ -198,7 +198,7 @@ public function resolveReferences(): array public function getIncludingBranches(bool $local = true, bool $remote = true): array { if (\func_num_args() > 0) { - trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing arguments to "%s()" is deprecated, filter the branches it returns with "%s::isLocal()" or "isRemote()" instead.', __METHOD__, Branch::class); + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing arguments to "%s()" is deprecated, filter the branches it returns with "%s::isLocal()" or "isRemote()" instead.', __METHOD__, Branch::class); } if (!$local && !$remote) { diff --git a/src/Gitonomy/Git/CommitReference.php b/src/Gitonomy/Git/CommitReference.php index 5a10b217..01a6d55f 100644 --- a/src/Gitonomy/Git/CommitReference.php +++ b/src/Gitonomy/Git/CommitReference.php @@ -12,7 +12,7 @@ namespace Gitonomy\Git; -trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s" class is deprecated, use "%s" instead.', CommitReference::class, Submodule::class); +trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s" class is deprecated, use "%s" instead.', CommitReference::class, Submodule::class); // Declares the alias, unless Submodule already did. class_exists(Submodule::class); @@ -20,7 +20,7 @@ class_exists(Submodule::class); // @phpstan-ignore if.alwaysFalse (Declares the class for IDEs only.) if (false) { /** - * @deprecated since gitonomy/gitlib 2.0, use Submodule instead + * @deprecated since gitonomy/gitlib 1.7, use Submodule instead */ final readonly class CommitReference { diff --git a/src/Gitonomy/Git/Diff/Diff.php b/src/Gitonomy/Git/Diff/Diff.php index bb26cc61..4abd4347 100644 --- a/src/Gitonomy/Git/Diff/Diff.php +++ b/src/Gitonomy/Git/Diff/Diff.php @@ -62,11 +62,11 @@ public static function fromData(DiffData $data, Repository $repository): self } /** - * @deprecated since gitonomy/gitlib 2.0, the diffs gitlib returns have their repository already + * @deprecated since gitonomy/gitlib 1.7, the diffs gitlib returns have their repository already */ public function setRepository(Repository $repository): void { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); foreach ($this->files as $file) { // Without one more deprecation for each file. diff --git a/src/Gitonomy/Git/Diff/DiffLine.php b/src/Gitonomy/Git/Diff/DiffLine.php index 303d8bc9..ddb1a59a 100644 --- a/src/Gitonomy/Git/Diff/DiffLine.php +++ b/src/Gitonomy/Git/Diff/DiffLine.php @@ -12,20 +12,12 @@ namespace Gitonomy\Git\Diff; -use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\LogicException; - /** * A line of a hunk. * - * Reading it as an array, [$type, $content] with the type as one of the - * FileChange::LINE_* constants, is deprecated. - * * @author Grégoire Pineau - * - * @implements \ArrayAccess */ -final readonly class DiffLine implements \ArrayAccess +final readonly class DiffLine { public function __construct( private DiffLineType $type, @@ -45,33 +37,4 @@ public function getContent(): string { return $this->content; } - - public function offsetExists(mixed $offset): bool - { - return 0 === $offset || 1 === $offset; - } - - /** - * @deprecated since gitonomy/gitlib 2.0, use getType() and getContent() instead - */ - public function offsetGet(mixed $offset): int|string - { - trigger_deprecation('gitonomy/gitlib', '2.0', 'Reading a "%s" as an array is deprecated, use its "getType()" and "getContent()" methods instead.', self::class); - - return match ($offset) { - 0 => $this->type->value, - 1 => $this->content, - default => throw new InvalidArgumentException(\sprintf('A diff line has no offset %s.', var_export($offset, true))), - }; - } - - public function offsetSet(mixed $offset, mixed $value): void - { - throw new LogicException('A diff line is immutable.'); - } - - public function offsetUnset(mixed $offset): void - { - throw new LogicException('A diff line is immutable.'); - } } diff --git a/src/Gitonomy/Git/Diff/File.php b/src/Gitonomy/Git/Diff/File.php index 516195b9..c18e2cd3 100644 --- a/src/Gitonomy/Git/Diff/File.php +++ b/src/Gitonomy/Git/Diff/File.php @@ -56,11 +56,11 @@ public function __construct( } /** - * @deprecated since gitonomy/gitlib 2.0, give the changes to the constructor instead + * @deprecated since gitonomy/gitlib 1.7, give the changes to the constructor instead */ public function addChange(FileChange $change): void { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, give the changes to the constructor instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, give the changes to the constructor instead.', __METHOD__); $this->changes[] = $change; } @@ -110,11 +110,11 @@ public function isModeChange(): bool } /** - * @deprecated since gitonomy/gitlib 2.0, use isModeChange() instead + * @deprecated since gitonomy/gitlib 1.7, use isModeChange() instead */ public function isChangeMode(): bool { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "isModeChange()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isModeChange()" instead.', __METHOD__); return $this->isModeChange(); } @@ -128,11 +128,11 @@ public function isDeletion(): bool } /** - * @deprecated since gitonomy/gitlib 2.0, use isDeletion() instead + * @deprecated since gitonomy/gitlib 1.7, use isDeletion() instead */ public function isDelete(): bool { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); return $this->isDeletion(); } @@ -251,11 +251,11 @@ public static function fromArray(array $array, ?Repository $repository = null): } /** - * @deprecated since gitonomy/gitlib 2.0, a presentation concern: use substr($file->getNewIndex(), 0, 12) instead + * @deprecated since gitonomy/gitlib 1.7, a presentation concern: use substr($file->getNewIndex(), 0, 12) instead */ public function getAnchor(): string { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.', __METHOD__); return substr($this->newIndex ?? '', 0, 12); } @@ -266,11 +266,11 @@ public function getRepository(): ?Repository } /** - * @deprecated since gitonomy/gitlib 2.0, the diffs gitlib returns have their repository already + * @deprecated since gitonomy/gitlib 1.7, the diffs gitlib returns have their repository already */ public function setRepository(Repository $repository): void { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); $this->repository = $repository; } diff --git a/src/Gitonomy/Git/Diff/FileChange.php b/src/Gitonomy/Git/Diff/FileChange.php index 08f28236..20f02e7e 100644 --- a/src/Gitonomy/Git/Diff/FileChange.php +++ b/src/Gitonomy/Git/Diff/FileChange.php @@ -22,38 +22,50 @@ final readonly class FileChange { /** - * @deprecated since gitonomy/gitlib 2.0, use DiffLineType::Context instead + * @deprecated since gitonomy/gitlib 1.7, use DiffLineType::Context instead */ public const int LINE_CONTEXT = 0; /** - * @deprecated since gitonomy/gitlib 2.0, use DiffLineType::Deletion instead + * @deprecated since gitonomy/gitlib 1.7, use DiffLineType::Deletion instead */ public const int LINE_REMOVE = -1; /** - * @deprecated since gitonomy/gitlib 2.0, use DiffLineType::Addition instead + * @deprecated since gitonomy/gitlib 1.7, use DiffLineType::Addition instead */ public const int LINE_ADD = 1; /** - * @param DiffLine[] $lines + * @var list + */ + private array $lines; + + /** + * @param array $lines Passing arrays is deprecated + * + * @throws InvalidArgumentException An array has none of the types of line */ public function __construct( private int $rangeOldStart, private int $rangeOldCount, private int $rangeNewStart, private int $rangeNewCount, - private array $lines, + array $lines, ) { + if ([] !== array_filter($lines, is_array(...))) { + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing the lines to "%s()" as arrays is deprecated, pass "%s" objects instead.', __METHOD__, DiffLine::class); + } + + $this->lines = array_map(static fn (DiffLine|array $line): DiffLine => \is_array($line) ? new DiffLine(self::lineType($line[0]), $line[1]) : $line, array_values($lines)); } /** - * @deprecated since gitonomy/gitlib 2.0, use getAdditions() or getDeletions() instead + * @deprecated since gitonomy/gitlib 1.7, use getAdditions() or getDeletions() instead */ public function getCount(int $type): int { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.', __METHOD__); return $this->count(self::lineType($type)); } @@ -89,13 +101,25 @@ public function getRangeNewCount(): int } /** - * @return DiffLine[] + * @return list */ - public function getLines(): array + public function getDiffLines(): array { return $this->lines; } + /** + * @return list The type of each line, one of the LINE_* constants, and its content + * + * @deprecated since gitonomy/gitlib 1.7, use getDiffLines() instead + */ + public function getLines(): array + { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getDiffLines()" instead.', __METHOD__); + + return $this->linesToArray(); + } + /** * @return FileChangeArray */ @@ -106,7 +130,7 @@ public function toArray(): array 'range_old_count' => $this->rangeOldCount, 'range_new_start' => $this->rangeNewStart, 'range_new_count' => $this->rangeNewCount, - 'lines' => array_map(static fn (DiffLine $line): array => [$line->getType()->value, $line->getContent()], array_values($this->lines)), + 'lines' => $this->linesToArray(), ]; } @@ -125,15 +149,23 @@ public static function fromArray(array $array): self } /** - * @throws InvalidArgumentException $type is none of the LINE_* constants + * @throws InvalidArgumentException $type is none of the values of DiffLineType */ private static function lineType(int $type): DiffLineType { - return DiffLineType::tryFrom($type) ?? throw new InvalidArgumentException(\sprintf('Unknown type of line %d: the types are FileChange::LINE_ADD, LINE_CONTEXT and LINE_REMOVE.', $type)); + return DiffLineType::tryFrom($type) ?? throw new InvalidArgumentException(\sprintf('Unknown type of line %d: the types are 1 (an addition), 0 (a context line) and -1 (a deletion).', $type)); } private function count(DiffLineType $type): int { return \count(array_filter($this->lines, static fn (DiffLine $line): bool => $line->getType() === $type)); } + + /** + * @return list + */ + private function linesToArray(): array + { + return array_map(static fn (DiffLine $line): array => [$line->getType()->value, $line->getContent()], $this->lines); + } } diff --git a/src/Gitonomy/Git/Exception/UnexpectedValueException.php b/src/Gitonomy/Git/Exception/UnexpectedValueException.php index cfe3e4c3..a9abcaa1 100644 --- a/src/Gitonomy/Git/Exception/UnexpectedValueException.php +++ b/src/Gitonomy/Git/Exception/UnexpectedValueException.php @@ -13,7 +13,8 @@ namespace Gitonomy\Git\Exception; /** - * @deprecated since gitonomy/gitlib 2.0, gitlib no longer throws it: Tree::resolvePath(), which threw it in 1.x, throws an InvalidArgumentException instead + * gitlib no longer throws it: Tree::resolvePath(), which threw it in 1.x, + * throws an InvalidArgumentException instead. */ final class UnexpectedValueException extends \UnexpectedValueException implements GitExceptionInterface { diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index c099e0be..1f4eddb7 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -72,11 +72,11 @@ public function __construct( } /** - * @deprecated since gitonomy/gitlib 2.0, use Repository::getDiff() instead: it ignored the paths of the log + * @deprecated since gitonomy/gitlib 1.7, use Repository::getDiff() instead: it ignored the paths of the log */ public function getDiff(): Diff { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::getDiff()" instead.', __METHOD__, Repository::class); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::getDiff()" instead.', __METHOD__, Repository::class); return $this->repository->getLegacyDiff($this->revisions); } @@ -113,11 +113,11 @@ public function withOffset(?int $offset): self } /** - * @deprecated since gitonomy/gitlib 2.0, use withOffset() instead + * @deprecated since gitonomy/gitlib 1.7, use withOffset() instead */ public function setOffset(?int $offset): static { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "withOffset()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "withOffset()" instead.', __METHOD__); $this->offset = self::offset($offset); @@ -143,11 +143,11 @@ public function withLimit(?int $limit): self } /** - * @deprecated since gitonomy/gitlib 2.0, use withLimit() instead + * @deprecated since gitonomy/gitlib 1.7, use withLimit() instead */ public function setLimit(?int $limit): static { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "withLimit()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "withLimit()" instead.', __METHOD__); $this->limit = self::limit($limit); diff --git a/src/Gitonomy/Git/PushReference.php b/src/Gitonomy/Git/PushReference.php index 4cf81c1c..0efdde24 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -53,11 +53,11 @@ public function getFullname(): string } /** - * @deprecated since gitonomy/gitlib 2.0, use getFullname() instead + * @deprecated since gitonomy/gitlib 1.7, use getFullname() instead */ public function getReference(): string { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getFullname()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getFullname()" instead.', __METHOD__); return $this->fullname; } @@ -81,11 +81,11 @@ public function getLog(array $excludes = []): Log } /** - * @deprecated since gitonomy/gitlib 2.0, use getRange() instead + * @deprecated since gitonomy/gitlib 1.7, use getRange() instead */ public function getRevision(): string { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getRange()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getRange()" instead.', __METHOD__); return $this->getRange(); } @@ -117,11 +117,11 @@ public function isCreation(): bool } /** - * @deprecated since gitonomy/gitlib 2.0, use isCreation() instead + * @deprecated since gitonomy/gitlib 1.7, use isCreation() instead */ public function isCreate(): bool { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "isCreation()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isCreation()" instead.', __METHOD__); return $this->isCreation(); } @@ -135,11 +135,11 @@ public function isDeletion(): bool } /** - * @deprecated since gitonomy/gitlib 2.0, use isDeletion() instead + * @deprecated since gitonomy/gitlib 1.7, use isDeletion() instead */ public function isDelete(): bool { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); return $this->isDeletion(); } diff --git a/src/Gitonomy/Git/Reference/Branch.php b/src/Gitonomy/Git/Reference/Branch.php index 1ac791da..5ee7ef42 100644 --- a/src/Gitonomy/Git/Reference/Branch.php +++ b/src/Gitonomy/Git/Reference/Branch.php @@ -62,11 +62,11 @@ public function isMergedInto(Revision|string $revision): bool * Check if this branch is merged to a destination branch * Optionally, check only with remote branches. * - * @deprecated since gitonomy/gitlib 2.0, use isMergedInto() instead + * @deprecated since gitonomy/gitlib 1.7, use isMergedInto() instead */ public function isMergedTo(string $destinationBranchName = 'master', bool $compareOnlyWithRemote = false): bool { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isMergedInto()" instead.', __METHOD__, self::class); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isMergedInto()" instead.', __METHOD__, self::class); if ($compareOnlyWithRemote && $this->isLocal()) { return false; diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 530d7828..8790f609 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -65,11 +65,11 @@ public function has(string $fullname): bool * * @return T * - * @deprecated since gitonomy/gitlib 2.0, use Branch::moveTo() instead + * @deprecated since gitonomy/gitlib 1.7, use Branch::moveTo() instead */ public function update(Reference $reference): Reference { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::moveTo()" instead.', __METHOD__, Branch::class); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::moveTo()" instead.', __METHOD__, Branch::class); $this->repository->getRepositoryDriver()->updateReference($reference->getFullname(), $reference->getCommitHash()); $this->invalidate(); diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 6b9ca6bd..22fd984c 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -109,7 +109,7 @@ final class Repository public function __construct(string $dir, array|string|null $workingDir = null, DriverInterface $driver = new CliDriver()) { if (\is_array($workingDir)) { - trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing an array of options to "%s()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing an array of options to "%s()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.', __METHOD__); $options = new LegacyOptions($workingDir); $optionsDriver = $options->repositoryDriver(); @@ -338,7 +338,7 @@ public function getReferences(bool $reload = false): ReferenceBag $this->referenceBag ??= new ReferenceBag($this); if (\func_num_args() > 0) { - trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing an argument to "%s()" is deprecated, gitlib reads the references again after its writes: open a new Repository to see the writes of other processes.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing an argument to "%s()" is deprecated, gitlib reads the references again after its writes: open a new Repository to see the writes of other processes.', __METHOD__); } if ($reload) { @@ -428,7 +428,7 @@ public function getMergeBase(Revision|string $one, Revision|string $two): ?Commi public function getBlame(Revision|string $revision, string $file, int|string|null $firstLine = null, ?int $lastLine = null): Blame { if (\is_string($firstLine)) { - trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing a range of lines as a string to "%s()" is deprecated, pass the first and the last lines instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a range of lines as a string to "%s()" is deprecated, pass the first and the last lines instead.', __METHOD__); if (!preg_match('/^(\d+),(\+?)(\d*)$/', $firstLine, $vars)) { throw new InvalidArgumentException(\sprintf('Unsupported range of lines "%s": pass the first and the last lines instead.', $firstLine)); @@ -476,7 +476,7 @@ public function getLog(RevisionList|Revision|string|array|null $revisions = null public function getDiff(RevisionList|Revision|string|array|null $from, Revision|string|null $to = null): Diff { if (null === $to) { - trigger_deprecation('gitonomy/gitlib', '2.0', 'Calling "%s()" with one argument is deprecated, pass the revisions to compare as "from" and "to" arguments, or use "%s::getDiff()" to compare a revision with the working tree.', __METHOD__, WorkingCopy::class); + trigger_deprecation('gitonomy/gitlib', '1.7', 'Calling "%s()" with one argument is deprecated, pass the revisions to compare as "from" and "to" arguments, or use "%s::getDiff()" to compare a revision with the working tree.', __METHOD__, WorkingCopy::class); return $this->getLegacyDiff($from); } @@ -546,11 +546,11 @@ public function getSizeInBytes(): int * * @return int A sum, in kilobytes * - * @deprecated since gitonomy/gitlib 2.0, use getSizeInBytes() instead + * @deprecated since gitonomy/gitlib 1.7, use getSizeInBytes() instead */ public function getSize(): int { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getSizeInBytes()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getSizeInBytes()" instead.', __METHOD__); return (int) round($this->getSizeInBytes() / 1000); } @@ -561,11 +561,11 @@ public function getSize(): int * @param string $command The command to execute * @param array $env Environment variables of the command * - * @deprecated since gitonomy/gitlib 2.0, run "git shell" with Symfony Process instead + * @deprecated since gitonomy/gitlib 1.7, run "git shell" with Symfony Process instead */ public function shell(string $command, array $env = []): void { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, run "git shell" with Symfony Process instead: it ignores the driver.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, run "git shell" with Symfony Process instead: it ignores the driver.', __METHOD__); $argument = \sprintf('%s %s', $command, escapeshellarg($this->gitDir)); @@ -689,11 +689,11 @@ public function runProcess(string $command, #[\SensitiveParameter] array $args = * * @throws UnsupportedOperationException The driver isn't a CliDriver * - * @deprecated since gitonomy/gitlib 2.0, pass a CliDriver configured with the logger as driver argument instead + * @deprecated since gitonomy/gitlib 1.7, pass a CliDriver configured with the logger as driver argument instead */ public function setLogger(LoggerInterface $logger): static { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, pass a "%s" configured with the logger as driver argument instead.', __METHOD__, CliDriver::class); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, pass a "%s" configured with the logger as driver argument instead.', __METHOD__, CliDriver::class); if (!$this->driver instanceof CliDriver) { throw new UnsupportedOperationException('Only CliDriver logs the git commands it runs.'); @@ -708,11 +708,11 @@ public function setLogger(LoggerInterface $logger): static /** * Returns the logger of the CliDriver. * - * @deprecated since gitonomy/gitlib 2.0, the logger is an argument of CliDriver + * @deprecated since gitonomy/gitlib 1.7, the logger is an argument of CliDriver */ public function getLogger(): ?LoggerInterface { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, the logger is an argument of "%s".', __METHOD__, CliDriver::class); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, the logger is an argument of "%s".', __METHOD__, CliDriver::class); return $this->driver instanceof CliDriver ? $this->driver->getLogger() : null; } @@ -728,11 +728,11 @@ public function getLogger(): ?LoggerInterface * * @return Repository the newly created repository * - * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() with getGitDir() as URL instead + * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() with getGitDir() as URL instead */ public function cloneTo(string $path, bool $bare = true, array $options = []): self { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" with "getGitDir()" as URL instead.', __METHOD__, self::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" with "getGitDir()" as URL instead.', __METHOD__, self::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); // Like in 1.x, the options configuring git apply to the clone. $options = new LegacyOptions($options); diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index bef883c4..b36ef119 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -16,9 +16,13 @@ use Gitonomy\Git\Exception\InvalidArgumentException; /** + * Iterating a tree gives its entries, by name. + * * @author Alexandre Salomé + * + * @implements \IteratorAggregate */ -final class Tree +final class Tree implements \IteratorAggregate { /** * @var array|null By name, null until read @@ -40,62 +44,86 @@ public function getHash(): string } /** - * @return array By name + * @return \ArrayIterator The entries, by name */ - public function getEntries(): array + public function getIterator(): \ArrayIterator { - if (null !== $this->entries) { - return $this->entries; - } - - $this->entries = []; - foreach ($this->repository->getRepositoryDriver()->getTreeEntries($this->hash) as $data) { - $object = match ($data->type) { - ObjectType::Blob => $this->repository->getBlob($data->hash), - ObjectType::Tree => $this->repository->getTree($data->hash), - // A commit in a tree is a submodule. - default => new Submodule($data->hash), - }; - $this->entries[$data->name] = new TreeEntry($data->name, $data->mode, $object); - } + return new \ArrayIterator($this->entries()); + } - return $this->entries; + /** + * @return array The blobs, by name + */ + public function getBlobs(): array + { + return array_filter($this->entries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof Blob); } /** * @return array The submodules, by name */ - public function getSubmoduleEntries(): array + public function getSubmodules(): array { - return array_filter($this->getEntries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof Submodule); + return array_filter($this->entries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof Submodule); } /** - * @return array The submodules, by name + * @return array The trees, by name + */ + public function getTrees(): array + { + return array_filter($this->entries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof self); + } + + /** + * @return array The mode and the object of each entry, by name * - * @deprecated since gitonomy/gitlib 2.0, use getSubmoduleEntries() instead + * @deprecated since gitonomy/gitlib 1.7, iterate the tree instead */ - public function getCommitReferenceEntries(): array + public function getEntries(): array { - trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getSubmoduleEntries()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, iterate the tree instead.', __METHOD__); - return $this->getSubmoduleEntries(); + return self::toArrays($this->entries()); } /** - * @return array The trees, by name + * @return array The mode and the blob of each blob, by name + * + * @deprecated since gitonomy/gitlib 1.7, use getBlobs() instead */ - public function getTreeEntries(): array + public function getBlobEntries(): array { - return array_filter($this->getEntries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof self); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getBlobs()" instead.', __METHOD__); + + // @phpstan-ignore return.type (getBlobs() returns blobs only.) + return self::toArrays($this->getBlobs()); } /** - * @return array The blobs, by name + * @return array The mode and the submodule of each submodule, by name + * + * @deprecated since gitonomy/gitlib 1.7, use getSubmodules() instead */ - public function getBlobEntries(): array + public function getCommitReferenceEntries(): array + { + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getSubmodules()" instead.', __METHOD__); + + // @phpstan-ignore return.type (getSubmodules() returns submodules only.) + return self::toArrays($this->getSubmodules()); + } + + /** + * @return array The mode and the tree of each tree, by name + * + * @deprecated since gitonomy/gitlib 1.7, use getTrees() instead + */ + public function getTreeEntries(): array { - return array_filter($this->getEntries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof Blob); + trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getTrees()" instead.', __METHOD__); + + // @phpstan-ignore return.type (getTrees() returns trees only.) + return self::toArrays($this->getTrees()); } /** @@ -103,7 +131,7 @@ public function getBlobEntries(): array */ public function getEntry(string $name): Submodule|self|Blob { - return ($this->getEntries()[$name] ?? throw new InvalidArgumentException(\sprintf('The tree "%s" has no entry "%s".', $this->hash, $name)))->getObject(); + return ($this->entries()[$name] ?? throw new InvalidArgumentException(\sprintf('The tree "%s" has no entry "%s".', $this->hash, $name)))->getObject(); } /** @@ -121,9 +149,44 @@ public function resolvePath(string $path): Submodule|self|Blob } $resolved[] = $segment; - $element = ($element->getEntries()[$segment] ?? throw new InvalidArgumentException(\sprintf('Unable to resolve "%s": "%s" doesn\'t exist.', $path, implode('/', $resolved))))->getObject(); + $element = ($element->entries()[$segment] ?? throw new InvalidArgumentException(\sprintf('Unable to resolve "%s": "%s" doesn\'t exist.', $path, implode('/', $resolved))))->getObject(); } return $element; } + + /** + * @return array By name + */ + private function entries(): array + { + if (null !== $this->entries) { + return $this->entries; + } + + $this->entries = []; + foreach ($this->repository->getRepositoryDriver()->getTreeEntries($this->hash) as $data) { + $object = match ($data->type) { + ObjectType::Blob => $this->repository->getBlob($data->hash), + ObjectType::Tree => $this->repository->getTree($data->hash), + // A commit in a tree is a submodule. + default => new Submodule($data->hash), + }; + $this->entries[$data->name] = new TreeEntry($data->name, $data->mode, $object); + } + + return $this->entries; + } + + /** + * The entries in the format of 1.x. + * + * @param array $entries + * + * @return array + */ + private static function toArrays(array $entries): array + { + return array_map(static fn (TreeEntry $entry): array => [$entry->getMode(), $entry->getObject()], $entries); + } } diff --git a/src/Gitonomy/Git/TreeEntry.php b/src/Gitonomy/Git/TreeEntry.php index d4dd8daa..a7eab659 100644 --- a/src/Gitonomy/Git/TreeEntry.php +++ b/src/Gitonomy/Git/TreeEntry.php @@ -12,19 +12,12 @@ namespace Gitonomy\Git; -use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\LogicException; - /** * An entry of a tree: a blob, a tree or a submodule, with its name and mode. * - * Reading it as an array, [$mode, $object], is deprecated. - * * @author Grégoire Pineau - * - * @implements \ArrayAccess */ -final readonly class TreeEntry implements \ArrayAccess +final readonly class TreeEntry { public function __construct( private string $name, @@ -50,33 +43,4 @@ public function getObject(): Blob|Tree|Submodule { return $this->object; } - - public function offsetExists(mixed $offset): bool - { - return 0 === $offset || 1 === $offset; - } - - /** - * @deprecated since gitonomy/gitlib 2.0, use getMode() and getObject() instead - */ - public function offsetGet(mixed $offset): string|Blob|Tree|Submodule - { - trigger_deprecation('gitonomy/gitlib', '2.0', 'Reading a "%s" as an array is deprecated, use its "getMode()" and "getObject()" methods instead.', self::class); - - return match ($offset) { - 0 => $this->mode, - 1 => $this->object, - default => throw new InvalidArgumentException(\sprintf('A tree entry has no offset %s.', var_export($offset, true))), - }; - } - - public function offsetSet(mixed $offset, mixed $value): void - { - throw new LogicException('A tree entry is immutable.'); - } - - public function offsetUnset(mixed $offset): void - { - throw new LogicException('A tree entry is immutable.'); - } } diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index c2cc2982..fc959417 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -83,7 +83,7 @@ public function getDiffStaged(): Diff public function checkout(Revision|string $revision, ?string $branch = null): static { if (null !== $branch) { - trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing a branch to create to "%s()" is deprecated, use "checkoutNewBranch()" instead.', __METHOD__); + trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a branch to create to "%s()" is deprecated, use "checkoutNewBranch()" instead.', __METHOD__); return $this->checkoutNewBranch($branch, $revision); } diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index d310ab3e..80701707 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -30,7 +30,7 @@ class AdminTest extends AbstractTestCase { public function testInit(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::initBare()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::initBare()" instead.'); $repository = Admin::init(self::createTempDir(), options: self::getOptions()); @@ -39,7 +39,7 @@ public function testInit(): void public function testInitNotBare(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::init()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::init()" instead.'); $repository = Admin::init(self::createTempDir(), false, self::getOptions()); @@ -48,14 +48,14 @@ public function testInitNotBare(): void public function testIsValidRepository(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::isValidRepository()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepository()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::isValidRepository()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepository()" instead.'); $this->assertTrue(Admin::isValidRepository(self::repositoryUrl(), self::getOptions())); } public function testIsValidRepositoryAndBranch(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::isValidRepositoryAndBranch()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepositoryAndBranch()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::isValidRepositoryAndBranch()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepositoryAndBranch()" instead.'); $this->assertTrue(Admin::isValidRepositoryAndBranch(self::repositoryUrl(), 'new-feature', self::getOptions())); $this->assertFalse(Admin::isValidRepositoryAndBranch(self::repositoryUrl(), 'unknown', self::getOptions())); @@ -63,7 +63,7 @@ public function testIsValidRepositoryAndBranch(): void public function testCloneTo(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" instead, which takes the URL before the path.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" instead, which takes the URL before the path.'); $repository = Admin::cloneTo(self::createTempDir(), self::repositoryUrl(), options: self::getOptions()); @@ -73,7 +73,7 @@ public function testCloneTo(): void public function testCloneToNotBare(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" instead, which takes the URL before the path.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" instead, which takes the URL before the path.'); $repository = Admin::cloneTo(self::createTempDir(), self::repositoryUrl(), false, self::getOptions()); @@ -82,7 +82,7 @@ public function testCloneToNotBare(): void public function testCloneBranchTo(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with its "branch" argument instead, which takes the URL before the path.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with its "branch" argument instead, which takes the URL before the path.'); $repository = Admin::cloneBranchTo(self::createTempDir(), self::repositoryUrl(), 'new-feature', options: self::getOptions()); @@ -94,7 +94,7 @@ public function testCloneBranchTo(): void public function testCloneBranchToNotBare(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with its "branch" argument instead, which takes the URL before the path.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with its "branch" argument instead, which takes the URL before the path.'); $repository = Admin::cloneBranchTo(self::createTempDir(), self::repositoryUrl(), 'new-feature', false, self::getOptions()); @@ -106,7 +106,7 @@ public function testCloneBranchToNotBare(): void public function testMirrorTo(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::mirrorTo()" method is deprecated, use "Gitonomy\Git\Repository::mirrorFrom()" instead, which takes the URL before the path.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::mirrorTo()" method is deprecated, use "Gitonomy\Git\Repository::mirrorFrom()" instead, which takes the URL before the path.'); $repository = Admin::mirrorTo(self::createTempDir(), self::repositoryUrl(), self::getOptions()); @@ -115,7 +115,7 @@ public function testMirrorTo(): void public function testCloneRepositoryPassesItsArgumentsToGit(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneRepository()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()", "Gitonomy\Git\Repository::cloneBareFrom()" or "Gitonomy\Git\Repository::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneRepository()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()", "Gitonomy\Git\Repository::cloneBareFrom()" or "Gitonomy\Git\Repository::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".'); // --config is the only argument --bare accepts on every git version. $repository = Admin::cloneRepository(self::createTempDir(), self::repositoryUrl(), ['--bare', '--config', 'gitlib.test=yes'], self::getOptions()); @@ -176,7 +176,7 @@ public function testRemovedOptionsAreRefused(): void public function testRepositoryCloneTo(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with "getGitDir()" as URL instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with "getGitDir()" as URL instead.'); $repository = self::createFoobarRepository(); $new = $repository->cloneTo(self::createTempDir()); @@ -187,7 +187,7 @@ public function testRepositoryCloneTo(): void public function testRepositoryCloneToNotBare(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with "getGitDir()" as URL instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with "getGitDir()" as URL instead.'); $repository = self::createFoobarRepository(); $new = $repository->cloneTo(self::createTempDir(), false); diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index f05fe4ed..9d71544e 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -154,7 +154,7 @@ public function testBlameOfAPathNormalizesIt(): void #[IgnoreDeprecations] public function testLegacyBlameLineRange(bool $bare): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing a range of lines as a string to "Gitonomy\Git\Repository::getBlame()" is deprecated, pass the first and the last lines instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing a range of lines as a string to "Gitonomy\Git\Repository::getBlame()" is deprecated, pass the first and the last lines instead.'); $repository = self::createFoobarRepository($bare); diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 8f5c214b..c6066389 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -39,7 +39,7 @@ public function testGetContentSiblingToSubmodule(bool $bare): void // making sibling blobs unreadable. $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $this->assertNotEmpty($tree->getSubmoduleEntries(), 'Tree contains a submodule entry'); + $this->assertNotEmpty($tree->getSubmodules(), 'Tree contains a submodule entry'); $blob = $tree->resolvePath('README.md'); diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index a3dba6e3..e0174d98 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -325,7 +325,7 @@ public function testGetSignedMessage(bool $bare): void #[IgnoreDeprecations] public function testLegacyGetFixedShortHash(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Commit::getFixedShortHash()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Commit::getFixedShortHash()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.'); $this->assertSame(substr(self::LONGFILE_COMMIT, 0, 6), self::createFoobarRepository()->getCommit(self::LONGFILE_COMMIT)->getFixedShortHash()); } @@ -335,7 +335,7 @@ public function testLegacyGetFixedShortHash(): void #[IgnoreDeprecations] public function testGetShortMessage(bool $bare): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Commit::getShortMessage()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Commit::getShortMessage()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".'); $repository = self::createFoobarRepository($bare); // tests with a multi-line message @@ -414,7 +414,7 @@ public function testGetIncludingBranches(bool $bare): void #[IgnoreDeprecations] public function testLegacyGetIncludingLocalOrRemoteBranches(bool $bare): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing arguments to "Gitonomy\Git\Commit::getIncludingBranches()" is deprecated, filter the branches it returns with "Gitonomy\Git\Reference\Branch::isLocal()" or "isRemote()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing arguments to "Gitonomy\Git\Commit::getIncludingBranches()" is deprecated, filter the branches it returns with "Gitonomy\Git\Reference\Branch::isLocal()" or "isRemote()" instead.'); $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::INITIAL_COMMIT); diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 4ce9a8a3..5cb585f6 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -395,7 +395,7 @@ public function testDiffsOfAConflictLeaveUnmergedPathsOut(): void // Like git diff HEAD, which compares the unmerged file, conflict markers included, to HEAD. $files = $repository->getWorkingCopy()->getDiff()->getFiles(); $this->assertSame(['conflict', 'other'], array_map(static fn (File $file): string => $file->getName(), $files)); - $this->assertContains('<<<<<<< HEAD', array_map(static fn (DiffLine $line): string => $line->getContent(), $files[0]->getChanges()[0]->getLines())); + $this->assertContains('<<<<<<< HEAD', array_map(static fn (DiffLine $line): string => $line->getContent(), $files[0]->getChanges()[0]->getDiffLines())); } public function testWorkingCopyDiff(): void @@ -408,8 +408,8 @@ public function testWorkingCopyDiff(): void $this->assertCount(1, $files); $this->assertSame(1, $files[0]->getAdditions()); - $this->assertSame([DiffLineType::Context, DiffLineType::Addition], array_map(static fn (DiffLine $line): DiffLineType => $line->getType(), $files[0]->getChanges()[0]->getLines())); - $this->assertSame('b', $files[0]->getChanges()[0]->getLines()[1]->getContent()); + $this->assertSame([DiffLineType::Context, DiffLineType::Addition], array_map(static fn (DiffLine $line): DiffLineType => $line->getType(), $files[0]->getChanges()[0]->getDiffLines())); + $this->assertSame('b', $files[0]->getChanges()[0]->getDiffLines()[1]->getContent()); } public function testHunkWithOneLine(): void @@ -442,7 +442,7 @@ public function testParseQuotedNames(): void #[IgnoreDeprecations] public function testLegacyGetDiffOfARange(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Calling "Gitonomy\Git\Repository::getDiff()" with one argument is deprecated, pass the revisions to compare as "from" and "to" arguments, or use "Gitonomy\Git\WorkingCopy::getDiff()" to compare a revision with the working tree.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Calling "Gitonomy\Git\Repository::getDiff()" with one argument is deprecated, pass the revisions to compare as "from" and "to" arguments, or use "Gitonomy\Git\WorkingCopy::getDiff()" to compare a revision with the working tree.'); $repository = self::createFoobarRepository(); @@ -452,20 +452,42 @@ public function testLegacyGetDiffOfARange(): void #[Group('legacy')] #[IgnoreDeprecations] - public function testLegacyLinesAsArrays(): void + public function testLegacyGetLines(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Reading a "Gitonomy\Git\Diff\DiffLine" as an array is deprecated, use its "getType()" and "getContent()" methods instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\FileChange::getLines()" method is deprecated, use "getDiffLines()" instead.'); - $line = new DiffLine(DiffLineType::Addition, 'added'); - [$type, $content] = $line; + $change = new FileChange(1, 1, 1, 2, [new DiffLine(DiffLineType::Context, 'kept'), new DiffLine(DiffLineType::Addition, 'added')]); + + $this->assertSame([[FileChange::LINE_CONTEXT, 'kept'], [FileChange::LINE_ADD, 'added']], $change->getLines()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyConstructorWithLinesAsArrays(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing the lines to "Gitonomy\Git\Diff\FileChange::__construct()" as arrays is deprecated, pass "Gitonomy\Git\Diff\DiffLine" objects instead.'); + + $change = new FileChange(1, 1, 1, 2, [[FileChange::LINE_CONTEXT, 'kept'], new DiffLine(DiffLineType::Addition, 'added')]); + + $this->assertSame([[DiffLineType::Context, 'kept'], [DiffLineType::Addition, 'added']], array_map(static fn (DiffLine $line): array => [$line->getType(), $line->getContent()], $change->getDiffLines())); + $this->assertSame(1, $change->getAdditions()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyConstructorRefusesAnUnknownTypeOfLine(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing the lines to "Gitonomy\Git\Diff\FileChange::__construct()" as arrays is deprecated, pass "Gitonomy\Git\Diff\DiffLine" objects instead.'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown type of line 2: the types are 1 (an addition), 0 (a context line) and -1 (a deletion).'); - $this->assertSame([FileChange::LINE_ADD, 'added'], [$type, $content]); + new FileChange(1, 1, 1, 1, [[2, 'line']]); } public function testFromArrayRefusesAnUnknownTypeOfLine(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown type of line 2: the types are FileChange::LINE_ADD, LINE_CONTEXT and LINE_REMOVE.'); + $this->expectExceptionMessage('Unknown type of line 2: the types are 1 (an addition), 0 (a context line) and -1 (a deletion).'); FileChange::fromArray(['range_old_start' => 1, 'range_old_count' => 1, 'range_new_start' => 1, 'range_new_count' => 1, 'lines' => [[2, 'line']]]); } @@ -506,7 +528,7 @@ public function testGetBlobWithoutRepositoryThrows(): void #[IgnoreDeprecations] public function testLegacySetRepository(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\File::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); $repository = self::createFoobarRepository(); $file = new File('README.md', 'README.md', '100644', '100644', null, null, false); @@ -519,7 +541,7 @@ public function testLegacySetRepository(): void #[IgnoreDeprecations] public function testLegacyDiffSetRepository(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\Diff::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\Diff::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); $repository = self::createFoobarRepository(); $diff = Diff::parse(":100644 100644 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 M\tREADME.md\n"); @@ -532,7 +554,7 @@ public function testLegacyDiffSetRepository(): void #[IgnoreDeprecations] public function testLegacyAddChange(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\File::addChange()" method is deprecated, give the changes to the constructor instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::addChange()" method is deprecated, give the changes to the constructor instead.'); $file = new File('README.md', 'README.md', '100644', '100644', null, null, false); $change = new FileChange(1, 1, 1, 1, [new DiffLine(DiffLineType::Addition, 'added')]); @@ -545,7 +567,7 @@ public function testLegacyAddChange(): void #[IgnoreDeprecations] public function testLegacyIsChangeMode(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\File::isChangeMode()" method is deprecated, use "isModeChange()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::isChangeMode()" method is deprecated, use "isModeChange()" instead.'); $this->assertTrue(new File('run.sh', 'run.sh', '100644', '100755', null, null, false)->isChangeMode()); } @@ -554,7 +576,7 @@ public function testLegacyIsChangeMode(): void #[IgnoreDeprecations] public function testLegacyFileIsDelete(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\File::isDelete()" method is deprecated, use "isDeletion()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::isDelete()" method is deprecated, use "isDeletion()" instead.'); $this->assertTrue(new File('README.md', null, '100644', null, null, null, false)->isDelete()); } @@ -563,7 +585,7 @@ public function testLegacyFileIsDelete(): void #[IgnoreDeprecations] public function testLegacyGetAnchor(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\File::getAnchor()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::getAnchor()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.'); $this->assertSame('222222222222', new File('README.md', 'README.md', '100644', '100644', str_repeat('1', 40), str_repeat('2', 40), false)->getAnchor()); } @@ -572,7 +594,7 @@ public function testLegacyGetAnchor(): void #[IgnoreDeprecations] public function testLegacyGetCount(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\FileChange::getCount()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\FileChange::getCount()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.'); $change = new FileChange(1, 1, 1, 2, [new DiffLine(DiffLineType::Context, 'kept'), new DiffLine(DiffLineType::Addition, 'added')]); @@ -766,7 +788,7 @@ private static function changes(Diff $diff): array $change->getRangeOldCount(), $change->getRangeNewStart(), $change->getRangeNewCount(), - array_map(static fn (DiffLine $line): array => [$line->getType()->name, $line->getContent()], $change->getLines()), + array_map(static fn (DiffLine $line): array => [$line->getType()->name, $line->getContent()], $change->getDiffLines()), ], $file->getChanges())], $diff->getFiles()); } diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index ebca07c0..045bd33b 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -339,7 +339,7 @@ public function testWithersReturnAnotherLog(bool $bare): void #[IgnoreDeprecations] public function testLegacySetOffset(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Log::setOffset()" method is deprecated, use "withOffset()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Log::setOffset()" method is deprecated, use "withOffset()" instead.'); $log = self::createFoobarRepository()->getLog(); $log->setOffset(2); @@ -351,7 +351,7 @@ public function testLegacySetOffset(): void #[IgnoreDeprecations] public function testLegacySetLimit(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Log::setLimit()" method is deprecated, use "withLimit()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Log::setLimit()" method is deprecated, use "withLimit()" instead.'); $log = self::createFoobarRepository()->getLog(self::LONGFILE_COMMIT); @@ -363,7 +363,7 @@ public function testLegacySetLimit(): void #[IgnoreDeprecations] public function testLegacyGetDiffIgnoresThePathsOfTheLog(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Log::getDiff()" method is deprecated, use "Gitonomy\Git\Repository::getDiff()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Log::getDiff()" method is deprecated, use "Gitonomy\Git\Repository::getDiff()" instead.'); $repository = self::createFoobarRepository(); $diff = $repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(); diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index 191bbfc8..6092b15c 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -53,7 +53,7 @@ public static function provideIsers(): array #[IgnoreDeprecations] public function testLegacyIsCreate(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\PushReference::isCreate()" method is deprecated, use "isCreation()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::isCreate()" method is deprecated, use "isCreation()" instead.'); $this->assertTrue(new PushReference(self::createFoobarRepository(), 'foo', PushReference::ZERO, self::LONGFILE_COMMIT)->isCreate()); } @@ -62,7 +62,7 @@ public function testLegacyIsCreate(): void #[IgnoreDeprecations] public function testLegacyIsDelete(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\PushReference::isDelete()" method is deprecated, use "isDeletion()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::isDelete()" method is deprecated, use "isDeletion()" instead.'); $this->assertTrue(new PushReference(self::createFoobarRepository(), 'foo', self::LONGFILE_COMMIT, PushReference::ZERO)->isDelete()); } @@ -71,7 +71,7 @@ public function testLegacyIsDelete(): void #[IgnoreDeprecations] public function testLegacyGetReference(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\PushReference::getReference()" method is deprecated, use "getFullname()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::getReference()" method is deprecated, use "getFullname()" instead.'); $this->assertSame('refs/heads/foo', new PushReference(self::createFoobarRepository(), 'refs/heads/foo', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->getReference()); } @@ -80,7 +80,7 @@ public function testLegacyGetReference(): void #[IgnoreDeprecations] public function testLegacyGetRevision(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\PushReference::getRevision()" method is deprecated, use "getRange()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::getRevision()" method is deprecated, use "getRange()" instead.'); $this->assertSame(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT, new PushReference(self::createFoobarRepository(), 'refs/heads/foo', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->getRevision()); } diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index b944031a..d17fc808 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -21,6 +21,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use Symfony\Component\Process\Process; class ReferenceTest extends AbstractTestCase { @@ -283,7 +284,7 @@ public function testIsBranchMergedToMaster(): void #[IgnoreDeprecations] public function testLegacyIsMergedTo(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Reference\Branch::isMergedTo()" method is deprecated, use "Gitonomy\Git\Reference\Branch::isMergedInto()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Reference\Branch::isMergedTo()" method is deprecated, use "Gitonomy\Git\Reference\Branch::isMergedInto()" instead.'); $repository = self::createFoobarRepository(false); $references = $repository->getReferences(); @@ -344,7 +345,7 @@ public function testBranchMoveTo(): void #[IgnoreDeprecations] public function testLegacyUpdate(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\ReferenceBag::update()" method is deprecated, use "Gitonomy\Git\Reference\Branch::moveTo()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\ReferenceBag::update()" method is deprecated, use "Gitonomy\Git\Reference\Branch::moveTo()" instead.'); $repository = self::createFoobarRepository(); $references = $repository->getReferences(); @@ -354,6 +355,19 @@ public function testLegacyUpdate(): void $this->assertSame(self::INITIAL_COMMIT, $references->getBranch('master')->getCommitHash()); } + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetReferencesReadsThemAgain(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing an argument to "Gitonomy\Git\Repository::getReferences()" is deprecated, gitlib reads the references again after its writes: open a new Repository to see the writes of other processes.'); + + $repository = self::createFoobarRepository(); + $this->assertFalse($repository->getReferences()->hasBranch('elsewhere')); + new Process([self::gitCommand(), 'branch', 'elsewhere', 'master'], $repository->getGitDir(), self::gitEnvs())->mustRun(); + + $this->assertTrue($repository->getReferences(true)->hasBranch('elsewhere')); + } + public function testBranchesEndingWithHead(): void { $repository = self::createFoobarRepository(); diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 7a907b17..236c4b74 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -480,7 +480,7 @@ public function testAnObjectOfAnotherTypeThrows(): void foreach ([ 'blob' => static fn () => $repository->getBlob(self::LONGFILE_COMMIT)->getContent(), - 'tree' => static fn () => $repository->getTree(self::LONGFILE_COMMIT)->getEntries(), + 'tree' => static fn () => iterator_to_array($repository->getTree(self::LONGFILE_COMMIT)), 'commit' => static fn () => $repository->getCommit($treeHash)->getMessage(), ] as $type => $read) { try { @@ -825,7 +825,7 @@ public function testGetSizeInBytesOfAnUnreadableDirectoryThrows(): void #[IgnoreDeprecations] public function testLegacyGetSizeInKilobytes(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::getSize()" method is deprecated, use "getSizeInBytes()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::getSize()" method is deprecated, use "getSizeInBytes()" instead.'); $repository = self::createFoobarRepository(); @@ -955,7 +955,7 @@ public function testDriverOpensTheRepository(): void #[IgnoreDeprecations] public function testLegacySetLogger(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::setLogger()" method is deprecated, pass a "Gitonomy\Git\Driver\Cli\CliDriver" configured with the logger as driver argument instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::setLogger()" method is deprecated, pass a "Gitonomy\Git\Driver\Cli\CliDriver" configured with the logger as driver argument instead.'); $logger = $this->createMock(LoggerInterface::class); $logger->expects($this->once()) @@ -987,7 +987,7 @@ public function testLegacyShell(): void $this->markTestSkipped('The command of git shell is a shell script.'); } - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::shell()" method is deprecated, run "git shell" with Symfony Process instead: it ignores the driver.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::shell()" method is deprecated, run "git shell" with Symfony Process instead: it ignores the driver.'); // git shell runs the commands of ~/git-shell-commands. $home = self::createTempDir(); @@ -1029,7 +1029,7 @@ public function testUnknownOptionsAreRefused(): void #[IgnoreDeprecations] public function testLegacyOptionsConfigureACliDriver(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing an array of options to "Gitonomy\Git\Repository::__construct()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing an array of options to "Gitonomy\Git\Repository::__construct()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.'); $logger = $this->createStub(LoggerInterface::class); $repository = new Repository(self::createFoobarRepository()->getGitDir(), [ @@ -1056,7 +1056,7 @@ public function testLegacyOptionsCantBeCombinedWithADriver(): void #[IgnoreDeprecations] public function testLegacyDebugOption(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing an array of options to "Gitonomy\Git\Repository::__construct()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing an array of options to "Gitonomy\Git\Repository::__construct()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.'); $repository = new Repository(self::createFoobarRepository()->getGitDir(), ['debug' => true], self::getDriver()); diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index e702aaff..2e64b54b 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -27,12 +27,12 @@ class TreeTest extends AbstractTestCase public const PATH_RESOLVING_COMMIT = 'cc06ac171d884282202dff88c1ded499a1f89420'; #[DataProvider('provideBare')] - public function testGetEntries(bool $bare): void + public function testIterate(bool $bare): void { $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::LONGFILE_COMMIT)->getTree(); - $entries = $tree->getEntries(); + $entries = iterator_to_array($tree); $this->assertInstanceOf(Blob::class, $entries['long.php']->getObject()); $this->assertSame('long.php', $entries['long.php']->getName()); @@ -41,15 +41,15 @@ public function testGetEntries(bool $bare): void } #[DataProvider('provideBare')] - public function testGetEntriesWithNonAsciiNames(bool $bare): void + public function testIterateWithNonAsciiNames(bool $bare): void { $repository = self::createFoobarRepository($bare); - $entries = $repository->getCommit(DiffTest::FILE_WITH_UMLAUTS_COMMIT)->getTree()->getEntries(); + $entries = iterator_to_array($repository->getCommit(DiffTest::FILE_WITH_UMLAUTS_COMMIT)->getTree()); $this->assertArrayHasKey('file_with_umlauts_äöü', $entries); } - public function testGetEntriesWithSpecialCharactersInNames(): void + public function testIterateWithSpecialCharactersInNames(): void { $repository = self::createFoobarRepository(false); self::git($repository, 'config', ['user.name', 'Unit Test']); @@ -58,18 +58,18 @@ public function testGetEntriesWithSpecialCharactersInNames(): void self::git($repository, 'add', ['--all']); self::git($repository, 'commit', ['-m', 'Add a file with special characters in its name']); - $entries = $repository->getHeadCommit()->getTree()->getEntries(); + $entries = iterator_to_array($repository->getHeadCommit()->getTree()); $this->assertArrayHasKey('back\slash'."\ttab\"quote", $entries); } #[DataProvider('provideBare')] - public function testGetSubmoduleEntries(bool $bare): void + public function testGetSubmodules(bool $bare): void { $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $submodules = $tree->getSubmoduleEntries(); + $submodules = $tree->getSubmodules(); $this->assertSame(['barbaz'], array_keys($submodules)); $this->assertInstanceOf(Submodule::class, $submodules['barbaz']->getObject()); @@ -85,7 +85,7 @@ public function testGetSubmoduleEntries(bool $bare): void #[RunInSeparateProcess] public function testLegacyCommitReference(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\CommitReference" class is deprecated, use "Gitonomy\Git\Submodule" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\CommitReference" class is deprecated, use "Gitonomy\Git\Submodule" instead.'); $this->assertTrue(class_exists(CommitReference::class)); } @@ -95,8 +95,7 @@ public function testLegacyCommitReference(): void #[IgnoreDeprecations] public function testLegacyGetCommitReferenceEntries(bool $bare): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Tree::getCommitReferenceEntries()" method is deprecated, use "getSubmoduleEntries()" instead.'); - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Reading a "Gitonomy\Git\TreeEntry" as an array is deprecated, use its "getMode()" and "getObject()" methods instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getCommitReferenceEntries()" method is deprecated, use "getSubmodules()" instead.'); $tree = self::createFoobarRepository($bare)->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); @@ -136,27 +135,71 @@ public function testResolveTheRootPath(): void } #[DataProvider('provideBare')] - public function testGetTreeEntries(bool $bare): void + public function testGetTrees(bool $bare): void { $repository = self::createFoobarRepository($bare); - $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $trees = $tree->getTreeEntries(); + $this->assertSame([], $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree()->getTrees()); - $this->assertEmpty($trees); + $trees = $repository->getCommit(self::PATH_RESOLVING_COMMIT)->getTree()->getTrees(); + + $this->assertSame(['test'], array_keys($trees)); + $this->assertInstanceOf(Tree::class, $trees['test']->getObject()); + $this->assertSame('040000', $trees['test']->getMode()); } #[DataProvider('provideBare')] - public function testGetBlobEntries(bool $bare): void + public function testGetBlobs(bool $bare): void { $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $blobs = $tree->getBlobEntries(); + $blobs = $tree->getBlobs(); + $this->assertArrayNotHasKey('barbaz', $blobs); $this->assertInstanceOf(Blob::class, $blobs['README.md']->getObject()); } + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetEntries(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getEntries()" method is deprecated, iterate the tree instead.'); + + $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $entries = $tree->getEntries(); + + $this->assertSame(array_keys(iterator_to_array($tree)), array_keys($entries)); + $this->assertSame(['100644', $tree->getEntry('README.md')], $entries['README.md']); + $this->assertSame(['160000', $tree->getEntry('barbaz')], $entries['barbaz']); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetBlobEntries(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getBlobEntries()" method is deprecated, use "getBlobs()" instead.'); + + $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $blobs = $tree->getBlobEntries(); + + $this->assertSame(array_keys($tree->getBlobs()), array_keys($blobs)); + $this->assertSame(['100644', $tree->getEntry('README.md')], $blobs['README.md']); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetTreeEntries(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getTreeEntries()" method is deprecated, use "getTrees()" instead.'); + + $tree = self::createFoobarRepository()->getCommit(self::PATH_RESOLVING_COMMIT)->getTree(); + + $this->assertSame(['test' => ['040000', $tree->getEntry('test')]], $tree->getTreeEntries()); + } + #[DataProvider('provideBare')] public function testResolvePath(bool $bare): void { @@ -166,7 +209,7 @@ public function testResolvePath(bool $bare): void $resolved = $tree->resolvePath($path); $this->assertInstanceOf(Tree::class, $resolved); - $entries = $resolved->getEntries(); + $entries = iterator_to_array($resolved); $this->assertArrayHasKey('d', $entries, 'Successfully resolved source folder'); } diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 0b756c06..f5d52c04 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -65,7 +65,7 @@ public function testCheckoutOfABranchObjectAttachesHead(): void #[IgnoreDeprecations] public function testLegacyCheckoutOfANewBranch(): void { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing a branch to create to "Gitonomy\Git\WorkingCopy::checkout()" is deprecated, use "checkoutNewBranch()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing a branch to create to "Gitonomy\Git\WorkingCopy::checkout()" is deprecated, use "checkoutNewBranch()" instead.'); $repository = self::createFoobarRepository(false); $repository->getWorkingCopy()->checkout('origin/new-feature', 'new-feature'); From 7a38f69940697766f5f6948a4f4f9350597a63bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:58:16 +0000 Subject: [PATCH 144/150] Bump phpstan/phpstan in /tools/phpstan in the php group Bumps the php group in /tools/phpstan with 1 update: [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source). Updates `phpstan/phpstan` from 2.3.1 to 2.3.2 - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.3.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: php ... Signed-off-by: dependabot[bot] --- tools/phpstan/composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/phpstan/composer.lock b/tools/phpstan/composer.lock index b92b4a12..7304ae16 100644 --- a/tools/phpstan/composer.lock +++ b/tools/phpstan/composer.lock @@ -59,11 +59,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.3.1", + "version": "2.3.2", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db", - "reference": "6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/18bc23a0682f08591529377d271b5c4dfe9ed1dd", + "reference": "18bc23a0682f08591529377d271b5c4dfe9ed1dd", "shasum": "" }, "require": { @@ -119,7 +119,7 @@ "type": "github" } ], - "time": "2026-10-08T21:09:11+00:00" + "time": "2026-10-24T11:36:00+00:00" }, { "name": "phpstan/phpstan-phpunit", From 98cf15d2c8d3f7b45678bf1292b7163a74c9a25c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:58:40 +0000 Subject: [PATCH 145/150] Bump the pip group in /tools/zensical with 3 updates Bumps the pip group in /tools/zensical with 3 updates: [markdown](https://github.com/Python-Markdown/markdown), [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) and [zensical](https://github.com/zensical/zensical). Updates `markdown` from 3.11.1 to 3.11.2 - [Release notes](https://github.com/Python-Markdown/markdown/releases) - [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md) - [Commits](https://github.com/Python-Markdown/markdown/compare/3.11.1...3.11.2) Updates `pymdown-extensions` from 12.2 to 12.3 - [Release notes](https://github.com/facelessuser/pymdown-extensions/releases) - [Commits](https://github.com/facelessuser/pymdown-extensions/compare/12.2...12.3) Updates `zensical` from 0.0.70 to 0.0.72 - [Release notes](https://github.com/zensical/zensical/releases) - [Commits](https://github.com/zensical/zensical/compare/v0.0.70...v0.0.72) --- updated-dependencies: - dependency-name: markdown dependency-version: 3.11.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip - dependency-name: pymdown-extensions dependency-version: '12.3' dependency-type: direct:production update-type: version-update:semver-minor dependency-group: pip - dependency-name: zensical dependency-version: 0.0.72 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip ... Signed-off-by: dependabot[bot] --- tools/zensical/requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/zensical/requirements.txt b/tools/zensical/requirements.txt index ce2e03aa..0dadf2cc 100644 --- a/tools/zensical/requirements.txt +++ b/tools/zensical/requirements.txt @@ -3,11 +3,11 @@ click==8.5.0 deepmerge==3.0.1 jinja2==3.1.6 -markdown==3.11.1 +markdown==3.11.2 markupsafe==3.0.4 pathspec==1.1.1 pygments==2.21.0 -pymdown-extensions==12.2 +pymdown-extensions==12.3 pyyaml==6.0.3 tomli==2.5.0 -zensical==0.0.70 +zensical==0.0.72 From 5ea2a9e6b7b10034acf1bba2111db836b4e68328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Sat, 7 Nov 2026 21:38:17 +0100 Subject: [PATCH 146/150] Backport the security fixes of 2.0 Only the ones breaking nothing of 1.6: the changelog lists those left out. --- CHANGELOG.md | 66 ++ composer.json | 2 +- doc/repository.md | 18 + src/Gitonomy/Git/Admin.php | 28 +- src/Gitonomy/Git/Blame.php | 3 + src/Gitonomy/Git/Driver/Cli/CliDriver.php | 43 +- .../Git/Exception/ProcessException.php | 9 +- src/Gitonomy/Git/Hooks.php | 32 +- src/Gitonomy/Git/Log.php | 7 +- src/Gitonomy/Git/ReferenceBag.php | 23 +- src/Gitonomy/Git/Repository.php | 41 +- src/Gitonomy/Git/Util/Credentials.php | 54 ++ src/Gitonomy/Git/Util/LeadingDash.php | 83 ++ src/Gitonomy/Git/Util/RemoteUrl.php | 78 ++ src/Gitonomy/Git/WorkingCopy.php | 7 +- .../Git/Tests/BackwardCompatibilityTest.php | 110 +++ tests/Gitonomy/Git/Tests/SecurityTest.php | 794 ++++++++++++++++++ 17 files changed, 1346 insertions(+), 52 deletions(-) create mode 100644 src/Gitonomy/Git/Util/Credentials.php create mode 100644 src/Gitonomy/Git/Util/LeadingDash.php create mode 100644 src/Gitonomy/Git/Util/RemoteUrl.php create mode 100644 tests/Gitonomy/Git/Tests/SecurityTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index cc0535fc..88b45813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,71 @@ All notable changes to this project are documented in this file. +## [v1.7.1] - Unreleased + +Changes since [v1.7.0](https://github.com/gitonomy/gitlib/releases/tag/v1.7.0). + +1.7.1 has the security fixes of 2.0 that break nothing of 1.6. Where 2.0 refuses every option, +1.7.1 only refuses what reads or writes a file: the other options of `git log` as revisions stay +deprecated, and work. The checks of a URL keep returning `false`, and clones keep throwing a +`RuntimeException`, where 2.0 throws an `InvalidArgumentException`. + +### Security + +- Refuse `--output` as a revision, which made git write a file: `getRevision('--output=/path')` + and everything reading a revision through it, `getLog()`, `getDiff()`, `getMergeBase()` or + `Branch::moveTo()` for instance. +- Refuse the options of `git blame` reading or writing a file as a revision: + `getBlame('--contents=/path', 'README.md')` returned the lines of any file, and the error of + `--ignore-revs-file` or `-S` printed its first line. +- Refuse a URL starting with a dash, which git read as an option: + `Admin::isValidRepository('--upload-pack=...')` ran the command in a repository with a remote, + `Admin::isValidRepositoryAndBranch()` and the clones when the second argument was a + repository. A path may start with a dash: `init()` and the clones end the options of git with + `--`, as `cloneFrom($url, '--upload-pack=...')` ran the command too. +- Refuse a hash starting with a dash in `ReferenceBag::createBranch()` and `createTag()`: + `createBranch('foo', '-d')` deleted the branch. So do the branch and the start point of + `WorkingCopy::checkoutNewBranch()`. +- Refuse an `fd::` URL, which makes git talk through a file descriptor it inherits from PHP, like + the socket of a database, and wait on it. +- Refuse an ssh URL whose host or user starts with a dash, even encoded (`%2D`), like + `ssh://-oProxyCommand=.../repository`: git before 2.14.1 passes it to ssh, which reads it as an + option and runs the command (CVE-2017-1000117). A `git://` URL whose host starts with a dash + is refused too: git passes it to the command of `core.gitProxy`. +- Hide the credentials of URLs (`https://user:token@host`), a password holding a `#` or a `?` + included, in the messages of `ProcessException` and of failed clones, in the logs, and in the + messages of the exceptions of Symfony Process, a timeout for instance, which keep their class. + From PHP 8.2, the traces hide them too (`#[\SensitiveParameter]`). +- Refuse a hook name leading out of the hooks directory: `Hooks::set('../../file', ...)`, + `remove('../config')` and `get('../config')` wrote, deleted and read files of the repository. + The names staying in the directory, like `post-receive.d/notify`, still work. +- Fix `Hooks` writing through a symbolic link to a missing file, out of the hooks directory: + `has()` returned `false` for it, and `set()` followed it. +- Ignore `core.fsmonitor`: git ran the command the configuration of a repository names there to + read the working tree, and even to diff a commit. +- Fix deleting a symbolic reference, which deleted the branch it points to instead: + `ReferenceBag::delete('refs/remotes/origin/HEAD')` deleted `origin/main`. +- Fix `ReferenceBag::delete('HEAD')` deleting a detached `HEAD`, which left no repository: it + throws an `InvalidArgumentException`. +- Require `symfony/process` 5.4.51, 6.4.33, 7.3.11, 7.4.5 or 8.0.5 and later: the older versions + escape the arguments wrongly on Windows (CVE-2024-51736, CVE-2026-24739). + +### Not fixed + +These fixes of 2.0 would break code written for 1.6. An application giving gitlib values it can't +trust has to check them itself: + +- `ext::` URLs stay accepted, as an application may use this transport on purpose. git refuses + them by default since 2.12: before that, `ext::sh -c ...` runs the command. +- The replacements of objects (`git replace`, `refs/replace/`) stay read, as `git replace --graft` + relies on them: whoever can push such a reference changes the message of a commit, or the + content of a blob, gitlib reads for a hash. Set `GIT_NO_REPLACE_OBJECTS` to `1` in the + `environmentVariables` of `CliDriver` to ignore them. +- `WorkingCopy::checkout()` still takes a path or an option: `checkout('README.md')` discards the + changes of the file, and `checkout('--force')` all of them. +- Moving a symbolic reference, with `Branch::moveTo()` or `ReferenceBag::update()`, still moves + the branch it points to. + ## [v1.7.0] - 2026-10-15 Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). @@ -100,5 +165,6 @@ _Earlier versions are not documented here. See the [GitHub releases](https://github.com/gitonomy/gitlib/releases) and [commit history](https://github.com/gitonomy/gitlib/commits/v1.6.0) for prior changes._ +[v1.7.1]: https://github.com/gitonomy/gitlib/compare/v1.7.0...v1.7.1 [v1.7.0]: https://github.com/gitonomy/gitlib/compare/v1.6.0...v1.7.0 [v1.6.0]: https://github.com/gitonomy/gitlib/releases/tag/v1.6.0 diff --git a/composer.json b/composer.json index 4b9a2fca..076b0753 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "ext-pcre": "*", "symfony/deprecation-contracts": "^2.5 || ^3", "symfony/polyfill-mbstring": "^1.7", - "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0" + "symfony/process": "^5.4.51 || ^6.4.33 || ~7.3.11 || ^7.4.5 || ^8.0.5" }, "require-dev": { "ext-fileinfo": "*", diff --git a/doc/repository.md b/doc/repository.md index 626a8af2..2ac7214d 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -49,6 +49,24 @@ option is the second argument, and its `logger`, `command`, `CliDriver`. Setting its `debug` option to `false` is deprecated: gitlib 2.0 always throws when git fails. +Ignore the replacements of objects +---------------------------------- + +git reads the replacements of a repository (`git replace`): whoever can +push one changes the message of a commit, or the content of a blob, you +read for a hash. For a repository you don't trust, ignore them: + +```php +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repo', driver: new CliDriver( + environmentVariables: ['GIT_NO_REPLACE_OBJECTS' => '1'], +)); +``` + +gitlib 2.0 always ignores them. + Test if a repository is bare ---------------------------- diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 9d561284..75f92956 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -14,7 +14,9 @@ use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Util\Credentials; use Gitonomy\Git\Util\LegacyOverride; +use Gitonomy\Git\Util\RemoteUrl; /** * Administration class for Git repositories. @@ -68,14 +70,18 @@ public static function init($path, $bare = true, array $options = []) * * @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepository() instead */ - public static function isValidRepository($url, array $options = []) + public static function isValidRepository(#[\SensitiveParameter] $url, array $options = []) { trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class); + if (null !== RemoteUrl::unsafeReason((string) $url)) { + return false; + } + // Unlike Repository::isValidRepository(), in the working directory of PHP, where a relative path may name a remote. $process = CliDriver::fromOptions($options)->createProcess('ls-remote', [$url]); - $process->run(); + Credentials::runProcess($process); return $process->isSuccessFul(); } @@ -96,15 +102,19 @@ public static function isValidRepository($url, array $options = []) * * @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepositoryAndBranch() instead */ - public static function isValidRepositoryAndBranch($url, $branchName, array $options = []) + public static function isValidRepositoryAndBranch(#[\SensitiveParameter] $url, $branchName, array $options = []) { trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class); + if (null !== RemoteUrl::unsafeReason((string) $url)) { + return false; + } + // Unlike Repository::isValidRepositoryAndBranch(), "release" matches "feature/release", and "rel" "release", // in the working directory of PHP. $process = CliDriver::fromOptions($options)->createProcess('ls-remote', ['--heads', $url, $branchName]); - $process->run(); + Credentials::runProcess($process); $processOutput = $process->getOutput(); return $process->isSuccessFul() && strpos($processOutput, $branchName) !== false; @@ -122,7 +132,7 @@ public static function isValidRepositoryAndBranch($url, $branchName, array $opti * * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() instead */ - public static function cloneTo($path, $url, $bare = true, array $options = []) + public static function cloneTo($path, #[\SensitiveParameter] $url, $bare = true, array $options = []) { trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); @@ -144,7 +154,7 @@ public static function cloneTo($path, $url, $bare = true, array $options = []) * * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() with their "branch" argument instead */ - public static function cloneBranchTo($path, $url, $branch, $bare = true, $options = []) + public static function cloneBranchTo($path, #[\SensitiveParameter] $url, $branch, $bare = true, $options = []) { trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); @@ -167,7 +177,7 @@ public static function cloneBranchTo($path, $url, $branch, $bare = true, $option * * @deprecated since gitonomy/gitlib 1.7, use Repository::mirrorFrom() instead */ - public static function mirrorTo($path, $url, array $options = []) + public static function mirrorTo($path, #[\SensitiveParameter] $url, array $options = []) { trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class); @@ -186,7 +196,7 @@ public static function mirrorTo($path, $url, array $options = []) * * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneFrom(), Repository::cloneBareFrom() or Repository::mirrorFrom() instead */ - public static function cloneRepository($path, $url, array $args = [], array $options = []) + public static function cloneRepository($path, #[\SensitiveParameter] $url, array $args = [], array $options = []) { trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class); @@ -195,7 +205,7 @@ public static function cloneRepository($path, $url, array $args = [], array $opt return new Repository($path, $options); } - private static function cloneWith($path, $url, array $args, array $options) + private static function cloneWith($path, #[\SensitiveParameter] $url, array $args, array $options) { // Like in 1.6, a subclass overriding cloneRepository() clones the repository. if (LegacyOverride::isOverridden(static::class, self::class, 'cloneRepository')) { diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index e9d3e407..73370820 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -16,6 +16,7 @@ use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Parser\BlameParser; +use Gitonomy\Git\Util\LeadingDash; /** * @author Alexandre Salomé @@ -167,6 +168,8 @@ public function getLines() return $this->lines; } + LeadingDash::assertNoBlameFileOption($this->revision->getRevision()); + $args = ['-p']; if (null !== $this->lineRange) { diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index c999bd2e..4d434635 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -13,6 +13,8 @@ namespace Gitonomy\Git\Driver\Cli; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Util\Credentials; +use Gitonomy\Git\Util\RemoteUrl; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; @@ -77,7 +79,8 @@ public function getOptions(): array */ public function init(string $path): void { - $this->initRepository(['-q', $path]); + // --: a path may start with a dash. + $this->initRepository(['-q', '--', $path]); } /** @@ -87,7 +90,7 @@ public function init(string $path): void */ public function initBare(string $path): void { - $this->initRepository(['-q', '--bare', $path]); + $this->initRepository(['-q', '--bare', '--', $path]); } /** @@ -95,7 +98,7 @@ public function initBare(string $path): void * * @throws RuntimeException */ - public function cloneFrom(string $url, string $path, ?string $branch): void + public function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void { $this->cloneRepository($url, $path, null === $branch ? [] : ['--branch', $branch]); } @@ -105,7 +108,7 @@ public function cloneFrom(string $url, string $path, ?string $branch): void * * @throws RuntimeException */ - public function cloneBareFrom(string $url, string $path, ?string $branch): void + public function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void { $this->cloneRepository($url, $path, null === $branch ? ['--bare'] : ['--branch', $branch, '--bare']); } @@ -115,7 +118,7 @@ public function cloneBareFrom(string $url, string $path, ?string $branch): void * * @throws RuntimeException */ - public function mirrorFrom(string $url, string $path): void + public function mirrorFrom(#[\SensitiveParameter] string $url, string $path): void { $this->cloneRepository($url, $path, ['--mirror']); } @@ -123,10 +126,14 @@ public function mirrorFrom(string $url, string $path): void /** * @internal For Repository::isValidRepository() */ - public function isValidRepository(string $url): bool + public function isValidRepository(#[\SensitiveParameter] string $url): bool { + if (null !== RemoteUrl::unsafeReason($url)) { + return false; + } + $process = $this->createLsRemoteProcess([], $url, []); - $process->run(); + Credentials::runProcess($process); return $process->isSuccessful(); } @@ -134,11 +141,15 @@ public function isValidRepository(string $url): bool /** * @internal For Repository::isValidRepositoryAndBranch() */ - public function isValidRepositoryAndBranch(string $url, string $branch): bool + public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch): bool { + if (null !== RemoteUrl::unsafeReason($url)) { + return false; + } + // git lists the branches whose name ends with the pattern: "release" matches "feature/release". $process = $this->createLsRemoteProcess(['--heads'], $url, ['refs/heads/'.$branch]); - $process->run(); + Credentials::runProcess($process); if (!$process->isSuccessful()) { return false; } @@ -159,13 +170,19 @@ public function isValidRepositoryAndBranch(string $url, string $branch): bool * * @throws RuntimeException */ - public function cloneRepository(string $url, string $path, array $args): void + public function cloneRepository(#[\SensitiveParameter] string $url, string $path, array $args): void { - $process = $this->createProcess('clone', array_merge(['-q'], $args, [$url, $path])); - $process->run(); + $reason = RemoteUrl::unsafeReason($url); + if (null !== $reason) { + throw new RuntimeException(sprintf('Error while initializing repository: %s', $reason)); + } + + // --: a path may start with a dash. + $process = $this->createProcess('clone', array_merge(['-q'], $args, ['--', $url, $path])); + Credentials::runProcess($process); if (!$process->isSuccessful()) { - throw new RuntimeException(sprintf('Error while initializing repository: %s', $process->getErrorOutput())); + throw new RuntimeException(sprintf('Error while initializing repository: %s', Credentials::redact($process->getErrorOutput()))); } } diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index c4e20028..ead27c0d 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -2,6 +2,7 @@ namespace Gitonomy\Git\Exception; +use Gitonomy\Git\Util\Credentials; use Symfony\Component\Process\Process; /** @@ -11,16 +12,20 @@ class ProcessException extends RuntimeException implements GitExceptionInterface { protected $process; + /** + * The message hides the credentials of the URLs, unlike getErrorOutput() + * and getOutput(). + */ public function __construct(Process $process) { - parent::__construct( + parent::__construct(Credentials::redact( "Error while running git command:\n". $process->getCommandLine()."\n". "\n". $process->getErrorOutput()."\n". "\n". $process->getOutput() - ); + )); $this->process = $process; } diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index d5fdd0a6..ecb9d344 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -43,11 +43,17 @@ public function __construct(Repository $repository) * * @param string $name Name of the hook * + * @throws InvalidArgumentException The name leads out of the hooks directory + * * @return bool */ public function has($name) { - return file_exists($this->getPath($name)); + $path = $this->getPath($name); + + // A symbolic link to a file not deployed yet is a hook too: set() + // would write through it, out of the hooks directory. + return is_link($path) || file_exists($path); } /** @@ -55,13 +61,13 @@ public function has($name) * * @param string $name Name of the hook * - * @throws InvalidArgumentException Hook does not exist + * @throws InvalidArgumentException Hook does not exist, or the name leads out of the hooks directory * * @return string Content of the hook */ public function get($name) { - if (!$this->has($name)) { + if (!$this->has($name) || !file_exists($this->getPath($name))) { throw new InvalidArgumentException(sprintf('Hook named "%s" is not present', $name)); } @@ -74,8 +80,9 @@ public function get($name) * @param string $name Name of the hook to insert * @param string $file Target of symlink * - * @throws LogicException Hook is already present - * @throws RuntimeException Error on symlink creation + * @throws InvalidArgumentException The name leads out of the hooks directory + * @throws LogicException Hook is already present + * @throws RuntimeException Error on symlink creation */ public function setSymlink($name, $file) { @@ -95,7 +102,8 @@ public function setSymlink($name, $file) * @param string $name Name of the hook * @param string $content Content of the hook * - * @throws LogicException The hook is already defined + * @throws InvalidArgumentException The name leads out of the hooks directory + * @throws LogicException The hook is already defined */ public function set($name, $content) { @@ -113,7 +121,8 @@ public function set($name, $content) * * @param string $name Name of the hook * - * @throws LogicException The hook is not present + * @throws InvalidArgumentException The name leads out of the hooks directory + * @throws LogicException The hook is not present */ public function remove($name) { @@ -125,10 +134,19 @@ public function remove($name) } /** + * Unlike 2.0, 1.x accepts a name in a subdirectory, like "post-receive.d/notify", + * or starting with a dot. + * + * @throws InvalidArgumentException The name leads out of the hooks directory + * * @return string */ protected function getPath($name) { + if (1 === preg_match('#(?:^|[/\\\\])\.\.(?:[/\\\\]|$)#', (string) $name)) { + throw new InvalidArgumentException(sprintf('The name of the hook "%s" leads out of the hooks directory.', $name)); + } + return $this->repository->getGitDir().'/hooks/'.$name; } } diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index fae16366..cd1c16f6 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -16,6 +16,7 @@ use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\LegacyOverride; use Gitonomy\Git\Util\StringHelper; @@ -59,6 +60,8 @@ class Log implements \Countable, \IteratorAggregate * @param array $paths paths to filter on * @param int|null $offset start list from a given position * @param int|null $limit limit number of fetched elements + * + * @throws InvalidArgumentException A revision is an option making git write a file */ public function __construct(Repository $repository, $revisions = null, $paths = null, $offset = null, $limit = null) { @@ -67,11 +70,13 @@ public function __construct(Repository $repository, $revisions = null, $paths = } foreach (null === $revisions ? [] : $revisions->getAll() as $revision) { + $name = $revision->getRevision(); + LeadingDash::assertNoLogFileOption($name); + if ($revision instanceof Commit || $revision instanceof Reference) { continue; } - $name = $revision->getRevision(); if (str_starts_with($name, '-') && '--all' !== $name) { trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing "%s", an option of "git log", as revision to "%s()" is deprecated, it throws in 2.0: pass revisions, ranges, names to exclude or "--all".', $name, __METHOD__); } diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 2358a46b..113fe846 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -12,11 +12,13 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Stash; use Gitonomy\Git\Reference\Tag; +use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\LegacyOverride; /** @@ -158,15 +160,28 @@ public function createTag($name, $commitHash) /** * Deleting a reference by another name than its full name under "refs/", like "HEAD" or "master", is deprecated. * + * @throws InvalidArgumentException HEAD is detached: deleting it would leave no repository + * * @return void */ public function delete($fullname) { + // --no-deref: a symbolic reference, like refs/remotes/origin/HEAD, is + // deleted, not the branch it points to. + $args = ['--no-deref', '-d', $fullname]; + if (!str_starts_with($fullname, 'refs/')) { trigger_deprecation('gitonomy/gitlib', '1.7', 'Deleting "%s", which is not the full name of a reference under "refs/", is deprecated, it throws in 2.0.', $fullname); + + if ('HEAD' === $fullname && $this->repository->isHeadDetached()) { + throw new InvalidArgumentException('A detached HEAD can\'t be deleted, which would leave no repository.'); + } + + // Like in 1.6: deleting an attached HEAD deletes its branch. + $args = ['-d', $fullname]; } - $this->repository->run('update-ref', ['-d', $fullname]); + $this->repository->run('update-ref', $args); unset($this->references[$fullname]); } @@ -447,8 +462,12 @@ private function updateReference(Reference $reference) { $fullname = $reference->getFullname(); + $hash = $reference->getCommitHash(); + // "git update-ref refs/heads/foo -d" deletes the branch. + LeadingDash::assertNone($hash); + $this->initialize(); - $this->repository->run('update-ref', [$fullname, $reference->getCommitHash()]); + $this->repository->run('update-ref', [$fullname, $hash]); $this->references[$fullname] = $reference; diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index fc78d09e..cb358f95 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -18,6 +18,8 @@ use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Util\Credentials; +use Gitonomy\Git\Util\LeadingDash; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; @@ -217,7 +219,7 @@ public static function initBare(string $path, ?CliDriver $driver = null): self * * @throws RuntimeException */ - public static function cloneFrom(string $url, string $path, ?string $branch = null, ?CliDriver $driver = null): self + public static function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, ?CliDriver $driver = null): self { $driver = $driver ?? new CliDriver(); $driver->cloneFrom($url, $path, $branch); @@ -233,7 +235,7 @@ public static function cloneFrom(string $url, string $path, ?string $branch = nu * * @throws RuntimeException */ - public static function cloneBareFrom(string $url, string $path, ?string $branch = null, ?CliDriver $driver = null): self + public static function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, ?CliDriver $driver = null): self { $driver = $driver ?? new CliDriver(); $driver->cloneBareFrom($url, $path, $branch); @@ -249,7 +251,7 @@ public static function cloneBareFrom(string $url, string $path, ?string $branch * * @throws RuntimeException */ - public static function mirrorFrom(string $url, string $path, ?CliDriver $driver = null): self + public static function mirrorFrom(#[\SensitiveParameter] string $url, string $path, ?CliDriver $driver = null): self { $driver = $driver ?? new CliDriver(); $driver->mirrorFrom($url, $path); @@ -263,7 +265,7 @@ public static function mirrorFrom(string $url, string $path, ?CliDriver $driver * * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null */ - public static function isValidRepository(string $url, ?CliDriver $driver = null): bool + public static function isValidRepository(#[\SensitiveParameter] string $url, ?CliDriver $driver = null): bool { return ($driver ?? new CliDriver())->isValidRepository($url); } @@ -274,7 +276,7 @@ public static function isValidRepository(string $url, ?CliDriver $driver = null) * * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null */ - public static function isValidRepositoryAndBranch(string $url, string $branch, ?CliDriver $driver = null): bool + public static function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch, ?CliDriver $driver = null): bool { return ($driver ?? new CliDriver())->isValidRepositoryAndBranch($url, $branch); } @@ -637,6 +639,8 @@ public function getLegacyDiff($revisions): Diff $revisions = new RevisionList($this, $revisions); } + LeadingDash::assertNoLogFileOption(...$revisions->getAsTextArray()); + return $this->runDiff($revisions->getAsTextArray()); } @@ -768,7 +772,7 @@ public function setDescription($description) * * @return string Output of a successful process or null if execution failed and debug-mode is disabled. */ - public function run($command, $args = []) + public function run($command, #[\SensitiveParameter] $args = []) { $process = $this->execute($command, $args); @@ -853,22 +857,22 @@ private function commitHash(Revision|string $revision): string /** * Runs a git command, and logs it. */ - private function execute($command, $args = []): Process + private function execute($command, #[\SensitiveParameter] $args = []): Process { $process = $this->getProcess($command, $args); if ($this->logger) { - $this->logger->info(sprintf('run command: %s "%s" ', $command, implode(' ', $args))); + $this->logger->info(Credentials::redact(sprintf('run command: %s "%s" ', $command, implode(' ', $args)))); $before = microtime(true); } - $process->run(); + Credentials::runProcess($process); if ($this->logger && $this->debug) { $duration = microtime(true) - $before; $this->logger->debug(sprintf('last command (%s) duration: %sms', $command, sprintf('%.2f', $duration * 1000))); $this->logger->debug(sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); - $this->logger->debug(sprintf('last command (%s) output: %s', $command, $process->getOutput())); + $this->logger->debug(Credentials::redact(sprintf('last command (%s) output: %s', $command, $process->getOutput()))); } return $process; @@ -882,7 +886,7 @@ private function execute($command, $args = []): Process private function fail($command, Process $process): void { if ($this->logger) { - $this->logger->error(sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput())); + $this->logger->error(Credentials::redact(sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput()))); } if ($this->debug) { @@ -910,11 +914,16 @@ private function getProcess($command, $args = []) $process = new Process(array_merge($base, $args)); - if ($this->inheritEnvironmentVariables) { - $process->setEnv(array_replace($_SERVER, $this->environmentVariables)); - } else { - $process->setEnv($this->environmentVariables); - } + $environmentVariables = $this->inheritEnvironmentVariables ? array_replace($_SERVER, $this->environmentVariables) : $this->environmentVariables; + + // git runs the command core.fsmonitor names, which the configuration of a + // repository may set, to read the working tree, and even to diff a commit. + // What "git -c" sets, in its old format, the only one git before 2.31 reads: + // git before 1.7.2 has no -c. After the values of git's parent, to win over them. + $configParameters = (string) ($environmentVariables['GIT_CONFIG_PARAMETERS'] ?? getenv('GIT_CONFIG_PARAMETERS')); + $environmentVariables['GIT_CONFIG_PARAMETERS'] = ltrim($configParameters." 'core.fsmonitor=false'"); + + $process->setEnv($environmentVariables); $process->setTimeout($this->processTimeout); $process->setIdleTimeout($this->processTimeout); diff --git a/src/Gitonomy/Git/Util/Credentials.php b/src/Gitonomy/Git/Util/Credentials.php new file mode 100644 index 00000000..7863f99d --- /dev/null +++ b/src/Gitonomy/Git/Util/Credentials.php @@ -0,0 +1,54 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Symfony\Component\Process\Exception\ExceptionInterface; +use Symfony\Component\Process\Process; + +/** + * A URL like "https://user:token@host/repository.git" carries credentials: + * the messages of exceptions and of the logger hide them. + * + * @author Grégoire Pineau + * + * @internal + */ +final class Credentials +{ + /** + * Replaces the user information of the URLs of $text, a token or a user + * and a password, by "***". + */ + public static function redact(#[\SensitiveParameter] string $text): string + { + // Up to the last "@" before the host, as a password may hold one, or a + // "#" or a "?", which git accepts unencoded there. + return preg_replace('#\b([a-z][a-z0-9+.-]*+://)(?:[^/\s@]*+@)++#i', '$1***@', $text); + } + + /** + * Runs $process. The messages of the exceptions of Symfony Process hold + * the command line, with its URLs: unlike in 2.0, they keep their class, + * which code written for 1.6 catches. + */ + public static function runProcess(Process $process): void + { + try { + $process->run(); + } catch (ExceptionInterface $e) { + (new \ReflectionProperty(\Exception::class, 'message'))->setValue($e, self::redact($e->getMessage())); + + throw $e; + } + } +} diff --git a/src/Gitonomy/Git/Util/LeadingDash.php b/src/Gitonomy/Git/Util/LeadingDash.php new file mode 100644 index 00000000..fe24d3f7 --- /dev/null +++ b/src/Gitonomy/Git/Util/LeadingDash.php @@ -0,0 +1,83 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * git reads an argument starting with a dash as an option: the revision + * "--output=/path" of a log writes a file. git before 2.24 has no + * "--end-of-options", and what follows "--" is a path for most commands. + * + * 2.0 refuses them all. 1.x refuses them all only where no option can be + * used: the options of "git log" as revisions are deprecated, not removed, so + * logs and blames keep them, but the ones reading or writing a file. + * + * @author Grégoire Pineau + * + * @internal + */ +final class LeadingDash +{ + /** + * Not the abbreviations of --output, which "git log" doesn't accept. + * --output-indicator-new and the like write no file. + */ + private const LOG_FILE_OPTION = '/^--output(?:=|$)/'; + + /** + * "git blame" takes the options of "git log", and reads files: it returns + * the lines of --contents, and prints the first one of --ignore-revs-file + * or -S in its error. It accepts abbreviations, of any length that isn't + * ambiguous for the git in use, and a short option in a group, like "-wS". + */ + private const BLAME_FILE_OPTION = '/^(?:--output(?:=|$)|--c(?:o|on|ont|onte|onten|ontent|ontents)?(?:=|$)|--ignore-revs|-[^-]*S)/'; + + /** + * @throws InvalidArgumentException One of the values starts with a dash + */ + public static function assertNone(string ...$values): void + { + foreach ($values as $value) { + if (str_starts_with($value, '-')) { + throw new InvalidArgumentException(sprintf('"%s" starts with a dash, which git would read as an option.', $value)); + } + } + } + + /** + * @param mixed ...$revisions The revisions of a log or a diff + * + * @throws InvalidArgumentException One of the revisions is an option making git write a file + */ + public static function assertNoLogFileOption(...$revisions): void + { + foreach ($revisions as $revision) { + if (1 === preg_match(self::LOG_FILE_OPTION, (string) $revision)) { + throw new InvalidArgumentException(sprintf('"%s" is an option of git writing a file, not a revision.', $revision)); + } + } + } + + /** + * @param mixed $revision The revision of a blame + * + * @throws InvalidArgumentException The revision is an option making git read or write a file + */ + public static function assertNoBlameFileOption($revision): void + { + if (1 === preg_match(self::BLAME_FILE_OPTION, (string) $revision)) { + throw new InvalidArgumentException(sprintf('"%s" is an option of git reading or writing a file, not a revision.', $revision)); + } + } +} diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php new file mode 100644 index 00000000..01f0d4ea --- /dev/null +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -0,0 +1,78 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * Tells the URLs of a repository that make git run a command, or use a file + * descriptor of PHP, as an application may get them from its users. + * + * Unlike 2.0, 1.x accepts "ext::" URLs: an application may use this transport + * on purpose, through git's protocol.ext.allow setting or GIT_ALLOW_PROTOCOL. + * + * @author Grégoire Pineau + * + * @internal + */ +final class RemoteUrl +{ + /** + * @return string|null Why git must not get $url, null when it can + */ + public static function unsafeReason(#[\SensitiveParameter] string $url): ?string + { + // Before the URL, git would read it as an option: "--upload-pack=..." + // runs the command. ls-remote only takes "--" since git 2.8. + if (str_starts_with($url, '-')) { + return sprintf('"%s" starts with a dash, which git would read as an option.', Credentials::redact($url)); + } + + // "fd::7" talks through the file descriptors git inherits from PHP: + // an open file, the socket of a database... git waits on it until it + // times out, an hour by default. + if (str_starts_with($url, 'fd::')) { + return sprintf('"%s" uses the "fd::" transport, which talks through a file descriptor of PHP.', Credentials::redact($url)); + } + + // Before 2.14.1 (CVE-2017-1000117), git passes the "user@host" of an ssh + // URL to ssh, without its brackets: ssh reads "ssh://-oProxyCommand=..." + // as an option, and runs the command. The command of core.gitProxy gets + // the host of a git URL the same way. A valid host never starts with a dash. + $authority = self::authority($url); + if (null !== $authority && 1 === preg_match('/(?:^|@)\[?-/', $authority)) { + return sprintf('"%s" has a host or a user starting with a dash, which ssh, or the command of core.gitProxy, would read as an option.', Credentials::redact($url)); + } + + return null; + } + + /** + * @return string|null The "user@host:port" of an ssh or git URL, decoded, + * or of an address like "user@host:path", which git + * reaches through ssh; null for any other URL, or a + * local path + */ + private static function authority(#[\SensitiveParameter] string $url): ?string + { + // git decodes a URL before it reads its host: "%2D" is a dash, "%5B" a bracket. + if (1 === preg_match('#^(?:git|ssh|git\+ssh|ssh\+git)://([^/]*)#', $url, $matches)) { + return rawurldecode($matches[1]); + } + + // Like git: a colon before any slash, but not the one of "scheme://". + if (1 === preg_match('#^([^/:]*):(?!//)#', $url, $matches)) { + return $matches[1]; + } + + return null; + } +} diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index faf209ee..94ea2474 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -17,6 +17,7 @@ use Gitonomy\Git\Exception\LogicException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Util\LeadingDash; /** * @author Alexandre Salomé @@ -116,14 +117,18 @@ public function checkout($revision, $branch = null) * * @return $this * + * @throws InvalidArgumentException $branch or $startPoint starts with a dash * @throws ReferenceNotFoundException $startPoint names no commit */ public function checkoutNewBranch(string $branch, Revision|string $startPoint = 'HEAD'): self { + $startPointName = $startPoint instanceof Revision ? $startPoint->getRevision() : $startPoint; + LeadingDash::assertNone($branch, $startPointName); + // Like in 2.0. git gets the name, which tells a remote branch, to track it. ($startPoint instanceof Revision ? $startPoint : $this->repository->getRevision($startPoint))->getCommit(); - $this->run('checkout', ['-b', $branch, $startPoint instanceof Revision ? $startPoint->getRevision() : $startPoint]); + $this->run('checkout', ['-b', $branch, $startPointName]); return $this; } diff --git a/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php b/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php index 6c4434bd..d630a973 100644 --- a/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php +++ b/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php @@ -14,9 +14,11 @@ use Gitonomy\Git\Admin; use Gitonomy\Git\Blame; +use Gitonomy\Git\Commit; use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Diff\File; use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Log; use Gitonomy\Git\PushReference; use Gitonomy\Git\Reference; @@ -29,6 +31,114 @@ */ class BackwardCompatibilityTest extends AbstractTest { + /** + * The options of "git log" as revisions are deprecated, not removed: 1.x + * only refuses --output, which writes a file. + * + * @group legacy + */ + public function testLogsKeepTheOptionsOfGit() + { + $repository = self::createFoobarRepository(); + + $this->assertNotCount(0, $repository->getLog('--all')->getCommits()); + $this->assertNotCount(0, $repository->getLog(['--no-merges', 'master'])->getCommits()); + $this->assertNotCount(0, $repository->getLog(['--since=1970-01-01', 'master'])->getCommits()); + $this->assertSame(self::LONGFILE_COMMIT, $repository->getLog([self::LONGFILE_COMMIT, '--max-count=1'])->getSingleCommit()->getHash()); + $this->assertInstanceOf(Commit::class, $repository->getRevision('--all')->getCommit()); + } + + /** + * @group legacy + */ + public function testDiffsKeepTheOptionsOfGit() + { + $repository = self::createFoobarRepository(); + + $this->assertCount(1, $repository->getDiff(['--no-renames', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT])->getFiles()); + } + + public function testBlamesKeepTheOptionsOfGitReadingNoFile() + { + $repository = self::createFoobarRepository(false); + + $this->assertNotCount(0, $repository->getBlame('-w', 'README.md')); + } + + public function testCheckoutKeepsThePreviousBranch() + { + $repository = self::createFoobarRepository(false); + $repository->run('checkout', ['-q', '-b', 'foo']); + $repository->run('checkout', ['-q', 'master']); + + $repository->getWorkingCopy()->checkout('-'); + + $this->assertSame("foo\n", $repository->run('rev-parse', ['--abbrev-ref', 'HEAD'])); + } + + public function testHooksKeepTheNamesThatStayInTheHooksDirectory() + { + $repository = self::createEmptyRepository(); + mkdir($repository->getGitDir().'/hooks/post-receive.d'); + + $hooks = $repository->getHooks(); + $hooks->set('post-receive.d/notify', 'content'); + $hooks->set('.common', 'content'); + $hooks->set('foo..bar', 'content'); + + $this->assertSame('content', $hooks->get('post-receive.d/notify')); + $this->assertSame('content', $hooks->get('.common')); + $this->assertSame('content', $hooks->get('foo..bar')); + } + + /** + * A failing check is false, and a failing clone a RuntimeException, like + * in 1.6: code validating a URL handles nothing else. + * + * @group legacy + */ + public function testRefusedUrlsKeepTheContractsOf16() + { + $this->assertFalse(Admin::isValidRepository('--upload-pack=foo', self::getOptions())); + $this->assertFalse(Admin::isValidRepositoryAndBranch('--upload-pack=foo', 'master', self::getOptions())); + $this->assertFalse(Admin::isValidRepository(null, self::getOptions())); + + $this->expectException(RuntimeException::class); + + Admin::cloneTo(self::createTempDir().'/clone', '--upload-pack=foo', true, self::getOptions()); + } + + /** + * The transport an application allows through git's protocol.ext.allow + * setting, or GIT_ALLOW_PROTOCOL. + * + * @group legacy + */ + public function testAnExtUrlIsUpToGit() + { + $bare = self::createFoobarRepository()->getGitDir(); + $options = self::getOptions(); + $options['environment_variables']['GIT_ALLOW_PROTOCOL'] = 'ext:file'; + + $this->assertTrue(Admin::isValidRepository('ext::git-upload-pack '.$bare, $options)); + } + + /** + * Like in 1.6, deleting an attached HEAD deletes its branch. + * + * @group legacy + */ + public function testDeletingAnAttachedHeadStillDeletesItsBranch() + { + $repository = self::createFoobarRepository(false); + $repository->run('checkout', ['-q', '-b', 'foo']); + + $repository->getReferences()->delete('HEAD'); + + $this->assertStringNotContainsString('refs/heads/foo', $repository->run('show-ref')); + $this->assertFileExists($repository->getGitDir().'/HEAD'); + } + public function testEmptyOptions() { $source = self::createFoobarRepository(false); diff --git a/tests/Gitonomy/Git/Tests/SecurityTest.php b/tests/Gitonomy/Git/Tests/SecurityTest.php new file mode 100644 index 00000000..66150c94 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/SecurityTest.php @@ -0,0 +1,794 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Admin; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\Credentials; +use Gitonomy\Git\Util\RemoteUrl; +use Psr\Log\AbstractLogger; +use Symfony\Component\Process\Exception\ExceptionInterface; +use Symfony\Component\Process\Exception\ProcessTimedOutException; + +/** + * An application may get a revision, a URL or the name of a hook from its + * users: git must not read them as options, and run nothing for them. + */ +class SecurityTest extends AbstractTest +{ + /** + * Nothing listens on port 1: the commands fail. + */ + private const URL = 'http://user:secret@127.0.0.1:1/repository.git'; + + /** + * @group legacy + * + * @dataProvider provideOutputCalls + */ + public function testAnOptionWritingAFileIsRefused(\Closure $call) + { + $repository = self::createFoobarRepository(false); + $file = self::createTempDir().'/written'; + + try { + $call($repository, '--output='.$file); + $this->fail('--output is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('writing a file', $e->getMessage()); + } + + $this->assertFileIsAbsent($file); + } + + public static function provideOutputCalls() + { + yield 'Revision::getCommit()' => [function (Repository $repository, string $option) { + $repository->getRevision($option)->getCommit(); + }]; + yield 'Revision::getLog()' => [function (Repository $repository, string $option) { + $repository->getRevision($option)->getLog()->getCommits(); + }]; + yield 'Repository::getLog()' => [function (Repository $repository, string $option) { + $repository->getLog($option)->getCommits(); + }]; + yield 'Repository::getLog(), among revisions' => [function (Repository $repository, string $option) { + $repository->getLog(['master', $option])->getCommits(); + }]; + yield 'Repository::getDiff(), one argument' => [function (Repository $repository, string $option) { + $repository->getDiff($option); + }]; + yield 'Repository::getDiff(), two arguments' => [function (Repository $repository, string $option) { + $repository->getDiff($option, 'master'); + }]; + yield 'Repository::getMergeBase()' => [function (Repository $repository, string $option) { + $repository->getMergeBase($option, 'master'); + }]; + yield 'WorkingCopy::getDiff()' => [function (Repository $repository, string $option) { + $repository->getWorkingCopy()->getDiff($option); + }]; + yield 'Branch::moveTo()' => [function (Repository $repository, string $option) { + $repository->getReferences()->getBranch('master')->moveTo($option); + }]; + yield 'Branch::isMergedInto()' => [function (Repository $repository, string $option) { + $repository->getReferences()->getBranch('master')->isMergedInto($option); + }]; + yield 'ReferenceBag::createBranch()' => [function (Repository $repository, string $option) { + $repository->getReferences()->createBranch('foo', $repository->getRevision($option)); + }]; + } + + /** + * git takes the file as the next argument too. + */ + public function testAnOptionWritingAFileIsRefusedInTwoArguments() + { + $repository = self::createFoobarRepository(); + $file = self::createTempDir().'/written'; + + try { + $repository->getLog(['--output', $file])->getCommits(); + $this->fail('--output is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('writing a file', $e->getMessage()); + } + + $this->assertFileIsAbsent($file); + } + + /** + * @dataProvider provideBlameFileOptions + */ + public function testBlameRefusesTheOptionsReadingOrWritingAFile(string $option) + { + $repository = self::createFoobarRepository(false); + $file = self::createTempDir().'/secret'; + file_put_contents($file, "secret\n"); + + try { + $repository->getBlame($option.$file, 'README.md')->getLines(); + $this->fail('The option is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('reading or writing a file', $e->getMessage()); + } + } + + public static function provideBlameFileOptions() + { + yield 'contents' => ['--contents=']; + yield 'abbreviation of contents' => ['--con=']; + yield 'shortest abbreviation of contents, before git knew --color-lines' => ['--c=']; + yield 'ignore-revs-file' => ['--ignore-revs-file=']; + yield 'abbreviation of ignore-revs-file' => ['--ignore-revs=']; + yield 'revs-file' => ['-S']; + yield 'revs-file in a group' => ['-wS']; + yield 'output' => ['--output=']; + } + + /** + * @group legacy + */ + public function testCreatingAReferenceFromAnOptionIsRefused() + { + $repository = self::createFoobarRepository(); + $references = $repository->getReferences(); + + // "git update-ref refs/heads/foo -d" deletes it. + foreach (['createBranch' => 'refs/heads/foo', 'createTag' => 'refs/tags/foo'] as $method => $name) { + $repository->run('update-ref', [$name, self::INITIAL_COMMIT]); + + try { + $references->$method('foo', '-d'); + $this->fail('The dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertSame('"-d" starts with a dash, which git would read as an option.', $e->getMessage()); + } + + $this->assertSame(self::INITIAL_COMMIT, trim($repository->run('rev-parse', [$name]))); + } + + try { + $references->update(new Branch($repository, 'refs/heads/foo', '-d')); + $this->fail('The dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertSame(self::INITIAL_COMMIT, trim($repository->run('rev-parse', ['refs/heads/foo']))); + } + } + + public function testCheckoutNewBranchRefusesOptions() + { + $workingCopy = self::createFoobarRepository(false)->getWorkingCopy(); + + foreach ([['--orphan=foo', 'master'], ['foo', '--force']] as [$branch, $startPoint]) { + try { + $workingCopy->checkoutNewBranch($branch, $startPoint); + $this->fail('The dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('starts with a dash', $e->getMessage()); + } + } + } + + /** + * In a repository with a remote, "git ls-remote --upload-pack=..." runs + * the command to list the references of the remote. + * + * @group legacy + * + * @dataProvider provideUrlChecks + */ + public function testCheckingAUrlStartingWithADashRunsNothing(\Closure $check) + { + [$bare, $command, $file] = $this->createCommand(); + + $repository = self::createEmptyRepository(false); + $repository->run('remote', ['add', 'origin', $bare]); + + $cwd = getcwd(); + chdir($repository->getWorkingDir()); + + try { + $this->assertFalse($check('--upload-pack='.$command, $bare)); + } finally { + chdir($cwd); + } + + $this->assertFileIsAbsent($file); + } + + public static function provideUrlChecks() + { + $options = self::getOptions(); + $driver = self::getDriver(); + + yield 'Admin::isValidRepository()' => [fn (string $url) => Admin::isValidRepository($url, $options)]; + // The second argument is the repository for git. + yield 'Admin::isValidRepositoryAndBranch()' => [fn (string $url, string $bare) => Admin::isValidRepositoryAndBranch($url, $bare, $options)]; + yield 'Repository::isValidRepository()' => [fn (string $url) => Repository::isValidRepository($url, $driver)]; + yield 'Repository::isValidRepositoryAndBranch()' => [fn (string $url, string $bare) => Repository::isValidRepositoryAndBranch($url, $bare, $driver)]; + } + + /** + * The path is the repository for git. + * + * @group legacy + * + * @dataProvider provideClones + */ + public function testCloningAUrlStartingWithADashRunsNothing(\Closure $clone) + { + [$bare, $command, $file] = $this->createCommand(); + + try { + $clone('--upload-pack='.$command, $bare); + $this->fail('The URL is refused.'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('starts with a dash', $e->getMessage()); + } + + $this->assertFileIsAbsent($file); + } + + public static function provideClones() + { + $options = self::getOptions(); + $driver = self::getDriver(); + + yield 'Admin::cloneTo()' => [fn (string $url, string $path) => Admin::cloneTo($path, $url, true, $options)]; + yield 'Admin::cloneBranchTo()' => [fn (string $url, string $path) => Admin::cloneBranchTo($path, $url, 'master', true, $options)]; + yield 'Admin::mirrorTo()' => [fn (string $url, string $path) => Admin::mirrorTo($path, $url, $options)]; + yield 'Admin::cloneRepository()' => [fn (string $url, string $path) => Admin::cloneRepository($path, $url, [], $options)]; + yield 'Repository::cloneFrom()' => [fn (string $url, string $path) => Repository::cloneFrom($url, $path, null, $driver)]; + yield 'Repository::cloneBareFrom()' => [fn (string $url, string $path) => Repository::cloneBareFrom($url, $path, null, $driver)]; + yield 'Repository::mirrorFrom()' => [fn (string $url, string $path) => Repository::mirrorFrom($url, $path, $driver)]; + } + + /** + * After the URL, git would read the path as an option too. + * + * @group legacy + */ + public function testAPathMayStartWithADash() + { + [$bare, $command, $file] = $this->createCommand(); + $dir = self::createTempDir(); + $option = '--upload-pack='.basename($command); + + $cwd = getcwd(); + chdir($dir); + + try { + $clone = Repository::cloneBareFrom($bare, $option, null, self::getDriver()); + $this->assertSame(realpath($dir.'/'.$option), $clone->getGitDir()); + + $this->assertInstanceOf(Repository::class, Admin::cloneTo('-clone', $bare, true, self::getOptions())); + $this->assertInstanceOf(Repository::class, Admin::init('-init', true, self::getOptions())); + $this->assertInstanceOf(Repository::class, Repository::init('-working-copy', self::getDriver())); + $this->assertInstanceOf(Repository::class, Repository::initBare('--separate-git-dir=foo', self::getDriver())); + } finally { + chdir($cwd); + } + + $this->assertFileIsAbsent($file); + $this->assertFileExists($dir.'/-clone/HEAD'); + $this->assertFileExists($dir.'/-init/HEAD'); + $this->assertFileExists($dir.'/-working-copy/.git/HEAD'); + $this->assertFileExists($dir.'/--separate-git-dir=foo/HEAD'); + } + + /** + * git talks through a file descriptor it inherits from PHP, and waits on it. + * + * @group legacy + */ + public function testAFileDescriptorUrlIsRefused() + { + $this->assertFalse(Admin::isValidRepository('fd::3', self::getOptions())); + $this->assertFalse(Admin::isValidRepositoryAndBranch('fd::3', 'master', self::getOptions())); + $this->assertFalse(Repository::isValidRepository('fd::3', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch('fd::3', 'master', self::getDriver())); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('uses the "fd::" transport'); + + Repository::cloneFrom('fd::3', self::createTempDir().'/clone', null, self::getDriver()); + } + + /** + * @dataProvider provideHostsStartingWithADash + */ + public function testAHostStartingWithADashIsRefused(string $url) + { + $this->assertStringContainsString('starting with a dash', RemoteUrl::unsafeReason($url)); + } + + public static function provideHostsStartingWithADash() + { + $command = '-oProxyCommand=touch${IFS}pwned'; + + yield 'ssh' => ['ssh://'.$command.'/repository']; + yield 'git+ssh' => ['git+ssh://'.$command.'/repository']; + yield 'ssh+git' => ['ssh+git://'.$command.'/repository']; + yield 'host between brackets' => ['ssh://['.$command.']/repository']; + yield 'user' => ['ssh://'.$command.'@example.com/repository']; + yield 'host after a user' => ['ssh://git@'.$command.'/repository']; + yield 'address' => ['git@'.$command.':repository']; + yield 'address between brackets' => ['['.$command.']:repository']; + yield 'git, for the command of core.gitProxy' => ['git://'.$command.'/repository']; + yield 'encoded dash' => ['ssh://%2D'.substr($command, 1).'/repository']; + yield 'encoded dash, lowercase' => ['git+ssh://%2d'.substr($command, 1).'/repository']; + yield 'encoded dash of a user' => ['ssh://%2D'.substr($command, 1).'@example.com/repository']; + yield 'encoded brackets' => ['ssh://%5B'.$command.'%5D/repository']; + } + + /** + * @dataProvider provideUrlsGitReachesWithoutRunningACommand + */ + public function testAUrlGitReachesWithoutRunningACommandIsAccepted(string $url) + { + $this->assertNull(RemoteUrl::unsafeReason($url)); + } + + public static function provideUrlsGitReachesWithoutRunningACommand() + { + yield 'ssh' => ['ssh://git@example.com:2222/repository.git']; + yield 'host between brackets' => ['ssh://git@[::1]/repository.git']; + yield 'address' => ['git@example.com:repository.git']; + yield 'dash in the path of an address' => ['git@example.com:-repository.git']; + yield 'dash in the host' => ['ssh://git@my-host.example.com/repository.git']; + yield 'encoded character in the user' => ['ssh://first%2Dlast@example.com/repository.git']; + // Not reached through ssh: a token may start with a dash. + yield 'https' => ['https://-token@example.com/repository.git']; + yield 'local path' => ['/path/to/repository.git']; + yield 'local path with a colon' => ['./a:b']; + } + + /** + * Recent versions of git hide the credentials of an http URL in their + * error, not the old ones. + */ + public function testAFailedCloneHidesTheCredentials() + { + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('The failing git is a shell script.'); + } + + $failingGit = self::createTempDir().'/git'; + file_put_contents($failingGit, "#!/bin/sh\necho \"fatal: unable to access '\$4'\" >&2\nexit 128\n"); + chmod($failingGit, 0700); + + try { + Repository::cloneFrom(self::URL, self::createTempDir().'/clone', null, new CliDriver($failingGit)); + $this->fail('The clone fails.'); + } catch (RuntimeException $e) { + $this->assertStringContainsString("fatal: unable to access 'http://***@127.0.0.1:1/repository.git'", $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + } + } + + public function testARefusedUrlHidesTheCredentials() + { + $this->assertSame('"fd::http://***@host" uses the "fd::" transport, which talks through a file descriptor of PHP.', RemoteUrl::unsafeReason('fd::http://user:secret@host')); + } + + /** + * git writes the name of a branch it refuses in its error output, and the + * message holds the command line. + */ + public function testTheMessageOfAProcessExceptionHidesTheCredentials() + { + try { + self::createFoobarRepository()->run('branch', [self::URL]); + $this->fail('A URL is no valid branch name.'); + } catch (ProcessException $e) { + $this->assertStringContainsString('http://***@127.0.0.1:1/repository.git', $e->getMessage()); + $this->assertStringNotContainsString('secret', $e->getMessage()); + $this->assertStringContainsString(self::URL, $e->getErrorOutput(), 'The error output is the one of git'); + } + } + + /** + * @group legacy + * + * @dataProvider provideSlowCalls + */ + public function testATimeoutKeepsItsClassAndHidesTheCredentials(\Closure $call) + { + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('The slow git is a shell script.'); + } + + $slowGit = self::createTempDir().'/git'; + file_put_contents($slowGit, "#!/bin/sh\nexec sleep 10\n"); + chmod($slowGit, 0700); + + $ignoreArgs = ini_set('zend.exception_ignore_args', '0'); + + try { + $call($slowGit, self::URL); + $this->fail('git times out.'); + } catch (ProcessTimedOutException $e) { + $this->assertStringContainsString('exceeded the timeout', $e->getMessage()); + $this->assertStringContainsString('http://***@127.0.0.1:1/repository.git', $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + if (\PHP_VERSION_ID >= 80200) { + $this->assertTheTraceHidesTheCredentials($e); + } + } finally { + ini_set('zend.exception_ignore_args', $ignoreArgs); + } + } + + public static function provideSlowCalls() + { + yield 'Repository::run()' => [function (string $git, string $url) { + $repository = self::createEmptyRepository(); + (new Repository($repository->getGitDir(), null, new CliDriver($git, [], 0.2)))->run('fetch', [$url]); + }]; + yield 'Repository::cloneFrom()' => [function (string $git, string $url) { + Repository::cloneFrom($url, self::createTempDir().'/clone', null, new CliDriver($git, [], 0.2)); + }]; + yield 'Repository::isValidRepository()' => [function (string $git, string $url) { + Repository::isValidRepository($url, new CliDriver($git, [], 0.2)); + }]; + yield 'Repository::isValidRepositoryAndBranch()' => [function (string $git, string $url) { + Repository::isValidRepositoryAndBranch($url, 'master', new CliDriver($git, [], 0.2)); + }]; + yield 'Admin::isValidRepository()' => [function (string $git, string $url) { + Admin::isValidRepository($url, ['command' => $git, 'process_timeout' => 0.2]); + }]; + yield 'Admin::isValidRepositoryAndBranch()' => [function (string $git, string $url) { + Admin::isValidRepositoryAndBranch($url, 'master', ['command' => $git, 'process_timeout' => 0.2]); + }]; + } + + /** + * The exception of Symfony Process holds the command line: here, longer + * than what the system accepts. With PHP 8.1, which forks before running + * git, the process fails instead of not starting. + */ + public function testAProcessThatCannotStartHidesTheCredentials() + { + try { + // Over the 128 KiB of an argument on Linux. + self::createEmptyRepository()->run('fetch', [self::URL, str_repeat('a', 200 * 1024)]); + $this->fail('The command line is too long.'); + } catch (ExceptionInterface|ProcessException $e) { + $this->assertFalse(str_contains((string) $e, 'secret'), 'The message hides the credentials.'); + } + } + + /** + * @requires PHP 8.2 + * + * @group legacy + * + * @dataProvider provideFailingCalls + */ + public function testTheTracesHideTheCredentials(\Closure $call) + { + $ignoreArgs = ini_set('zend.exception_ignore_args', '0'); + + try { + $call(self::URL); + $this->fail('The command fails.'); + } catch (RuntimeException $e) { + $this->assertTheTraceHidesTheCredentials($e); + } finally { + ini_set('zend.exception_ignore_args', $ignoreArgs); + } + } + + public static function provideFailingCalls() + { + yield 'Repository::run()' => [function (string $url) { + self::createEmptyRepository()->run('fetch', [$url]); + }]; + yield 'Repository::cloneFrom()' => [function (string $url) { + Repository::cloneFrom($url, self::createTempDir().'/clone', null, self::getDriver()); + }]; + yield 'Repository::cloneBareFrom()' => [function (string $url) { + Repository::cloneBareFrom($url, self::createTempDir().'/clone', null, self::getDriver()); + }]; + yield 'Repository::mirrorFrom()' => [function (string $url) { + Repository::mirrorFrom($url, self::createTempDir().'/clone', self::getDriver()); + }]; + yield 'Admin::cloneTo()' => [function (string $url) { + Admin::cloneTo(self::createTempDir().'/clone', $url, true, self::getOptions()); + }]; + yield 'Admin::cloneBranchTo()' => [function (string $url) { + Admin::cloneBranchTo(self::createTempDir().'/clone', $url, 'master', true, self::getOptions()); + }]; + yield 'Admin::mirrorTo()' => [function (string $url) { + Admin::mirrorTo(self::createTempDir().'/clone', $url, self::getOptions()); + }]; + yield 'Admin::cloneRepository()' => [function (string $url) { + Admin::cloneRepository(self::createTempDir().'/clone', $url, [], self::getOptions()); + }]; + } + + public function testTheLogsHideTheCredentials() + { + $logger = new class() extends AbstractLogger { + public $messages = []; + + public function log($level, $message, array $context = []) + { + $this->messages[] = $message; + } + }; + + $source = self::createFoobarRepository(); + $options = self::getOptions(); + $repository = new Repository($source->getGitDir(), null, new CliDriver($options['command'], $options['environment_variables'], 60, $logger)); + $repository->run('config', ['remote.foo.url', self::URL]); + + // The arguments of the command, and its output. + $repository->run('remote', ['-v']); + try { + // Its error output. + $repository->run('branch', [self::URL]); + $this->fail('A URL is no valid branch name.'); + } catch (ProcessException $e) { + } + + $logs = implode("\n", $logger->messages); + $this->assertStringContainsString('http://***@127.0.0.1:1/repository.git', $logs); + $this->assertStringNotContainsString('secret', $logs); + } + + /** + * @dataProvider provideTexts + */ + public function testRedact(string $expected, string $text) + { + $this->assertSame($expected, Credentials::redact($text)); + } + + public static function provideTexts() + { + yield 'user and password' => ['https://***@example.com/a.git', 'https://user:secret@example.com/a.git']; + yield 'token' => ['https://***@example.com/a.git', 'https://secret@example.com/a.git']; + yield 'at sign in the password' => ['https://***@example.com/a.git', 'https://user:se@cret@example.com/a.git']; + yield 'hash in the password' => ['https://***@example.com/a.git', 'https://user:se#cret@example.com/a.git']; + yield 'question mark in the password' => ['https://***@example.com/a.git', 'https://user:se?cret@example.com/a.git']; + yield 'in a sentence' => ["fatal: unable to access 'https://***@example.com/': error", "fatal: unable to access 'https://user:secret@example.com/': error"]; + yield 'twice' => ['http://***@a http://***@b', 'http://u:p@a http://u:p@b']; + yield 'no credentials' => ['https://example.com/a.git', 'https://example.com/a.git']; + yield 'address' => ['git@example.com:a.git', 'git@example.com:a.git']; + yield 'an email after a URL' => ['https://example.com/ user@example.com', 'https://example.com/ user@example.com']; + } + + /** + * @dataProvider provideHookCalls + */ + public function testAHookNameLeadingOutOfTheHooksDirectoryIsRefused(\Closure $call, string $name) + { + $repository = self::createEmptyRepository(); + $hooks = $repository->getHooks(); + $config = file_get_contents($repository->getGitDir().'/config'); + + try { + $call($hooks, $name); + $this->fail('The name is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('leads out of the hooks directory', $e->getMessage()); + } + + $this->assertSame($config, file_get_contents($repository->getGitDir().'/config')); + $this->assertFileIsAbsent($repository->getGitDir().'/created'); + } + + public static function provideHookCalls() + { + $calls = [ + 'has' => fn ($hooks, $name) => $hooks->has($name), + 'get' => fn ($hooks, $name) => $hooks->get($name), + 'remove' => fn ($hooks, $name) => $hooks->remove($name), + 'set' => fn ($hooks, $name) => $hooks->set($name, 'content'), + 'setSymlink' => fn ($hooks, $name) => $hooks->setSymlink($name, '/etc/hostname'), + ]; + + foreach ($calls as $method => $call) { + yield $method.' of the configuration' => [$call, '../config']; + yield $method.' of a new file' => [$call, '../created']; + yield $method.' through a directory' => [$call, 'foo/../../config']; + yield $method.' of the parent' => [$call, '..']; + yield $method.' with a backslash' => [$call, '..\\config']; + } + } + + /** + * A link to a file not deployed yet: set() wrote through it, out of the + * hooks directory. + */ + public function testADanglingLinkIsAHook() + { + $repository = self::createEmptyRepository(); + $hooks = $repository->getHooks(); + $target = self::createTempDir().'/missing'; + symlink($target, $repository->getGitDir().'/hooks/pre-commit'); + + $this->assertTrue($hooks->has('pre-commit')); + + try { + $hooks->set('pre-commit', 'content'); + $this->fail('The hook exists.'); + } catch (LogicException $e) { + $this->assertFileIsAbsent($target); + } + + try { + $hooks->setSymlink('pre-commit', '/etc/hostname'); + $this->fail('The hook exists.'); + } catch (LogicException $e) { + $this->assertSame($target, readlink($repository->getGitDir().'/hooks/pre-commit')); + } + + try { + $hooks->get('pre-commit'); + $this->fail('The hook has no content.'); + } catch (InvalidArgumentException $e) { + $this->assertSame('Hook named "pre-commit" is not present', $e->getMessage()); + } + + $hooks->remove('pre-commit'); + $this->assertFalse(is_link($repository->getGitDir().'/hooks/pre-commit')); + } + + /** + * @dataProvider provideWorkingTreeReads + */ + public function testTheCommandOfCoreFsmonitorIsNotRun(\Closure $read) + { + $repository = self::createFoobarRepository(false); + [, $command, $file] = $this->createCommand(); + $repository->run('config', ['core.fsmonitor', $command]); + + $read($repository); + + $this->assertFileIsAbsent($file); + } + + public static function provideWorkingTreeReads() + { + yield 'WorkingCopy::getUntrackedFiles()' => [fn (Repository $repository) => $repository->getWorkingCopy()->getUntrackedFiles()]; + yield 'WorkingCopy::getDiffPending()' => [fn (Repository $repository) => $repository->getWorkingCopy()->getDiffPending()]; + yield 'WorkingCopy::getDiffStaged()' => [fn (Repository $repository) => $repository->getWorkingCopy()->getDiffStaged()]; + yield 'Commit::getDiff()' => [fn (Repository $repository) => $repository->getHeadCommit()->getDiff()]; + yield 'Repository::run()' => [fn (Repository $repository) => $repository->run('status')]; + } + + /** + * What gitlib sets comes after the values of the application, and of the + * "git -c" running PHP, which git passes through the same variable. + */ + public function testTheConfigurationParametersOfTheApplicationAreKept() + { + $source = self::createEmptyRepository(); + $options = self::getOptions(); + $repository = function (array $env) use ($source, $options) { + return new Repository($source->getGitDir(), null, new CliDriver($options['command'], $env + $options['environment_variables'])); + }; + + $this->assertSame("foo\n", $repository(['GIT_CONFIG_PARAMETERS' => "'user.name=foo'"])->run('config', ['user.name'])); + $this->assertSame("false\n", $repository(['GIT_CONFIG_PARAMETERS' => "'core.fsmonitor=/command'"])->run('config', ['core.fsmonitor'])); + $this->assertSame("false\n", $repository(['GIT_CONFIG_PARAMETERS' => false])->run('config', ['core.fsmonitor'])); + + putenv("GIT_CONFIG_PARAMETERS='user.name=inherited'"); + + try { + $this->assertSame("inherited\n", $source->run('config', ['user.name'])); + } finally { + putenv('GIT_CONFIG_PARAMETERS'); + } + } + + /** + * Unlike 2.0, 1.x reads them by default, as "git replace --graft" relies + * on them: the changelog of 1.7.1 points to this variable. + */ + public function testTheReplacementsOfObjectsCanBeIgnored() + { + $source = self::createFoobarRepository(); + $message = $source->getCommit(self::LONGMESSAGE_COMMIT)->getMessage(); + $source->run('replace', [self::LONGMESSAGE_COMMIT, self::INITIAL_COMMIT]); + + $read = function (array $environmentVariables) use ($source) { + $repository = new Repository($source->getGitDir(), null, new CliDriver(environmentVariables: $environmentVariables)); + + return $repository->getCommit(self::LONGMESSAGE_COMMIT)->getMessage(); + }; + + $this->assertNotSame($message, $read([])); + $this->assertSame($message, $read(['GIT_NO_REPLACE_OBJECTS' => '1'])); + } + + public function testDeletingASymbolicReferenceKeepsTheBranch() + { + $repository = self::createFoobarRepository(); + $repository->run('update-ref', ['refs/remotes/origin/master', self::INITIAL_COMMIT]); + $repository->run('symbolic-ref', ['refs/remotes/origin/HEAD', 'refs/remotes/origin/master']); + $repository->run('symbolic-ref', ['refs/heads/alias', 'refs/heads/master']); + + $references = $repository->getReferences(); + $references->delete('refs/remotes/origin/HEAD'); + $references->delete('refs/heads/alias'); + + $this->assertSame(self::INITIAL_COMMIT, trim($repository->run('rev-parse', ['refs/remotes/origin/master']))); + $this->assertStringNotContainsString('refs/remotes/origin/HEAD', $repository->run('show-ref')); + $this->assertStringContainsString('refs/heads/master', $repository->run('show-ref')); + $this->assertStringNotContainsString('refs/heads/alias', $repository->run('show-ref')); + } + + /** + * @group legacy + */ + public function testDeletingADetachedHeadIsRefused() + { + $repository = self::createFoobarRepository(false); + $repository->run('checkout', ['-q', self::LONGFILE_COMMIT]); + + try { + $repository->getReferences()->delete('HEAD'); + $this->fail('A detached HEAD is kept.'); + } catch (InvalidArgumentException $e) { + $this->assertFileExists($repository->getGitDir().'/HEAD'); + } + } + + private function assertTheTraceHidesTheCredentials(\Throwable $e): void + { + // The frames of gitlib: the ones of the test hold the URL too. + $frames = array_filter($e->getTrace(), fn (array $frame) => 1 === preg_match('/^Gitonomy\\\\Git\\\\(?!Tests)/', $frame['class'] ?? '')); + $args = array_merge(...array_map(fn (array $frame) => $frame['args'] ?? [], $frames)); + $this->assertNotSame([], $args, 'The trace holds the arguments'); + + array_walk_recursive($args, function ($arg) { + if (is_string($arg)) { + $this->assertStringNotContainsString('secret', $arg); + } + }); + } + + /** + * Creates a bare repository, and a command writing a file. + * + * @return string[] The path of the repository, of the command, and of the file + */ + private function createCommand(): array + { + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('The command is a shell script.'); + } + + $bare = self::createFoobarRepository()->getGitDir(); + $dir = self::createTempDir(); + $command = $dir.'/command'; + file_put_contents($command, "#!/bin/sh\ntouch '$dir/run'\nexit 1\n"); + chmod($command, 0700); + + return [$bare, $command, $dir.'/run']; + } + + private function assertFileIsAbsent(string $file): void + { + $this->assertFalse(file_exists($file), sprintf('"%s" does not exist.', $file)); + } +} From 6da07267e07b240616ec8a016e1614eeb3a1016c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Sat, 7 Nov 2026 21:55:23 +0100 Subject: [PATCH 147/150] Release v1.7.1 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88b45813..77e0af6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project are documented in this file. -## [v1.7.1] - Unreleased +## [v1.7.1] - 2026-11-07 Changes since [v1.7.0](https://github.com/gitonomy/gitlib/releases/tag/v1.7.0). From f677d07df99fcbf6552e09e9f4bd4de57dba3df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Sat, 7 Nov 2026 21:59:59 +0100 Subject: [PATCH 148/150] Mention the security fixes of 1.7.1 --- .github/SECURITY.md | 8 ++++---- CHANGELOG.md | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index e8e4f0c5..eaf2ad22 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,10 +2,10 @@ ## Supported Versions -Only 2.x gets security fixes: 1.x is no longer maintained. 2.0 fixes security -issues 1.x has, which won't be fixed there, like revisions and URLs starting -with a dash, which git reads as options, or the credentials of URLs in the -messages of exceptions. To upgrade, see +Only 2.x gets security fixes: 1.x is no longer maintained. 1.7.1 has the +security fixes of 2.0 that break nothing of 1.x, and +[its changelog](https://github.com/gitonomy/gitlib/blob/1.x/CHANGELOG.md) lists +the ones it doesn't have. To upgrade, see [UPGRADE-2.0.md](https://github.com/gitonomy/gitlib/blob/main/UPGRADE-2.0.md). After each new major release from 3.0 on, the previous release will be diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6c9876..d6459ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,9 @@ all deprecated since 1.7. ### Security +1.7.1 has those that break nothing of 1.x: see +[its changelog](https://github.com/gitonomy/gitlib/blob/1.x/CHANGELOG.md). + - Refuse a revision, a reference, a hash or a URL starting with a dash, which git read as an option: `getRevision('--output=/path')`, `WorkingCopy::checkout()` or `merge()` could write a file, and `Repository::isValidRepository('--upload-pack=...')` run a command. A path may From 9a0c795e20d8341d5c45b8d9d8a368000b9e8567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Sat, 7 Nov 2026 22:10:04 +0100 Subject: [PATCH 149/150] Recommend 1.7.1 in the upgrade guide --- UPGRADE-2.0.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 8b3b3e0b..dbe90c3e 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -12,7 +12,7 @@ call triggers a deprecation (`E_USER_DEPRECATED`, through them. Log the deprecations of your application to find them. The other changes, listed below too, break code that relies on them. -Upgrade to 1.7 first, which requires PHP 8.1: it has most of the +Upgrade to 1.7.1 first, which requires PHP 8.1: it has most of the replacements, and triggers the deprecations of the API they replace (see [Supporting 1.x and 2.x](#supporting-1x-and-2x)). @@ -42,7 +42,7 @@ $driver)` with a `CliDriver` of the same arguments, the static methods of `Repository::getDiff($from, $to)`, `FileChange::getDiffLines()`, iterating a `Tree`, `Log::withLimit()`, `Branch::isMergedInto()`, `Blame::getHunks()`... Fix the deprecations of 1.7, then require -`^1.7 || ^2.0`: a library supporting PHP 8.1 to 8.3 gets 1.7 there, and 2.0 +`^1.7.1 || ^2.0`: a library supporting PHP 8.1 to 8.3 gets 1.7 there, and 2.0 from PHP 8.4, with the same code. Before: From 027fe7f44732e6e5de0a6d700179e22af71953f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Sat, 7 Nov 2026 23:36:46 +0100 Subject: [PATCH 150/150] Remove the API deprecated in 1.7 --- CHANGELOG.md | 73 +++--- UPGRADE-2.0.md | 118 ++++----- composer.json | 1 - doc/admin.md | 2 +- doc/blame.md | 8 - doc/branch.md | 6 +- doc/commit.md | 9 +- doc/diff.md | 19 -- doc/log.md | 9 - doc/references.md | 6 +- doc/repository.md | 23 +- doc/tree.md | 7 - doc/workingcopy.md | 6 - src/Gitonomy/Git/Admin.php | 195 -------------- src/Gitonomy/Git/Blame.php | 14 - src/Gitonomy/Git/Blame/Line.php | 10 - src/Gitonomy/Git/Commit.php | 54 +--- src/Gitonomy/Git/CommitReference.php | 28 -- src/Gitonomy/Git/Diff/Diff.php | 13 - src/Gitonomy/Git/Diff/DiffLineType.php | 4 +- src/Gitonomy/Git/Diff/File.php | 70 +---- src/Gitonomy/Git/Diff/FileChange.php | 57 +--- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 20 +- src/Gitonomy/Git/Driver/Cli/LegacyOptions.php | 111 -------- src/Gitonomy/Git/Log.php | 35 --- src/Gitonomy/Git/PushReference.php | 40 --- src/Gitonomy/Git/Reference/Branch.php | 17 -- src/Gitonomy/Git/ReferenceBag.php | 19 -- src/Gitonomy/Git/Repository.php | 243 +----------------- src/Gitonomy/Git/Submodule.php | 5 - src/Gitonomy/Git/Tree.php | 63 ----- src/Gitonomy/Git/WorkingCopy.php | 10 +- tests/Gitonomy/Git/Tests/AdminTest.php | 219 ---------------- tests/Gitonomy/Git/Tests/BlameTest.php | 20 -- tests/Gitonomy/Git/Tests/CommitTest.php | 63 ----- tests/Gitonomy/Git/Tests/DiffTest.php | 125 --------- tests/Gitonomy/Git/Tests/LogTest.php | 42 --- .../Gitonomy/Git/Tests/PushReferenceTest.php | 38 --- tests/Gitonomy/Git/Tests/ReferenceTest.php | 44 ---- tests/Gitonomy/Git/Tests/RepositoryTest.php | 135 ---------- tests/Gitonomy/Git/Tests/TreeTest.php | 74 ------ tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 14 - 42 files changed, 122 insertions(+), 1947 deletions(-) delete mode 100644 src/Gitonomy/Git/Admin.php delete mode 100644 src/Gitonomy/Git/CommitReference.php delete mode 100644 src/Gitonomy/Git/Driver/Cli/LegacyOptions.php delete mode 100644 tests/Gitonomy/Git/Tests/AdminTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d6459ea0..11570c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,8 @@ All notable changes to this project are documented in this file. ## [Unreleased] -Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). To upgrade, see -[UPGRADE-2.0.md](UPGRADE-2.0.md): most of the API of 1.x that 2.0 replaces keeps working, -deprecated until 3.0. +Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). To upgrade, fix +the deprecations of 1.7 first, then see [UPGRADE-2.0.md](UPGRADE-2.0.md). ### Breaking changes @@ -54,6 +53,32 @@ deprecated until 3.0. with both drivers: the name of a remote branch alone, like `feature` for `origin/feature`, created the local branch. Use `checkoutNewBranch()`. ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- Remove the API that 1.7 deprecates; see [UPGRADE-2.0.md](UPGRADE-2.0.md): + - `Admin`, replaced by the static methods of `Repository`, and `Repository::cloneTo()`, + replaced by `cloneFrom()` and `cloneBareFrom()`. + - The array of options of `Repository` (`working_dir`, `logger`, `command`, + `environment_variables`, `inherit_environment_variables`, `process_timeout`, `debug`): pass + the working directory and a `CliDriver` instead; `inherit_environment_variables` has no + counterpart, pass the variables git needs instead. `Repository::setLogger()` and + `getLogger()`. + - The `$reload` argument of `Repository::getReferences()`, and the arguments of + `Commit::getIncludingBranches()`. `ReferenceBag::update()` and `Branch::isMergedTo()`, + replaced by `Branch::moveTo()` and `isMergedInto()`. + - `Repository::getDiff()` with one argument, `Log::getDiff()`, `Diff::setRepository()`, + `File::setRepository()`, `addChange()`, `isChangeMode()`, `isDelete()` and `getAnchor()`, + `FileChange::getCount()`, `getLines()`, replaced by `getDiffLines()`, and the `LINE_*` + constants, replaced by `DiffLineType`, and the lines passed to the constructor of + `FileChange` as arrays. + - `Log::setOffset()` and `setLimit()`. + - A range of lines as a string in `Repository::getBlame()`, `Blame::getGroupedLines()` and + `Blame\Line::getLine()`. + - `CommitReference`, `Tree::getCommitReferenceEntries()`, `getEntries()`, `getBlobEntries()` + and `getTreeEntries()`: iterate the tree, or use `getBlobs()`, `getTrees()` and + `getSubmodules()`. + - The branch to create of `WorkingCopy::checkout()`. + - `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, + `PushReference::getReference()`, `getRevision()`, `isCreate()` and `isDelete()`. + - `Repository::getSize()`, in kilobytes: use `getSizeInBytes()`. ### Features @@ -105,38 +130,6 @@ deprecated until 3.0. still exists afterward. ([#244](https://github.com/gitonomy/gitlib/pull/244), fixes #14) -### Deprecations - -All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). -([#253](https://github.com/gitonomy/gitlib/pull/253)) 1.7 deprecates them already: they are -all deprecated since 1.7. - -- `Admin`, replaced by the static methods of `Repository`, and `Repository::cloneTo()`, - replaced by `cloneFrom()` and `cloneBareFrom()`. -- The array of options of `Repository` and `Admin` (`working_dir`, `logger`, `command`, - `environment_variables`, `inherit_environment_variables`, `process_timeout`, `debug`): pass - the working directory and a `CliDriver` instead; `inherit_environment_variables` has no - counterpart, pass the variables git needs instead. `Repository::setLogger()` and `getLogger()`. -- Passing `$reload` to `Repository::getReferences()`, and arguments to - `Commit::getIncludingBranches()`. `ReferenceBag::update()` and `Branch::isMergedTo()`, - replaced by `Branch::moveTo()` and `isMergedInto()`. -- Calling `Repository::getDiff()` with one argument, `Log::getDiff()`, `Diff::setRepository()`, - `File::setRepository()`, `addChange()`, `isChangeMode()`, `isDelete()` and `getAnchor()`, - `FileChange::getCount()`, `getLines()`, replaced by `getDiffLines()`, and the `LINE_*` - constants, replaced by `DiffLineType`, lines passed to the constructor of `FileChange` as - arrays. -- `Log::setOffset()` and `setLimit()`. -- A range of lines as a string in `Repository::getBlame()`, `Blame::getGroupedLines()` and - `Blame\Line::getLine()`. -- `CommitReference`, `Tree::getCommitReferenceEntries()`, `getEntries()`, `getBlobEntries()` - and `getTreeEntries()`: iterate the tree, or use `getBlobs()`, `getTrees()` and - `getSubmodules()`. -- The branch to create of `WorkingCopy::checkout()`. -- `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, - `PushReference::getReference()`, `getRevision()`, `isCreate()` and `isDelete()`. -- `Repository::getSize()`, in kilobytes: use `getSizeInBytes()`. - ([#254](https://github.com/gitonomy/gitlib/pull/254)) - ### Security 1.7.1 has those that break nothing of 1.x: see @@ -246,8 +239,9 @@ all deprecated since 1.7. - Fix blame lines numbered from 1 for a range of lines. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix `Hooks` failing silently, in a repository without hooks directory, and writing the hooks of a linked worktree where git doesn't run them. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Fix `Repository::getSize()` throwing for a link to a missing file in the git directory, like - a hook not deployed yet: it no longer follows links. A file or a directory git deletes +- Fix the size of a repository (`getSizeInBytes()`, which replaces `getSize()`) throwing for a + link to a missing file in the git directory, like a hook not deployed yet: it no longer follows + links. A file or a directory git deletes meanwhile, like a lock during a `gc` or the quarantine of a push, no longer makes it throw either, and one it can't read throws a `RuntimeException` of gitlib, instead of PHP's. ([#254](https://github.com/gitonomy/gitlib/pull/254), [#263](https://github.com/gitonomy/gitlib/pull/263)) @@ -256,7 +250,7 @@ all deprecated since 1.7. - Fix reading a commit whose `encoding` header isn't UTF-8, which threw: its message, author and committer are now re-encoded to UTF-8, like `git log` does. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix file names holding non-ASCII or special characters (tab, quote, backslash) in trees - (`Tree::getEntries()`), and special characters in diffs, which kept git's C-style quoting. + (iterating a `Tree`), and special characters in diffs, which kept git's C-style quoting. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix relative paths not resolving against the repository in `Repository::run()`: the git process spawned by `run()` never had its working directory set, so commands such as @@ -273,11 +267,8 @@ all deprecated since 1.7. branch existed. - `Tag::getBodyMessage()` dropped or truncated a line for annotated tags using the standard `subject\n\nbody` convention. - - `Repository::shell()` interpolated the repository path into the shell command without - escaping it; it is now passed through `escapeshellarg()`. - `Hooks::set()` created hook scripts world-writable (`0777`); they are now created `0700`. - - Fixed the `isSuccessFul()` casing typo in `Admin`. ### Minor diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index dbe90c3e..2f95c00e 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -6,15 +6,13 @@ default, runs the `git` binary like 1.x did, and the experimental `LibGit2Driver` runs libgit2 in-process (see ["Choosing a driver"](doc/repository.md#choosing-a-driver)). -Most of the API of 1.x that 2.0 replaces keeps working, deprecated: each -call triggers a deprecation (`E_USER_DEPRECATED`, through -`symfony/deprecation-contracts`) naming its replacement, and 3.0 removes -them. Log the deprecations of your application to find them. The other -changes, listed below too, break code that relies on them. - -Upgrade to 1.7.1 first, which requires PHP 8.1: it has most of the -replacements, and triggers the deprecations of the API they replace (see -[Supporting 1.x and 2.x](#supporting-1x-and-2x)). +2.0 removes the API of 1.x that it replaces. Upgrade to 1.7.1 first, which +requires PHP 8.1: it has the replacements, and each call to the API that +2.0 removes triggers a deprecation (`E_USER_DEPRECATED`) naming its +replacement. Log the deprecations of your application to find them, and fix +them before upgrading to 2.0 (see +[Supporting 1.x and 2.x](#supporting-1x-and-2x)). The other changes, listed +below too, break code that relies on them. Requirements ------------ @@ -26,9 +24,8 @@ Requirements * `symfony/process` must be `^6.4.33`, `^7.4.5` or `^8.1`: the older versions escape the arguments wrongly on Windows (CVE-2024-51736, CVE-2026-24739). -* `symfony/string` `^7.4.6` or `^8.0.6`, `symfony/deprecation-contracts` - `^2.5` or `^3`, and `psr/log` `^1.1`, `^2.0` or `^3.0` are new - dependencies. +* `symfony/string` `^7.4.6` or `^8.0.6`, and `psr/log` `^1.1`, `^2.0` or + `^3.0` are new dependencies. * Repositories using SHA-256 hashes are refused, with an `UnsupportedOperationException`, by both drivers. @@ -36,7 +33,7 @@ Supporting 1.x and 2.x ---------------------- gitlib 1.7 has the API of 2.0 that can be added without breaking 1.x, and -deprecates what 2.0 replaces or breaks: `new Repository($dir, $workingDir, +deprecates what 2.0 removes or breaks: `new Repository($dir, $workingDir, $driver)` with a `CliDriver` of the same arguments, the static methods of `Repository` creating repositories, `Diff::parse($rawDiff, $repository)`, `Repository::getDiff($from, $to)`, `FileChange::getDiffLines()`, iterating @@ -85,7 +82,7 @@ both versions: `Blame::getHunks()`, numbered like the file in 1.7 too. * 1.7 doesn't read the references again after a write: open a new `Repository` to read them, with both versions. `getReferences(true)` is - deprecated in 1.7 too. + deprecated in 1.7, and 2.0 removes its argument. * The classes `final` in 2.0 are only `@final` in 1.7: see [Testing code using gitlib](#testing-code-using-gitlib). * The other changes listed below: dates, the stricter parser of diffs, @@ -95,11 +92,12 @@ Opening a repository -------------------- * The second argument of `Repository` is the working directory, when the - git directory is kept apart from it. An array of options there is - deprecated: its `working_dir` option is this argument, and its `logger`, - `command`, `environment_variables` and `process_timeout` options configure - a `Gitonomy\Git\Driver\Cli\CliDriver`, which `Repository` takes as its last - argument. They can't be combined with this argument. + git directory is kept apart from it, instead of an array of options: its + `working_dir` option is this argument, its `logger`, `command`, + `environment_variables` and `process_timeout` options configure a + `Gitonomy\Git\Driver\Cli\CliDriver`, which `Repository` takes as its last + argument, and its `debug` option is removed: a failing git operation + always throws, see [Errors](#errors). * The `inherit_environment_variables` option has no counterpart in `CliDriver`: git inherits the environment variables of PHP anyway, and `$_SERVER` holds, under php-fpm, the headers and the credentials of the @@ -137,12 +135,8 @@ Opening a repository * The logger is an argument of `CliDriver`, which logs every git command it runs, cloning and initializing included: `Repository::setLogger()` and - `Repository::getLogger()` are deprecated. `LibGit2Driver` runs no command, + `Repository::getLogger()` are removed. `LibGit2Driver` runs no command, and logs nothing. -* An unknown option throws an `InvalidArgumentException`, instead of being - ignored. -* The `debug` option is deprecated, and setting it to `false` throws an - `InvalidArgumentException`: see [Errors](#errors). * `$path` may be a linked worktree, a submodule or a clone with `--separate-git-dir`, whose `.git` is a file. * A directory that isn't a repository throws an `InvalidArgumentException` @@ -151,7 +145,8 @@ Opening a repository Creating repositories --------------------- -* `Admin` is deprecated: use the static methods of `Repository`. They take +* `Admin` and `Repository::cloneTo()` are removed: use the static methods + of `Repository`. They take the URL before the path, have a method for each kind of repository instead of the `$bare` argument, whose default was a bare repository, and take the driver as last argument: @@ -170,9 +165,6 @@ Creating repositories | `$repository->cloneTo($path)` | `Repository::cloneBareFrom($repository->getGitDir(), $path)` | | `$repository->cloneTo($path, false)` | `Repository::cloneFrom($repository->getGitDir(), $path)` | - Until 3.0, `Admin` keeps working like in 1.x, with `CliDriver` and the - options of 1.x. - * `isValidRepositoryAndBranch()` checks the exact name of the branch: `release` no longer matches `feature/release`, nor `rel*` `release`. * `isValidRepository()` and `isValidRepositoryAndBranch()` read a relative @@ -197,9 +189,9 @@ Creating repositories * `Repository::cloneTo()` cloned with the driver of the repository: pass it to `cloneFrom()` or `cloneBareFrom()` as well. -* `Admin::cloneRepository()` is deprecated, with no replacement for the - arguments of `git clone` other than `--bare`, `--branch` and `--mirror`: - run `git clone` yourself, then open the repository with +* `Admin::cloneRepository()` has no replacement for the arguments of + `git clone` other than `--bare`, `--branch` and `--mirror`: run + `git clone` yourself, then open the repository with `new Repository($path)`. References @@ -207,9 +199,9 @@ References * `Repository::getReferences()` reads the references again after every write gitlib makes (creating or deleting a reference, a checkout, a merge, - `run()`...): passing `true` to read them again is deprecated. Open a new - `Repository` to see the writes of other processes. A `Reference` object - keeps the commit it pointed to when it was read. + `run()`...), and takes no argument: open a new `Repository` to see the + writes of other processes. A `Reference` object keeps the commit it + pointed to when it was read. * `Reference::getCommitHash()` returns the commit an annotated tag points to, instead of the hash of the tag object: `ReferenceBag::resolve()`, `resolveTags()` and `Commit::resolveReferences()` find annotated tags. @@ -217,7 +209,7 @@ References `InvalidArgumentException` when the reference exists, instead of moving it, and take a `Revision` or a revision name as second argument, instead of a commit hash only. -* `ReferenceBag::update()` is deprecated: use `Branch::moveTo()`. +* `ReferenceBag::update()` is removed: use `Branch::moveTo()`. Before: @@ -231,12 +223,11 @@ References $repository->getReferences()->getBranch('main')->moveTo($hash); ``` -* `Branch::isMergedTo()` is deprecated: use `isMergedInto()`, which takes +* `Branch::isMergedTo()` is removed: use `isMergedInto()`, which takes the destination as a `Revision` or a revision name, without a default (`'master'` before) nor `$compareOnlyWithRemote`. -* Passing arguments to `Commit::getIncludingBranches()` is deprecated: it - returns the local and remote branches, which `Branch::isLocal()` and - `isRemote()` filter. +* `Commit::getIncludingBranches()` takes no argument: it returns the local + and remote branches, which `Branch::isLocal()` and `isRemote()` filter. Before: @@ -275,7 +266,7 @@ References History ------- -* `Log::setOffset()` and `setLimit()` are deprecated: `withOffset()` and +* `Log::setOffset()` and `setLimit()` are removed: `withOffset()` and `withLimit()` return another `Log`. * `count($log)` returns the number of commits the log iterates, after its offset and up to its limit; `countCommits()` still counts them all. @@ -284,14 +275,14 @@ History exclude (`^a`) or `--all`: any other option of `git log` throws an `InvalidArgumentException`, instead of being passed to git. An unknown revision throws a `ReferenceNotFoundException` naming it. -* `Log::getDiff()` is deprecated: it ignored the paths of the log. Use +* `Log::getDiff()` is removed: it ignored the paths of the log. Use `Repository::getDiff()`. Diffs ----- -* `Repository::getDiff()` takes the commits to compare: `$from` and `$to`. - Calling it with one argument is deprecated. +* `Repository::getDiff()` takes the commits to compare: `$from` and `$to`, + both required. | 1.x | 2.0 | |------------------------------------|------------------------------------------------------------------| @@ -304,15 +295,15 @@ Diffs parent, like GitHub shows it, with both drivers. * `Diff`, `Diff\File` and `Diff\FileChange` get their repository and their changes when constructed: `Diff::setRepository()`, - `File::setRepository()` and `File::addChange()` are deprecated. + `File::setRepository()` and `File::addChange()` are removed. `Diff::parse()` and `fromArray()` take the repository to read blobs from. * `FileChange::getDiffLines()` returns the lines of a hunk as `Diff\DiffLine` objects, with `getType()`, a `Diff\DiffLineType`, and `getContent()`: `FileChange::getLines()`, which returns them as arrays, - is deprecated, as the `FileChange::LINE_*` constants and + is removed, as the `FileChange::LINE_*` constants and `FileChange::getCount()`, replaced by `getAdditions()` and `getDeletions()`. The constructor of `FileChange` takes `DiffLine` - objects: arrays are deprecated. + objects, instead of arrays. Before: @@ -336,7 +327,7 @@ Diffs } ``` -* `File::isChangeMode()`, `isDelete()` and `getAnchor()` are deprecated: +* `File::isChangeMode()`, `isDelete()` and `getAnchor()` are removed: use `isModeChange()`, `isDeletion()`, and `substr($file->getNewIndex(), 0, 12)`. * `File::getOldIndex()` and `getNewIndex()` return `null` for a missing @@ -363,15 +354,15 @@ Diffs Blame ----- -* `Repository::getBlame()` takes the first and the last lines to blame: - a range as a string (`'3,5'`, `'3,+3'`) is deprecated, and a regular - expression is no longer supported. +* `Repository::getBlame()` takes the first and the last lines to blame, + instead of a range as a string (`'3,5'`, `'3,+3'` or a regular + expression). * `Blame::getLines()` and `getLine()` number the lines like the file, from the first blamed line, instead of from 1. * `Blame\Line::getNumber()` and `getOriginalNumber()` return the number of the line in the file and in the commit that changed it, as integers: - `Line::getLine()` is deprecated. -* `Blame::getGroupedLines()` is deprecated: `getHunks()` returns + `Line::getLine()` is removed. +* `Blame::getGroupedLines()` is removed: `getHunks()` returns `Blame\Hunk` objects. Trees @@ -381,7 +372,7 @@ Trees `getName()`, `getMode()` and `getObject()`; `getBlobs()`, `getTrees()` and `getSubmodules()` return them by kind. `getEntries()`, `getBlobEntries()` and `getTreeEntries()`, which return them as arrays - (`[$mode, $object]`), are deprecated. + (`[$mode, $object]`), are removed. Before: @@ -399,7 +390,7 @@ Trees } ``` -* `CommitReference` is deprecated: it is `Submodule`. So is +* `CommitReference` is removed: it is `Submodule`. So is `Tree::getCommitReferenceEntries()`: use `getSubmodules()`. * `Tree::resolvePath()` throws an `InvalidArgumentException` explaining why a path doesn't resolve, a path through a submodule included, which @@ -410,7 +401,7 @@ Working copy * `WorkingCopy::checkout()` takes a `Revision` or a revision name: a local `Branch` object attaches `HEAD` to the branch, instead of detaching it. - Its second argument, a branch to create, is deprecated: use + Its second argument, a branch to create, is removed: use `checkoutNewBranch($branch, $startPoint)`. * `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a name that no revision has, with both drivers: the name of a remote @@ -442,7 +433,7 @@ Commits, trees and blobs its body what follows, like `git log --format=%s` and `%b`: a message without an empty line after its first line has no body, where its second line was lost. -* `Commit::getShortMessage()` and `getFixedShortHash()` are deprecated: use +* `Commit::getShortMessage()` and `getFixedShortHash()` are removed: use `symfony/string` (`u($commit->getSubjectMessage())->truncate(50, '...')`) and `substr()`. * `Blob::getMimetype()` returns the mimetype alone (`text/plain`, instead @@ -530,9 +521,6 @@ Errors creates the hooks directory. `remove()` of a hook that doesn't exist throws an `InvalidArgumentException`, like `get()`, still a `LogicException`. -* The `Admin` methods throw a `ProcessException`, still a - `RuntimeException`, with the first line of git's error output in its - message, or a `DriverException` when git can't start. * An argument holding a NUL byte throws an `InvalidArgumentException`, where PHP threw a `ValueError`: git and libgit2 read their arguments as C strings, which end at the first NUL byte. @@ -605,14 +593,14 @@ Dates Extending gitlib ---------------- -* These classes are `final`: `Admin`, `Blame`, `Blob`, `Commit`, - `Diff\File`, `Log`, `Parser\DiffParser`, `PushReference`, +* These classes are `final`: `Blame`, `Blob`, `Commit`, `Log`, + `Parser\DiffParser`, `PushReference`, `Reference\Branch`, `Reference\Stash`, `Reference\Tag`, `ReferenceBag`, `Repository`, `Tree`, and the exceptions `LogicException`, `ProcessException`, `ReferenceNotFoundException` and `UnexpectedValueException`. * These classes are `final readonly`: `Blame\Line`, `Diff\Diff`, - `Diff\FileChange`, `Hooks`, `RevisionList`, `WorkingCopy`. + `Diff\File`, `Diff\FileChange`, `Hooks`, `RevisionList`, `WorkingCopy`. * Every method has native parameter and return types. A class extending `Parser\ParserBase`, which remains extendable, must declare the same types, and must not redeclare its properties. @@ -695,7 +683,7 @@ Data returned * `PushReference::isForce()` returns `true` for histories without a common ancestor, where the constructor threw. `PushReference` runs git when `isForce()` needs it, not when constructed. `getReference()`, - `getRevision()`, `isCreate()` and `isDelete()` are deprecated: use + `getRevision()`, `isCreate()` and `isDelete()` are removed: use `getFullname()`, `getRange()`, `isCreation()` and `isDeletion()`, named like the ones of `Diff\File`. The `$reference` argument of its constructor is named `$fullname`. @@ -728,13 +716,13 @@ Other changes Nothing changes when PHP runs out of the repository. * `Hooks::set()` makes hooks executable by their owner only (`0700`, instead of `0777`): change their permissions if another user runs them. -* `Repository::shell()` is deprecated: it ignored the driver. Run +* `Repository::shell()` is removed: it ignored the driver. Run `git shell` with Symfony Process instead. * `Hooks::has()` returns `true` for a symbolic link to a missing file: `get()` throws a `RuntimeException`, as it can't read it, `set()` and `setSymlink()` refuse it, as a hook that exists already, and `remove()` removes the link. -* `Repository::getSize()` is deprecated: `getSizeInBytes()` returns bytes, +* `Repository::getSize()` is removed: `getSizeInBytes()` returns bytes, instead of kilobytes. Neither follows links: a link in the git directory, to a hook not deployed yet for instance, counts for nothing, where `getSize()` counted the file it points to, and threw for a missing one. diff --git a/composer.json b/composer.json index 42bdc86b..4900ca6b 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,6 @@ "php": "^8.4", "ext-pcre": "*", "psr/log": "^1.1 || ^2.0 || ^3.0", - "symfony/deprecation-contracts": "^2.5 || ^3", "symfony/process": "^6.4.33 || ^7.4.5 || ^8.1", "symfony/string": "^7.4.6 || ^8.0.6" }, diff --git a/doc/admin.md b/doc/admin.md index 5ba076fe..9237bed0 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -2,7 +2,7 @@ Create and access git repositories ================================== The static methods of `Repository` create new repositories, and return them. -They replace `Admin`, deprecated since 1.7: see +They replace `Admin`, removed in 2.0: see [UPGRADE-2.0.md](../UPGRADE-2.0.md#creating-repositories). To open a repository that exists already, use `new Repository($path)`: see diff --git a/doc/blame.md b/doc/blame.md index 9b24da4e..b9299ebe 100644 --- a/doc/blame.md +++ b/doc/blame.md @@ -25,9 +25,6 @@ Like git, the blame stops at the end of the file, and a first line after it throws a `DriverException`. A last line before the first one blames the lines in order, like `git blame -L 5,3`. -Passing a range as a string (`'2,3'` or `'2,+2'`, like `git blame -L`) is -deprecated since 1.7: pass the first and the last lines instead. - Line-per-line iteration ----------------------- @@ -66,9 +63,6 @@ $line->getCommit(); // The commit that last changed it $author = $line->getCommit()->getAuthorName(); ``` -`Line::getLine()` is deprecated since 1.7: use `getOriginalNumber()`, which -returns an integer. - Group reading by commit ----------------------- @@ -86,5 +80,3 @@ foreach ($blame->getHunks() as $hunk) { } } ``` - -`getGroupedLines()` is deprecated since 1.7: use `getHunks()`. diff --git a/doc/branch.md b/doc/branch.md index 42879169..64afd16c 100644 --- a/doc/branch.md +++ b/doc/branch.md @@ -41,8 +41,7 @@ $branch = $branch->moveTo('master~2'); ``` Like `git update-ref`, it doesn't touch the working tree, even for the -branch checked out. `ReferenceBag::update()` is deprecated since 1.7: use -`moveTo()`. +branch checked out. Merged branches --------------- @@ -56,9 +55,6 @@ if ($branch->isMergedInto('master')) { } ``` -`isMergedTo()` is deprecated since 1.7: use `isMergedInto()`, which has no -default destination (`master`) nor flag to compare remote branches only. - Branches containing a commit ---------------------------- diff --git a/doc/commit.md b/doc/commit.md index aac1dfca..82f0c15c 100644 --- a/doc/commit.md +++ b/doc/commit.md @@ -30,9 +30,6 @@ $commit->getHash(); // "7e9ba14939b0ce67ab95d83e03bd64710db91ef4" $commit->getShortHash(); // "7e9ba14", abbreviated and unique in the repository ``` -`getFixedShortHash()` is deprecated since 1.7: use -`substr($commit->getHash(), 0, 6)`. - Browsing parents ---------------- @@ -125,8 +122,7 @@ the subject is `"Fix the parser of dates"`, and the body `"It failed on leap years.\n"`. A message without an empty line has no body. -`getShortMessage()` is deprecated since 1.7: truncate the subject with -`symfony/string`, a dependency of gitlib, instead: +To truncate the subject, use `symfony/string`, a dependency of gitlib: ```php use function Symfony\Component\String\u; @@ -195,6 +191,3 @@ $branches = $commit->getIncludingBranches(); $localBranches = array_filter($branches, static fn (Branch $branch): bool => $branch->isLocal()); $remoteBranches = array_filter($branches, static fn (Branch $branch): bool => $branch->isRemote()); ``` - -Passing arguments to `getIncludingBranches()`, to keep the local or the -remote branches only, is deprecated since 1.7. diff --git a/doc/diff.md b/doc/diff.md index e474f5f3..55b25113 100644 --- a/doc/diff.md +++ b/doc/diff.md @@ -23,11 +23,6 @@ start from their best common ancestor: $diff = $repository->getDiff($repository->getMergeBase('master', 'feature'), 'feature'); ``` -Calling `getDiff()` with one argument (a range like `a..b` or `a...b`, two -revisions in an array, or one revision to compare with the working tree) -is deprecated since 1.7: see [UPGRADE-2.0.md](../UPGRADE-2.0.md). -`Log::getDiff()` is deprecated too: it ignored the paths of the log. - Other diffs ----------- @@ -126,9 +121,6 @@ $file->getDeletions(); $file->getChanges(); // FileChange objects: see below ``` -`isChangeMode()`, `isDelete()` and `getAnchor()` are deprecated since 1.7: -use `isModeChange()`, `isDeletion()` and `substr($file->getNewIndex(), 0, 12)`. - The FileChange object --------------------- @@ -164,13 +156,6 @@ foreach ($file->getChanges() as $change) { `getAdditions()` and `getDeletions()` count the lines the hunk adds and deletes. -`getLines()`, which returns each line as an array (`[$type, $content]`), the -`FileChange::LINE_ADD`, `LINE_CONTEXT` and `LINE_REMOVE` constants, and -`FileChange::getCount()` are deprecated since 1.7: use `getDiffLines()`, -`DiffLineType`, and `getAdditions()` or `getDeletions()`. So is passing the -lines to the constructor of `FileChange` as arrays: pass `DiffLine` -objects. - Parsing a diff -------------- @@ -211,7 +196,3 @@ $diff = Diff::fromArray($array, $repository); ``` The arrays of 1.x are read too. - -`Diff::setRepository()`, `File::setRepository()` and `File::addChange()` are -deprecated since 1.7: pass the repository to `Diff::parse()` or -`Diff::fromArray()`, and the changes to the constructor of `File`. diff --git a/doc/log.md b/doc/log.md index 1cd66947..86b73329 100644 --- a/doc/log.md +++ b/doc/log.md @@ -131,12 +131,3 @@ $secondPage = $firstPage->withOffset(10); $secondPage->getOffset(); // 10 $secondPage->getLimit(); // 10 ``` - -`setOffset()` and `setLimit()`, which changed the log, are deprecated since -2.0: use `withOffset()` and `withLimit()`. - -Diff of a log -------------- - -`Log::getDiff()` is deprecated since 1.7: it ignored the paths of the log. -Use `Repository::getDiff($from, $to)`, see [diff.md](diff.md). diff --git a/doc/references.md b/doc/references.md index adab71f6..1d891383 100644 --- a/doc/references.md +++ b/doc/references.md @@ -13,8 +13,7 @@ $references = $repository->getReferences(); The bag reads the references again after every write gitlib makes: creating or deleting a reference, a checkout, a merge, `Repository::run()`... To see -the writes of other processes, open a new `Repository`. Passing an argument -to `getReferences()`, to read them again, is deprecated since 1.7. +the writes of other processes, open a new `Repository`. First, you can test the existence of tags and branches like this: @@ -109,8 +108,7 @@ like `refs/heads/foobar`, and does nothing when it doesn't exist, like `createBranch()` and `createTag()` throw an `InvalidArgumentException` when the reference exists: they never move it. To move a branch, use -`Branch::moveTo()`: see [branch.md](branch.md). `ReferenceBag::update()` is -deprecated since 1.7: use `Branch::moveTo()`. +`Branch::moveTo()`: see [branch.md](branch.md). Tags ---- diff --git a/doc/repository.md b/doc/repository.md index a81e85de..de78ef81 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -46,11 +46,6 @@ use Gitonomy\Git\Repository; $repository = new Repository('/srv/project.git', '/srv/project', new CliDriver(processTimeout: 60)); ``` -An array of options as second argument (`working_dir`, `logger`, `command`, -`process_timeout`...) is deprecated since 1.7: pass the working directory, -and configure a `CliDriver` instead. See -[UPGRADE-2.0.md](../UPGRADE-2.0.md#opening-a-repository). - A directory that doesn't exist, or that isn't a repository, throws an `InvalidArgumentException`. @@ -150,8 +145,7 @@ $references = $repository->getReferences(); gitlib reads them again after every write it makes: creating or deleting a reference, a checkout, a merge, `run()`... It doesn't see the writes of other processes (a push to the repository, another worker...): open a new -`Repository` to see them. The `$reload` argument of `getReferences()` is -deprecated since 1.7. +`Repository` to see them. Description ----------- @@ -176,9 +170,6 @@ $size = $repository->getSizeInBytes(); echo 'Your repository size is '.round($size / 1000).' KB'; ``` -`getSize()`, which returns kilobytes (1,000 bytes), is deprecated since 1.7: -use `getSizeInBytes()`. - Running git commands -------------------- @@ -208,9 +199,6 @@ your users starting with a dash is an option for git, unless `--` comes before it. gitlib reads the references again after the command, which may have written them. -`shell()` is deprecated since 1.7: run `git shell` with Symfony Process -instead. - Errors ------ @@ -480,12 +468,6 @@ $repository = new Repository('/tmp/foo', driver: new CliDriver( )); ``` -The `command`, `environment_variables` and `process_timeout` options of -`Repository` and `Admin`, from 1.x, are deprecated since 1.7 in favor of these -arguments. So is `inherit_environment_variables`, which passed the entries of -`$_SERVER` to git, the headers and credentials of an HTTP request included: -pass the variables git needs to `environmentVariables`. - Git configuration ----------------- @@ -560,9 +542,6 @@ A failing command also logs its error output, at the `debug` level: the exception it throws reports the failure. The logs hide the credentials of URLs, like exceptions. `LibGit2Driver` logs nothing. -`Repository::setLogger()` and `Repository::getLogger()` are deprecated since -2.0: give the logger to `CliDriver`. - Using your own driver --------------------- diff --git a/doc/tree.md b/doc/tree.md index 21451d12..9a671544 100644 --- a/doc/tree.md +++ b/doc/tree.md @@ -69,10 +69,6 @@ function displayTree(Tree $tree, int $depth = 0): void displayTree($commit->getTree()); ``` -`getEntries()`, `getBlobEntries()` and `getTreeEntries()`, which return -the mode and the object of each entry as an array (`[$mode, $object]`), are -deprecated since 1.7: iterate the tree, or use `getBlobs()` and `getTrees()`. - Submodules ---------- @@ -86,9 +82,6 @@ foreach ($tree->getSubmodules() as $name => $entry) { } ``` -`CommitReference` is deprecated since 1.7: it is `Submodule`. So is -`Tree::getCommitReferenceEntries()`: use `getSubmodules()`. - Resolve a path -------------- diff --git a/doc/workingcopy.md b/doc/workingcopy.md index 462c7a46..8e84c3df 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -71,9 +71,6 @@ Like `git checkout`, both methods keep the local changes, and refuse to overwrite the ones the checkout would change: they throw a `DriverException`. *checkoutNewBranch* then creates no branch. -Passing the branch to create as second argument of *checkout* is deprecated -since 1.7: use *checkoutNewBranch*. - Merge a revision ---------------- @@ -131,9 +128,6 @@ $diff = $wc->getDiff(); $diff = $wc->getDiff('master'); ``` -Calling `Repository::getDiff()` with one revision, to compare it with the -working copy, is deprecated since 1.7: use `WorkingCopy::getDiff()`. - Staged modifications -------------------- diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php deleted file mode 100644 index d29f7b57..00000000 --- a/src/Gitonomy/Git/Admin.php +++ /dev/null @@ -1,195 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git; - -use Gitonomy\Git\Driver\Cli\CliDriver; -use Gitonomy\Git\Driver\Cli\LegacyOptions; - -/** - * Administration class for Git repositories. - * - * Its methods behave like in 1.x: the git options of 1.x (command, - * environment_variables, inherit_environment_variables and process_timeout) - * configure the CliDriver they run, and the repositories they return; - * inherit_environment_variables only applies to these repositories. - * - * @author Alexandre Salomé - * - * @deprecated since gitonomy/gitlib 1.7, use the static methods of Repository instead - * - * @phpstan-import-type Options from LegacyOptions - */ -final class Admin -{ - /** - * Initializes a repository and returns the instance. - * - * @param string $path path to the repository - * @param bool $bare indicate to create a bare repository - * @param Options $options options for Repository creation - * - * @throws Exception\DriverException Directory exists or not writable - * - * @deprecated since gitonomy/gitlib 1.7, use Repository::initBare() or Repository::init() instead - */ - public static function init(string $path, bool $bare = true, array $options = []): Repository - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" instead.', __METHOD__, Repository::class, $bare ? 'initBare' : 'init'); - - $options = new LegacyOptions($options); - $driver = $options->adminDriver() ?? new CliDriver(); - if ($bare) { - $driver->initBare($path); - } else { - $driver->init($path); - } - - return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); - } - - /** - * Checks the validity of a git repository url without cloning it. - * - * This will use the `ls-remote` command of git against the given url. - * Usually, this command returns 0 when successful, and 128 when the - * repository is not found. - * - * @param string $url url of repository to check - * @param Options $options options for Repository creation - * - * @return bool true if url is valid - * - * @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepository() instead - */ - public static function isValidRepository(#[\SensitiveParameter] string $url, array $options = []): bool - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class); - - return (new LegacyOptions($options)->adminDriver() ?? new CliDriver())->isValidRepository($url); - } - - /** - * Checks the validity of a git repository url without cloning it and - * check if a certain branch exists in that repository. - * - * This will use the `ls-remote` command of git against the given url. - * Usually, this command returns 0 when successful, and 128 when the - * repository is not found. - * - * @param string $url url of repository to check - * @param string $branchName name of branch to check - * @param Options $options options for Repository creation - * - * @return bool true if url is valid and branch exists - * - * @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepositoryAndBranch() instead - */ - public static function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branchName, array $options = []): bool - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class); - - return (new LegacyOptions($options)->adminDriver() ?? new CliDriver())->isValidRepositoryAndBranch($url, $branchName); - } - - /** - * Clone a repository to a local path. - * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param bool $bare indicates if repository should be bare or have a working copy - * @param Options $options options for Repository creation - * - * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() instead - */ - public static function cloneTo(string $path, #[\SensitiveParameter] string $url, bool $bare = true, array $options = []): Repository - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); - - $options = new LegacyOptions($options); - $driver = $options->adminDriver() ?? new CliDriver(); - if ($bare) { - $driver->cloneBareFrom($url, $path, null); - } else { - $driver->cloneFrom($url, $path, null); - } - - return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); - } - - /** - * Clone a repository branch to a local path. - * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param string $branch branch to clone - * @param bool $bare indicates if repository should be bare or have a working copy - * @param Options $options options for Repository creation - * - * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() with their "branch" argument instead - */ - public static function cloneBranchTo(string $path, #[\SensitiveParameter] string $url, string $branch, bool $bare = true, array $options = []): Repository - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); - - $options = new LegacyOptions($options); - $driver = $options->adminDriver() ?? new CliDriver(); - if ($bare) { - $driver->cloneBareFrom($url, $path, $branch); - } else { - $driver->cloneFrom($url, $path, $branch); - } - - return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); - } - - /** - * Mirrors a repository (fetch all revisions, not only branches). - * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param Options $options options for Repository creation - * - * @deprecated since gitonomy/gitlib 1.7, use Repository::mirrorFrom() instead - */ - public static function mirrorTo(string $path, #[\SensitiveParameter] string $url, array $options = []): Repository - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class); - - $options = new LegacyOptions($options); - $driver = $options->adminDriver() ?? new CliDriver(); - $driver->mirrorFrom($url, $path); - - return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); - } - - /** - * Internal method to launch effective ``git clone`` command. - * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param string[] $args arguments to be added to the command-line - * @param Options $options options for Repository creation - * - * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneFrom(), Repository::cloneBareFrom() or Repository::mirrorFrom() instead - */ - public static function cloneRepository(string $path, #[\SensitiveParameter] string $url, array $args = [], array $options = []): Repository - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class); - - $options = new LegacyOptions($options); - $driver = $options->adminDriver() ?? new CliDriver(); - $driver->cloneRepository($path, $url, $args); - - return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); - } -} diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index d9b31acd..b935ecfd 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -87,20 +87,6 @@ public function getHunks(): array return $hunks; } - /** - * Returns lines grouped by commit. - * - * @return list}> - * - * @deprecated since gitonomy/gitlib 1.7, use getHunks() instead - */ - public function getGroupedLines(): array - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getHunks()" instead.', __METHOD__); - - return array_map(static fn (Hunk $hunk): array => [$hunk->getCommit(), $hunk->getLines()], $this->getHunks()); - } - /** * @return array The lines of the blame, by number */ diff --git a/src/Gitonomy/Git/Blame/Line.php b/src/Gitonomy/Git/Blame/Line.php index 6bc8b01b..87156771 100644 --- a/src/Gitonomy/Git/Blame/Line.php +++ b/src/Gitonomy/Git/Blame/Line.php @@ -52,16 +52,6 @@ public function getOriginalNumber(): int return $this->originalNumber; } - /** - * @deprecated since gitonomy/gitlib 1.7, use getOriginalNumber() instead - */ - public function getLine(): string - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getOriginalNumber()" instead.', __METHOD__); - - return (string) $this->originalNumber; - } - /** * @return Commit The commit that last changed the line */ diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 9b82f896..54c05fb7 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -14,11 +14,9 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Driver\Data\CommitData; -use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Util\Message; -use Symfony\Component\String\CodePointString; /** * Representation of a Git commit. @@ -86,18 +84,6 @@ public function getShortHash(): string return $this->shortHash ??= $this->repository->getRepositoryDriver()->getShortHash($this->revision); } - /** - * Returns a fixed-with short hash. - * - * @deprecated since gitonomy/gitlib 1.7, use substr($commit->getHash(), 0, $length) instead - */ - public function getFixedShortHash(int $length = 6): string - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.', __METHOD__); - - return new CodePointString($this->revision)->slice(0, $length)->toString(); - } - /** * Returns parent hashes. * @@ -152,32 +138,6 @@ public function getLastModification(?string $path = null): self } } - /** - * Returns the first line of the commit, and the first 50 characters. - * - * Ported from https://github.com/fabpot/Twig-extensions/blob/d67bc7e69788795d7905b52d31188bbc1d390e01/lib/Twig/Extensions/Extension/Text.php#L52-L109 - * - * @deprecated since gitonomy/gitlib 1.7, use symfony/string instead: u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve) - */ - public function getShortMessage(int $length = 50, bool $preserve = false, string $separator = '...'): string - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".', __METHOD__); - - $message = $this->getSubjectMessage(); - - $codePointMessage = new CodePointString($message); - - if ($codePointMessage->length() > $length) { - if ($preserve && null !== ($breakpoint = $codePointMessage->indexOf(' ', $length))) { - $length = $breakpoint; - } - - return rtrim($codePointMessage->slice(0, $length)->toString()).$separator; - } - - return $message; - } - /** * Resolves all references associated to this commit. * @@ -191,20 +151,10 @@ public function resolveReferences(): array /** * Returns the local and remote branches whose history contains the commit. * - * Passing arguments, to keep local or remote branches only, is deprecated. - * * @return Branch[] */ - public function getIncludingBranches(bool $local = true, bool $remote = true): array + public function getIncludingBranches(): array { - if (\func_num_args() > 0) { - trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing arguments to "%s()" is deprecated, filter the branches it returns with "%s::isLocal()" or "isRemote()" instead.', __METHOD__, Branch::class); - } - - if (!$local && !$remote) { - throw new InvalidArgumentException('Keep the local branches, the remote ones, or both: $local and $remote can\'t both be false.'); - } - $fullnames = $this->repository->getRepositoryDriver()->getIncludingBranches($this->revision); $references = $this->repository->getReferences(); // A branch another process created since gitlib read the references. @@ -221,7 +171,7 @@ public function getIncludingBranches(bool $local = true, bool $remote = true): a } $branch = $references->get($fullname); - if ($branch instanceof Branch && ($branch->isLocal() ? $local : $remote)) { + if ($branch instanceof Branch) { $branches[] = $branch; } } diff --git a/src/Gitonomy/Git/CommitReference.php b/src/Gitonomy/Git/CommitReference.php deleted file mode 100644 index 01a6d55f..00000000 --- a/src/Gitonomy/Git/CommitReference.php +++ /dev/null @@ -1,28 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git; - -trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s" class is deprecated, use "%s" instead.', CommitReference::class, Submodule::class); - -// Declares the alias, unless Submodule already did. -class_exists(Submodule::class); - -// @phpstan-ignore if.alwaysFalse (Declares the class for IDEs only.) -if (false) { - /** - * @deprecated since gitonomy/gitlib 1.7, use Submodule instead - */ - final readonly class CommitReference - { - } -} diff --git a/src/Gitonomy/Git/Diff/Diff.php b/src/Gitonomy/Git/Diff/Diff.php index 4abd4347..ee3ff2e9 100644 --- a/src/Gitonomy/Git/Diff/Diff.php +++ b/src/Gitonomy/Git/Diff/Diff.php @@ -61,19 +61,6 @@ public static function fromData(DiffData $data, Repository $repository): self return new self(array_map(static fn (DiffFileData $file): File => File::fromData($file, $repository), $data->files), $data->rawDiff); } - /** - * @deprecated since gitonomy/gitlib 1.7, the diffs gitlib returns have their repository already - */ - public function setRepository(Repository $repository): void - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); - - foreach ($this->files as $file) { - // Without one more deprecation for each file. - \Closure::bind(static fn () => $file->repository = $repository, null, File::class)(); - } - } - /** * Get list of files modified in the diff's revision. * diff --git a/src/Gitonomy/Git/Diff/DiffLineType.php b/src/Gitonomy/Git/Diff/DiffLineType.php index b7a88a70..0ee2e6bf 100644 --- a/src/Gitonomy/Git/Diff/DiffLineType.php +++ b/src/Gitonomy/Git/Diff/DiffLineType.php @@ -13,8 +13,8 @@ namespace Gitonomy\Git\Diff; /** - * The kind of a line of a hunk, its values those of the deprecated - * FileChange::LINE_* constants. + * The kind of a line of a hunk, its values the types of the lines of + * FileChange::toArray(). * * @author Grégoire Pineau */ diff --git a/src/Gitonomy/Git/Diff/File.php b/src/Gitonomy/Git/Diff/File.php index c18e2cd3..ebe7e622 100644 --- a/src/Gitonomy/Git/Diff/File.php +++ b/src/Gitonomy/Git/Diff/File.php @@ -26,9 +26,9 @@ * * @phpstan-type FileArray array{old_name: string|null, new_name: string|null, old_mode: string|null, new_mode: string|null, old_index: string|null, new_index: string|null, is_binary: bool, is_copy: bool, changes: list} */ -final class File +final readonly class File { - private readonly string $name; + private string $name; /** * @param string|null $oldName Null for a creation @@ -41,30 +41,20 @@ final class File * @throws InvalidArgumentException Both names are null */ public function __construct( - private readonly ?string $oldName, - private readonly ?string $newName, - private readonly ?string $oldMode, - private readonly ?string $newMode, - private readonly ?string $oldIndex, - private readonly ?string $newIndex, - private readonly bool $isBinary, - private readonly bool $isCopy = false, + private ?string $oldName, + private ?string $newName, + private ?string $oldMode, + private ?string $newMode, + private ?string $oldIndex, + private ?string $newIndex, + private bool $isBinary, + private bool $isCopy = false, private array $changes = [], private ?Repository $repository = null, ) { $this->name = $newName ?? $oldName ?? throw new InvalidArgumentException('A file of a diff has an old name, a new name, or both.'); } - /** - * @deprecated since gitonomy/gitlib 1.7, give the changes to the constructor instead - */ - public function addChange(FileChange $change): void - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, give the changes to the constructor instead.', __METHOD__); - - $this->changes[] = $change; - } - /** * Indicates if this diff file is a creation. */ @@ -109,16 +99,6 @@ public function isModeChange(): bool return $this->isModification() && $this->oldMode !== $this->newMode; } - /** - * @deprecated since gitonomy/gitlib 1.7, use isModeChange() instead - */ - public function isChangeMode(): bool - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isModeChange()" instead.', __METHOD__); - - return $this->isModeChange(); - } - /** * Indicates if this diff file is a deletion. */ @@ -127,16 +107,6 @@ public function isDeletion(): bool return null === $this->newName; } - /** - * @deprecated since gitonomy/gitlib 1.7, use isDeletion() instead - */ - public function isDelete(): bool - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); - - return $this->isDeletion(); - } - /** * @return int Number of added lines */ @@ -250,31 +220,11 @@ public static function fromArray(array $array, ?Repository $repository = null): ); } - /** - * @deprecated since gitonomy/gitlib 1.7, a presentation concern: use substr($file->getNewIndex(), 0, 12) instead - */ - public function getAnchor(): string - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.', __METHOD__); - - return substr($this->newIndex ?? '', 0, 12); - } - public function getRepository(): ?Repository { return $this->repository; } - /** - * @deprecated since gitonomy/gitlib 1.7, the diffs gitlib returns have their repository already - */ - public function setRepository(Repository $repository): void - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); - - $this->repository = $repository; - } - /** * @throws LogicException The file has no repository, or is a creation * @throws RuntimeException The diff doesn't know the hash of the old blob diff --git a/src/Gitonomy/Git/Diff/FileChange.php b/src/Gitonomy/Git/Diff/FileChange.php index 20f02e7e..cab89c02 100644 --- a/src/Gitonomy/Git/Diff/FileChange.php +++ b/src/Gitonomy/Git/Diff/FileChange.php @@ -21,30 +21,13 @@ */ final readonly class FileChange { - /** - * @deprecated since gitonomy/gitlib 1.7, use DiffLineType::Context instead - */ - public const int LINE_CONTEXT = 0; - - /** - * @deprecated since gitonomy/gitlib 1.7, use DiffLineType::Deletion instead - */ - public const int LINE_REMOVE = -1; - - /** - * @deprecated since gitonomy/gitlib 1.7, use DiffLineType::Addition instead - */ - public const int LINE_ADD = 1; - /** * @var list */ private array $lines; /** - * @param array $lines Passing arrays is deprecated - * - * @throws InvalidArgumentException An array has none of the types of line + * @param DiffLine[] $lines */ public function __construct( private int $rangeOldStart, @@ -53,21 +36,7 @@ public function __construct( private int $rangeNewCount, array $lines, ) { - if ([] !== array_filter($lines, is_array(...))) { - trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing the lines to "%s()" as arrays is deprecated, pass "%s" objects instead.', __METHOD__, DiffLine::class); - } - - $this->lines = array_map(static fn (DiffLine|array $line): DiffLine => \is_array($line) ? new DiffLine(self::lineType($line[0]), $line[1]) : $line, array_values($lines)); - } - - /** - * @deprecated since gitonomy/gitlib 1.7, use getAdditions() or getDeletions() instead - */ - public function getCount(int $type): int - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.', __METHOD__); - - return $this->count(self::lineType($type)); + $this->lines = array_values($lines); } public function getAdditions(): int @@ -108,18 +77,6 @@ public function getDiffLines(): array return $this->lines; } - /** - * @return list The type of each line, one of the LINE_* constants, and its content - * - * @deprecated since gitonomy/gitlib 1.7, use getDiffLines() instead - */ - public function getLines(): array - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getDiffLines()" instead.', __METHOD__); - - return $this->linesToArray(); - } - /** * @return FileChangeArray */ @@ -130,7 +87,7 @@ public function toArray(): array 'range_old_count' => $this->rangeOldCount, 'range_new_start' => $this->rangeNewStart, 'range_new_count' => $this->rangeNewCount, - 'lines' => $this->linesToArray(), + 'lines' => array_map(static fn (DiffLine $line): array => [$line->getType()->value, $line->getContent()], $this->lines), ]; } @@ -160,12 +117,4 @@ private function count(DiffLineType $type): int { return \count(array_filter($this->lines, static fn (DiffLine $line): bool => $line->getType() === $type)); } - - /** - * @return list - */ - private function linesToArray(): array - { - return array_map(static fn (DiffLine $line): array => [$line->getType()->value, $line->getContent()], $this->lines); - } } diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index 005a2b04..3f1ec458 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -102,13 +102,11 @@ public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, s } /** - * @internal For the deprecated Admin::cloneRepository(), to be removed in 3.0 - * * @param string[] $args Arguments of ``git clone`` * * @throws ProcessException */ - public function cloneRepository(string $path, #[\SensitiveParameter] string $url, array $args): void + private function cloneRepository(string $path, #[\SensitiveParameter] string $url, array $args): void { // Like isValidRepository(), whose ls-remote can't take "--". RemoteUrl::assertSafe($url); @@ -117,22 +115,6 @@ public function cloneRepository(string $path, #[\SensitiveParameter] string $url $this->mustRun('clone', ['-q', ...$args, '--', $url, $path]); } - /** - * @internal For the deprecated Repository::getLogger(), to be removed in 3.0 - */ - public function getLogger(): ?LoggerInterface - { - return $this->logger; - } - - /** - * @internal For the deprecated Repository::setLogger(), to be removed in 3.0 - */ - public function withLogger(LoggerInterface $logger): self - { - return new self($this->command, $this->environmentVariables, $this->processTimeout, $logger); - } - private function runner(): GitRunner { return new GitRunner($this->command, $this->environmentVariables, $this->processTimeout, $this->logger); diff --git a/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php b/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php deleted file mode 100644 index d4effff3..00000000 --- a/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php +++ /dev/null @@ -1,111 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Driver\Cli; - -use Gitonomy\Git\Exception\InvalidArgumentException; -use Psr\Log\LoggerInterface; - -/** - * The options of Repository and Admin in 1.x, deprecated in favor of the - * "workingDir" argument of Repository, and of the arguments of CliDriver. - * - * @author Grégoire Pineau - * - * @internal To be removed in 3.0, with these options - * - * @phpstan-type Options array{command?: string, debug?: bool, environment_variables?: array, inherit_environment_variables?: bool, logger?: LoggerInterface|null, process_timeout?: float|null, working_dir?: string|null} - */ -final readonly class LegacyOptions -{ - /** - * The options configuring CliDriver. - */ - private const array DRIVER_OPTIONS = ['command', 'environment_variables', 'inherit_environment_variables', 'logger', 'process_timeout']; - - public ?string $workingDir; - - /** - * @var Options - */ - private array $options; - - /** - * @param Options $options - * - * @throws InvalidArgumentException An unknown option, or the "debug" one set to false - */ - public function __construct(array $options) - { - if (\array_key_exists('debug', $options) && !$options['debug']) { - throw new InvalidArgumentException('The "debug" option set to false is not supported anymore: a failing git operation always throws, catch its exception instead.'); - } - - $unknownOptions = array_diff(array_keys($options), [...self::DRIVER_OPTIONS, 'debug', 'working_dir']); - if ([] !== $unknownOptions) { - throw new InvalidArgumentException(\sprintf(1 === \count($unknownOptions) ? 'Unknown option "%s".' : 'Unknown options "%s".', implode('", "', $unknownOptions))); - } - - // PHP doesn't check the types of the options, unlike PHPStan. - if (null !== ($options['logger'] ?? null) && !$options['logger'] instanceof LoggerInterface) { // @phpstan-ignore instanceof.alwaysTrue, booleanAnd.alwaysFalse - throw new InvalidArgumentException(\sprintf('The "logger" option must be a Psr\Log\LoggerInterface, "%s" given.', get_debug_type($options['logger']))); - } - - $this->workingDir = $options['working_dir'] ?? null; - $this->options = $options; - } - - /** - * The CliDriver running the git processes of a repository, null when no option configures one. - * - * Like in 1.x, "inherit_environment_variables" also gives them the entries of $_SERVER. - */ - public function repositoryDriver(): ?CliDriver - { - $environmentVariables = $this->options['environment_variables'] ?? []; - if ($this->options['inherit_environment_variables'] ?? false) { - // $_SERVER also holds entries that aren't environment variables (argv, REQUEST_TIME...). - $environmentVariables = array_replace(array_filter($_SERVER, \is_string(...)), $environmentVariables); - } - - return $this->driver($environmentVariables); - } - - /** - * The CliDriver running the commands of Admin (init, clone, ls-remote), null when no option configures one. - * - * Like in 1.x, it ignores "inherit_environment_variables": these commands - * reach remotes, and $_SERVER holds the credentials of an HTTP request. - */ - public function adminDriver(): ?CliDriver - { - return $this->driver($this->options['environment_variables'] ?? []); - } - - /** - * @param array $environmentVariables - */ - private function driver(array $environmentVariables): ?CliDriver - { - if ([] === array_intersect(array_keys($this->options), self::DRIVER_OPTIONS)) { - return null; - } - - return new CliDriver( - $this->options['command'] ?? 'git', - $environmentVariables, - // null disables the timeout. - \array_key_exists('process_timeout', $this->options) ? $this->options['process_timeout'] : 3600, - $this->options['logger'] ?? null, - ); - } -} diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index 1f4eddb7..a39780fd 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -12,7 +12,6 @@ namespace Gitonomy\Git; -use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; @@ -71,16 +70,6 @@ public function __construct( $this->limit = self::limit($limit); } - /** - * @deprecated since gitonomy/gitlib 1.7, use Repository::getDiff() instead: it ignored the paths of the log - */ - public function getDiff(): Diff - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::getDiff()" instead.', __METHOD__, Repository::class); - - return $this->repository->getLegacyDiff($this->revisions); - } - public function getRevisions(): ?RevisionList { return $this->revisions; @@ -112,18 +101,6 @@ public function withOffset(?int $offset): self return $log; } - /** - * @deprecated since gitonomy/gitlib 1.7, use withOffset() instead - */ - public function setOffset(?int $offset): static - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "withOffset()" instead.', __METHOD__); - - $this->offset = self::offset($offset); - - return $this; - } - public function getLimit(): ?int { return $this->limit; @@ -142,18 +119,6 @@ public function withLimit(?int $limit): self return $log; } - /** - * @deprecated since gitonomy/gitlib 1.7, use withLimit() instead - */ - public function setLimit(?int $limit): static - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "withLimit()" instead.', __METHOD__); - - $this->limit = self::limit($limit); - - return $this; - } - /** * @throws ReferenceNotFoundException The log is empty */ diff --git a/src/Gitonomy/Git/PushReference.php b/src/Gitonomy/Git/PushReference.php index 0efdde24..4ed8727b 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -52,16 +52,6 @@ public function getFullname(): string return $this->fullname; } - /** - * @deprecated since gitonomy/gitlib 1.7, use getFullname() instead - */ - public function getReference(): string - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getFullname()" instead.', __METHOD__); - - return $this->fullname; - } - public function getBefore(): string { return $this->before; @@ -80,16 +70,6 @@ public function getLog(array $excludes = []): Log return $this->repository->getLog([$this->getRange(), ...array_map(static fn (string $exclude): string => '^'.$exclude, $excludes)]); } - /** - * @deprecated since gitonomy/gitlib 1.7, use getRange() instead - */ - public function getRevision(): string - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getRange()" instead.', __METHOD__); - - return $this->getRange(); - } - /** * @return string The pushed commits: "before..after", or "after" for a creation * @@ -116,16 +96,6 @@ public function isCreation(): bool return $this->isZero($this->before); } - /** - * @deprecated since gitonomy/gitlib 1.7, use isCreation() instead - */ - public function isCreate(): bool - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isCreation()" instead.', __METHOD__); - - return $this->isCreation(); - } - /** * @return bool Whether the push deletes the reference, like File::isDeletion() for a file */ @@ -134,16 +104,6 @@ public function isDeletion(): bool return $this->isZero($this->after); } - /** - * @deprecated since gitonomy/gitlib 1.7, use isDeletion() instead - */ - public function isDelete(): bool - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); - - return $this->isDeletion(); - } - public function isForce(): bool { return $this->isForce ??= $this->getForce(); diff --git a/src/Gitonomy/Git/Reference/Branch.php b/src/Gitonomy/Git/Reference/Branch.php index 5ee7ef42..039b97f1 100644 --- a/src/Gitonomy/Git/Reference/Branch.php +++ b/src/Gitonomy/Git/Reference/Branch.php @@ -58,23 +58,6 @@ public function isMergedInto(Revision|string $revision): bool return \in_array($this->commitHash, $this->repository->getRepositoryDriver()->getMergeBases($this->commitHash, $destination), true); } - /** - * Check if this branch is merged to a destination branch - * Optionally, check only with remote branches. - * - * @deprecated since gitonomy/gitlib 1.7, use isMergedInto() instead - */ - public function isMergedTo(string $destinationBranchName = 'master', bool $compareOnlyWithRemote = false): bool - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isMergedInto()" instead.', __METHOD__, self::class); - - if ($compareOnlyWithRemote && $this->isLocal()) { - return false; - } - - return $this->isMergedInto($destinationBranchName); - } - /** * Points the branch to $revision. * diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 8790f609..78970c06 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -58,25 +58,6 @@ public function has(string $fullname): bool return isset($this->references()[$fullname]); } - /** - * @template T of Reference - * - * @param T $reference - * - * @return T - * - * @deprecated since gitonomy/gitlib 1.7, use Branch::moveTo() instead - */ - public function update(Reference $reference): Reference - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::moveTo()" instead.', __METHOD__, Branch::class); - - $this->repository->getRepositoryDriver()->updateReference($reference->getFullname(), $reference->getCommitHash()); - $this->invalidate(); - - return $reference; - } - /** * @throws InvalidArgumentException The branch exists already */ diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 22fd984c..b2505e41 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -15,7 +15,6 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Driver\Cli\CliRepositoryDriver; -use Gitonomy\Git\Driver\Cli\LegacyOptions; use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\RepositoryDriverInterface; @@ -28,7 +27,6 @@ use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Util\WeakCache; -use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; /** @@ -37,8 +35,6 @@ * Main entry point for browsing a Git repository. * * @author Alexandre Salomé - * - * @phpstan-import-type Options from LegacyOptions */ final class Repository { @@ -80,55 +76,27 @@ final class Repository */ private ?ReferenceBag $referenceBag = null; - /** - * Backend used to run operations. - */ - private DriverInterface $driver; - /** * Operations on this repository, opened by the driver. */ - private RepositoryDriverInterface $repositoryDriver; + private readonly RepositoryDriverInterface $repositoryDriver; /** * Opens the repository in $dir: a bare repository, or a working tree with * its ".git" directory or file (a linked worktree, a submodule...). * - * An array of options as second argument is deprecated: its "working_dir" - * option is the $workingDir argument, and its "logger", "command", - * "environment_variables", "inherit_environment_variables" and - * "process_timeout" options configure a CliDriver. + * @param string|null $workingDir The working tree, when $dir is a git directory kept apart from it + * @param DriverInterface $driver Backend running the operations * - * @param Options|string|null $workingDir The working tree, when $dir is a git directory kept apart from it - * @param DriverInterface $driver Backend running the operations - * - * @throws InvalidArgumentException $dir doesn't exist or isn't a repository, or an option is unknown + * @throws InvalidArgumentException $dir doesn't exist or isn't a repository * @throws DriverException The driver can't open the repository * @throws UnsupportedOperationException The repository uses SHA-256 hashes, or with LibGit2Driver the reftable format */ - public function __construct(string $dir, array|string|null $workingDir = null, DriverInterface $driver = new CliDriver()) + public function __construct(string $dir, ?string $workingDir = null, DriverInterface $driver = new CliDriver()) { - if (\is_array($workingDir)) { - trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing an array of options to "%s()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.', __METHOD__); - - $options = new LegacyOptions($workingDir); - $optionsDriver = $options->repositoryDriver(); - if (null !== $optionsDriver) { - // The driver is the third argument. - if (\func_num_args() > 2) { - throw new InvalidArgumentException('The driver argument can\'t be combined with the "logger" or git options: configure the driver instead.'); - } - - $driver = $optionsDriver; - } - - $workingDir = $options->workingDir; - } - $this->commits = new WeakCache(); $this->trees = new WeakCache(); $this->blobs = new WeakCache(); - $this->driver = $driver; $this->repositoryDriver = $driver->open($dir, $workingDir); $this->gitDir = $this->repositoryDriver->getGitDir(); $this->workingDir = $this->repositoryDriver->getWorkingDir(); @@ -330,22 +298,10 @@ public function getWorkingCopy(): WorkingCopy * Returns the references of the repository, read again after every write * gitlib makes. Open a new Repository to see the writes of other * processes. - * - * Passing true, to read them again, is deprecated. */ - public function getReferences(bool $reload = false): ReferenceBag + public function getReferences(): ReferenceBag { - $this->referenceBag ??= new ReferenceBag($this); - - if (\func_num_args() > 0) { - trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing an argument to "%s()" is deprecated, gitlib reads the references again after its writes: open a new Repository to see the writes of other processes.', __METHOD__); - } - - if ($reload) { - $this->referenceBag->invalidate(); - } - - return $this->referenceBag; + return $this->referenceBag ??= new ReferenceBag($this); } /** @@ -419,29 +375,13 @@ public function getMergeBase(Revision|string $one, Revision|string $two): ?Commi /** * Returns the commits that last changed the lines of $file. * - * A string as third argument, "," or ",+" like - * "git blame -L", is deprecated. + * @param int|null $firstLine The first line to blame, from 1, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null * - * @param int|string|null $firstLine The first line to blame, from 1, the first one of the file when null - * @param int|null $lastLine The last line to blame, the last one of the file when null + * @throws InvalidArgumentException An invalid range of lines */ - public function getBlame(Revision|string $revision, string $file, int|string|null $firstLine = null, ?int $lastLine = null): Blame + public function getBlame(Revision|string $revision, string $file, ?int $firstLine = null, ?int $lastLine = null): Blame { - if (\is_string($firstLine)) { - trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a range of lines as a string to "%s()" is deprecated, pass the first and the last lines instead.', __METHOD__); - - if (!preg_match('/^(\d+),(\+?)(\d*)$/', $firstLine, $vars)) { - throw new InvalidArgumentException(\sprintf('Unsupported range of lines "%s": pass the first and the last lines instead.', $firstLine)); - } - - $firstLine = (int) $vars[1]; - $lastLine = match (true) { - '' === $vars[3] => null, - '+' === $vars[2] => $firstLine + (int) $vars[3] - 1, - default => (int) $vars[3], - }; - } - return new Blame($this, \is_string($revision) ? $this->getRevision($revision) : $revision, $file, $firstLine, $lastLine); } @@ -466,71 +406,12 @@ public function getLog(RevisionList|Revision|string|array|null $revisions = null /** * Returns the changes from the commit $from to the commit $to. - * - * Passing one argument is deprecated: an "a..b" or "a...b" range, two - * revisions in an array or a RevisionList, or one revision to compare - * with the working tree, like WorkingCopy::getDiff() does. - * - * @param RevisionList|Revision|string|array|null $from */ - public function getDiff(RevisionList|Revision|string|array|null $from, Revision|string|null $to = null): Diff + public function getDiff(Revision|string $from, Revision|string $to): Diff { - if (null === $to) { - trigger_deprecation('gitonomy/gitlib', '1.7', 'Calling "%s()" with one argument is deprecated, pass the revisions to compare as "from" and "to" arguments, or use "%s::getDiff()" to compare a revision with the working tree.', __METHOD__, WorkingCopy::class); - - return $this->getLegacyDiff($from); - } - - if (\is_array($from) || $from instanceof RevisionList || null === $from) { - throw new InvalidArgumentException('The "from" argument of "Repository::getDiff()" must be a revision, when "to" is given.'); - } - return Diff::fromData($this->repositoryDriver->getDiff($this->commitHash($from), $this->commitHash($to)), $this); } - /** - * For the deprecated forms of getDiff() and Log::getDiff(), to be removed in 3.0. - * - * @param RevisionList|Revision|string|array|null $revisions - * - * @internal - */ - public function getLegacyDiff(RevisionList|Revision|string|array|null $revisions): Diff - { - if (null === $revisions) { - throw new InvalidArgumentException('Cannot compute a diff without revisions.'); - } - - if (!$revisions instanceof RevisionList) { - $revisions = new RevisionList($this, $revisions); - } - $revisions = $revisions->getAsTextArray(); - - if (2 === \count($revisions)) { - return $this->getDiff($revisions[0], $revisions[1]); - } - - if (1 === \count($revisions) && str_contains($revisions[0], '..')) { - $symmetric = str_contains($revisions[0], '...'); - [$from, $to] = explode($symmetric ? '...' : '..', $revisions[0], 2); - $from = '' === $from ? 'HEAD' : $from; - $to = '' === $to ? 'HEAD' : $to; - - if ($symmetric) { - // Like git diff: from the best common ancestor. - $from = $this->getMergeBase($from, $to) ?? throw new InvalidArgumentException(\sprintf('The revisions of "%s" have no common ancestor.', $revisions[0])); - } - - return $this->getDiff($from, $to); - } - - if (1 === \count($revisions)) { - return $this->getWorkingCopy()->getDiff($revisions[0]); - } - - throw new InvalidArgumentException(\sprintf('Cannot compute a diff of "%s".', implode('", "', $revisions))); - } - /** * @return int The size of the files of the git directory, working tree excluded, in bytes * @@ -541,42 +422,6 @@ public function getSizeInBytes(): int return self::directorySize($this->gitDir); } - /** - * Returns the size of repository, in kilobytes. - * - * @return int A sum, in kilobytes - * - * @deprecated since gitonomy/gitlib 1.7, use getSizeInBytes() instead - */ - public function getSize(): int - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getSizeInBytes()" instead.', __METHOD__); - - return (int) round($this->getSizeInBytes() / 1000); - } - - /** - * Executes a shell command on the repository, using PHP pipes. - * - * @param string $command The command to execute - * @param array $env Environment variables of the command - * - * @deprecated since gitonomy/gitlib 1.7, run "git shell" with Symfony Process instead - */ - public function shell(string $command, array $env = []): void - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, run "git shell" with Symfony Process instead: it ignores the driver.', __METHOD__); - - $argument = \sprintf('%s %s', $command, escapeshellarg($this->gitDir)); - - $prefix = ''; - foreach ($env as $name => $value) { - $prefix .= \sprintf('export %s=%s;', escapeshellarg($name), escapeshellarg($value)); - } - - proc_open($prefix.'git shell -c '.escapeshellarg($argument), [\STDIN, \STDOUT, \STDERR], $pipes); - } - /** * Returns the hooks object. */ @@ -682,70 +527,6 @@ public function runProcess(string $command, #[\SensitiveParameter] array $args = } } - /** - * Set repository logger. - * - * @return $this - * - * @throws UnsupportedOperationException The driver isn't a CliDriver - * - * @deprecated since gitonomy/gitlib 1.7, pass a CliDriver configured with the logger as driver argument instead - */ - public function setLogger(LoggerInterface $logger): static - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, pass a "%s" configured with the logger as driver argument instead.', __METHOD__, CliDriver::class); - - if (!$this->driver instanceof CliDriver) { - throw new UnsupportedOperationException('Only CliDriver logs the git commands it runs.'); - } - - $this->driver = $this->driver->withLogger($logger); - $this->repositoryDriver = $this->driver->open($this->gitDir, $this->workingDir); - - return $this; - } - - /** - * Returns the logger of the CliDriver. - * - * @deprecated since gitonomy/gitlib 1.7, the logger is an argument of CliDriver - */ - public function getLogger(): ?LoggerInterface - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, the logger is an argument of "%s".', __METHOD__, CliDriver::class); - - return $this->driver instanceof CliDriver ? $this->driver->getLogger() : null; - } - - /** - * Clones the current repository to a new directory and return instance of new repository. - * - * The clone uses the driver of the current repository. - * - * @param string $path path to the new repository in which current repository will be cloned - * @param bool $bare flag indicating if repository is bare or has a working-copy - * @param Options $options options of the new repository - * - * @return Repository the newly created repository - * - * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() with getGitDir() as URL instead - */ - public function cloneTo(string $path, bool $bare = true, array $options = []): self - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" with "getGitDir()" as URL instead.', __METHOD__, self::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); - - // Like in 1.x, the options configuring git apply to the clone. - $options = new LegacyOptions($options); - $driver = $options->adminDriver() ?? $this->driver; - if ($bare) { - $driver->cloneBareFrom($this->gitDir, $path, null); - } else { - $driver->cloneFrom($this->gitDir, $path, null); - } - - return new self($path, $options->workingDir, $options->repositoryDriver() ?? $driver); - } - private function commitHash(Revision|string $revision): string { return ($revision instanceof Revision ? $revision : $this->getRevision($revision))->getCommit()->getHash(); diff --git a/src/Gitonomy/Git/Submodule.php b/src/Gitonomy/Git/Submodule.php index 92990d2a..754a4556 100644 --- a/src/Gitonomy/Git/Submodule.php +++ b/src/Gitonomy/Git/Submodule.php @@ -31,8 +31,3 @@ public function getHash(): string return $this->hash; } } - -// For the deprecated name, like Symfony renames classes. -if (!class_exists(CommitReference::class, false)) { - class_alias(Submodule::class, CommitReference::class); -} diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index b36ef119..b2a86943 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -75,57 +75,6 @@ public function getTrees(): array return array_filter($this->entries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof self); } - /** - * @return array The mode and the object of each entry, by name - * - * @deprecated since gitonomy/gitlib 1.7, iterate the tree instead - */ - public function getEntries(): array - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, iterate the tree instead.', __METHOD__); - - return self::toArrays($this->entries()); - } - - /** - * @return array The mode and the blob of each blob, by name - * - * @deprecated since gitonomy/gitlib 1.7, use getBlobs() instead - */ - public function getBlobEntries(): array - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getBlobs()" instead.', __METHOD__); - - // @phpstan-ignore return.type (getBlobs() returns blobs only.) - return self::toArrays($this->getBlobs()); - } - - /** - * @return array The mode and the submodule of each submodule, by name - * - * @deprecated since gitonomy/gitlib 1.7, use getSubmodules() instead - */ - public function getCommitReferenceEntries(): array - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getSubmodules()" instead.', __METHOD__); - - // @phpstan-ignore return.type (getSubmodules() returns submodules only.) - return self::toArrays($this->getSubmodules()); - } - - /** - * @return array The mode and the tree of each tree, by name - * - * @deprecated since gitonomy/gitlib 1.7, use getTrees() instead - */ - public function getTreeEntries(): array - { - trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "getTrees()" instead.', __METHOD__); - - // @phpstan-ignore return.type (getTrees() returns trees only.) - return self::toArrays($this->getTrees()); - } - /** * @throws InvalidArgumentException The tree has no entry $name */ @@ -177,16 +126,4 @@ private function entries(): array return $this->entries; } - - /** - * The entries in the format of 1.x. - * - * @param array $entries - * - * @return array - */ - private static function toArrays(array $entries): array - { - return array_map(static fn (TreeEntry $entry): array => [$entry->getMode(), $entry->getObject()], $entries); - } } diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index fc959417..27a59f03 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -75,19 +75,11 @@ public function getDiffStaged(): Diff * a remote branch alone included, like "feature" for "origin/feature": * create its local branch with checkoutNewBranch(). * - * Passing a branch to create as second argument is deprecated. - * * @throws ReferenceNotFoundException $revision names no commit * @throws DriverException The checkout would overwrite local changes */ - public function checkout(Revision|string $revision, ?string $branch = null): static + public function checkout(Revision|string $revision): static { - if (null !== $branch) { - trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing a branch to create to "%s()" is deprecated, use "checkoutNewBranch()" instead.', __METHOD__); - - return $this->checkoutNewBranch($branch, $revision); - } - // A name that no revision has throws, where "git checkout" would create // the branch of a remote one, or discard the changes of a file. ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit(); diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php deleted file mode 100644 index 80701707..00000000 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ /dev/null @@ -1,219 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Tests; - -use Gitonomy\Git\Admin; -use Gitonomy\Git\Exception\DriverException; -use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Reference\Branch; -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; -use Psr\Log\LoggerInterface; - -/** - * Admin is deprecated: its methods must keep behaving like in 1.x, always - * with CliDriver, whatever the driver under test. - */ -#[Group('legacy')] -#[IgnoreDeprecations] -class AdminTest extends AbstractTestCase -{ - public function testInit(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::initBare()" instead.'); - - $repository = Admin::init(self::createTempDir(), options: self::getOptions()); - - $this->assertTrue($repository->isBare()); - } - - public function testInitNotBare(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::init()" instead.'); - - $repository = Admin::init(self::createTempDir(), false, self::getOptions()); - - $this->assertFalse($repository->isBare()); - } - - public function testIsValidRepository(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::isValidRepository()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepository()" instead.'); - - $this->assertTrue(Admin::isValidRepository(self::repositoryUrl(), self::getOptions())); - } - - public function testIsValidRepositoryAndBranch(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::isValidRepositoryAndBranch()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepositoryAndBranch()" instead.'); - - $this->assertTrue(Admin::isValidRepositoryAndBranch(self::repositoryUrl(), 'new-feature', self::getOptions())); - $this->assertFalse(Admin::isValidRepositoryAndBranch(self::repositoryUrl(), 'unknown', self::getOptions())); - } - - public function testCloneTo(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" instead, which takes the URL before the path.'); - - $repository = Admin::cloneTo(self::createTempDir(), self::repositoryUrl(), options: self::getOptions()); - - $this->assertTrue($repository->isBare()); - $this->assertContains('refs/heads/new-feature', array_keys($repository->getReferences()->getAll())); - } - - public function testCloneToNotBare(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" instead, which takes the URL before the path.'); - - $repository = Admin::cloneTo(self::createTempDir(), self::repositoryUrl(), false, self::getOptions()); - - $this->assertFalse($repository->isBare()); - } - - public function testCloneBranchTo(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with its "branch" argument instead, which takes the URL before the path.'); - - $repository = Admin::cloneBranchTo(self::createTempDir(), self::repositoryUrl(), 'new-feature', options: self::getOptions()); - - $this->assertTrue($repository->isBare()); - $head = $repository->getHead(); - $this->assertInstanceOf(Branch::class, $head); - $this->assertSame('new-feature', $head->getName()); - } - - public function testCloneBranchToNotBare(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with its "branch" argument instead, which takes the URL before the path.'); - - $repository = Admin::cloneBranchTo(self::createTempDir(), self::repositoryUrl(), 'new-feature', false, self::getOptions()); - - $this->assertFalse($repository->isBare()); - $head = $repository->getHead(); - $this->assertInstanceOf(Branch::class, $head); - $this->assertSame('new-feature', $head->getName()); - } - - public function testMirrorTo(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::mirrorTo()" method is deprecated, use "Gitonomy\Git\Repository::mirrorFrom()" instead, which takes the URL before the path.'); - - $repository = Admin::mirrorTo(self::createTempDir(), self::repositoryUrl(), self::getOptions()); - - $this->assertSame('true', trim($repository->run('config', ['remote.origin.mirror']))); - } - - public function testCloneRepositoryPassesItsArgumentsToGit(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Admin::cloneRepository()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()", "Gitonomy\Git\Repository::cloneBareFrom()" or "Gitonomy\Git\Repository::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".'); - - // --config is the only argument --bare accepts on every git version. - $repository = Admin::cloneRepository(self::createTempDir(), self::repositoryUrl(), ['--bare', '--config', 'gitlib.test=yes'], self::getOptions()); - - $this->assertTrue($repository->isBare()); - $this->assertSame('yes', trim($repository->run('config', ['gitlib.test']))); - } - - public function testOptionsConfigureGitAndTheRepository(): void - { - $logger = $this->createStub(LoggerInterface::class); - - $repository = Admin::init(self::createTempDir(), options: [...self::getOptions(), 'logger' => $logger, 'process_timeout' => 42]); - - $this->assertSame($logger, $repository->getLogger()); - $this->assertSame(42.0, $repository->runProcess('config', ['core.bare'])->getTimeout()); - } - - public function testCommandOptionRunsAnotherGitBinary(): void - { - $this->expectException(DriverException::class); - $this->expectExceptionMessage('The git command "init" could not start: '); - - Admin::init(self::createTempDir(), options: ['command' => 'not-git']); - } - - public function testInheritEnvironmentVariablesOnlyAppliesToTheRepository(): void - { - if (\defined('PHP_WINDOWS_VERSION_MAJOR')) { - $this->markTestSkipped('The fake git binary is a shell script.'); - } - - // A git binary logging whether it gets SECRET. - $dir = self::createTempDir(); - file_put_contents($dir.'/git', \sprintf("#!/bin/sh\necho \"\${GITLIB_TEST_SECRET:-none}\" >> %s\nexec %s \"\$@\"\n", escapeshellarg($dir.'/log'), escapeshellarg(self::gitCommand()))); - chmod($dir.'/git', 0o755); - // Like the credentials of an HTTP request, in $_SERVER only. - $_SERVER['GITLIB_TEST_SECRET'] = 'secret'; - - try { - $repository = Admin::init($dir.'/repository', options: [...self::getOptions(), 'command' => $dir.'/git', 'inherit_environment_variables' => true]); - $repository->run('config', ['core.bare']); - } finally { - unset($_SERVER['GITLIB_TEST_SECRET']); - } - - // Like in 1.x, git init doesn't get $_SERVER, the git processes of the repository do. - $this->assertSame("none\nsecret\n", file_get_contents($dir.'/log'), 'git init, then git config'); - } - - public function testRemovedOptionsAreRefused(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The "debug" option set to false is not supported anymore: a failing git operation always throws, catch its exception instead.'); - - Admin::init(self::createTempDir(), options: ['debug' => false]); - } - - public function testRepositoryCloneTo(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with "getGitDir()" as URL instead.'); - - $repository = self::createFoobarRepository(); - $new = $repository->cloneTo(self::createTempDir()); - - $this->assertTrue($new->isBare()); - $this->assertSame($repository->getHeadCommit()->getHash(), $new->getHeadCommit()->getHash()); - } - - public function testRepositoryCloneToNotBare(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with "getGitDir()" as URL instead.'); - - $repository = self::createFoobarRepository(); - $new = $repository->cloneTo(self::createTempDir(), false); - - $this->assertFalse($new->isBare()); - $this->assertSame($repository->getHeadCommit()->getHash(), $new->getHeadCommit()->getHash()); - } - - public function testRepositoryCloneToWithGitOptions(): void - { - $repository = self::createFoobarRepository(); - $new = $repository->cloneTo(self::createTempDir(), options: [...self::getOptions(), 'process_timeout' => 42]); - - $this->assertSame(42.0, $new->runProcess('config', ['core.bare'])->getTimeout()); - } - - /** - * The options of 1.x running the git binary of the tests. - * - * @return array{command: string, environment_variables: array} - */ - private static function getOptions(): array - { - return [ - 'command' => self::gitCommand(), - 'environment_variables' => self::gitEnvs(), - ]; - } -} diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index 9d71544e..b6ddf22b 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -18,8 +18,6 @@ use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; class BlameTest extends AbstractTestCase { @@ -148,22 +146,4 @@ public function testBlameOfAPathNormalizesIt(): void $this->assertSame($lines('README.md'), $lines('./README.md')); $this->assertSame($lines('README.md'), $lines('dir/../README.md')); } - - #[DataProvider('provideBare')] - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyBlameLineRange(bool $bare): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing a range of lines as a string to "Gitonomy\Git\Repository::getBlame()" is deprecated, pass the first and the last lines instead.'); - - $repository = self::createFoobarRepository($bare); - - foreach (['3,+3', '3,5'] as $lineRange) { - $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', $lineRange); - - $this->assertSame([3, 4, 5], array_keys($blame->getLines()), $lineRange); - $this->assertSame('3', $blame->getLine(3)->getLine(), $lineRange); - } - $this->assertSame(self::INITIAL_COMMIT, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getGroupedLines()[0][0]->getHash()); - } } diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index e0174d98..a37fcee3 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -15,14 +15,11 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\Commit; use Gitonomy\Git\Diff\File; -use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use Symfony\Component\Process\Process; class CommitTest extends AbstractTestCase @@ -321,38 +318,6 @@ public function testGetSignedMessage(bool $bare): void $this->assertSame('signed commit'."\n", $commit->getMessage()); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetFixedShortHash(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Commit::getFixedShortHash()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.'); - - $this->assertSame(substr(self::LONGFILE_COMMIT, 0, 6), self::createFoobarRepository()->getCommit(self::LONGFILE_COMMIT)->getFixedShortHash()); - } - - #[DataProvider('provideBare')] - #[Group('legacy')] - #[IgnoreDeprecations] - public function testGetShortMessage(bool $bare): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Commit::getShortMessage()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".'); - - $repository = self::createFoobarRepository($bare); - // tests with a multi-line message - $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); - - $this->assertSame('Fixed perm...', $commit->getShortMessage(10)); - $this->assertSame('Fixed perm!!!', $commit->getShortMessage(10, false, '!!!')); - $this->assertSame('Fixed permissions!!!', $commit->getShortMessage(10, true, '!!!')); - - // tests with a single-line message - $commit = $repository->getCommit(self::INITIAL_COMMIT); - - $this->assertSame('Add README', $commit->getShortMessage(20)); - $this->assertSame('A', $commit->getShortMessage(1, false, '')); - $this->assertSame('Add!!!', $commit->getShortMessage(1, true, '!!!')); - } - public function testSubjectIsTheFirstParagraph(): void { $repository = self::createFoobarRepository(); @@ -378,20 +343,6 @@ public function testGetBodyMessage(bool $bare): void $this->assertSame('', $commit->getBodyMessage()); } - #[DataProvider('provideBare')] - #[Group('legacy')] - #[IgnoreDeprecations] - public function testGetIncludingBranchesException(bool $bare): void - { - $repository = self::createFoobarRepository($bare); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Keep the local branches, the remote ones, or both: $local and $remote can\'t both be false.'); - - $commit = $repository->getCommit(self::INITIAL_COMMIT); - - $commit->getIncludingBranches(false, false); - } - public function testTheLastModificationOfAPathNoCommitChanges(): void { $this->expectException(ReferenceNotFoundException::class); @@ -409,20 +360,6 @@ public function testGetIncludingBranches(bool $bare): void $this->assertSame($repository->getReferences()->getBranches(), $commit->getIncludingBranches()); } - #[DataProvider('provideBare')] - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetIncludingLocalOrRemoteBranches(bool $bare): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing arguments to "Gitonomy\Git\Commit::getIncludingBranches()" is deprecated, filter the branches it returns with "Gitonomy\Git\Reference\Branch::isLocal()" or "isRemote()" instead.'); - - $repository = self::createFoobarRepository($bare); - $commit = $repository->getCommit(self::INITIAL_COMMIT); - - $this->assertSame($repository->getReferences()->getLocalBranches(), $commit->getIncludingBranches(true, false)); - $this->assertSame($repository->getReferences()->getRemoteBranches(), $commit->getIncludingBranches(false, true)); - } - public function testGetIncludingBranchesNamedLikeRemoteOnes(): void { $repository = self::createFoobarRepository(); diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 5cb585f6..fab81f49 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -24,8 +24,6 @@ use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; class DiffTest extends AbstractTestCase { @@ -438,52 +436,6 @@ public function testParseQuotedNames(): void $this->assertSame("caf\u{e9}\t.txt", $files[0]->getName()); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetDiffOfARange(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Calling "Gitonomy\Git\Repository::getDiff()" with one argument is deprecated, pass the revisions to compare as "from" and "to" arguments, or use "Gitonomy\Git\WorkingCopy::getDiff()" to compare a revision with the working tree.'); - - $repository = self::createFoobarRepository(); - - $this->assertSame($repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(), $repository->getDiff(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT)->toArray()); - $this->assertSame($repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(), $repository->getDiff([self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT])->toArray()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetLines(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\FileChange::getLines()" method is deprecated, use "getDiffLines()" instead.'); - - $change = new FileChange(1, 1, 1, 2, [new DiffLine(DiffLineType::Context, 'kept'), new DiffLine(DiffLineType::Addition, 'added')]); - - $this->assertSame([[FileChange::LINE_CONTEXT, 'kept'], [FileChange::LINE_ADD, 'added']], $change->getLines()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyConstructorWithLinesAsArrays(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing the lines to "Gitonomy\Git\Diff\FileChange::__construct()" as arrays is deprecated, pass "Gitonomy\Git\Diff\DiffLine" objects instead.'); - - $change = new FileChange(1, 1, 1, 2, [[FileChange::LINE_CONTEXT, 'kept'], new DiffLine(DiffLineType::Addition, 'added')]); - - $this->assertSame([[DiffLineType::Context, 'kept'], [DiffLineType::Addition, 'added']], array_map(static fn (DiffLine $line): array => [$line->getType(), $line->getContent()], $change->getDiffLines())); - $this->assertSame(1, $change->getAdditions()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyConstructorRefusesAnUnknownTypeOfLine(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing the lines to "Gitonomy\Git\Diff\FileChange::__construct()" as arrays is deprecated, pass "Gitonomy\Git\Diff\DiffLine" objects instead.'); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown type of line 2: the types are 1 (an addition), 0 (a context line) and -1 (a deletion).'); - - new FileChange(1, 1, 1, 1, [[2, 'line']]); - } - public function testFromArrayRefusesAnUnknownTypeOfLine(): void { $this->expectException(InvalidArgumentException::class); @@ -524,83 +476,6 @@ public function testGetBlobWithoutRepositoryThrows(): void $file->getOldBlob(); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacySetRepository(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); - - $repository = self::createFoobarRepository(); - $file = new File('README.md', 'README.md', '100644', '100644', null, null, false); - $file->setRepository($repository); - - $this->assertSame($repository, $file->getRepository()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyDiffSetRepository(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\Diff::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); - - $repository = self::createFoobarRepository(); - $diff = Diff::parse(":100644 100644 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 M\tREADME.md\n"); - $diff->setRepository($repository); - - $this->assertSame($repository, $diff->getFiles()[0]->getRepository()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyAddChange(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::addChange()" method is deprecated, give the changes to the constructor instead.'); - - $file = new File('README.md', 'README.md', '100644', '100644', null, null, false); - $change = new FileChange(1, 1, 1, 1, [new DiffLine(DiffLineType::Addition, 'added')]); - $file->addChange($change); - - $this->assertSame([$change], $file->getChanges()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyIsChangeMode(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::isChangeMode()" method is deprecated, use "isModeChange()" instead.'); - - $this->assertTrue(new File('run.sh', 'run.sh', '100644', '100755', null, null, false)->isChangeMode()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyFileIsDelete(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::isDelete()" method is deprecated, use "isDeletion()" instead.'); - - $this->assertTrue(new File('README.md', null, '100644', null, null, null, false)->isDelete()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetAnchor(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\File::getAnchor()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.'); - - $this->assertSame('222222222222', new File('README.md', 'README.md', '100644', '100644', str_repeat('1', 40), str_repeat('2', 40), false)->getAnchor()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetCount(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Diff\FileChange::getCount()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.'); - - $change = new FileChange(1, 1, 1, 2, [new DiffLine(DiffLineType::Context, 'kept'), new DiffLine(DiffLineType::Addition, 'added')]); - - $this->assertSame(1, $change->getCount(FileChange::LINE_ADD)); - } - public function testMnemonicPrefix(): void { // With `diff.mnemonicPrefix` enabled, git replaces the default "a/" and "b/" diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 045bd33b..993b53db 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -23,8 +23,6 @@ use Gitonomy\Git\Log; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\Attributes\TestWith; class LogTest extends AbstractTestCase @@ -335,46 +333,6 @@ public function testWithersReturnAnotherLog(bool $bare): void $this->assertSame(\array_slice(self::hashes($log), 0, 2), self::hashes($limited)); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacySetOffset(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Log::setOffset()" method is deprecated, use "withOffset()" instead.'); - - $log = self::createFoobarRepository()->getLog(); - $log->setOffset(2); - - $this->assertSame(2, $log->getOffset()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacySetLimit(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Log::setLimit()" method is deprecated, use "withLimit()" instead.'); - - $log = self::createFoobarRepository()->getLog(self::LONGFILE_COMMIT); - - $this->assertSame($log, $log->setLimit(1)); - $this->assertCount(1, $log->getCommits()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetDiffIgnoresThePathsOfTheLog(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Log::getDiff()" method is deprecated, use "Gitonomy\Git\Repository::getDiff()" instead.'); - - $repository = self::createFoobarRepository(); - $diff = $repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(); - $legacyDiff = $repository->getLog(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT, 'README.md')->getDiff(); - - // A log of README.md, but the diff of the range, which changes long.php only. - $this->assertSame(['long.php'], array_map(static fn (File $file): string => $file->getName(), $legacyDiff->getFiles())); - $this->assertSame($diff, $legacyDiff->toArray()); - $this->assertSame($diff, $repository->getLog([self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT])->getDiff()->toArray()); - } - public function testLogOfExcludedAndSymmetricRevisions(): void { $repository = self::createRepositoryToCommitTo(); diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index 6092b15c..583f533d 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -15,8 +15,6 @@ use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\PushReference; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; class PushReferenceTest extends AbstractTestCase { @@ -49,42 +47,6 @@ public static function provideIsers(): array ]; } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyIsCreate(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::isCreate()" method is deprecated, use "isCreation()" instead.'); - - $this->assertTrue(new PushReference(self::createFoobarRepository(), 'foo', PushReference::ZERO, self::LONGFILE_COMMIT)->isCreate()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyIsDelete(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::isDelete()" method is deprecated, use "isDeletion()" instead.'); - - $this->assertTrue(new PushReference(self::createFoobarRepository(), 'foo', self::LONGFILE_COMMIT, PushReference::ZERO)->isDelete()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetReference(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::getReference()" method is deprecated, use "getFullname()" instead.'); - - $this->assertSame('refs/heads/foo', new PushReference(self::createFoobarRepository(), 'refs/heads/foo', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->getReference()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetRevision(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\PushReference::getRevision()" method is deprecated, use "getRange()" instead.'); - - $this->assertSame(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT, new PushReference(self::createFoobarRepository(), 'refs/heads/foo', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->getRevision()); - } - public function testUnknownBeforeIsAnErrorNotAForcePush(): void { $reference = new PushReference(self::createFoobarRepository(), 'foo', str_repeat('1', 40), self::LONGFILE_COMMIT); diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index d17fc808..c9ab4785 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -19,9 +19,6 @@ use Gitonomy\Git\Reference\Tag; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; -use Symfony\Component\Process\Process; class ReferenceTest extends AbstractTestCase { @@ -280,20 +277,6 @@ public function testIsBranchMergedToMaster(): void $this->assertTrue($master->isMergedInto('foobar-new')); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyIsMergedTo(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Reference\Branch::isMergedTo()" method is deprecated, use "Gitonomy\Git\Reference\Branch::isMergedInto()" instead.'); - - $repository = self::createFoobarRepository(false); - $references = $repository->getReferences(); - $branch = $references->createBranch('foobar-new', 'master'); - - $this->assertTrue($branch->isMergedTo()); - $this->assertFalse($branch->isMergedTo('master', true), 'A local branch is not remote'); - } - #[DataProvider('provideBare')] public function testCreateRefusesAnExistingReference(bool $bare): void { @@ -341,33 +324,6 @@ public function testBranchMoveTo(): void $this->assertSame(self::INITIAL_COMMIT, $branch->getCommitHash(), 'A branch is read once'); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyUpdate(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\ReferenceBag::update()" method is deprecated, use "Gitonomy\Git\Reference\Branch::moveTo()" instead.'); - - $repository = self::createFoobarRepository(); - $references = $repository->getReferences(); - - $references->update(new Branch($repository, 'refs/heads/master', self::INITIAL_COMMIT)); - - $this->assertSame(self::INITIAL_COMMIT, $references->getBranch('master')->getCommitHash()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetReferencesReadsThemAgain(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing an argument to "Gitonomy\Git\Repository::getReferences()" is deprecated, gitlib reads the references again after its writes: open a new Repository to see the writes of other processes.'); - - $repository = self::createFoobarRepository(); - $this->assertFalse($repository->getReferences()->hasBranch('elsewhere')); - new Process([self::gitCommand(), 'branch', 'elsewhere', 'master'], $repository->getGitDir(), self::gitEnvs())->mustRun(); - - $this->assertTrue($repository->getReferences(true)->hasBranch('elsewhere')); - } - public function testBranchesEndingWithHead(): void { $repository = self::createFoobarRepository(); diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 236c4b74..21202642 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -29,8 +29,6 @@ use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; @@ -821,17 +819,6 @@ public function testGetSizeInBytesOfAnUnreadableDirectoryThrows(): void } } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetSizeInKilobytes(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::getSize()" method is deprecated, use "getSizeInBytes()" instead.'); - - $repository = self::createFoobarRepository(); - - $this->assertSame((int) round($repository->getSizeInBytes() / 1000), $repository->getSize()); - } - public function testIsBare(): void { $bare = self::createFoobarRepository(true); @@ -951,128 +938,6 @@ public function testDriverOpensTheRepository(): void $this->assertSame(self::INITIAL_COMMIT, $repository->getReferences()->getBranch('main')->getCommitHash()); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacySetLogger(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::setLogger()" method is deprecated, pass a "Gitonomy\Git\Driver\Cli\CliDriver" configured with the logger as driver argument instead.'); - - $logger = $this->createMock(LoggerInterface::class); - $logger->expects($this->once()) - ->method('info') - ->with('run command: remote "" ') - ; - - $repository = new Repository(self::createFoobarRepository()->getPath(), driver: self::getCliDriver()); - $repository->setLogger($logger); - $repository->run('remote'); - - $this->assertSame($logger, $repository->getLogger()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacySetLoggerRequiresCliDriver(): void - { - $this->expectException(UnsupportedOperationException::class); - - new Repository(self::createTempDir(), driver: $this->createStub(DriverInterface::class))->setLogger($this->createStub(LoggerInterface::class)); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyShell(): void - { - if (\defined('PHP_WINDOWS_VERSION_BUILD')) { - $this->markTestSkipped('The command of git shell is a shell script.'); - } - - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Repository::shell()" method is deprecated, run "git shell" with Symfony Process instead: it ignores the driver.'); - - // git shell runs the commands of ~/git-shell-commands. - $home = self::createTempDir(); - mkdir($home.'/git-shell-commands'); - file_put_contents($home.'/git-shell-commands/record', "#!/bin/sh\n".'echo "$1" > "$HOME/recorded"'."\n"); - chmod($home.'/git-shell-commands/record', 0o700); - $repository = self::createFoobarRepository(); - - $repository->shell('record', ['HOME' => $home]); - - // shell() doesn't wait for git. - for ($i = 0; $i < 100 && !is_file($home.'/recorded'); ++$i) { - usleep(50_000); - } - $this->assertStringEqualsFile($home.'/recorded', $repository->getGitDir()."\n"); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testAnUnknownOptionIsRefused(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown option "foo".'); - - new Repository(self::createTempDir(), ['foo' => 1]); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testUnknownOptionsAreRefused(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown options "foo", "bar".'); - - new Repository(self::createTempDir(), ['foo' => 1, 'bar' => 2]); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyOptionsConfigureACliDriver(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing an array of options to "Gitonomy\Git\Repository::__construct()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.'); - - $logger = $this->createStub(LoggerInterface::class); - $repository = new Repository(self::createFoobarRepository()->getGitDir(), [ - 'command' => self::gitCommand(), - 'logger' => $logger, - 'process_timeout' => 42, - ]); - - $this->assertSame($logger, $repository->getLogger()); - $this->assertSame(42.0, $repository->runProcess('rev-parse', ['HEAD'])->getTimeout()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyOptionsCantBeCombinedWithADriver(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The driver argument can\'t be combined with the "logger" or git options: configure the driver instead.'); - - new Repository(self::createTempDir(), ['process_timeout' => 60], self::getDriver()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyDebugOption(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing an array of options to "Gitonomy\Git\Repository::__construct()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.'); - - $repository = new Repository(self::createFoobarRepository()->getGitDir(), ['debug' => true], self::getDriver()); - - $this->assertSame(self::LONGFILE_COMMIT, $repository->getCommit(self::LONGFILE_COMMIT)->getHash()); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testDebugOptionSetToFalseIsRefused(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The "debug" option set to false is not supported anymore: a failing git operation always throws, catch its exception instead.'); - - new Repository(self::createTempDir(), ['debug' => false]); - } - /** * @return array{string, string} The working tree of the clone, and its git directory */ diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index 2e64b54b..1dab3e00 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -13,14 +13,10 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Blob; -use Gitonomy\Git\CommitReference; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Submodule; use Gitonomy\Git\Tree; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; -use PHPUnit\Framework\Attributes\RunInSeparateProcess; class TreeTest extends AbstractTestCase { @@ -76,36 +72,6 @@ public function testGetSubmodules(bool $bare): void $this->assertSame('160000', $submodules['barbaz']->getMode()); } - /** - * In a process of its own: once Submodule is loaded, it declares the alias, - * whose file isn't loaded anymore. - */ - #[Group('legacy')] - #[IgnoreDeprecations] - #[RunInSeparateProcess] - public function testLegacyCommitReference(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\CommitReference" class is deprecated, use "Gitonomy\Git\Submodule" instead.'); - - $this->assertTrue(class_exists(CommitReference::class)); - } - - #[DataProvider('provideBare')] - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetCommitReferenceEntries(bool $bare): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getCommitReferenceEntries()" method is deprecated, use "getSubmodules()" instead.'); - - $tree = self::createFoobarRepository($bare)->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - - [$mode, $object] = $tree->getCommitReferenceEntries()['barbaz']; - - $this->assertSame('160000', $mode); - // @phpstan-ignore method.impossibleType (PHPStan doesn't follow the alias of the deprecated class.) - $this->assertInstanceOf(CommitReference::class, $object); - } - public function testResolvePathThroughASubmoduleThrows(): void { $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); @@ -160,46 +126,6 @@ public function testGetBlobs(bool $bare): void $this->assertInstanceOf(Blob::class, $blobs['README.md']->getObject()); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetEntries(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getEntries()" method is deprecated, iterate the tree instead.'); - - $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - - $entries = $tree->getEntries(); - - $this->assertSame(array_keys(iterator_to_array($tree)), array_keys($entries)); - $this->assertSame(['100644', $tree->getEntry('README.md')], $entries['README.md']); - $this->assertSame(['160000', $tree->getEntry('barbaz')], $entries['barbaz']); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetBlobEntries(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getBlobEntries()" method is deprecated, use "getBlobs()" instead.'); - - $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - - $blobs = $tree->getBlobEntries(); - - $this->assertSame(array_keys($tree->getBlobs()), array_keys($blobs)); - $this->assertSame(['100644', $tree->getEntry('README.md')], $blobs['README.md']); - } - - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyGetTreeEntries(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: The "Gitonomy\Git\Tree::getTreeEntries()" method is deprecated, use "getTrees()" instead.'); - - $tree = self::createFoobarRepository()->getCommit(self::PATH_RESOLVING_COMMIT)->getTree(); - - $this->assertSame(['test' => ['040000', $tree->getEntry('test')]], $tree->getTreeEntries()); - } - #[DataProvider('provideBare')] public function testResolvePath(bool $bare): void { diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index f5d52c04..77971430 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -21,8 +21,6 @@ use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\Attributes\TestWith; class WorkingCopyTest extends AbstractTestCase @@ -61,18 +59,6 @@ public function testCheckoutOfABranchObjectAttachesHead(): void $this->assertSame('refs/heads/twin', self::headBranch($repository)->getFullname()); } - #[Group('legacy')] - #[IgnoreDeprecations] - public function testLegacyCheckoutOfANewBranch(): void - { - $this->expectUserDeprecationMessage('Since gitonomy/gitlib 1.7: Passing a branch to create to "Gitonomy\Git\WorkingCopy::checkout()" is deprecated, use "checkoutNewBranch()" instead.'); - - $repository = self::createFoobarRepository(false); - $repository->getWorkingCopy()->checkout('origin/new-feature', 'new-feature'); - - $this->assertSame('new-feature', self::headBranch($repository)->getName()); - } - public function testDiffStaged(): void { $repository = self::createFoobarRepository(false);