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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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'] +```