From ec071c4508c7f3defcd33ad5a8544999c05c6383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 10 Nov 2026 11:21:32 +0100 Subject: [PATCH] Fix what the last audit of 2.0 found Before the tag freezes the API: the parsers are internal, and GitExceptionInterface extends Throwable. --- .gitattributes | 3 +- .github/SECURITY.md | 2 +- .github/workflows/docs.yml | 10 +- .github/workflows/tests.yml | 79 ++++- CHANGELOG.md | 60 +++- UPGRADE-2.0.md | 56 ++-- doc/admin.md | 10 +- doc/diff.md | 2 + doc/references.md | 3 +- doc/repository.md | 78 +++-- doc/tree.md | 5 +- doc/workingcopy.md | 4 +- src/Gitonomy/Git/Blob.php | 6 +- src/Gitonomy/Git/Commit.php | 4 +- src/Gitonomy/Git/Diff/FileChange.php | 8 + src/Gitonomy/Git/Driver/Cli/CliDriver.php | 60 ++-- .../Git/Driver/Cli/CliRepositoryDriver.php | 111 +++++-- src/Gitonomy/Git/Driver/Cli/GitRunner.php | 21 +- .../Git/Driver/Cli/Parser/LogParser.php | 30 +- .../Git/Driver/Cli/Parser/TagParser.php | 8 +- .../Git/Driver/Cli/Parser/TreeParser.php | 6 + src/Gitonomy/Git/Driver/DriverInterface.php | 5 +- src/Gitonomy/Git/Driver/Ffi/LibGit2.php | 41 ++- src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 161 ++++++++-- .../Driver/Ffi/LibGit2RepositoryDriver.php | 304 ++++++++++++++++-- src/Gitonomy/Git/Driver/Ffi/PathFilter.php | 6 + src/Gitonomy/Git/Driver/Ffi/libgit2.h | 4 + .../Git/Driver/RepositoryDriverInterface.php | 6 +- src/Gitonomy/Git/Driver/RepositoryPaths.php | 21 ++ .../Git/Exception/GitExceptionInterface.php | 2 +- src/Gitonomy/Git/Hooks.php | 15 +- src/Gitonomy/Git/Log.php | 4 +- src/Gitonomy/Git/Parser/DiffParser.php | 10 + src/Gitonomy/Git/Parser/ParserBase.php | 3 + src/Gitonomy/Git/ReferenceBag.php | 10 +- src/Gitonomy/Git/Repository.php | 2 +- src/Gitonomy/Git/Tree.php | 14 +- src/Gitonomy/Git/Util/Credentials.php | 61 +++- src/Gitonomy/Git/Util/RemoteUrl.php | 9 +- src/Gitonomy/Git/WorkingCopy.php | 6 +- test-git-versions.php | 3 +- tests/Gitonomy/Git/Tests/AbstractTestCase.php | 33 +- tests/Gitonomy/Git/Tests/BlameTest.php | 20 +- tests/Gitonomy/Git/Tests/BootstrapTest.php | 38 ++- tests/Gitonomy/Git/Tests/CommitTest.php | 123 ++++++- tests/Gitonomy/Git/Tests/CredentialsTest.php | 214 +++++++++++- tests/Gitonomy/Git/Tests/DiffTest.php | 89 +++++ .../Git/Tests/DriverArgumentsTest.php | 253 +++++++++++++++ tests/Gitonomy/Git/Tests/DriverParityTest.php | 19 ++ tests/Gitonomy/Git/Tests/HooksTest.php | 5 + .../Gitonomy/Git/Tests/LibGit2DriverTest.php | 81 +++++ tests/Gitonomy/Git/Tests/LogTest.php | 41 ++- tests/Gitonomy/Git/Tests/NulByteTest.php | 10 + .../Gitonomy/Git/Tests/PushReferenceTest.php | 21 +- tests/Gitonomy/Git/Tests/ReferenceBagTest.php | 34 ++ tests/Gitonomy/Git/Tests/ReferenceTest.php | 80 +++++ tests/Gitonomy/Git/Tests/RemoteUrlTest.php | 76 +++-- tests/Gitonomy/Git/Tests/RepositoryTest.php | 189 ++++++++++- tests/Gitonomy/Git/Tests/RevisionTest.php | 66 ++++ tests/Gitonomy/Git/Tests/TreeTest.php | 55 ++++ tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 300 ++++++++++++++++- tests/bootstrap.php | 22 ++ 62 files changed, 2733 insertions(+), 289 deletions(-) create mode 100644 tests/Gitonomy/Git/Tests/DriverArgumentsTest.php diff --git a/.gitattributes b/.gitattributes index e39bddbb..f643fd3f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ -* text=auto +# With LF on Windows too: the tests hold the outputs of git they parse. +* text=auto eol=lf /tests/fixtures/*.bundle binary # Left out of the archive Composer installs: only src/, the docs and the license ship. diff --git a/.github/SECURITY.md b/.github/SECURITY.md index eaf2ad22..7a2a16f6 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -Only 2.x gets security fixes: 1.x is no longer maintained. 1.7.1 has the +Only 2.x gets security fixes: 1.x is no longer maintained. 1.7.2 has the security fixes of 2.0 that break nothing of 1.x, and [its changelog](https://github.com/gitonomy/gitlib/blob/1.x/CHANGELOG.md) lists the ones it doesn't have. To upgrade, see diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 14513081..5b91b07c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,13 +28,13 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout Code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # The site is published: keep the token out of the workspace. persist-credentials: false - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: '3.14' cache: pip @@ -52,7 +52,7 @@ jobs: - name: Upload the site if: github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: site @@ -69,8 +69,8 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - name: Setup Pages - uses: actions/configure-pages@v6 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c25cb5a1..60f90706 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,9 @@ on: push: branches: [ main ] pull_request: + schedule: + # The dependencies aren't locked: a release of one of them may break the tests of an unchanged main. + - cron: '23 5 * * 1' permissions: contents: read @@ -14,7 +17,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout Code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Verify fixture bundle integrity, refs and size run: tests/fixtures/verify-bundle.sh @@ -24,7 +27,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout Code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup PHP uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 @@ -42,7 +45,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout Code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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) @@ -52,7 +55,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout Code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup PHP uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 @@ -71,6 +74,8 @@ jobs: tests: name: Test PHP ${{ matrix.php }} ${{ matrix.name }} runs-on: ubuntu-24.04 + # The next version of PHP may fail, without failing the workflow. + continue-on-error: ${{ matrix.php == 'nightly' }} strategy: fail-fast: false matrix: @@ -81,10 +86,18 @@ jobs: - php: '8.4' composer-flags: '--prefer-lowest' name: '(prefer lowest dependencies)' + # Between 6.4, of the lowest dependencies, and 8.x, of the other jobs. + - php: '8.4' + composer-flags: '--with=symfony/process:^7.4.5' + name: '(symfony/process 7.4)' + - php: 'nightly' + # The dependencies may not allow this version of PHP yet. + composer-flags: '--ignore-platform-req=php+' + name: '(allowed to fail)' steps: - name: Checkout Code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup PHP uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 @@ -116,17 +129,20 @@ jobs: sha256: '5e0efe7cd30e258bc3be9ae023790743974e76eb1b24ecac24de6fc2af2b2786' - git: '2.25.1' sha256: 'aaef0cd609a7730cfbae530d7c91214a941d7e26054d2314cc99794880156b33' + env: + GIT_CFLAGS: '-std=gnu17 -O2' + GIT_MAKE_FLAGS: 'NO_OPENSSL=1 NO_CURL=1 NO_EXPAT=1 NO_GETTEXT=1 NO_PERL=1 NO_PYTHON=1 NO_TCLTK=1' steps: - name: Checkout Code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Cache git id: cache-git - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/git - key: git-${{ matrix.git }}-ubuntu-24.04 + key: git-${{ matrix.git }}-${{ env.GIT_CFLAGS }}-${{ env.GIT_MAKE_FLAGS }}-ubuntu-24.04 - name: Build git if: steps.cache-git.outputs.cache-hit != 'true' @@ -134,7 +150,7 @@ jobs: 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 + make -C "git-${{ matrix.git }}" prefix="$HOME/git" CFLAGS="$GIT_CFLAGS" $GIT_MAKE_FLAGS --jobs="$(nproc)" install - name: Setup PHP uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 @@ -151,12 +167,18 @@ jobs: run: GIT_COMMAND="$HOME/git/bin/git" vendor/bin/phpunit tests-ffi: - name: Test PHP ${{ matrix.php }} (LibGit2Driver) + name: Test PHP ${{ matrix.php }} (LibGit2Driver${{ matrix.name }}) runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: php: ['8.4', '8.5'] + composer-flags: [''] + name: [''] + include: + - php: '8.4' + composer-flags: '--prefer-lowest' + name: ', prefer lowest dependencies' env: # LibGit2Driver only supports libgit2 1.9, which Ubuntu 24.04 doesn't ship. LIBGIT2_VERSION: '1.9.1' @@ -165,11 +187,11 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Cache libgit2 id: cache-libgit2 - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/libgit2 key: libgit2-${{ env.LIBGIT2_VERSION }}-${{ env.LIBGIT2_CMAKE_FLAGS }}-ubuntu-24.04 @@ -194,9 +216,40 @@ jobs: run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - name: Install Composer dependencies - run: composer update --prefer-dist --no-interaction + run: composer update --prefer-dist --no-interaction ${{ matrix.composer-flags }} - name: Execute PHPUnit run: LD_LIBRARY_PATH="$HOME/libgit2/lib" vendor/bin/phpunit env: GITLIB_DRIVER: ffi + + tests-os: + name: Test ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ['macos-latest', 'windows-latest'] + defaults: + run: + shell: bash + + steps: + - name: Checkout Code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup PHP + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.4' + # Not all enabled by default on Windows. + extensions: fileinfo, zlib + + - 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: php vendor/bin/phpunit diff --git a/CHANGELOG.md b/CHANGELOG.md index 11570c08..65318c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,9 @@ the deprecations of 1.7 first, then see [UPGRADE-2.0.md](UPGRADE-2.0.md). - Return the dates of commits and tags at the UTC offset of their author, committer or tagger, like `git log` shows them, instead of in UTC: the instant doesn't change, but `format()` returns their local time. ([#254](https://github.com/gitonomy/gitlib/pull/254)) -- Require PHP 8.4 or later, Symfony 7.4/8.1+: most classes are `final` (some `readonly`), - and every method has native types. ([#238](https://github.com/gitonomy/gitlib/pull/238)) +- Require PHP 8.4 or later, and `symfony/process` `^6.4.33`, `^7.4.5` or `^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 @@ -53,7 +54,8 @@ the deprecations of 1.7 first, then see [UPGRADE-2.0.md](UPGRADE-2.0.md). with both drivers: the name of a remote branch alone, like `feature` for `origin/feature`, created the local branch. Use `checkoutNewBranch()`. ([#254](https://github.com/gitonomy/gitlib/pull/254)) -- Remove the API that 1.7 deprecates; see [UPGRADE-2.0.md](UPGRADE-2.0.md): +- Remove the API that 1.7 deprecates + ([#271](https://github.com/gitonomy/gitlib/pull/271)); see [UPGRADE-2.0.md](UPGRADE-2.0.md): - `Admin`, replaced by the static methods of `Repository`, and `Repository::cloneTo()`, replaced by `cloneFrom()` and `cloneBareFrom()`. - The array of options of `Repository` (`working_dir`, `logger`, `command`, @@ -79,6 +81,15 @@ the deprecations of 1.7 first, then see [UPGRADE-2.0.md](UPGRADE-2.0.md). - `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, `PushReference::getReference()`, `getRevision()`, `isCreate()` and `isDelete()`. - `Repository::getSize()`, in kilobytes: use `getSizeInBytes()`. +- Make `Parser\DiffParser` and `Parser\ParserBase` internal, like the other parsers: parse a + diff with `Diff::parse()`. + ([#274](https://github.com/gitonomy/gitlib/pull/274)) +- `Log::getIterator()` returns a `\Traversable` instead of an `\ArrayIterator`: iterate the + log, or use `getCommits()`. + ([#274](https://github.com/gitonomy/gitlib/pull/274)) +- Run git in English with `CliDriver` (`LANGUAGE=C`), unless its `environmentVariables` set + `LANGUAGE`: gitlib reads messages of git, see the bug fixes. + ([#274](https://github.com/gitonomy/gitlib/pull/274)) ### Features @@ -132,7 +143,7 @@ the deprecations of 1.7 first, then see [UPGRADE-2.0.md](UPGRADE-2.0.md). ### Security -1.7.1 has those that break nothing of 1.x: see +1.7.2 has those that break nothing of 1.x: see [its changelog](https://github.com/gitonomy/gitlib/blob/1.x/CHANGELOG.md). - Refuse a revision, a reference, a hash or a URL starting with a dash, which git read as an @@ -149,13 +160,16 @@ the deprecations of 1.7 first, then see [UPGRADE-2.0.md](UPGRADE-2.0.md). ([#254](https://github.com/gitonomy/gitlib/pull/254), [#263](https://github.com/gitonomy/gitlib/pull/263)) - Refuse an ssh URL whose host or user starts with a dash, even encoded (`%2D`), like `ssh://-oProxyCommand=.../repository`: git before 2.14.1 passes it to ssh, which reads it as an - option and runs the command (CVE-2017-1000117). A `git://` URL whose host starts with a dash - is refused too: git passes it to the command of `core.gitProxy`. - ([#254](https://github.com/gitonomy/gitlib/pull/254)) + option and runs the command (CVE-2017-1000117). A `git://` URL whose host or port starts with + a dash is refused too: git passes them to the command of `core.gitProxy`. + ([#254](https://github.com/gitonomy/gitlib/pull/254), + [#274](https://github.com/gitonomy/gitlib/pull/274)) - Hide the credentials of URLs (`https://user:token@host`), a password holding a `#` or a `?` included, in the messages of exceptions, in their traces (`#[\SensitiveParameter]`), and in - the logs. The exceptions of Symfony Process, which hold the command line, no longer leak, not - even as the previous exception. ([#254](https://github.com/gitonomy/gitlib/pull/254)) + the logs, and those git prints without their scheme (`user:token@host`). The exceptions of + Symfony Process, which hold the command line, no longer leak, not even as the previous + exception. ([#254](https://github.com/gitonomy/gitlib/pull/254), + [#272](https://github.com/gitonomy/gitlib/pull/272)) - Refuse a hook name that isn't the one of a file of the hooks directory: `Hooks::set('../config', ...)` and `remove('../config')` wrote and deleted the configuration of the repository. ([#254](https://github.com/gitonomy/gitlib/pull/254)) @@ -183,6 +197,26 @@ the deprecations of 1.7 first, then see [UPGRADE-2.0.md](UPGRADE-2.0.md). ### Bug fixes +- Fix a translated git adding a branch to `Commit::getIncludingBranches()` on a detached HEAD, + in Chinese, and hiding the error of git from the message of a `ProcessException`, in French, + German and others: `CliDriver` runs git with `LANGUAGE=C`. + ([#274](https://github.com/gitonomy/gitlib/pull/274)) +- Fix the parents of the commits a shallow clone cuts the history at: `Commit::getParents()` + returned commits the repository doesn't have, and `Commit::getDiff()` threw. They have no + parent, like in `git log`. + ([#274](https://github.com/gitonomy/gitlib/pull/274)) +- Fix a missing git binary taken for a failure of git on Windows: `isValidRepository()` + returned `false`, instead of throwing a `DriverException`. + ([#274](https://github.com/gitonomy/gitlib/pull/274)) +- Fix `WorkingCopy::getUntrackedFiles()` returning the former name of a staged rename. + ([#274](https://github.com/gitonomy/gitlib/pull/274)) +- Fix a hash followed by a new line read as a hash by `Repository::getCommit()`, `getTree()`, + `getBlob()` and `ReferenceBag::resolve()`. + ([#274](https://github.com/gitonomy/gitlib/pull/274)) +- Fix the tags of other tools throwing, with headers after their tagger, or without the empty + line before their message, and the mode of a file in a tree old versions of git wrote, like + `100664`: it is `100644` or `100755`, like `git ls-tree` prints it. + ([#274](https://github.com/gitonomy/gitlib/pull/274)) - Fix `Commit::getIncludingBranches()` and `Repository::getHead()` throwing for a branch another process created or checked out since gitlib read the references, and `getIncludingBranches()` throwing for a regular `refs/remotes/origin/HEAD`, as a mirror leaves @@ -272,6 +306,14 @@ the deprecations of 1.7 first, then see [UPGRADE-2.0.md](UPGRADE-2.0.md). ### Minor +- `GitExceptionInterface` extends `\Throwable`. `ReferenceBag::resolve()`, `resolveBranches()` + and `resolveTags()` take any revision, a branch for instance. `Blob::getMimetype()` throws a + `RuntimeException` without the fileinfo extension, instead of an `Error`. + ([#274](https://github.com/gitonomy/gitlib/pull/274)) +- CI also runs every week, with `symfony/process` 7.4, with the lowest dependencies and + libgit2, on the next version of PHP, which may fail, and on macOS and Windows. The tests call + every method of the drivers with the arguments they must refuse. + ([#274](https://github.com/gitonomy/gitlib/pull/274)) - CI tests git 1.8.4 and 2.25.1, analyses the tests and the libgit2 driver with PHPStan, without baseline, checks the archive Composer installs, pins the third-party actions by commit, and the suite deletes what it creates, in any order. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 2f95c00e..948f7f2d 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -6,7 +6,7 @@ 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)). -2.0 removes the API of 1.x that it replaces. Upgrade to 1.7.1 first, which +2.0 removes the API of 1.x that it replaces. Upgrade to 1.7.2 first, which requires PHP 8.1: it has the replacements, and each call to the API that 2.0 removes triggers a deprecation (`E_USER_DEPRECATED`) naming its replacement. Log the deprecations of your application to find them, and fix @@ -39,7 +39,7 @@ $driver)` with a `CliDriver` of the same arguments, the static methods of `Repository::getDiff($from, $to)`, `FileChange::getDiffLines()`, iterating a `Tree`, `Log::withLimit()`, `Branch::isMergedInto()`, `Blame::getHunks()`... Fix the deprecations of 1.7, then require -`^1.7.1 || ^2.0`: a library supporting PHP 8.1 to 8.3 gets 1.7 there, and 2.0 +`^1.7.2 || ^2.0`: a library supporting PHP 8.1 to 8.3 gets 1.7 there, and 2.0 from PHP 8.4, with the same code. Before: @@ -98,6 +98,9 @@ Opening a repository `Gitonomy\Git\Driver\Cli\CliDriver`, which `Repository` takes as its last argument, and its `debug` option is removed: a failing git operation always throws, see [Errors](#errors). +* The driver can't be `null`, for `Repository` nor for its static + methods, which 1.7 accepts without a deprecation: leave the argument + out, or pass `$driver ?? new CliDriver()`, to get a `CliDriver`. * 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 @@ -277,6 +280,10 @@ History revision throws a `ReferenceNotFoundException` naming it. * `Log::getDiff()` is removed: it ignored the paths of the log. Use `Repository::getDiff()`. +* `Log::getIterator()` returns a `\Traversable` instead of an + `\ArrayIterator`: iterate the log, or use `getCommits()` for an array. +* The commits a shallow clone cuts the history at have no parent, like in + `git log`: `getParents()` returned commits the repository doesn't have. Diffs ----- @@ -291,6 +298,9 @@ Diffs | `$repository->getDiff('a...b')` | `$repository->getDiff($repository->getMergeBase('a', 'b'), 'b')` | | `$repository->getDiff('a')` | `$repository->getWorkingCopy()->getDiff('a')` | + `getMergeBase()` returns `null` when the commits have no common history, + which `getDiff()` doesn't take. + * 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 @@ -361,7 +371,10 @@ Blame 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 removed. + `Line::getLine()` is removed. Its constructor is `new Line($commit, + $number, $originalNumber, $content)`, where 1.x took the original number + first, and the number of the line in its block: `$commit, $sourceLine, + $targetLine, $blockLine, $content`. * `Blame::getGroupedLines()` is removed: `getHunks()` returns `Blame\Hunk` objects. @@ -519,8 +532,9 @@ Errors * `Hooks` throws a `RuntimeException` when a hook exists already, or can't be written, instead of a `LogicException` or a silent failure, and creates the hooks directory. `remove()` of a hook that doesn't exist - throws an `InvalidArgumentException`, like `get()`, still a - `LogicException`. + throws an `InvalidArgumentException`, like `get()`: still a + `\LogicException` of PHP, but no longer a + `Gitonomy\Git\Exception\LogicException`. * 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. @@ -564,6 +578,11 @@ Git configuration Run git yourself to read them. * `CliDriver` ignores `core.fsmonitor`, whose command git runs to read the working tree: `run()` and `runProcess()` too. +* `CliDriver` runs git in English, with `LANGUAGE=C`, `run()` and + `runProcess()` included: gitlib reads messages of git, and a translation + changed the branches of a commit or the message of a `ProcessException`. + To keep a translation, pass it yourself: + `new CliDriver(environmentVariables: ['LANGUAGE' => 'fr'])`. Dates ----- @@ -601,9 +620,7 @@ Extending gitlib `UnexpectedValueException`. * These classes are `final readonly`: `Blame\Line`, `Diff\Diff`, `Diff\File`, `Diff\FileChange`, `Hooks`, `RevisionList`, `WorkingCopy`. -* Every method has native parameter and return types. A class extending - `Parser\ParserBase`, which remains extendable, must declare the same - types, and must not redeclare its properties. +* Every method has native parameter and return types. * The constructors of `Commit`, `Tree`, `Blob`, `Revision`, `Reference`, `Reference\Tag` and `ReferenceBag` are internal: get these objects from `Repository` (`getCommit()`, `getTree()`, `getBlob()`, `getRevision()`, @@ -629,20 +646,15 @@ Extending gitlib instead of an array, and `Commit::setData()` is internal. * `Gitonomy\Git\Util\StringHelper` is removed, with its configurable encoding: use `symfony/string`. Logs are always read in UTF-8. -* The parsers of the git output moved to `Gitonomy\Git\Driver\Cli\Parser`, - and are `final` and internal, used by `CliDriver` only, except - `Parser\DiffParser` and `Parser\ParserBase`. `ParserBase` loses - `consumeGPGSignature()` and `consumeMergeTag()`, and gets - `consumeNameEmailDate()`, `parseSignature()` and `signatureDate()`. - `CommitParser` is merged into `LogParser`, and their results are objects - of `Gitonomy\Git\Driver\Data`: `LogParser::$commits` (instead of `$log`), - `TagParser::$data`, `ReferenceParser::$references`, `TreeParser::$entries` - and `BlameParser::$lines`. `TreeParser` parses `git ls-tree -z`, and - `BlameParser` no longer takes a `Repository`. `ParserBase::parse()` - takes a `string`. -* Objects serialized with 1.x (a `Commit` or a `Diff` in a cache...) can't - be unserialized: clear such caches. `Diff::toArray()` and - `Diff::fromArray()` remain compatible. +* The parsers of the git output are internal, `Parser\DiffParser` and + `Parser\ParserBase` included: parse a diff with `Diff::parse()`. The + others moved to `Gitonomy\Git\Driver\Cli\Parser`, for `CliDriver` only, + and `CommitParser` is merged into `LogParser`. Their results are objects + of `Gitonomy\Git\Driver\Data`. +* A `Commit`, and a `Diff` or a `Diff\File` read from a repository, can't + be serialized anymore, and objects serialized with 1.x (in a cache...) + can't be unserialized: clear such caches, and store `Diff::toArray()`, + which `Diff::fromArray()` reads, as in 1.x. Testing code using gitlib ------------------------- diff --git a/doc/admin.md b/doc/admin.md index 9237bed0..f64f4807 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -112,10 +112,12 @@ A URL that makes git run a command, or use a file of PHP, throws an - an `fd::` URL, like `fd::7`, which makes git talk through a file descriptor it inherits from PHP, like the socket of a database: git waits on it until it times out; -- an ssh URL whose host or user starts with a dash, even encoded (`%2D`), - like `ssh://-oProxyCommand=.../repository` or - `ssh://-oProxyCommand=...@host/repository`: before 2.14.1, git passes it - to ssh, which reads it as an option and runs the command. +- an ssh or git URL whose host, user or port starts with a dash, even + encoded (`%2D`), like `ssh://-oProxyCommand=.../repository`, + `ssh://-oProxyCommand=...@host/repository` or + `git://host:-oProxyCommand=.../repository`: before 2.14.1, git passes it + to ssh, or to the command of `core.gitProxy`, which reads it as an option + and runs the command. The message of an exception hides the credentials of the URL (`https://***@example.com/repository.git`). gitlib doesn't check the URLs diff --git a/doc/diff.md b/doc/diff.md index 55b25113..b1401753 100644 --- a/doc/diff.md +++ b/doc/diff.md @@ -23,6 +23,8 @@ start from their best common ancestor: $diff = $repository->getDiff($repository->getMergeBase('master', 'feature'), 'feature'); ``` +`getMergeBase()` returns `null` when the branches have no common history. + Other diffs ----------- diff --git a/doc/references.md b/doc/references.md index 1d891383..27c9b821 100644 --- a/doc/references.md +++ b/doc/references.md @@ -153,5 +153,6 @@ $tags = $references->resolveTags($commit); // Annotated tags included $all = $references->resolve($commit); ``` -You can pass a `Commit` object, a hash or any revision name. +You can pass a `Commit`, another revision like a `Branch`, a hash or any +revision name. `Commit::resolveReferences()` is a shortcut for `resolve()`. diff --git a/doc/repository.md b/doc/repository.md index de78ef81..47635119 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -228,9 +228,9 @@ or empty data instead. Every exception of gitlib implements byte, which git doesn't accept, a revision, a reference, a hash or a URL starting with a dash, which git would read as an option (`getRevision('--output=/path')`), an `ext::` URL, whose command git - would run, an `fd::` URL, a URL whose ssh host or user starts with a - dash, which ssh would read as an option, or a reference to delete - outside `refs/`, like `HEAD`. + would run, an `fd::` URL, an ssh or git URL whose host, user or port + starts with a dash, which ssh would read as an option, or a reference to + delete outside `refs/`, like `HEAD`. - `RuntimeException`: any other failure, like a hook that exists already (`Hooks::set()`), a file of the git directory that can't be written, or an output of git that gitlib can't read. `DriverException` and @@ -251,8 +251,10 @@ try { The message of an exception hides the credentials of the URLs it holds (`https://***@example.com/repository.git`): `getErrorOutput()` and `getOutput()` of a `ProcessException` return the output of git as it is. -A `git://` URL takes no credentials: git quotes one without its scheme in -its errors, so a password put there isn't hidden. +A `git://` URL takes no credentials, and git prints one without its scheme +in its errors: gitlib still hides a user and a password there +(`***@example.com`), but not a token alone, which nothing tells from the +user of an ssh address, like `git@example.com`. Choosing a driver ----------------- @@ -320,22 +322,41 @@ Differences with `CliDriver`: - `Repository::run()` and `Repository::runProcess()` throw an `UnsupportedOperationException`: there is no `git` process to run. -- Nothing is logged, and nothing times out, not even network operations. +- Nothing is logged, and no operation times out as a whole. A network + operation only fails when the server doesn't accept the connection + within 60 seconds, or neither sends nor takes data for an hour, the + default `processTimeout` of `CliDriver`. - SSH and git's credential helpers aren't supported: clone a private repository through HTTPS, with the credentials in the URL (`https://user:token@example.com/repo.git`). -- Bundles (`.bundle` files) can't be cloned, and repositories storing - their references in the reftable format can't be opened: both throw an - `UnsupportedOperationException`. +- Bundles (`.bundle` files) can't be cloned, repositories storing their + references in the reftable format, or with an extension libgit2 doesn't + know (`extensions.*` in their configuration), can't be opened, and the + working copy of one with a split or a sparse index can't be read: all + throw an `UnsupportedOperationException`. +- A repository another user owns can't be opened, unless `safe.directory` + lists it in the configuration of the user running PHP: `CliDriver` + opens it, see ["Git configuration"](#git-configuration). - The paths of a log don't support the magic of git's pathspecs (`:(exclude)src`, `:/src`...): a path starting with `:` throws an `UnsupportedOperationException`. - Neither hooks nor external filters (Git LFS...) are run: a checkout or a merge writes the files without them. +- In a sparse checkout (`core.sparseCheckout`), `WorkingCopy::checkout()`, + `checkoutNewBranch()` and `merge()` throw an + `UnsupportedOperationException`: libgit2 doesn't read its patterns. +- A checkout or a merge is refused, with a `DriverException` and before it + writes anything, in cases where git goes on: when it changes a tracked + file that is deleted from the working tree, which git writes again; when + it removes a submodule that is checked out, whose directory git keeps; + and when it replaces a directory with a file while the directory holds + files git ignores or empty directories, which recent versions of git + delete. - Like git, `LibGit2Driver` doesn't read `/etc/gitconfig` when the `GIT_CONFIG_NOSYSTEM` environment variable is true, but it doesn't read `/etc/gitattributes` either, which git only skips with - `GIT_ATTR_NOSYSTEM`. + `GIT_ATTR_NOSYSTEM`. It ignores `GIT_CONFIG_GLOBAL`, and reads + `~/.gitconfig` whatever its value. - `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. @@ -344,6 +365,19 @@ Differences with `CliDriver`: working copy. A merge commit is never signed, whatever `commit.gpgSign`, and its message never lists the merged commits, whatever `merge.log`, nor has the output of gpg checking a signed tag. + A merge may also commit another result than git: libgit2 doesn't run the + merge drivers of the configuration (`merge..driver`), doesn't + detect the renames of directories, so a file added to a directory the + other side renamed stays in the old one, and ignores + `branch..mergeoptions`. +- A clone doesn't learn the branch of an empty repository, unless it is a + local one: HEAD is on `init.defaultBranch`, or `master`, where git since + 2.31 is on the branch of the remote. +- A shallow clone can't be read through one of its linked worktrees + (`git worktree add`): libgit2 1.9 doesn't find where its history stops + from there, so its last commit has a parent the repository doesn't + have, and its log throws a `DriverException`. Open its main working + tree instead. - Blame is much slower on a long history: see ["Performance"](#performance). It may attribute some lines to another commit than git, which detects renames and aligns identical lines @@ -451,10 +485,12 @@ The arguments of `CliDriver` are: - `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; + environment variables of PHP. `CliDriver` sets `LANGUAGE` to `C`, unless + you pass it: gitlib reads messages of git, which a translation would + change, so git writes them in English, the output of `run()` included; - `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`; + whole duration and without output, `3600` by default, `null` or `0` for + none. A git process running longer throws a `ProcessTimedOutException`; - `logger`: see ["Logger"](#logger). ```php @@ -499,13 +535,15 @@ credentials and the rewrites of URLs, line endings, hooks, `merge.log`, `blame.ignoreRevsFile`, or `core.abbrev`, the length of short hashes, with `LibGit2Driver` too. -`safe.directory` doesn't apply: `CliDriver` runs git with `--git-dir`, which -git trusts, whoever owns the repository. Like git, gitlib then runs the -commands the configuration of the repository names: its hooks, and the -filters of its `.gitattributes`, when it checks files out, merges, or reads -the working tree. Only open a repository whose `.git` directory you trust: -not one another user of the machine owns, nor one extracted from an archive -your users send. `LibGit2Driver` runs neither hooks nor external filters. +`safe.directory` doesn't apply to `CliDriver`, which runs git with +`--git-dir`: git trusts it, whoever owns the repository. Like git, gitlib +then runs the commands the configuration of the repository names: its hooks, +and the filters of its `.gitattributes`, when it checks files out, merges, +or reads the working tree. Only open a repository whose `.git` directory you +trust: not one another user of the machine owns, nor one extracted from an +archive your users send. `LibGit2Driver` runs neither hooks nor external +filters, and `safe.directory` applies to it: it throws a `DriverException` +for a repository another user owns, unless `safe.directory` lists it. `run()` and `runProcess()` get `branch.autoSetupMerge`, `branch.autoSetupRebase`, `branch.sort`, `core.fsmonitor`, `log.follow`, diff --git a/doc/tree.md b/doc/tree.md index 9a671544..4ed48339 100644 --- a/doc/tree.md +++ b/doc/tree.md @@ -14,14 +14,15 @@ $tree = $commit->getTree(); Entries of a tree ----------------- -Iterating a tree gives its entries, keyed by their name. Each one is a +Iterating a tree gives its entries, keyed by their name: an integer for a +name made of digits only, like `2026`, as in any PHP array. Each one is a `TreeEntry` object, with the name of the entry, its mode, and its object: a `Blob` for a file (see [blob.md](blob.md)), a `Tree` for a folder, or a `Submodule`. ```php foreach ($tree as $name => $entry) { - $entry->getName(); // The name of the file or the folder, the same as $name + $entry->getName(); // The name of the file or the folder, always a string $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 } diff --git a/doc/workingcopy.md b/doc/workingcopy.md index 8e84c3df..686210b0 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -69,7 +69,9 @@ this name too, with a `DriverException`: use its full name, Like `git checkout`, both methods keep the local changes, and refuse to overwrite the ones the checkout would change: they throw a -`DriverException`. *checkoutNewBranch* then creates no branch. +`DriverException`. *checkoutNewBranch* then creates no branch. With +`CliDriver`, a `post-checkout` hook that fails throws one too, once the +branch is created and checked out, like with `git checkout -b`. Merge a revision ---------------- diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index 4d4d4f8e..9c5cfd0c 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -69,10 +69,14 @@ public function getContent(): string /** * @return string The mimetype of the content, like "text/plain" * - * @throws RuntimeException fileinfo can't read the content + * @throws RuntimeException The fileinfo extension is missing, or can't read the content */ public function getMimetype(): string { + if (!\extension_loaded('fileinfo')) { + throw new RuntimeException('The fileinfo extension of PHP is required to read the mimetype of a blob.'); + } + 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 54c05fb7..87c4abf6 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -42,7 +42,7 @@ final class Commit extends Revision */ public function __construct(Repository $repository, string $hash, ?CommitData $data = null) { - if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { + if (!preg_match('/^[a-f0-9]{40}$/D', $hash)) { throw new ReferenceNotFoundException(\sprintf('"%s" is not the hash of a commit: get a commit by its name with getRevision(...)->getCommit().', $hash)); } @@ -141,7 +141,7 @@ public function getLastModification(?string $path = null): self /** * Resolves all references associated to this commit. * - * @return Reference[] An array of references (Branch, Tag, Squash) + * @return Reference[] An array of references (Branch, Tag, Stash) */ public function resolveReferences(): array { diff --git a/src/Gitonomy/Git/Diff/FileChange.php b/src/Gitonomy/Git/Diff/FileChange.php index cab89c02..36b2e70f 100644 --- a/src/Gitonomy/Git/Diff/FileChange.php +++ b/src/Gitonomy/Git/Diff/FileChange.php @@ -28,6 +28,8 @@ /** * @param DiffLine[] $lines + * + * @throws InvalidArgumentException A line isn't a DiffLine, like the arrays 1.x took */ public function __construct( private int $rangeOldStart, @@ -36,6 +38,12 @@ public function __construct( private int $rangeNewCount, array $lines, ) { + foreach ($lines as $line) { + if (!$line instanceof DiffLine) { // @phpstan-ignore instanceof.alwaysTrue (PHP doesn't check the types of the elements, unlike PHPStan.) + throw new InvalidArgumentException(\sprintf('The lines of a change must be "%s" objects, "%s" given.', DiffLine::class, get_debug_type($line))); + } + } + $this->lines = array_values($lines); } diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index 3f1ec458..4cd4f545 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -14,9 +14,11 @@ use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\RepositoryPaths; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Util\RemoteUrl; use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Process; /** * Runs the git binary, through Symfony Process. @@ -26,12 +28,21 @@ final readonly class CliDriver implements DriverInterface { /** - * git inherits the environment variables of PHP, like any process run by Symfony Process. + * A path under a file is never a directory: git finds no repository there. + */ + private const string NO_GIT_DIR = __FILE__.'/none'; + + /** + * git inherits the environment variables of PHP, like any process run by + * Symfony Process. LANGUAGE is "C", unless $environmentVariables has it: + * gitlib reads messages of git, which a translation would change. * * @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 float|null $processTimeout In seconds, for the whole process and without output; null or 0 for none * @param LoggerInterface|null $logger Receives every git command run, with its output + * + * @throws InvalidArgumentException $processTimeout is negative */ public function __construct( private string $command = 'git', @@ -39,6 +50,9 @@ public function __construct( private ?float $processTimeout = 3600, private ?LoggerInterface $logger = null, ) { + if (null !== $processTimeout && $processTimeout < 0) { + throw new InvalidArgumentException(\sprintf('The timeout of git processes can\'t be negative, got %s.', $processTimeout)); + } } public function open(string $dir, ?string $workingDir): CliRepositoryDriver @@ -79,7 +93,7 @@ public function isValidRepository(#[\SensitiveParameter] string $url): bool // ls-remote only takes "--" since git 2.8. RemoteUrl::assertSafe($url); - return $this->runner()->run('ls-remote', [self::absoluteUrl($url)], workingDirectory: self::neutralDirectory())->isSuccessful(); + return $this->lsRemote([$url])->isSuccessful(); } public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch): bool @@ -87,7 +101,7 @@ public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, s RemoteUrl::assertSafe($url); // git lists the branches whose name ends with the pattern: "release" matches "feature/release". - $process = $this->runner()->run('ls-remote', ['--heads', self::absoluteUrl($url), 'refs/heads/'.$branch], workingDirectory: self::neutralDirectory()); + $process = $this->lsRemote(['--heads', $url, 'refs/heads/'.$branch]); if (!$process->isSuccessful()) { return false; } @@ -133,36 +147,16 @@ private function mustRun(string $command, #[\SensitiveParameter] array $args): v } /** - * Out of the working directory of PHP, which may be a repository, like - * the checkout of an application: ls-remote, unlike clone, would read its - * configuration, its rewrites of URLs for instance. git 1.8.4 can't even - * run in a linked worktree. - */ - private static function neutralDirectory(): string - { - return sys_get_temp_dir(); - } - - /** - * Run in a repository, ls-remote reads a relative path as the name of a - * remote first: "origin" would be the URL of the remote of PHP's working - * directory. The path stays relative to it. + * --git-dir keeps git from looking for a repository from the working + * directory of PHP, which may be in one, like the checkout of an + * application: ls-remote, unlike clone, would read its configuration, + * whose rewrites of URLs may run a command, and a relative path as the + * name of one of its remotes. + * + * @param string[] $args Arguments of ``git ls-remote`` */ - private static function absoluteUrl(#[\SensitiveParameter] string $url): string + private function lsRemote(#[\SensitiveParameter] array $args): Process { - 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; + return $this->runner()->run('ls-remote', $args, ['--git-dir', self::NO_GIT_DIR]); } } diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index 7d060dbc..f2321d17 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -22,12 +22,15 @@ use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Driver\Data\TagData; use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\FastForward; use Gitonomy\Git\Parser\DiffParser; use Gitonomy\Git\Util\LeadingDash; +use Gitonomy\Git\Util\NulByte; use Gitonomy\Git\Util\ReferenceName; use Symfony\Component\Process\Process; @@ -117,7 +120,8 @@ public function resolveRevision(string $revision): string { LeadingDash::assertNone($revision); - $process = $this->execute('rev-parse', ['--verify', '--quiet', $revision]); + // git takes a full hash at its word, unless it has to read its object. + $process = $this->execute('rev-parse', ['--verify', '--quiet', self::isFullHash($revision) ? $revision.'^{object}' : $revision]); // --quiet: git exits with 1 when nothing has this name, or when it // names several objects, like the range "a...b", which it prints. @@ -129,22 +133,39 @@ public function resolveRevision(string $revision): string throw new ProcessException($process, 'rev-parse'); } - return trim($process->getOutput()); + $hash = trim($process->getOutput()); + // git prints "^" for "^HEAD", the commits to exclude from a log. + if (!self::isFullHash($hash)) { + throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist.', $revision)); + } + + return $hash; } public function getShortHash(string $hash): string { LeadingDash::assertNone($hash); + NulByte::assertNone($hash); + + // ^{object}: without it, git abbreviates a hash no object has. + $process = $this->execute('rev-parse', ['--verify', '--quiet', '--short', $hash.'^{object}']); + + if (1 === $process->getExitCode()) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" does not exist.', $hash)); + } + + if (!$process->isSuccessful()) { + throw new ProcessException($process, 'rev-parse'); + } - // --: a file of the working tree may have the name of the hash. - return trim($this->runOnObject($hash, null, 'log', ['--abbrev-commit', '--format=%h', '-n', '1', $hash, '--'])); + return trim($process->getOutput()); } public function getCommitData(string $hash): CommitData { LeadingDash::assertNone($hash); - $parser = new LogParser(); + $parser = new LogParser($this->shallowHashes()); $parser->parse($this->runOnObject($hash, ObjectType::Commit, 'log', ['--encoding=UTF-8', '--format=raw', '--no-color', '-n', '1', $hash, '--'])); $commit = $parser->commits[0] ?? null; @@ -172,7 +193,7 @@ public function getCommitLog(?array $includes, array $excludes, array $paths, in $args[] = (string) $limit; } - $parser = new LogParser(); + $parser = new LogParser($this->shallowHashes()); $parser->parse($this->runOnRevisions('log', $args, $includes, $excludes, $paths)); return $parser->commits; @@ -331,9 +352,14 @@ public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int 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); + $entries = explode("\0", $this->run('status', ['--porcelain', '--untracked-files=all', '-z'])); + for ($i = 0; $i < \count($entries); ++$i) { + // "XY ": the path a renamed or copied file had before comes + // next, alone, and may start with "?? " itself. + if (false !== strpbrk(substr($entries[$i], 0, 2), 'CR')) { + ++$i; + } elseif (str_starts_with($entries[$i], '?? ')) { + $files[] = substr($entries[$i], 3); } } @@ -363,8 +389,12 @@ public function checkoutNewBranch(string $branch, string $startPoint): void $this->checkout($branch); } catch (DriverException $e) { // Like "checkout -b", which creates nothing when the checkout - // fails. The configuration of the branch goes with it. - $this->run('branch', ['-D', $branch]); + // fails: the configuration of the branch goes with it. When the + // post-checkout hook is what fails, the branch is checked out: + // "checkout -b" keeps it, and git can't delete it. + if ('refs/heads/'.$branch !== $this->getHeadReference()) { + $this->run('branch', ['-D', $branch]); + } throw $e; } @@ -436,7 +466,9 @@ private function diff(string $command, array $args): DiffData */ private function readObject(string $hash, ObjectType $type): string { - $output = $this->runProcess('cat-file', ['--batch'], $hash."\n")->getOutput(); + LeadingDash::assertNone($hash); + + $output = $this->runProcess('cat-file', ['--batch'], self::inputLine($hash))->getOutput(); // " ", or " missing". $header = explode(' ', (string) strstr($output, "\n", true)); @@ -453,12 +485,12 @@ private function readObject(string $hash, ObjectType $type): string /** * Runs a command reading the object $hash. * - * @param ObjectType|null $type The type $hash must have, null for any - * @param string[] $args + * @param ObjectType $type The type $hash must have + * @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 + private function runOnObject(string $hash, ObjectType $type, string $command, array $args): string { try { return $this->run($command, $args); @@ -468,7 +500,7 @@ private function runOnObject(string $hash, ?ObjectType $type, string $command, a if (!$process->isSuccessful()) { throw new ReferenceNotFoundException(\sprintf('The object "%s" does not exist.', $hash), 0, $e); } - if (null !== $type && $type->value !== trim($process->getOutput())) { + if ($type->value !== trim($process->getOutput())) { throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value), 0, $e); } @@ -492,13 +524,56 @@ private function runOnRevisions(string $command, array $args, ?array $includes, { // A hash per line, "^" excluding it. The standard input takes no option, like "--all". $input = ''; - foreach ([...$includes ?? [], ...array_map(static fn (string $hash): string => '^'.$hash, $excludes)] as $revision) { - $input .= $revision."\n"; + foreach ($includes ?? [] as $hash) { + $input .= self::inputLine($hash); + } + foreach ($excludes as $hash) { + $input .= '^'.self::inputLine($hash); } return $this->runProcess($command, [...$args, ...null === $includes ? ['--all'] : [], '--stdin', '--', ...$paths], $input)->getOutput(); } + /** + * @throws InvalidArgumentException $revision contains a NUL byte or a new line, where git stops reading it, or reads another one + */ + private static function inputLine(string $revision): string + { + NulByte::assertNone($revision); + + if (str_contains($revision, "\n")) { + throw new InvalidArgumentException(\sprintf('"%s" contains a new line, which git doesn\'t accept.', addcslashes($revision, "\n"))); + } + + return $revision."\n"; + } + + /** + * @return array The commits a shallow clone cuts the history at, as keys: their parents are missing + * + * @throws DriverException The "shallow" file can't be read + */ + private function shallowHashes(): array + { + // A linked worktree shares it with its repository. + $file = RepositoryPaths::commonDir($this->gitDir).'/shallow'; + if (!is_file($file)) { + return []; + } + + $hashes = @file($file, \FILE_IGNORE_NEW_LINES | \FILE_SKIP_EMPTY_LINES); + if (false === $hashes) { + throw new DriverException(\sprintf('Unable to read "%s".', $file)); + } + + return array_fill_keys($hashes, true); + } + + private static function isFullHash(string $revision): bool + { + return 1 === preg_match('/^[0-9a-f]{40}$/Di', $revision); + } + /** * @param string[] $args Arguments of ``git branch`` * diff --git a/src/Gitonomy/Git/Driver/Cli/GitRunner.php b/src/Gitonomy/Git/Driver/Cli/GitRunner.php index 7cf1bb59..81f03ec6 100644 --- a/src/Gitonomy/Git/Driver/Cli/GitRunner.php +++ b/src/Gitonomy/Git/Driver/Cli/GitRunner.php @@ -21,6 +21,7 @@ use Symfony\Component\Process\Exception\ProcessStartFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException; use Symfony\Component\Process\Exception\RuntimeException as SymfonyRuntimeException; +use Symfony\Component\Process\ExecutableFinder; use Symfony\Component\Process\Process; /** @@ -32,6 +33,15 @@ */ final readonly class GitRunner { + /** + * gitlib reads messages of git, which a translation changes: the list of + * "git branch", where "(HEAD detached at ...)" may lose its parenthesis, + * and the "fatal:" of the errors. Unlike LC_ALL, LANGUAGE leaves the + * character encoding as it is, for the hooks and the filters of the + * repository too. + */ + private const array ENVIRONMENT_VARIABLES = ['LANGUAGE' => 'C']; + /** * @param array $environmentVariables */ @@ -50,15 +60,15 @@ public function __construct( * @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 InvalidArgumentException The command or an argument contains a NUL byte * @throws DriverException git can't start, or is killed */ public function run(string $command, #[\SensitiveParameter] array $args, array $globalOptions = [], ?string $workingDirectory = null, ?string $input = null): Process { - NulByte::assertNone(...$globalOptions, ...$args); + NulByte::assertNone($command, ...$globalOptions, ...$args); $process = new Process([$this->command, ...$globalOptions, $command, ...$args], $workingDirectory); - $process->setEnv($this->environmentVariables); + $process->setEnv([...self::ENVIRONMENT_VARIABLES, ...$this->environmentVariables]); $process->setInput($input); $process->setTimeout($this->timeout); @@ -102,6 +112,11 @@ public function run(string $command, #[\SensitiveParameter] array $args, array $ throw self::notStarted($command, $process->getErrorOutput()); } + // The shell of Windows exits with 1 instead, like git for a failure of its own. + if ('\\' === \DIRECTORY_SEPARATOR && 1 === $process->getExitCode() && !is_file($this->command) && null === new ExecutableFinder()->find($this->command)) { + throw self::notStarted($command, $process->getErrorOutput()); + } + 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())); diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php index 50116806..f9b0fa26 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php @@ -28,6 +28,14 @@ final class LogParser extends ParserBase */ public array $commits = []; + /** + * @param array $shallowHashes The commits a shallow clone cuts the history at, as keys + */ + public function __construct( + private readonly array $shallowHashes, + ) { + } + protected function doParse(): void { $this->commits = []; @@ -41,6 +49,11 @@ protected function doParse(): void $parentHashes = []; $author = null; $committer = null; + // Like git, which reads the tree then the parents on the first + // lines only: a forged commit may have these headers again below, + // which git ignores. The last author and committer are the ones + // "git log" shows. + $atTop = true; // 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 @@ -53,11 +66,21 @@ protected function doParse(): void $this->consumeNewLine(); } + if ($atTop && 'tree' === $name && null === $treeHash) { + $treeHash = $value; + + continue; + } + if ($atTop && 'parent' === $name && null !== $treeHash) { + $parentHashes[] = $value; + + continue; + } + + $atTop = false; match ($name) { 'author' => $author = self::parseSignature($value), 'committer' => $committer = self::parseSignature($value), - 'parent' => $parentHashes[] = $value, - 'tree' => $treeHash = $value, // encoding, gpgsig, mergetag, and the headers of other tools. default => null, }; @@ -77,7 +100,8 @@ protected function doParse(): void // An empty line separates the commits. $this->expects("\n"); - $this->commits[] = new CommitData($hash, $treeHash, $parentHashes, ...$author, ...$committer, message: $message); + // git reads a shallow commit as a root commit, though it prints its "parent" headers. + $this->commits[] = new CommitData($hash, $treeHash, isset($this->shallowHashes[$hash]) ? [] : $parentHashes, ...$author, ...$committer, message: $message); } } } diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php index ebffed40..902b9d2d 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php @@ -40,7 +40,13 @@ protected function doParse(): void $this->consumeNewLine(); } - $this->consumeNewLine(); + // Like git and libgit2, the message starts after the first empty + // line: the tags of other tools may have more headers, or no message + // at all, not even the empty line. + while (!$this->isFinished() && !$this->expects("\n")) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } $this->data = TagData::fromSignedMessage($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 afc700fd..03982554 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php @@ -48,6 +48,12 @@ protected function doParse(): void } $this->cursor += 20; + // Like "git ls-tree" since 2.17, and libgit2: old versions of git + // wrote other modes for a file, like "100664". + if (str_starts_with($mode, '100')) { + $mode = 0 === (octdec($mode) & 0o100) ? '100644' : '100755'; + } + $type = match ($mode) { '040000' => ObjectType::Tree, // A submodule: the hash is a commit of another repository. diff --git a/src/Gitonomy/Git/Driver/DriverInterface.php b/src/Gitonomy/Git/Driver/DriverInterface.php index dea15a12..e2327013 100644 --- a/src/Gitonomy/Git/Driver/DriverInterface.php +++ b/src/Gitonomy/Git/Driver/DriverInterface.php @@ -34,8 +34,9 @@ * taking one refuses, with an {@see InvalidArgumentException}, a URL git would * misread, before running git. A URL starting with a dash is an option for * git, an "ext::" URL runs its command, an "fd::" URL talks through a file - * descriptor of PHP, and an ssh host or user starting with a dash is an - * option for ssh. + * descriptor of PHP, and the host, the user or the port of an ssh or git + * URL starting with a dash is an option for ssh, or for the command of + * core.gitProxy. * * gitlib may add methods to this interface in a minor version: the promise of * backward compatibility covers the code calling it, not the drivers diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php index 05feed0a..925148b3 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php @@ -31,6 +31,20 @@ final class LibGit2 private const int GIT_CONFIG_LEVEL_SYSTEM = 2; private const int GIT_FEATURE_SHA256 = 1 << 11; private const int GIT_OPT_SET_SEARCH_PATH = 5; + private const int GIT_OPT_SET_SERVER_CONNECT_TIMEOUT = 39; + private const int GIT_OPT_SET_SERVER_TIMEOUT = 41; + + /** + * How long libgit2 waits for a server to accept a connection: the + * system gives up after about 75 seconds anyway. + */ + private const int SERVER_CONNECT_TIMEOUT_IN_MILLISECONDS = 60_000; + + /** + * How long libgit2 waits for a server to send or to take data, which is + * forever by default: the default timeout of a process of CliDriver. + */ + private const int SERVER_TIMEOUT_IN_MILLISECONDS = 3_600_000; /** * The only libgit2 version libgit2.h is written for: libgit2 changes its @@ -98,14 +112,27 @@ public static function preload(?string $libraryPath): void } /** - * @throws DriverException When $rc is a libgit2 error code + * @throws DriverException When $rc is a libgit2 error code + * @throws UnsupportedOperationException When libgit2 doesn't read the format of the repository, or of its index */ public static function check(\FFI $ffi, int $rc, #[\SensitiveParameter] string $context): void { - if ($rc < 0) { - // $context and libgit2 may name a URL holding credentials. - throw new DriverException(Credentials::redact(\sprintf('%s: %s', $context, self::lastErrorMessage($ffi)))); + if ($rc >= 0) { + return; } + + // $context and libgit2 may name a URL holding credentials. + $message = Credentials::redact(\sprintf('%s: %s', $context, self::lastErrorMessage($ffi))); + + // libgit2 has no error code for an extension of the repository it + // doesn't know ("extensions.*" in its configuration), nor for a + // mandatory one of the index, like "link" of a split index or "sdir" + // of a sparse one. + if (1 === preg_match('/: unsupported (?:extension name|mandatory extension)/', $message)) { + throw new UnsupportedOperationException($message.' LibGit2Driver doesn\'t support it; use CliDriver.'); + } + + throw new DriverException($message); } /** @@ -231,6 +258,12 @@ private static function initialize(\FFI $ffi): void if (filter_var(getenv('GIT_CONFIG_NOSYSTEM'), \FILTER_VALIDATE_BOOL) && $ffi->git_libgit2_opts(self::GIT_OPT_SET_SEARCH_PATH, self::GIT_CONFIG_LEVEL_SYSTEM, '') < 0) { throw new UnsupportedOperationException(\sprintf('Unable to ignore the system configuration: %s', self::lastErrorMessage($ffi))); } + + // A server that keeps a connection open without answering would + // block PHP forever, whatever max_execution_time. libgit2 only + // refuses a negative timeout. + $ffi->git_libgit2_opts(self::GIT_OPT_SET_SERVER_CONNECT_TIMEOUT, self::SERVER_CONNECT_TIMEOUT_IN_MILLISECONDS); + $ffi->git_libgit2_opts(self::GIT_OPT_SET_SERVER_TIMEOUT, self::SERVER_TIMEOUT_IN_MILLISECONDS); } private static function locate(): string diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index 3d227ccf..d392c838 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -132,6 +132,8 @@ public function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?str try { if (null !== $tag) { $this->checkOutTag($repository, $tag); + } elseif (null === $branch) { + $this->trackUnbornBranch($repository, $url); } } finally { $this->ffi->git_repository_free($repository); @@ -193,21 +195,30 @@ public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, s */ private static function assertNotBundle(#[\SensitiveParameter] string $url): void { - // A local path only, a Windows one included: is_file() would hand any - // other URL to the stream wrapper of its scheme, like the one of - // "ftp://", which connects with the credentials of the URL. A - // "host:path" address isn't local either. - $path = str_starts_with($url, 'file://') ? substr($url, \strlen('file://')) : $url; - if (1 === preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $path) && 1 !== preg_match('#^[a-zA-Z]:[\\\/]#', $path)) { - return; - } + // A local path only: is_file() would hand any other URL to the stream + // wrapper of its scheme, like the one of "ftp://", which connects + // with the credentials of the URL. + $path = self::localPath($url); // The signatures of the formats of bundles. - if (is_file($path) && is_readable($path) && \in_array(file_get_contents($path, length: 16), ["# v2 git bundle\n", "# v3 git bundle\n"], true)) { + if (null !== $path && is_file($path) && is_readable($path) && \in_array(file_get_contents($path, length: 16), ["# v2 git bundle\n", "# v3 git bundle\n"], true)) { throw new UnsupportedOperationException(\sprintf('"%s" is a bundle, which LibGit2Driver doesn\'t support; use CliDriver.', $url)); } } + /** + * @return string|null The path $url names on this machine, a Windows one included, or null for the URL of another transport, or a "host:path" address + */ + private static function localPath(#[\SensitiveParameter] string $url): ?string + { + $path = str_starts_with($url, 'file://') ? substr($url, \strlen('file://')) : $url; + if (1 === preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $path) && 1 !== preg_match('#^[a-zA-Z]:[\\\/]#', $path)) { + return null; + } + + return $path; + } + private function initRepository(string $path, bool $bare): CData { $repository = $this->ffi->new('git_repository*'); @@ -239,7 +250,7 @@ private function fetchInto(string $path, #[\SensitiveParameter] string $url, str } finally { $this->ffi->git_repository_free($repository); } - } catch (DriverException $e) { + } catch (DriverException|UnsupportedOperationException $e) { // Like git, which removes what it created when a clone fails. self::remove($path, $existing); @@ -275,14 +286,7 @@ private function isTagOnly(#[\SensitiveParameter] string $url, string $name): bo */ 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); - } + $branch = $this->headBranch($repository); $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)); @@ -355,20 +359,35 @@ private function fetch(CData $repository, #[\SensitiveParameter] string $url, st $options->update_fetchhead = 0; $refspecBuffer = LibGit2::cString($this->ffi, $refspec); - $refspecs = $this->ffi->new('char*[1]'); + // Like git, the commit of HEAD too, which no branch may have when it is detached: it is written nowhere. + $headBuffer = LibGit2::cString($this->ffi, 'HEAD'); + $refspecs = $this->ffi->new('char*[2]'); $refspecs[0] = $this->ffi->cast('char *', $refspecBuffer); + $refspecs[1] = $this->ffi->cast('char *', $headBuffer); $refspecArray = $this->ffi->new('git_strarray'); $refspecArray->strings = $this->ffi->cast('char **', $refspecs); - $refspecArray->count = 1; + $refspecArray->count = 2; 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); + $detachedHead = null === $head ? $this->remoteHead($remote) : null; } finally { $this->ffi->git_remote_free($remote); } + if (null !== $detachedHead) { + // Like git, HEAD detached where the one of the remote is, at a commit none of its branches points to. + $oid = $this->ffi->new('git_oid'); + LibGit2::check($this->ffi, $this->ffi->git_oid_fromstrn(\FFI::addr($oid), $detachedHead, \strlen($detachedHead)), \sprintf('Invalid hash "%s"', $detachedHead)); + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($repository, \FFI::addr($oid)), 'Unable to detach HEAD'); + + return; + } + if (null === $head) { + $this->setHeadToUnbornBranch($repository, $url); + return; } @@ -424,6 +443,108 @@ private function defaultBranch(CData $remote): ?string } } + /** + * @return string|null The commit HEAD points to on the remote, or null for an empty repository + */ + private function remoteHead(CData $remote): ?string + { + $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), 'Unable to list the references of the remote'); + + // A remote lists HEAD first. + if (0 === $count->cdata || 'HEAD' !== \FFI::string($heads[0]->name)) { + return null; + } + + return LibGit2::oidToHex($this->ffi, \FFI::addr($heads[0]->oid)); + } + + /** + * Like git, points HEAD to the branch the one of an empty repository + * points to. libgit2 only learns it from a local repository: unlike git, + * it doesn't speak the version 2 of the protocol, which tells it. + * + * @return string|null The full name of the branch, or null when $url isn't a local repository + */ + private function setHeadToUnbornBranch(CData $repository, #[\SensitiveParameter] string $url): ?string + { + $path = self::localPath($url); + if (null === $path) { + return null; + } + + $remote = $this->ffi->new('git_repository*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_open(\FFI::addr($remote), $path), \sprintf('Unable to open the repository "%s"', $path)); + + try { + $branch = $this->headBranch($remote); + } finally { + $this->ffi->git_repository_free($remote); + } + + if (null !== $branch) { + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($repository, $branch), 'Unable to update HEAD'); + } + + return $branch; + } + + /** + * git_clone() leaves the clone of an empty repository on the default + * branch of libgit2, tracking the one of this name: like git, on the + * branch of the remote instead. + */ + private function trackUnbornBranch(CData $repository, #[\SensitiveParameter] string $url): void + { + $default = $this->headBranch($repository); + if (null === $default) { + return; + } + + $reference = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($reference), $repository, $default); + // The branch has commits: the remote isn't empty. + if (LibGit2::GIT_ENOTFOUND !== $rc) { + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up "%s"', $default)); + $this->ffi->git_reference_free($reference); + + return; + } + + $branch = $this->setHeadToUnbornBranch($repository, $url); + if (null === $branch || $branch === $default) { + return; + } + + $config = $this->ffi->new('git_config*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_config(\FFI::addr($config), $repository), 'Unable to open the repository configuration'); + + try { + foreach (['merge' => $branch, 'remote' => 'origin'] as $key => $value) { + LibGit2::check($this->ffi, $this->ffi->git_config_delete_entry($config, 'branch.'.substr($default, \strlen('refs/heads/')).'.'.$key), \sprintf('Unable to stop tracking "%s"', $default)); + LibGit2::check($this->ffi, $this->ffi->git_config_set_string($config, 'branch.'.substr($branch, \strlen('refs/heads/')).'.'.$key, $value), \sprintf('Unable to track "%s"', $branch)); + } + } finally { + $this->ffi->git_config_free($config); + } + } + + /** + * @return string|null The full name of the branch HEAD points to, or null when it is detached + */ + private function headBranch(CData $repository): ?string + { + $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 { + return $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); + } + } + /** * @param array $config */ diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index fb569e03..77f8f516 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -65,6 +65,7 @@ private const int GIT_DIFF_FIND_RENAMES = 1 << 0; private const int GIT_DIRECTION_FETCH = 0; private const int GIT_INDEX_ENTRY_INTENT_TO_ADD = 1 << 13; + private const int GIT_INDEX_ENTRY_SKIP_WORKTREE = 1 << 14; 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; @@ -80,7 +81,7 @@ * @param string $gitDir The git directory * @param string|null $workingDir The working tree, null for a bare repository * - * @throws UnsupportedOperationException The repository stores its references in the reftable format + * @throws UnsupportedOperationException The repository stores its references in the reftable format, or has an extension libgit2 doesn't know */ public function __construct( private \FFI $ffi, @@ -266,6 +267,7 @@ public function getShortHash(string $hash): string public function getTreeEntries(string $hash): array { + LeadingDash::assertNone($hash); NulByte::assertNone($hash); $tree = $this->lookupTree($hash); @@ -291,6 +293,7 @@ public function getTreeEntries(string $hash): array public function getBlobContent(string $hash): string { + LeadingDash::assertNone($hash); NulByte::assertNone($hash); $oid = $this->hexToOid($hash); @@ -306,6 +309,7 @@ public function getBlobContent(string $hash): string public function getTagData(string $hash): TagData { + LeadingDash::assertNone($hash); NulByte::assertNone($hash); $object = $this->lookupObject($hash); @@ -362,6 +366,7 @@ public function createReference(string $fullname, string $hash): void NulByte::assertNone($fullname, $hash); LeadingDash::assertNone($fullname, $hash); ReferenceName::assertUnderRefs($fullname); + $this->assertBranchPointsToCommit($fullname, $hash); $oid = $this->hexToOid($hash); $ref = $this->ffi->new('git_reference*'); @@ -374,6 +379,7 @@ public function updateReference(string $fullname, string $hash): void NulByte::assertNone($fullname, $hash); LeadingDash::assertNone($fullname, $hash); ReferenceName::assertUnderRefs($fullname); + $this->assertBranchPointsToCommit($fullname, $hash); $oid = $this->hexToOid($hash); $ref = $this->ffi->new('git_reference*'); @@ -466,12 +472,13 @@ public function getWorkingTreeDiff(string $hash): DiffData $tree = $this->treeForRevision($hash); try { + $skipped = $this->indexEntriesFlagged(self::GIT_INDEX_ENTRY_SKIP_WORKTREE); $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); - // libgit2 keeps a file the working tree changes back to the commit, after the index changed it, or `git add -N` added it. - $files = array_values(array_filter($this->buildDiff($diff)->files, static fn (DiffFileData $file): bool => [] !== $file->changes || $file->oldName !== $file->newName || $file->oldMode !== $file->newMode || $file->oldIndex !== $file->newIndex)); + // Like git, which reads a file a sparse checkout leaves out from the index: libgit2 finds it deleted. + $files = array_values(array_filter($this->buildDiff($diff)->files, static fn (DiffFileData $file): bool => !isset($skipped[$file->name]))); if ([] !== $unmerged) { // Like git diff , which compares the unmerged files, conflict markers included, to the commit. @@ -495,16 +502,23 @@ public function getWorkingTreeDiff(string $hash): DiffData public function getDiffPending(): DiffData { + // Like git since 2.19, which diffs the files `git add -N` added as + // absent from the index: created. Like any git, one deleted since is + // deleted, from the empty file the index has for it. + $intentToAdd = $this->indexEntriesFlagged(self::GIT_INDEX_ENTRY_INTENT_TO_ADD); + $skipped = $this->indexEntriesFlagged(self::GIT_INDEX_ENTRY_SKIP_WORKTREE); + $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'); - // Like git since 2.19, which diffs the files `git add -N` added as - // absent from the index: created. Like any git, one deleted since is - // deleted, from the empty file the index has for it. - $intentToAdd = $this->intentToAddFiles(); $files = []; foreach ($this->buildDiff($diff)->files as $file) { + // Like git, which reads a file a sparse checkout leaves out from the index: libgit2 finds it deleted. + if (isset($skipped[$file->name])) { + continue; + } + if (isset($intentToAdd[$file->name]) && null !== $file->newName) { $files[] = new DiffFileData(null, $file->newName, null, $file->newMode, null, $file->newIndex, $file->isBinary, $file->isCopy, $file->changes); } else { @@ -527,6 +541,8 @@ public function getDiffPending(): DiffData public function getDiffStaged(): DiffData { + // Like git since 2.19, which diffs the files `git add -N` added as absent from the index: not staged, or deleted when HEAD has them. + $intentToAdd = $this->indexEntriesFlagged(self::GIT_INDEX_ENTRY_INTENT_TO_ADD); $tree = $this->headTree(); try { @@ -539,8 +555,6 @@ public function getDiffStaged(): DiffData } } - // Like git since 2.19, which diffs the files `git add -N` added as absent from the index: not staged, or deleted when HEAD has them. - $intentToAdd = $this->intentToAddFiles(); $files = []; foreach ($this->buildDiff($diff)->files as $file) { if (!isset($intentToAdd[$file->name])) { @@ -624,6 +638,7 @@ public function checkout(string $revision): void { NulByte::assertNone($revision); LeadingDash::assertNone($revision); + $this->assertNoSparseCheckout(); // `git checkout ` attaches HEAD when names a // local branch, before a tag of the same name, or is @{-}, the nth @@ -649,6 +664,7 @@ public function checkoutNewBranch(string $branch, string $startPoint): void { NulByte::assertNone($branch, $startPoint); LeadingDash::assertNone($branch, $startPoint); + $this->assertNoSparseCheckout(); if ($this->referenceExists('refs/heads/'.$branch)) { throw new DriverException(\sprintf('A branch named "%s" already exists.', $branch)); @@ -679,7 +695,7 @@ public function checkoutNewBranch(string $branch, string $startPoint): void $this->setUpstream($branch, ...$upstream); } $this->checkoutRevision($startHash); - } catch (DriverException $e) { + } catch (DriverException|UnsupportedOperationException $e) { // Its configuration with it. LibGit2::check($this->ffi, $this->ffi->git_branch_delete($reference), \sprintf('Unable to delete the branch "%s" after a failed checkout', $branch)); @@ -695,6 +711,7 @@ public function merge(string $revision, FastForward $fastForward): void { NulByte::assertNone($revision); LeadingDash::assertNone($revision); + $this->assertNoSparseCheckout(); $branch = $this->getHeadReference(); $theirHash = $this->resolveCommit($revision); @@ -1061,9 +1078,11 @@ private function unmergedPaths(CData $diff): array } /** - * @return array The mode of the files `git add -N` added to the index, by path + * @param int $flag GIT_INDEX_ENTRY_INTENT_TO_ADD, for the files `git add -N` added, or GIT_INDEX_ENTRY_SKIP_WORKTREE, for the ones a sparse checkout leaves out + * + * @return array The mode of the files of the index with the extended flag $flag, by path */ - private function intentToAddFiles(): array + private function indexEntriesFlagged(int $flag): array { $index = $this->ffi->new('git_index*'); LibGit2::check($this->ffi, $this->ffi->git_repository_index(\FFI::addr($index), $this->repo), 'Unable to open the index'); @@ -1078,7 +1097,7 @@ private function intentToAddFiles(): array $files = []; for ($i = 0, $count = $this->ffi->git_index_entrycount($index); $i < $count; ++$i) { $entry = $this->ffi->git_index_get_byindex($index, $i); - if (0 !== ($entry->flags_extended & self::GIT_INDEX_ENTRY_INTENT_TO_ADD)) { + if (0 !== ($entry->flags_extended & $flag)) { $files[$entry->path] = \sprintf('%06o', $entry->mode); } } @@ -1134,6 +1153,15 @@ private function buildDiff(CData $diff): DiffData $file = $this->patchToFile($diff, $i); + // libgit2 lists files git finds unchanged: one the working + // tree changes back to the commit, after the index changed + // it, or `git add -N` added it, one whose line endings alone + // differ, which the filters normalize, and one whose mode + // alone differs, by bits git ignores (100664 and 100644). + if ([] === $file->changes && $file->oldName === $file->newName && $file->oldMode === $file->newMode && $file->oldIndex === $file->newIndex) { + continue; + } + // 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) { @@ -1208,8 +1236,8 @@ private function deltaToFile(CData $delta, array $changes): DiffFileData $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); + $oldMode = $isAdded ? null : self::canonicalMode($delta->old_file->mode); + $newMode = $isDeleted ? null : self::canonicalMode($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)); @@ -1217,6 +1245,20 @@ private function deltaToFile(CData $delta, array $changes): DiffFileData return new DiffFileData($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary, $isCopy, $changes); } + /** + * Like git's diffs, and git_tree_entry_filemode(): the trees of old + * versions of git have files of mode 100664 or 100600, which libgit2 + * diffs as they are. + */ + private static function canonicalMode(int $mode): string + { + if (0o100000 === ($mode & 0o170000)) { + return 0 === ($mode & 0o100) ? '100644' : '100755'; + } + + return \sprintf('%06o', $mode); + } + // -- working copy -------------------------------------------------------- private function checkoutRevision(string $revision): void @@ -1237,11 +1279,145 @@ private function checkoutRevision(string $revision): void */ private function checkoutObject(CData $treeish, string $context): void { + $this->assertCheckoutLosesNothing($treeish, $context); + $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); } + /** + * libgit2 refuses a checkout that overwrites local changes before it + * writes anything, but for what it doesn't see as one: + * + * - a submodule the checkout removes, whose directory it deletes, with + * the files git ignores in it, and its repository when ".git" is a + * directory (the branches that aren't pushed included), where git + * keeps it; + * - a directory the checkout replaces with a file, which it only finds + * not to be empty once it has written the files before it. + * + * @throws DriverException The checkout would delete the directory of a submodule, or stop halfway + */ + private function assertCheckoutLosesNothing(CData $treeish, string $context): void + { + // libgit2 refuses to check out in a bare repository. + if (null === $this->workingDir) { + return; + } + + $target = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_object_peel(\FFI::addr($target), $treeish, $this->ffi->GIT_OBJECT_TREE), $context); + + try { + // What libgit2 compares the target with. + $head = $this->headTree(); + + try { + $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, $head, $this->ffi->cast('git_tree *', $target), \FFI::addr($opts)), $context); + + try { + $removed = []; + $created = []; + for ($i = 0, $count = $this->ffi->git_diff_num_deltas($diff); $i < $count; ++$i) { + $delta = $this->ffi->git_diff_get_delta($diff, $i); + // A change of type is a deletion, then a creation. + if (self::GIT_DELTA_DELETED === $delta->status) { + $removed[$delta->old_file->path] = $delta->old_file->mode; + } elseif (self::GIT_DELTA_ADDED === $delta->status && $this->ffi->GIT_FILEMODE_COMMIT !== $delta->new_file->mode) { + $created[] = $delta->new_file->path; + } + } + } finally { + $this->ffi->git_diff_free($diff); + } + } finally { + if (null !== $head) { + $this->ffi->git_tree_free($head); + } + } + } finally { + $this->ffi->git_object_free($target); + } + + foreach ($removed as $path => $mode) { + // A submodule that isn't checked out is an empty directory. + if ($this->ffi->GIT_FILEMODE_COMMIT === $mode && file_exists($this->workingDir.'/'.$path.'/.git')) { + throw new DriverException(\sprintf('%s: it removes the submodule "%s", whose directory would be deleted with all its files.', $context, $path)); + } + } + + foreach ($created as $path) { + $directory = $this->workingDir.'/'.$path; + // libgit2 sees a directory no file is removed from as untracked, like git. + if (!is_dir($directory) || is_link($directory) || [] === array_filter(array_keys($removed), static fn (string $file): bool => str_starts_with($file, $path.'/'))) { + continue; + } + + $kept = self::entryKeptIn($directory, $path, $removed); + if (null !== $kept) { + throw new DriverException(\sprintf('%s: it replaces the directory "%s" with a file, but "%s" would be left in it.', $context, $path, $kept)); + } + } + } + + /** + * @param string $path The path of $directory in the repository + * @param array $removed The mode of the files a checkout removes, by path + * + * @return string|null The path of the first entry of $directory the checkout doesn't remove: an untracked or ignored file, or an empty directory; null when it removes them all + */ + private static function entryKeptIn(string $directory, string $path, array $removed): ?string + { + foreach (array_diff(scandir($directory) ?: [], ['.', '..']) as $name) { + $entry = $path.'/'.$name; + $file = $directory.'/'.$name; + if (!is_dir($file) || is_link($file)) { + if (!isset($removed[$entry])) { + return $entry; + } + + continue; + } + + $kept = [] === array_diff(scandir($file) ?: [], ['.', '..']) ? $entry : self::entryKeptIn($file, $entry, $removed); + if (null !== $kept) { + return $kept; + } + } + + return null; + } + + /** + * libgit2 doesn't read the patterns of a sparse checkout: it would write + * the files they leave out, and refuse to update the ones it finds missing. + * + * @throws UnsupportedOperationException core.sparseCheckout is on + */ + private function assertNoSparseCheckout(): void + { + $config = $this->ffi->new('git_config*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_config(\FFI::addr($config), $this->repo), 'Unable to open the repository configuration'); + + try { + $sparse = $this->ffi->new('int'); + $rc = $this->ffi->git_config_get_bool(\FFI::addr($sparse), $config, 'core.sparseCheckout'); + if (LibGit2::GIT_ENOTFOUND === $rc) { + return; + } + LibGit2::check($this->ffi, $rc, 'Unable to read "core.sparseCheckout"'); + + if (0 !== $sparse->cdata) { + throw new UnsupportedOperationException(\sprintf('The repository "%s" has a sparse checkout, in which LibGit2Driver checks out and merges nothing; use CliDriver.', $this->gitDir)); + } + } finally { + $this->ffi->git_config_free($config); + } + } + /** * Checks $hash out, and points the checked out branch, or a detached HEAD, to it. */ @@ -1621,6 +1797,32 @@ private function setUpstream(string $branch, string $remote, string $merge): voi } } + /** + * Like git, which writes nothing but a commit under refs/heads/, not + * even a tag of one: libgit2 writes any object. + * + * @throws DriverException $fullname is a branch, and $hash is no commit + */ + private function assertBranchPointsToCommit(string $fullname, string $hash): void + { + // git_reference_create() refuses an object that doesn't exist. + if (!str_starts_with($fullname, 'refs/heads/') || !$this->objectExists($hash)) { + return; + } + + $object = $this->lookupObject($hash); + + try { + $type = $this->objectType($this->ffi->git_object_type($object)); + } finally { + $this->ffi->git_object_free($object); + } + + if (ObjectType::Commit !== $type) { + throw new DriverException(\sprintf('Unable to write the reference "%s": the object "%s" is a %s, and a branch only points to a commit.', $fullname, $hash, $type->value)); + } + } + private function referenceExists(string $fullname): bool { $ref = $this->ffi->new('git_reference*'); @@ -1919,22 +2121,61 @@ private function commitToData(CData $commit): CommitData $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); + $header = $this->ffi->git_commit_raw_header($commit); + $lastAuthor = $this->lastSignature($header, 'author'); + + try { + $lastCommitter = $this->lastSignature($header, 'committer'); - 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), - $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)), - ); + try { + $author = $lastAuthor ?? $this->ffi->git_commit_author($commit); + $committer = $lastCommitter ?? $this->ffi->git_commit_committer($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), + $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)), + ); + } finally { + if (null !== $lastCommitter) { + $this->ffi->git_signature_free($lastCommitter); + } + } + } finally { + if (null !== $lastAuthor) { + $this->ffi->git_signature_free($lastAuthor); + } + } + } + + /** + * Like git, which reads the last "author" or "committer" header of a + * commit with several, as some tools wrote: libgit2 reads the first. + * + * @param string $header The headers of a commit + * @param string $field "author" or "committer" + * + * @return CData|null The git_signature* of the last header $field, to free, or null when the commit has only one + */ + private function lastSignature(string $header, string $field): ?CData + { + // The greedy start leaves the last header to the capture. + if (substr_count("\n".$header, "\n".$field.' ') < 2 || 1 !== preg_match('/(?s:.*)^'.$field.' (.*)$/m', $header, $match)) { + return null; + } + + $signature = $this->ffi->new('git_signature*'); + LibGit2::check($this->ffi, $this->ffi->git_signature_from_buffer(\FFI::addr($signature), $match[1]), \sprintf('Unable to read the last "%s" header of a commit', $field)); + + return $signature; } /** @@ -1971,6 +2212,11 @@ private function toUtf8(string $text, ?string $encoding): string */ private function signatureDate(CData $signature): \DateTimeImmutable { + // Like git, which reads a date before 1970 as 1970, in UTC. + if ($signature->when->time < 0) { + return new \DateTimeImmutable('@0'); + } + // In minutes. libgit2 gives 0 for an offset of more than 14 hours or // 59 minutes, which no timezone has, in a corrupt object. $offset = $signature->when->offset; diff --git a/src/Gitonomy/Git/Driver/Ffi/PathFilter.php b/src/Gitonomy/Git/Driver/Ffi/PathFilter.php index 3ce1f8ab..ffeae986 100644 --- a/src/Gitonomy/Git/Driver/Ffi/PathFilter.php +++ b/src/Gitonomy/Git/Driver/Ffi/PathFilter.php @@ -80,6 +80,12 @@ public function differ(?CData $oldTree, CData $newTree): bool $needsDiff = false; foreach ($this->paths as $i => $path) { $prefix = $this->prefixes[$i]; + // Like git, which also matches the file of this very name: + // libgit2 only reads a backslash as the escape of a wildcard. + if (str_contains($path, '\\') && $this->entryId($oldTree, $path) !== $this->entryId($newTree, $path)) { + return true; + } + if ($this->entryId($oldTree, $prefix) === $this->entryId($newTree, $prefix)) { continue; } diff --git a/src/Gitonomy/Git/Driver/Ffi/libgit2.h b/src/Gitonomy/Git/Driver/Ffi/libgit2.h index e8804746..21766c32 100644 --- a/src/Gitonomy/Git/Driver/Ffi/libgit2.h +++ b/src/Gitonomy/Git/Driver/Ffi/libgit2.h @@ -90,6 +90,7 @@ int git_libgit2_opts(int option, ...); void git_buf_dispose(git_buf *buffer); int git_signature_default_from_env(git_signature **author_out, git_signature **committer_out, git_repository *repo); +int git_signature_from_buffer(git_signature **out, const char *buf); void git_signature_free(git_signature *sig); void git_oidarray_dispose(git_oidarray *array); void git_strarray_dispose(git_strarray *array); @@ -119,6 +120,8 @@ int git_repository_index(git_index **out, git_repository *repo); /* -- config -- */ +int git_config_delete_entry(git_config *cfg, const char *name); +int git_config_get_bool(int *out, const git_config *cfg, const char *name); int git_config_set_string(git_config *cfg, const char *name, const char *value); void git_config_free(git_config *cfg); @@ -138,6 +141,7 @@ 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); +const char *git_commit_raw_header(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); diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index 89e59ac1..41b0661d 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -221,11 +221,13 @@ public function checkout(string $revision): void; * branch, like "origin/feature", as with branch.autoSetupMerge=true, * git's default, whatever the configuration. A failure leaves the * branches and the files as they were: like ``git checkout -b``, no - * branch is created when the checkout fails. + * branch is created when the checkout fails. Like it too, the failure of + * the post-checkout hook, for a driver that runs it, leaves the branch + * created and checked out. * * @param string $startPoint A revision naming a commit, which WorkingCopy::checkoutNewBranch() checks before * - * @throws DriverException $branch exists already or isn't a valid name, $startPoint is the short name of several references, which git refuses, or the checkout would overwrite local changes + * @throws DriverException $branch exists already or isn't a valid name, $startPoint is the short name of several references, which git refuses, the checkout would overwrite local changes, or the post-checkout hook fails */ public function checkoutNewBranch(string $branch, string $startPoint): void; diff --git a/src/Gitonomy/Git/Driver/RepositoryPaths.php b/src/Gitonomy/Git/Driver/RepositoryPaths.php index 71141e74..4b817e66 100644 --- a/src/Gitonomy/Git/Driver/RepositoryPaths.php +++ b/src/Gitonomy/Git/Driver/RepositoryPaths.php @@ -68,6 +68,27 @@ public static function resolve(string $dir, ?string $workingDir): self return $paths; } + /** + * @return string The git directory holding what a linked worktree shares with the repository it belongs to, like the hooks: $gitDir itself, but for a linked worktree + * + * @throws DriverException The "commondir" file of a linked worktree can't be read + */ + public static function commonDir(string $gitDir): string + { + if (!is_file($gitDir.'/commondir')) { + return $gitDir; + } + + $commonDir = file_get_contents($gitDir.'/commondir'); + if (false === $commonDir) { + throw new DriverException(\sprintf('Unable to read "%s/commondir".', $gitDir)); + } + + $commonDir = rtrim($commonDir, "\r\n"); + + return str_starts_with($commonDir, '/') || preg_match('/^[a-zA-Z]:[\\\\\/]/', $commonDir) ? $commonDir : $gitDir.'/'.$commonDir; + } + /** * Reads the file like git: "gitdir: " then the path, absolute or relative * to $dir, without the line ending, "\r\n" included. The path may end with diff --git a/src/Gitonomy/Git/Exception/GitExceptionInterface.php b/src/Gitonomy/Git/Exception/GitExceptionInterface.php index febbd51f..844cf14c 100644 --- a/src/Gitonomy/Git/Exception/GitExceptionInterface.php +++ b/src/Gitonomy/Git/Exception/GitExceptionInterface.php @@ -12,6 +12,6 @@ namespace Gitonomy\Git\Exception; -interface GitExceptionInterface +interface GitExceptionInterface extends \Throwable { } diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index 29d145ac..907f702f 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\RuntimeException; @@ -160,19 +161,7 @@ private function getPath(string $name): string */ 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'; + return RepositoryPaths::commonDir($this->repository->getGitDir()).'/hooks'; } } diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index a39780fd..c9c82a56 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -161,9 +161,9 @@ public function count(): int /** * @see IteratorAggregate * - * @return \ArrayIterator + * @return \Traversable */ - public function getIterator(): \ArrayIterator + public function getIterator(): \Traversable { return new \ArrayIterator($this->getCommits()); } diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index 7c5a343d..c5c739ee 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -25,6 +25,8 @@ * information, and the hunks from the patches. * * Unmerged paths, during a conflict, are left out. + * + * @internal Parse a diff with Diff::parse() */ final class DiffParser extends ParserBase { @@ -167,12 +169,14 @@ private function consumePatch(?string $oldName, ?string $newName, ?string $oldMo $this->consumeTo("\n"); $this->consumeNewLine(); + $hasIndex = false; 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]); + $hasIndex = true; } elseif (!$this->expectsOneOf(self::HEADERS)) { break; } @@ -181,6 +185,12 @@ private function consumePatch(?string $oldName, ?string $newName, ?string $oldMo $this->consumeNewLine(); } + // git writes no "index" line for a content that didn't change, when + // the mode alone did: the file of the working tree has the same hash. + if (!$hasIndex && null !== $newName) { + $newHash ??= $oldHash; + } + $isBinary = false; if ($this->expects('--- ')) { $this->consumeTo("\n"); diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index 56609014..216d57e1 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -14,6 +14,9 @@ use Gitonomy\Git\Exception\RuntimeException; +/** + * @internal + */ abstract class ParserBase { protected int $cursor; diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 78970c06..21a0d880 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -121,7 +121,7 @@ public function getFirstBranch(): ?Branch /** * @return Tag[] The tags pointing to $commit, annotated or not */ - public function resolveTags(Commit|string $commit): array + public function resolveTags(Revision|string $commit): array { return array_values(array_filter($this->resolve($commit), static fn (Reference $reference): bool => $reference instanceof Tag)); } @@ -129,7 +129,7 @@ public function resolveTags(Commit|string $commit): array /** * @return Branch[] The branches pointing to $commit */ - public function resolveBranches(Commit|string $commit): array + public function resolveBranches(Revision|string $commit): array { return array_values(array_filter($this->resolve($commit), static fn (Reference $reference): bool => $reference instanceof Branch)); } @@ -137,11 +137,11 @@ public function resolveBranches(Commit|string $commit): array /** * @return Reference[] The references pointing to $commit, through annotated tags */ - public function resolve(Commit|string $commit): array + public function resolve(Revision|string $commit): array { $hash = match (true) { - $commit instanceof Commit => $commit->getHash(), - 1 === preg_match('/^[0-9a-f]{40}$/', $commit) => $commit, + $commit instanceof Revision => $commit->getCommit()->getHash(), + 1 === preg_match('/^[0-9a-f]{40}$/D', $commit) => $commit, default => $this->repository->getRevision($commit)->getCommit()->getHash(), }; diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index b2505e41..8803655d 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -539,7 +539,7 @@ private function commitHash(Revision|string $revision): string */ private function fullHash(string $hash, ObjectType $type): string { - if (!preg_match('/^[0-9a-f]{4,40}$/', $hash)) { + if (!preg_match('/^[0-9a-f]{4,40}$/D', $hash)) { throw new ReferenceNotFoundException(\sprintf('"%s" is not a hash.', $hash)); } diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index b2a86943..bb5d2494 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -20,12 +20,12 @@ * * @author Alexandre Salomé * - * @implements \IteratorAggregate + * @implements \IteratorAggregate */ final class Tree implements \IteratorAggregate { /** - * @var array|null By name, null until read + * @var array|null By name, null until read */ private ?array $entries = null; @@ -44,7 +44,7 @@ public function getHash(): string } /** - * @return \ArrayIterator The entries, by name + * @return \ArrayIterator The entries, by name */ public function getIterator(): \ArrayIterator { @@ -52,7 +52,7 @@ public function getIterator(): \ArrayIterator } /** - * @return array The blobs, by name + * @return array The blobs, by name */ public function getBlobs(): array { @@ -60,7 +60,7 @@ public function getBlobs(): array } /** - * @return array The submodules, by name + * @return array The submodules, by name */ public function getSubmodules(): array { @@ -68,7 +68,7 @@ public function getSubmodules(): array } /** - * @return array The trees, by name + * @return array The trees, by name */ public function getTrees(): array { @@ -105,7 +105,7 @@ public function resolvePath(string $path): Submodule|self|Blob } /** - * @return array By name + * @return array By name */ private function entries(): array { diff --git a/src/Gitonomy/Git/Util/Credentials.php b/src/Gitonomy/Git/Util/Credentials.php index 737019e9..3b02be0d 100644 --- a/src/Gitonomy/Git/Util/Credentials.php +++ b/src/Gitonomy/Git/Util/Credentials.php @@ -22,18 +22,65 @@ */ final class Credentials { + /** + * What ends the "user:password@host" of a URL: a slash or a whitespace. + */ + private const string DELIMITERS = "/ \t\n\r\v\f"; + /** * Replaces the user information of the URLs of $text, a token or a user * and a password, by "***". + * + * Without a regular expression over $text, which may be the megabytes of + * a blob in the logs: it would reach the limits of PCRE, or take seconds. */ public static function redact(#[\SensitiveParameter] string $text): string { - // Up to the last "@" before the host, as a password may hold one, or a - // "#" or a "?", which git and libgit2 accept unencoded there. - // Without a scheme too: git prints "user:token@host" when it can't - // resolve the host of a git:// URL. A token alone stays, as nothing - // tells it from the "git@host" of the errors of ssh. - // @phpstan-ignore return.type (possessive quantifiers never reach the backtracking limit, and without "u" there is no UTF-8 to fail on) - return preg_replace('#(?:\b([a-z][a-z0-9+.-]*+://)|(?= 3 && '://' === substr($text, $start - 3, 3); + } + + /** + * Without a scheme: git prints "user:token@host" when it can't resolve + * the host of a git:// URL, and ssh "user:token@host: Permission denied". + * A token alone stays, as nothing tells it from the "git@host" of the + * errors of ssh, and so does what git quotes, a revision like + * 'HEAD:logo@2x.png', which ends with a quote instead of a host. + */ + private static function hasPassword(#[\SensitiveParameter] string $text, int $start, #[\SensitiveParameter] string $authority, string $host): bool + { + // After a slash, the "@" is in the path of a URL. + if ($start > 0 && '/' === $text[$start - 1]) { + return false; + } + + $user = strstr($authority, ':', true); + if (false === $user || '' === $user || str_contains($user, '@')) { + return false; + } + + // A host, its port, and the colon of "host: Permission denied". + // Anything but 0: an error of PCRE hides the text too. + return 0 !== preg_match('/^(?:[a-z0-9.-]++|\[[0-9a-f:.]++\])(?::\d++)?:?$/Di', $host); } } diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php index b29b3c82..a57b2722 100644 --- a/src/Gitonomy/Git/Util/RemoteUrl.php +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -25,7 +25,7 @@ final class RemoteUrl { /** - * @throws InvalidArgumentException $url is empty, starts with a dash, uses the "ext::" or "fd::" transport, or has a host or a user starting with a dash, encoded or not + * @throws InvalidArgumentException $url is empty, starts with a dash, uses the "ext::" or "fd::" transport, or has a host, a user or a port starting with a dash, encoded or not */ public static function assertSafe(#[\SensitiveParameter] string $url): void { @@ -52,10 +52,11 @@ public static function assertSafe(#[\SensitiveParameter] string $url): void // Before 2.14.1 (CVE-2017-1000117), git passes the "user@host" of an ssh // URL to ssh, without its brackets: ssh reads "ssh://-oProxyCommand=..." // as an option, and runs the command. The command of core.gitProxy gets - // the host of a git URL the same way. A valid host never starts with a dash. + // the host of a git URL the same way, and whatever follows its colon as + // the port. A valid host or port never starts with a dash. $authority = self::authority($url); - if (null !== $authority && 1 === preg_match('/(?:^|@)\[?-/', $authority)) { - throw new InvalidArgumentException(\sprintf('"%s" has a host or a user starting with a dash, which ssh, or the command of core.gitProxy, would read as an option.', Credentials::redact($url))); + if (null !== $authority && 1 === preg_match('/(?:^|[@:])\[?-/', $authority)) { + throw new InvalidArgumentException(\sprintf('"%s" has a host, a user or a port starting with a dash, which ssh, or the command of core.gitProxy, would read as an option.', Credentials::redact($url))); } } diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index 27a59f03..0a6fe086 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -99,10 +99,12 @@ public function checkout(Revision|string $revision): static * branch, like "origin/feature", as with branch.autoSetupMerge=true, * git's default, whatever the configuration. A failure leaves the * branches and the files as they were: like `git checkout -b`, no - * branch is created when the checkout fails. + * branch is created when the checkout fails. Like it too, the failure of + * the post-checkout hook, which CliDriver runs, leaves the branch created + * and checked out. * * @throws ReferenceNotFoundException $startPoint names no commit - * @throws DriverException $branch exists already or isn't a valid name, $startPoint is the short name of several references, which git refuses, or the checkout would overwrite local changes + * @throws DriverException $branch exists already or isn't a valid name, $startPoint is the short name of several references, which git refuses, the checkout would overwrite local changes, or the post-checkout hook fails */ public function checkoutNewBranch(string $branch, Revision|string $startPoint = 'HEAD'): static { diff --git a/test-git-versions.php b/test-git-versions.php index 787dd32e..ec25cee3 100755 --- a/test-git-versions.php +++ b/test-git-versions.php @@ -182,7 +182,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->writeln(sprintf('Command: %s', $binary)); // The bootstrap of the tests gives git a configuration of its own. - $process = new Process(['vendor/bin/phpunit'], __DIR__, ['GIT_COMMAND' => $binary], timeout: null); + // GITLIB_DRIVER: with "ffi" in the environment, the tests wouldn't run this git. + $process = new Process(['vendor/bin/phpunit'], __DIR__, ['GIT_COMMAND' => $binary, 'GITLIB_DRIVER' => 'cli'], timeout: null); $process->run(static function (string $type, string $data) use ($output): void { $output->write($data); }); diff --git a/tests/Gitonomy/Git/Tests/AbstractTestCase.php b/tests/Gitonomy/Git/Tests/AbstractTestCase.php index a67912e5..b0d41dc7 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTestCase.php +++ b/tests/Gitonomy/Git/Tests/AbstractTestCase.php @@ -15,6 +15,8 @@ use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\After; @@ -193,6 +195,26 @@ protected function skipUnlessCliDriver(): void } } + /** + * Expects the failure of an operation, whose message each driver words + * its own way: the error of git, in a ProcessException, with CliDriver. + * + * @param string $gitError The first line of the error output of git + */ + protected function expectDriverException(string $gitCommand, string $gitError, string $libGit2Message): void + { + if (self::getDriver() instanceof CliDriver) { + $this->expectException(ProcessException::class); + $message = \sprintf('The git command "%s" failed (exit 128): %s', $gitCommand, $gitError); + } else { + $this->expectException(DriverException::class); + $message = $libGit2Message; + } + + // The whole message: expectExceptionMessage() looks for a part of it. + $this->expectExceptionMessageMatches('/^'.preg_quote($message, '/').'$/'); + } + /** * Returns the branch HEAD points to, failing the test when it is detached. */ @@ -245,11 +267,18 @@ protected static function writeObject(Repository $repository, string $type, stri /** * Returns the driver under test: LibGit2Driver when the GITLIB_DRIVER - * environment variable is "ffi", CliDriver otherwise. + * environment variable is "ffi", CliDriver when it is "cli" or not set. */ protected static function getDriver(): DriverInterface { - return 'ffi' === ($_SERVER['GITLIB_DRIVER'] ?? 'cli') ? new LibGit2Driver() : self::getCliDriver(); + $driver = $_SERVER['GITLIB_DRIVER'] ?? 'cli'; + + return match ($driver) { + 'cli' => self::getCliDriver(), + 'ffi' => new LibGit2Driver(), + // A typo would run the suite of CliDriver, and pass. + default => throw new \UnexpectedValueException(\sprintf('The GITLIB_DRIVER environment variable must be "cli" or "ffi", %s given.', \is_string($driver) ? '"'.$driver.'"' : get_debug_type($driver))), + }; } /** diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index b6ddf22b..683c42ef 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -13,7 +13,6 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Blame\Line; -use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Repository; @@ -91,11 +90,28 @@ public function testBlameOfARangeAfterTheEndThrows(): void { $repository = self::createFoobarRepository(); - $this->expectException(DriverException::class); + $this->expectDriverException('blame', 'fatal: file README.md has only 7 lines.', 'The file "README.md" has only 7 lines.'); $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 100)->getLines(); } + /** + * git reads the path after "--": never as an option. + */ + public function testBlameOfAFileWhoseNameStartsWithADash(): void + { + $repository = Repository::init(self::createTempDir(), self::getDriver()); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + file_put_contents($repository->getWorkingDir().'/--porcelain', "L1\nL2\n"); + self::git($repository, 'add', ['--', '--porcelain']); + self::git($repository, 'commit', ['-q', '-m', 'An option of git blame']); + + $blame = $repository->getBlame('HEAD', '--porcelain'); + + $this->assertSame([1 => 'L1', 2 => 'L2'], array_map(static fn (Line $line): string => $line->getContent(), $blame->getLines())); + } + /** * After a merge whose sides kept a file under two names, git blames a * commit under both: "git blame -p" repeats its "filename" line on each diff --git a/tests/Gitonomy/Git/Tests/BootstrapTest.php b/tests/Gitonomy/Git/Tests/BootstrapTest.php index 78289031..0b1b715d 100644 --- a/tests/Gitonomy/Git/Tests/BootstrapTest.php +++ b/tests/Gitonomy/Git/Tests/BootstrapTest.php @@ -39,7 +39,8 @@ public function testTheBootstrapRemovesTheVariablesOfGit(): void require $argv[1]; $variables = []; foreach (['getenv' => getenv(), '_ENV' => $_ENV, '_SERVER' => $_SERVER] as $source => $values) { - $variables[$source] = array_filter($values, static fn (string $name): bool => str_starts_with($name, 'GIT_'), \ARRAY_FILTER_USE_KEY); + // Without GIT_ENVS, which the bootstrap sets on Windows. + $variables[$source] = array_filter($values, static fn (string $name): bool => str_starts_with($name, 'GIT_') && 'GIT_ENVS' !== $name, \ARRAY_FILTER_USE_KEY); ksort($variables[$source]); } echo json_encode($variables); @@ -52,4 +53,39 @@ public function testTheBootstrapRemovesTheVariablesOfGit(): void $expected = ['GIT_COMMAND' => 'git', 'GIT_CONFIG_NOSYSTEM' => '1']; $this->assertSame(['getenv' => $expected, '_ENV' => $expected, '_SERVER' => $expected], json_decode($process->getOutput(), true)); } + + /** + * gpg, which git runs to check the signature of a commit, creates its home when it is missing. + */ + public function testTheBootstrapKeepsGpgOutOfTheWorkingTree(): void + { + $gnupgHome = getenv('GNUPGHOME'); + + $this->assertIsString($gnupgHome); + $this->assertSame($gnupgHome, $_SERVER['GNUPGHOME'] ?? null, 'Symfony Process passes it on to git'); + $this->assertDirectoryExists($gnupgHome); + $this->assertStringStartsNotWith(\dirname(self::BOOTSTRAP), $gnupgHome, 'Out of tests/, where HOME is'); + } + + /** + * A typo in GITLIB_DRIVER must not run, and pass, the tests of CliDriver. + */ + public function testAnUnknownDriverIsRefused(): void + { + $driver = $_SERVER['GITLIB_DRIVER'] ?? null; + $_SERVER['GITLIB_DRIVER'] = 'libgit2'; + + try { + self::getDriver(); + $this->fail('GITLIB_DRIVER is "cli" or "ffi".'); + } catch (\UnexpectedValueException $e) { + $this->assertSame('The GITLIB_DRIVER environment variable must be "cli" or "ffi", "libgit2" given.', $e->getMessage()); + } finally { + if (null === $driver) { + unset($_SERVER['GITLIB_DRIVER']); + } else { + $_SERVER['GITLIB_DRIVER'] = $driver; + } + } + } } diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index a37fcee3..81a66aeb 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -15,6 +15,8 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\Commit; use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; @@ -40,10 +42,19 @@ 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(); + try { + $repository->getCommit($hash)->getAuthorDate(); + $this->fail('The date is out of range.'); + } catch (RuntimeException $e) { + // A RuntimeException of gitlib, not of PHP: git prints the date, which libgit2 refuses to parse. + if (self::getDriver() instanceof CliDriver) { + $this->assertSame(RuntimeException::class, $e::class); + $this->assertSame('The date "99999999999999999999" is out of range.', $e->getMessage()); + } else { + $this->assertSame(DriverException::class, $e::class); + $this->assertSame(\sprintf('Unable to read the commit "%s": failed to parse signature - invalid Unix timestamp.', $hash), $e->getMessage()); + } + } } /** @@ -99,6 +110,23 @@ public static function provideMalformedSignatures(): iterable yield 'no date' => ['A ', 'A', 'a@example.com', '1970-01-01T00:00:00+00:00']; yield 'an offset of 5 digits' => ['A 1700000000 +05030', 'A', 'a@example.com', '2023-11-14T22:13:20+00:00']; yield 'no name nor email' => ['<> 0 +0000', '', '', '1970-01-01T00:00:00+00:00']; + yield 'a date before 1970' => ['A -1 +0100', 'A', 'a@example.com', '1970-01-01T00:00:00+00:00']; + } + + /** + * Like git log, which shows the last "author" and "committer" headers of + * a commit with several, as some tools wrote: libgit2 reads the first. + */ + public function testTheLastOfSeveralSignaturesIsRead(): void + { + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + $hash = self::writeObject($repository, 'commit', "tree {$tree}\nauthor First 1700000000 +0100\nauthor Last 1700000100 +0200\ncommitter First 1700000200 +0300\ncommitter Last 1700000300 +0400\n\nSeveral\n"); + + $commit = $repository->getCommit($hash); + + $this->assertSame(['Last', 'last@example.com', '2023-11-15T00:15:00+02:00'], [$commit->getAuthorName(), $commit->getAuthorEmail(), $commit->getAuthorDate()->format(\DATE_ATOM)]); + $this->assertSame(['Last', 'last@example.com', '2023-11-15T02:18:20+04:00'], [$commit->getCommitterName(), $commit->getCommitterEmail(), $commit->getCommitterDate()->format(\DATE_ATOM)]); } /** @@ -175,6 +203,81 @@ public function testGetShortHashOfASignedCommit(): void $this->assertSame('e1a83f1', $repository->getCommit(self::SIGNED_COMMIT)->getShortHash()); } + /** + * "git log" abbreviates the hash of a commit only, and gives the one of + * the commit of a tag. + */ + public function testTheDriverAbbreviatesTheHashOfAnyObject(): void + { + $repository = self::createFoobarRepository(); + $driver = $repository->getRepositoryDriver(); + $tree = $repository->getCommit(self::LONGFILE_COMMIT)->getTreeHash(); + $tag = self::writeObject($repository, 'tag', \sprintf("object %s\ntype commit\ntag short\ntagger A 1700000000 +0000\n\nShort\n", self::LONGFILE_COMMIT)); + + $this->assertSame(substr($tree, 0, 7), $driver->getShortHash($tree)); + $this->assertSame(substr($tag, 0, 7), $driver->getShortHash($tag)); + + $this->expectException(ReferenceNotFoundException::class); + + $driver->getShortHash(str_repeat('1', 40)); + } + + /** + * Like git, which reads the tree, then the parents, on the first lines of + * a commit: a forged commit showed another tree and other parents than + * the ones git logs and checks out. + */ + public function testTheTreeAndTheParentsAreTheFirstHeadersOfACommit(): void + { + $repository = self::createFoobarRepository(); + $tree = $repository->getCommit(self::LONGFILE_COMMIT)->getTreeHash(); + $hash = self::writeObject($repository, 'commit', \sprintf("tree %s\nparent %s\nauthor A 1700000000 +0000\ncommitter A 1700000000 +0000\nparent %s\ntree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\nForged\n", $tree, self::LONGFILE_COMMIT, self::INITIAL_COMMIT)); + + $commit = $repository->getCommit($hash); + + $this->assertSame($tree, $commit->getTreeHash()); + $this->assertSame([self::LONGFILE_COMMIT], $commit->getParentHashes()); + $this->assertSame("Forged\n", $commit->getMessage()); + } + + /** + * A shallow clone has the commits it cuts the history at, without their + * parents: git logs and diffs them as root commits. + */ + public function testACommitAShallowCloneCutsTheHistoryAtHasNoParent(): void + { + $dir = self::createTempDir(); + new Process([self::gitCommand(), 'clone', '-q', '--depth', '1', 'file://'.self::createFoobarRepository()->getGitDir(), $dir])->mustRun(); + $repository = new Repository($dir, driver: self::getDriver()); + + $commit = $repository->getHeadCommit(); + + $this->assertSame([], $commit->getParentHashes()); + $this->assertSame([], $commit->getParents()); + $this->assertContains('README.md', array_map(static fn (File $file): string => $file->getName(), $commit->getDiff()->getFiles()), 'Every file is new'); + $this->assertSame([$commit], $repository->getLog()->getCommits()); + } + + /** + * The repository keeps its "shallow" file, not the worktree. + */ + public function testACommitOfALinkedWorktreeOfAShallowCloneHasNoParent(): void + { + if (version_compare(self::gitVersion(), '2.5.0', '<')) { + $this->markTestSkipped('git worktree requires git 2.5.'); + } + if (!self::getDriver() instanceof CliDriver) { + $this->markTestSkipped('libgit2 1.9 looks for the "shallow" file in the git directory of the worktree.'); + } + + $dir = self::createTempDir(); + new Process([self::gitCommand(), 'clone', '-q', '--depth', '1', 'file://'.self::createFoobarRepository()->getGitDir(), $dir])->mustRun(); + $worktree = self::createTempDir().'/worktree'; + self::git(new Repository($dir, driver: self::getDriver()), 'worktree', ['add', '-b', 'in-worktree', $worktree]); + + $this->assertSame([], new Repository($worktree, driver: self::getDriver())->getHeadCommit()->getParentHashes()); + } + #[DataProvider('provideBare')] public function testGetParentHashesWithNoParent(bool $bare): void { @@ -430,14 +533,10 @@ public function testEncoding(bool $bare): void 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'])); + // Written as an object: Windows passes no ISO-8859-1 argument to "git commit". + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + $hash = self::writeObject($repository, 'commit', "tree {$tree}\nauthor Andr\xe9 1700000000 +0000\ncommitter Andr\xe9 1700000000 +0000\nencoding ISO-8859-1\n\nCaf\xe9\n"); $commit = new Repository($repository->getPath(), driver: self::getDriver())->getCommit($hash); $this->assertSame("Caf\u{e9}\n", $commit->getMessage()); diff --git a/tests/Gitonomy/Git/Tests/CredentialsTest.php b/tests/Gitonomy/Git/Tests/CredentialsTest.php index 37e4cded..f4088940 100644 --- a/tests/Gitonomy/Git/Tests/CredentialsTest.php +++ b/tests/Gitonomy/Git/Tests/CredentialsTest.php @@ -23,6 +23,7 @@ use Gitonomy\Git\Util\Credentials; use PHPUnit\Framework\Attributes\DataProvider; use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Process; /** * The credentials of a URL must not end up in the logs or in an error tracker. @@ -43,9 +44,11 @@ public function testAFailedCloneHidesTheCredentials(): void // What loggers print: the message, the trace, and the chained exceptions. $this->assertStringNotContainsString('secret', (string) $e); if (self::getDriver() instanceof CliDriver) { - // The error of git: recent versions name the URL without its credentials, and the old - // builds of the tests, without http support, do not name it. - $this->assertStringStartsWith('The git command "clone" failed (exit 128): fatal: ', $e->getMessage()); + // The error of git: with http support, it names the URL without its credentials. Without, + // it does not name it, and git 2.34 prints no "fatal: ..." but "git: 'remote-http' is not + // a git command." + $this->assertStringStartsWith('The git command "clone" failed (exit 128): ', $e->getMessage()); + $this->assertStringNotContainsString('user', $e->getMessage()); } else { $this->assertStringStartsWith('Unable to clone "http://***@127.0.0.1:1/repository.git": ', $e->getMessage()); } @@ -132,6 +135,8 @@ public static function provideRefusedUrls(): iterable { yield 'NUL byte' => [self::URL."\0", '"http://***@127.0.0.1:1/repository.git\000" contains a NUL byte, which git doesn\'t accept.']; yield 'leading dash' => ['--upload-pack='.self::URL, '"--upload-pack=http://***@127.0.0.1:1/repository.git" starts with a dash, which git would read as an option.']; + yield 'ext' => ['ext::sh -c cat% '.self::URL, '"ext::sh -c cat% http://***@127.0.0.1:1/repository.git" uses the "ext::" transport, which runs a command.']; + yield 'fd' => ['fd::0/'.self::URL, '"fd::0/http://***@127.0.0.1:1/repository.git" uses the "fd::" transport, which talks through a file descriptor of PHP.']; } /** @@ -169,6 +174,7 @@ public static function provideFailingCalls(): iterable yield 'NUL byte in a branch' => [static function (DriverInterface $driver): void { Repository::isValidRepositoryAndBranch(self::URL, "master\0", $driver); }]; yield 'ext' => [static function (DriverInterface $driver): void { Repository::cloneFrom('ext::'.self::URL, self::createTempDir().'/clone', driver: $driver); }]; yield 'run' => [static function (DriverInterface $driver): void { new Repository(self::createFoobarRepository()->getPath(), driver: $driver)->run('remote', ['add', 'private', self::URL."\0"]); }]; + yield 'leading dash' => [static function (DriverInterface $driver): void { Repository::isValidRepository('--upload-pack='.self::URL, $driver); }]; } public function testTheLogsHideTheCredentials(): void @@ -219,6 +225,208 @@ public static function provideTexts(): iterable yield 'no scheme, the host git prints for a git:// URL' => ['fatal: unable to look up ***@example.com (port 9418)', 'fatal: unable to look up user:password@example.com (port 9418)']; yield 'no scheme, with a port' => ['unable to look up ***@example.com:9418', 'unable to look up user:password@example.com:9418']; yield 'no scheme, password holding an "@"' => ['unable to look up ***@example.com', 'unable to look up user:p@ss@example.com']; + yield 'no scheme, the error of ssh with a password' => ['***@example.com: Permission denied (publickey).', 'user:password@example.com: Permission denied (publickey).']; + yield 'no scheme, a revision git quotes' => ["fatal: bad revision 'HEAD:logo@2x.png'", "fatal: bad revision 'HEAD:logo@2x.png'"]; + yield 'no scheme, an address git quotes' => ["fatal: 'example.com:a@b.git' does not appear to be a git repository", "fatal: 'example.com:a@b.git' does not appear to be a git repository"]; + yield 'no scheme, a reference of the reflog' => ['error: src refspec main:refs/heads/x@{1} does not match any', 'error: src refspec main:refs/heads/x@{1} does not match any']; + yield 'no scheme, not followed by a host' => ['std::move:x@(y)', 'std::move:x@(y)']; + yield 'any scheme' => ['_https://***@example.com/a.git', '_https://user:password@example.com/a.git']; + } + + /** + * With a logger, the output of every command is redacted: megabytes, for + * a blob. The regular expression reached the limits of PCRE on them, and + * threw a TypeError. + */ + #[DataProvider('provideLongTexts')] + public function testRedactTakesALongText(string $expected, string $text): void + { + $this->assertSame($expected, Credentials::redact($text)); + } + + /** + * @return iterable + */ + public static function provideLongTexts(): iterable + { + $text = 'x:'.str_repeat('a@', 1_100_000); + yield 'no host after the last "@"' => [$text, $text]; + yield 'a password of "@"' => ['https://***@example.com/a.git', 'https://x:'.str_repeat('a@', 1_100_000).'example.com/a.git']; + $text = str_repeat("a.b-c+d e:f g@h\n", 100_000); + yield 'no credentials' => [$text, $text]; + } + + /** + * The logs hold the error output of git, which names the branch it refuses. + */ + public function testTheLogsHideTheCredentialsOfAnErrorOutput(): void + { + $this->skipUnlessCliDriver(); + + $messages = []; + $logger = $this->createStub(LoggerInterface::class); + $logger->method('debug')->willReturnCallback(static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + $repository = new Repository(self::createFoobarRepository()->getPath(), driver: self::getCliDriver(logger: $logger)); + + try { + $repository->run('branch', [self::URL]); + $this->fail('A URL is no valid branch name.'); + } catch (ProcessException) { + } + + $this->assertStringContainsString("last command (branch) error output: fatal: 'http://***@127.0.0.1:1/repository.git' is not a valid branch name", implode("\n", $messages)); + foreach ($messages as $message) { + $this->assertStringNotContainsString('secret', $message); + } + } + + /** + * Like the shell that can't run git, a wrapper of git may exit with 127, + * after an error naming its arguments. + */ + public function testAGitThatCannotRunHidesTheCredentials(): void + { + $this->skipUnlessCliDriver(); + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('The wrapper of git is a shell script.'); + } + + $wrapper = self::createTempDir().'/git'; + file_put_contents($wrapper, "#!/bin/sh\necho \"wrapper: unable to run git \$*\" >&2\nexit 127\n"); + chmod($wrapper, 0o700); + $path = self::createTempDir().'/clone'; + + try { + Repository::cloneFrom(self::URL, $path, driver: new CliDriver($wrapper, self::gitEnvs())); + $this->fail('git does not run.'); + } catch (DriverException $e) { + $this->assertSame(\sprintf('The git command "clone" could not start: wrapper: unable to run git clone -q -- http://***@127.0.0.1:1/repository.git %s.', $path), $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + } + } + + /** + * The exception of Symfony Process isn't chained either when git is killed. + */ + public function testAKilledGitHidesTheCredentials(): void + { + $this->skipUnlessCliDriver(); + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('The git killing itself is a shell script.'); + } + + $killedGit = self::createTempDir().'/git'; + file_put_contents($killedGit, "#!/bin/sh\nkill -9 \$\$\n"); + chmod($killedGit, 0o700); + + try { + Repository::cloneFrom(self::URL, self::createTempDir().'/clone', driver: new CliDriver($killedGit, self::gitEnvs())); + $this->fail('git is killed.'); + } catch (DriverException $e) { + $this->assertSame('Unable to run the git command "clone": The process has been signaled with signal "9".', $e->getMessage()); + $this->assertNull($e->getPrevious()); + $this->assertStringNotContainsString('secret', (string) $e); + } + } + + /** + * LibGit2Driver checks the directory itself, and names the URL like libgit2 does. + */ + public function testACloneIntoADirectoryWithFilesHidesTheCredentials(): void + { + $dir = self::createTempDir(); + touch($dir.'/file'); + + try { + Repository::cloneBareFrom(self::URL, $dir, driver: self::getDriver()); + $this->fail('The directory has files.'); + } catch (DriverException $e) { + $this->assertSame(self::getDriver() instanceof CliDriver + ? \sprintf('The git command "clone" failed (exit 128): fatal: destination path \'%s\' already exists and is not an empty directory.', $dir) + : \sprintf('Unable to clone "http://***@127.0.0.1:1/repository.git": "%s" exists and is not an empty directory.', $dir), $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + } + } + + /** + * LibGit2Driver names the URL that has no such branch. No other test + * reaches a server: "git daemon" serves the repository here. + */ + public function testACloneOfAMissingBranchHidesTheCredentials(): void + { + if (self::getDriver() instanceof CliDriver) { + $this->markTestSkipped('git reads no user in a git:// URL: it looks "user:secret@127.0.0.1" up as a host.'); + } + + $dir = self::createTempDir(); + Repository::cloneBareFrom(self::repositoryUrl(), $dir.'/repository.git', driver: self::getDriver()); + + $socket = stream_socket_server('tcp://127.0.0.1:0') ?: throw new \RuntimeException('Unable to find a free port.'); + $port = (int) substr((string) strrchr((string) stream_socket_get_name($socket, false), ':'), 1); + fclose($socket); + $url = \sprintf('git://user:secret@127.0.0.1:%d/repository.git', $port); + + $daemon = new Process([self::gitCommand(), 'daemon', '--base-path='.$dir, '--export-all', '--listen=127.0.0.1', '--port='.$port, '--reuseaddr', $dir], env: self::gitEnvs()); + $daemon->start(); + + try { + for ($attempts = 0; !Repository::isValidRepository($url, self::getDriver()); ++$attempts) { + if (50 === $attempts || !$daemon->isRunning()) { + $this->markTestSkipped('"git daemon" does not start: '.$daemon->getErrorOutput()); + } + usleep(100_000); + } + + try { + Repository::cloneBareFrom($url, $dir.'/clone', 'missing', self::getDriver()); + $this->fail('The branch is missing.'); + } catch (DriverException $e) { + $this->assertSame(\sprintf('Remote branch "missing" not found in "git://***@127.0.0.1:%d/repository.git".', $port), $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + } + } finally { + $daemon->stop(1); + } + } + + /** + * The trace of an exception holds the arguments of every call on the way: + * the methods a URL goes through hide it, even those no test makes fail. + */ + #[DataProvider('provideParametersHoldingAUrl')] + public function testAParameterHoldingAUrlIsSensitive(\ReflectionParameter $parameter): void + { + $this->assertCount(1, $parameter->getAttributes(\SensitiveParameter::class)); + } + + /** + * @return iterable + */ + public static function provideParametersHoldingAUrl(): iterable + { + $src = \dirname(__DIR__, 4).'/src/'; + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($src.'Gitonomy/Git', \FilesystemIterator::SKIP_DOTS)); + + /** @var \SplFileInfo $file */ + foreach ($files as $file) { + $class = strtr(substr($file->getPathname(), \strlen($src), -\strlen('.php')), '/', '\\'); + // Not an interface, which is in no trace, nor libgit2.h. + if (!class_exists($class)) { + continue; + } + + foreach (new \ReflectionClass($class)->getMethods() as $method) { + foreach ($method->getParameters() as $parameter) { + // $config: the configuration of a clone, with the URL of its remote. + if (\in_array($parameter->getName(), ['config', 'url'], true)) { + yield \sprintf('%s::%s(), $%s', $class, $method->getName(), $parameter->getName()) => [$parameter]; + } + } + } + } } /** diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index fab81f49..150a02ca 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -43,6 +43,19 @@ public function testSerialization(bool $bare): void $this->verifyCreateCommitDiff($diff); } + /** + * A file reads its blobs in the repository of its diff: a parsed diff has none, unless given one. + */ + public function testAFileHasTheRepositoryOfItsDiff(): void + { + $repository = self::createFoobarRepository(); + $rawDiff = ":000000 100644 0000000000000000000000000000000000000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 A\0empty\0"; + + $this->assertSame($repository, $repository->getCommit(self::CREATE_COMMIT)->getDiff()->getFiles()[0]->getRepository()); + $this->assertSame($repository, Diff::parse($rawDiff, $repository)->getFiles()[0]->getRepository()); + $this->assertNull(Diff::parse($rawDiff)->getFiles()[0]->getRepository()); + } + #[DataProvider('provideBare')] public function testGetFilesAddition(bool $bare): void { @@ -348,6 +361,10 @@ public function testNamesWithSpaces(): void public function testTypeChangeIsADeletionAndACreation(): void { + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('git stores no symbolic link on Windows.'); + } + $repository = self::createWorkingCopy(); self::commitFile($repository, 'link', "target\n"); self::commitFile($repository, 'renamed', "moved\n"); @@ -370,6 +387,78 @@ public function testTypeChangeIsADeletionAndACreation(): void $this->assertSame("moved\n", $files[2]->getNewBlob()->getContent()); } + /** + * The trees old versions of git wrote have files of mode 100664, which + * git diffs as 100644, like "git diff-tree". + */ + public function testDiffReadsTheModesOfOldTreesLikeGit(): void + { + $repository = self::createEmptyRepository(); + $blob = self::writeObject($repository, 'blob', "content\n"); + $oldTree = self::writeObject($repository, 'tree', "100664 file\0".hex2bin($blob)); + $tree = self::writeObject($repository, 'tree', "100644 file\0".hex2bin($blob)); + $old = self::writeObject($repository, 'commit', "tree {$oldTree}\nauthor A 1700000000 +0000\ncommitter A 1700000000 +0000\n\nOld\n"); + $new = self::writeObject($repository, 'commit', "tree {$tree}\nparent {$old}\nauthor A 1700000001 +0000\ncommitter A 1700000001 +0000\n\nNew\n"); + + $files = $repository->getCommit($old)->getDiff()->getFiles(); + + $this->assertCount(1, $files); + $this->assertSame('100644', $files[0]->getNewMode()); + $this->assertSame([], $repository->getCommit($new)->getDiff()->getFiles()); + } + + /** + * Like git, which compares a file once its filters have normalized its + * line endings. + */ + public function testPendingDiffLeavesOutAFileWhoseLineEndingsAloneDiffer(): void + { + if (!self::getDriver() instanceof LibGit2Driver && version_compare(self::gitVersion(), '2.17.0', '<')) { + $this->markTestSkipped('git 1.8.4 lists the file, without any change: git 2.17 leaves it out.'); + } + + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'file', "a\nb\n"); + self::git($repository, 'config', ['core.autocrlf', 'true']); + file_put_contents($repository->getWorkingDir().'/file', "a\r\nb\r\n"); + + $this->assertSame([], $repository->getWorkingCopy()->getDiffPending()->getFiles()); + $this->assertSame([], $repository->getWorkingCopy()->getDiff()->getFiles()); + } + + /** + * git writes no hash for it: its content didn't change. + */ + public function testAFileOfTheWorkingTreeWhoseModeAloneChangedHasItsHash(): void + { + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('Windows has no executable bit.'); + } + + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'script', "#!/bin/sh\n"); + chmod($repository->getWorkingDir().'/script', 0o755); + + foreach ([$repository->getWorkingCopy()->getDiffPending(), $repository->getWorkingCopy()->getDiff()] as $diff) { + $files = $diff->getFiles(); + + $this->assertCount(1, $files); + $this->assertSame('100644', $files[0]->getOldMode()); + $this->assertSame('100755', $files[0]->getNewMode()); + $this->assertNotNull($files[0]->getOldIndex()); + $this->assertSame($files[0]->getOldIndex(), $files[0]->getNewIndex()); + } + } + + public function testAChangeRefusesTheLinesOf1x(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The lines of a change must be "Gitonomy\Git\Diff\DiffLine" objects, "array" given.'); + + // @phpstan-ignore argument.type (PHP doesn't check the types of the elements, unlike PHPStan.) + new FileChange(1, 1, 1, 1, [[0, 'context']]); + } + public function testDiffsOfAConflictLeaveUnmergedPathsOut(): void { $repository = self::createWorkingCopy(); diff --git a/tests/Gitonomy/Git/Tests/DriverArgumentsTest.php b/tests/Gitonomy/Git/Tests/DriverArgumentsTest.php new file mode 100644 index 00000000..9e2e0732 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/DriverArgumentsTest.php @@ -0,0 +1,253 @@ + + * (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\DriverInterface; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\FastForward; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * An application may call a driver itself: it refuses the arguments git would + * misread, whatever Repository and the domain objects check before calling it. + * + * LeadingDashTest, NulByteTest and RemoteUrlTest call it through them. + */ +class DriverArgumentsTest extends AbstractTestCase +{ + private const string TREE = 'b06890c7b10904979d2f69613c2ccda30aafe262'; + private const string BLOB = '225fce09b8029e4c44cff4513ef86d89ca53f1cc'; + private const string ANNOTATED_TAG = 'b61d8c2cac78a443729ca16cad12b96e3d98365d'; + + /** + * The sprintf() format making an invalid argument of a valid one. + */ + private const string NUL_BYTE = "%s\0junk"; + + /** + * @param \Closure(RepositoryDriverInterface, string): mixed $call + */ + #[DataProvider('provideRepositoryDriverCallsWithARevision')] + #[DataProvider('provideRepositoryDriverCallsWithAnObject')] + public function testARepositoryDriverRefusesALeadingDash(\Closure $call): void + { + $driver = self::createFoobarRepository(false)->getRepositoryDriver(); + $file = self::createTempDir().'/written'; + + try { + $call($driver, self::leadingDash($file)); + $this->fail('A leading dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertSame(\sprintf('"--output=%s" starts with a dash, which git would read as an option.', $file), $e->getMessage()); + } + + $this->assertFileDoesNotExist($file); + $this->assertSame('refs/heads/master', $driver->getHeadReference(), 'Nothing is done'); + } + + /** + * Without the NUL byte and what follows it, every argument is valid: read + * as a C string, it would name something. + * + * @param \Closure(RepositoryDriverInterface, string): mixed $call + */ + #[DataProvider('provideRepositoryDriverCallsWithARevision')] + #[DataProvider('provideRepositoryDriverCallsWithAnObject')] + #[DataProvider('provideRepositoryDriverCallsWithAPath')] + public function testARepositoryDriverRefusesANulByte(\Closure $call): void + { + $driver = self::createFoobarRepository(false)->getRepositoryDriver(); + $references = array_column($driver->getReferences(), 'commitHash', 'fullname'); + + try { + $call($driver, self::NUL_BYTE); + $this->fail('A NUL byte is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringEndsWith('\000junk" contains a NUL byte, which git doesn\'t accept.', $e->getMessage()); + } + + $this->assertSame('refs/heads/master', $driver->getHeadReference(), 'Nothing is done'); + $this->assertSame($references, array_column($driver->getReferences(), 'commitHash', 'fullname'), 'Nothing is done'); + } + + /** + * The arguments naming a revision, a hash or a reference: $invalid is the + * sprintf() format making an invalid one of a valid one. + * + * @return iterable + */ + public static function provideRepositoryDriverCallsWithARevision(): iterable + { + yield 'resolveRevision()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->resolveRevision(\sprintf($invalid, 'master'))]; + yield 'getShortHash()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getShortHash(\sprintf($invalid, self::LONGFILE_COMMIT))]; + yield 'getCommitData()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getCommitData(\sprintf($invalid, self::LONGFILE_COMMIT))]; + yield 'getCommitLog(), a commit to include' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getCommitLog([\sprintf($invalid, self::LONGFILE_COMMIT)], [], [], 0, null)]; + yield 'getCommitLog(), a commit to exclude' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getCommitLog([self::LONGFILE_COMMIT], [\sprintf($invalid, self::INITIAL_COMMIT)], [], 0, null)]; + yield 'getCommitLog(), a commit to exclude from every reference' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getCommitLog(null, [\sprintf($invalid, self::INITIAL_COMMIT)], [], 0, null)]; + yield 'countCommits(), a commit to include' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->countCommits([\sprintf($invalid, self::LONGFILE_COMMIT)], [], [])]; + yield 'countCommits(), a commit to exclude' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->countCommits([self::LONGFILE_COMMIT], [\sprintf($invalid, self::INITIAL_COMMIT)], [])]; + yield 'countCommits(), a commit to exclude from every reference' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->countCommits(null, [\sprintf($invalid, self::INITIAL_COMMIT)], [])]; + yield 'getMergeBases(), the first commit' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getMergeBases(\sprintf($invalid, self::LONGFILE_COMMIT), self::INITIAL_COMMIT)]; + yield 'getMergeBases(), the second commit' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getMergeBases(self::LONGFILE_COMMIT, \sprintf($invalid, self::INITIAL_COMMIT))]; + yield 'createReference(), the reference' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->createReference(\sprintf($invalid, 'refs/heads/new'), self::LONGFILE_COMMIT)]; + yield 'createReference(), the hash' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->createReference('refs/heads/new', \sprintf($invalid, self::LONGFILE_COMMIT))]; + yield 'updateReference(), the reference' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->updateReference(\sprintf($invalid, 'refs/heads/master'), self::LONGFILE_COMMIT)]; + yield 'updateReference(), the hash' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->updateReference('refs/heads/master', \sprintf($invalid, self::LONGFILE_COMMIT))]; + yield 'deleteReference()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->deleteReference(\sprintf($invalid, 'refs/tags/0.1'))]; + yield 'getIncludingBranches()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getIncludingBranches(\sprintf($invalid, self::LONGFILE_COMMIT))]; + yield 'getDiff(), the first commit' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getDiff(\sprintf($invalid, self::INITIAL_COMMIT), self::LONGFILE_COMMIT)]; + yield 'getDiff(), the second commit' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getDiff(self::INITIAL_COMMIT, \sprintf($invalid, self::LONGFILE_COMMIT))]; + yield 'getDiff() from the empty tree' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getDiff(null, \sprintf($invalid, self::LONGFILE_COMMIT))]; + yield 'getWorkingTreeDiff()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getWorkingTreeDiff(\sprintf($invalid, self::LONGFILE_COMMIT))]; + yield 'getBlameLines(), the commit' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getBlameLines(\sprintf($invalid, self::LONGFILE_COMMIT), 'README.md', null, null)]; + yield 'checkout()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->checkout(\sprintf($invalid, self::LONGFILE_COMMIT))]; + yield 'checkoutNewBranch(), the branch' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->checkoutNewBranch(\sprintf($invalid, 'new'), self::LONGFILE_COMMIT)]; + yield 'checkoutNewBranch(), the start point' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->checkoutNewBranch('new', \sprintf($invalid, self::LONGFILE_COMMIT))]; + yield 'merge()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->merge(\sprintf($invalid, 'origin/new-feature'), FastForward::Allow)]; + } + + /** + * The methods reading an object by its hash. + * + * @return iterable + */ + public static function provideRepositoryDriverCallsWithAnObject(): iterable + { + yield 'getTreeEntries()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getTreeEntries(\sprintf($invalid, self::TREE))]; + yield 'getBlobContent()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getBlobContent(\sprintf($invalid, self::BLOB))]; + yield 'getTagData()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getTagData(\sprintf($invalid, self::ANNOTATED_TAG))]; + } + + /** + * A path may start with a dash. + * + * @return iterable + */ + public static function provideRepositoryDriverCallsWithAPath(): iterable + { + yield 'getCommitLog(), a path' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getCommitLog([self::LONGFILE_COMMIT], [], [\sprintf($invalid, 'README.md')], 0, null)]; + yield 'countCommits(), a path' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->countCommits([self::LONGFILE_COMMIT], [], [\sprintf($invalid, 'README.md')])]; + yield 'getBlameLines(), the path' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getBlameLines(self::LONGFILE_COMMIT, \sprintf($invalid, 'README.md'), null, null)]; + } + + /** + * CliDriver writes these hashes on the standard input of git, one per + * line: what follows a new line would be another one. + * + * @param \Closure(RepositoryDriverInterface, string): mixed $call + */ + #[DataProvider('provideCliRepositoryDriverCallsWritingAHashOnTheStandardInput')] + public function testCliDriverRefusesANewLineInAHashOfTheStandardInput(\Closure $call, string $hash): void + { + $this->skipUnlessCliDriver(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('"%s\n%s" contains a new line, which git doesn\'t accept.', $hash, self::INITIAL_COMMIT)); + + $call(self::createFoobarRepository(false)->getRepositoryDriver(), $hash."\n".self::INITIAL_COMMIT); + } + + /** + * @return iterable + */ + public static function provideCliRepositoryDriverCallsWritingAHashOnTheStandardInput(): iterable + { + yield 'getCommitLog(), a commit to include' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getCommitLog([$invalid], [], [], 0, null), self::LONGFILE_COMMIT]; + yield 'getCommitLog(), a commit to exclude' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getCommitLog(null, [$invalid], [], 0, null), self::LONGFILE_COMMIT]; + yield 'countCommits()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->countCommits([$invalid], [], []), self::LONGFILE_COMMIT]; + yield 'getBlobContent()' => [static fn (RepositoryDriverInterface $driver, string $invalid) => $driver->getBlobContent($invalid), self::BLOB]; + } + + /** + * @param \Closure(DriverInterface, string, string, string): mixed $call + */ + #[DataProvider('provideDriverCallsWithAUrl')] + public function testADriverRefusesAUrlStartingWithADash(\Closure $call): void + { + $dir = self::createTempDir(); + $file = $dir.'/written'; + + try { + $call(self::getDriver(), self::leadingDash($file), self::repositoryUrl(), $dir); + $this->fail('A leading dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertSame(\sprintf('"--output=%s" starts with a dash, which git would read as an option.', $file), $e->getMessage()); + } + + $this->assertSame(['.', '..'], scandir($dir), 'Nothing is created'); + } + + /** + * @param \Closure(DriverInterface, string, string, string): mixed $call + */ + #[DataProvider('provideDriverCallsWithAUrl')] + #[DataProvider('provideDriverCallsWithAPathOrABranch')] + public function testADriverRefusesANulByte(\Closure $call): void + { + $dir = self::createTempDir(); + + try { + $call(self::getDriver(), self::NUL_BYTE, self::repositoryUrl(), $dir); + $this->fail('A NUL byte is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringEndsWith('\000junk" contains a NUL byte, which git doesn\'t accept.', $e->getMessage()); + } + + $this->assertSame(['.', '..'], scandir($dir), 'Nothing is created'); + } + + /** + * $invalid is the sprintf() format making an invalid argument of a valid + * one; then the URL of a repository, and an empty directory. + * + * @return iterable + */ + public static function provideDriverCallsWithAUrl(): iterable + { + yield 'cloneFrom()' => [static fn (DriverInterface $driver, string $invalid, string $url, string $dir) => $driver->cloneFrom(\sprintf($invalid, $url), $dir.'/clone', null)]; + yield 'cloneBareFrom()' => [static fn (DriverInterface $driver, string $invalid, string $url, string $dir) => $driver->cloneBareFrom(\sprintf($invalid, $url), $dir.'/clone', null)]; + yield 'mirrorFrom()' => [static fn (DriverInterface $driver, string $invalid, string $url, string $dir) => $driver->mirrorFrom(\sprintf($invalid, $url), $dir.'/clone')]; + yield 'isValidRepository()' => [static fn (DriverInterface $driver, string $invalid, string $url) => $driver->isValidRepository(\sprintf($invalid, $url))]; + yield 'isValidRepositoryAndBranch()' => [static fn (DriverInterface $driver, string $invalid, string $url) => $driver->isValidRepositoryAndBranch(\sprintf($invalid, $url), 'master')]; + } + + /** + * A path may start with a dash, and git reads a branch after an option. + * + * @return iterable + */ + public static function provideDriverCallsWithAPathOrABranch(): iterable + { + yield 'open(), the directory' => [static fn (DriverInterface $driver, string $invalid, string $url, string $dir) => $driver->open(\sprintf($invalid, self::createFoobarRepository()->getGitDir()), null)]; + yield 'open(), the working directory' => [static fn (DriverInterface $driver, string $invalid, string $url, string $dir) => $driver->open(self::createFoobarRepository()->getGitDir(), \sprintf($invalid, $dir))]; + yield 'init()' => [static fn (DriverInterface $driver, string $invalid, string $url, string $dir) => $driver->init(\sprintf($invalid, $dir.'/repository'))]; + yield 'initBare()' => [static fn (DriverInterface $driver, string $invalid, string $url, string $dir) => $driver->initBare(\sprintf($invalid, $dir.'/repository'))]; + yield 'cloneFrom(), the path' => [static fn (DriverInterface $driver, string $invalid, string $url, string $dir) => $driver->cloneFrom($url, \sprintf($invalid, $dir.'/clone'), null)]; + yield 'cloneFrom(), the branch' => [static fn (DriverInterface $driver, string $invalid, string $url, string $dir) => $driver->cloneFrom($url, $dir.'/clone', \sprintf($invalid, 'master'))]; + yield 'cloneBareFrom(), the path' => [static fn (DriverInterface $driver, string $invalid, string $url, string $dir) => $driver->cloneBareFrom($url, \sprintf($invalid, $dir.'/clone'), null)]; + yield 'cloneBareFrom(), the branch' => [static fn (DriverInterface $driver, string $invalid, string $url, string $dir) => $driver->cloneBareFrom($url, $dir.'/clone', \sprintf($invalid, 'master'))]; + yield 'mirrorFrom(), the path' => [static fn (DriverInterface $driver, string $invalid, string $url, string $dir) => $driver->mirrorFrom($url, \sprintf($invalid, $dir.'/clone'))]; + yield 'isValidRepositoryAndBranch(), the branch' => [static fn (DriverInterface $driver, string $invalid, string $url) => $driver->isValidRepositoryAndBranch($url, \sprintf($invalid, 'master'))]; + } + + /** + * The "--output" option of git log writes a file. + * + * @return string A sprintf() format, like NUL_BYTE: whatever the valid argument, an option + */ + private static function leadingDash(string $file): string + { + return str_replace('%', '%%', '--output='.$file); + } +} diff --git a/tests/Gitonomy/Git/Tests/DriverParityTest.php b/tests/Gitonomy/Git/Tests/DriverParityTest.php index c27752ef..721f81ed 100644 --- a/tests/Gitonomy/Git/Tests/DriverParityTest.php +++ b/tests/Gitonomy/Git/Tests/DriverParityTest.php @@ -20,6 +20,7 @@ use Gitonomy\Git\Driver\RepositoryDriverInterface; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\Before; +use Symfony\Component\Process\Process; /** * Both drivers read the same data from the same repository, but where @@ -108,6 +109,24 @@ public function testRenameDetectionMayPairFilesDifferently(): void $this->assertCount(12, $libgit2); } + /** + * A shallow clone has the commits it cuts the history at, without their + * parents, which git still prints. + */ + public function testShallowClone(): void + { + $dir = self::createTempDir(); + new Process([self::gitCommand(), 'clone', '-q', '--depth', '1', 'file://'.$this->cli->getGitDir(), $dir])->mustRun(); + $cli = new Repository($dir, driver: self::getCliDriver())->getRepositoryDriver(); + $libgit2 = new Repository($dir, driver: new LibGit2Driver())->getRepositoryDriver(); + $head = $cli->resolveRevision('HEAD'); + + $this->assertSame([], $cli->getCommitData($head)->parentHashes); + $this->assertSameData($cli->getCommitData($head), $libgit2->getCommitData($head)); + $this->assertSameData($cli->getCommitLog(null, [], [], 0, null), $libgit2->getCommitLog(null, [], [], 0, null)); + $this->assertSameData($cli->getDiff(null, $head)->files, $libgit2->getDiff(null, $head)->files); + } + public function testBlame(): void { $master = $this->cli->resolveRevision('master'); diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 8c780fca..8500851f 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -112,6 +112,8 @@ public static function provideInvalidNames(): iterable yield 'empty' => ['']; yield 'hidden file' => ['.pre-commit']; yield 'NUL byte' => ["pre-commit\0.sample"]; + // "$" alone matches before a trailing line feed. + yield 'trailing line feed' => ["pre-commit\n"]; } #[DataProvider('provideBare')] @@ -242,6 +244,9 @@ public function testRemoveNotExistingThrows(bool $bare): void public function testASymlinkToAMissingFileIsAHook(): void { $this->markAsSkippedIfSymlinkIsMissing(); + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('Windows creates no link to a missing file.'); + } $repository = self::createFoobarRepository(); $target = self::createTempDir().'/pre-receive'; diff --git a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php index a69fecfa..b7f6651b 100644 --- a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php +++ b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php @@ -99,6 +99,87 @@ public function testTheSystemConfigurationIsLeftOutWithGitConfigNoSystem(): void $this->assertNotSame('', $process->getOutput()); } + /** + * libgit2 waits forever by default: a server that keeps a connection + * open without answering would block PHP, whatever max_execution_time. + */ + #[RequiresPhpExtension('ffi')] + public function testNetworkOperationsTimeOut(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + + // libgit2 is initialized once per process. 40 and 42 read the + // timeouts, in milliseconds, of a connection to a server + // (GIT_OPT_GET_SERVER_CONNECT_TIMEOUT) and of its answers + // (GIT_OPT_GET_SERVER_TIMEOUT). + $code = <<<'PHP' + require $argv[1]; + $ffi = Gitonomy\Git\Driver\Ffi\LibGit2::load(null); + $connect = $ffi->new('int'); + $ffi->git_libgit2_opts(40, FFI::addr($connect)); + $answer = $ffi->new('int'); + $ffi->git_libgit2_opts(42, FFI::addr($answer)); + echo $connect->cdata, ' ', $answer->cdata; + PHP; + + $process = new Process([\PHP_BINARY, '-r', $code, self::AUTOLOAD]); + $process->mustRun(); + + $this->assertSame('60000 3600000', $process->getOutput()); + } + + /** + * libgit2 1.9 reads neither a split index, which "git update-index + * --split-index" or core.splitIndex write, nor a sparse one. + */ + public function testASplitIndexIsUnsupported(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + if (version_compare(self::gitVersion(), '2.0.0', '<')) { + $this->markTestSkipped('git splits the index since 2.0.'); + } + + $path = self::createFoobarRepository(false)->getPath(); + new Process([self::gitCommand(), 'update-index', '--split-index'], $path, self::gitEnvs())->mustRun(); + $repository = new Repository($path, driver: new LibGit2Driver()); + + try { + $repository->getWorkingCopy()->getDiffStaged(); + $this->fail('libgit2 reads no split index.'); + } catch (UnsupportedOperationException $e) { + $this->assertSame('Unable to open the index: unsupported mandatory extension: \'link\'. LibGit2Driver doesn\'t support it; use CliDriver.', $e->getMessage()); + } + + try { + $repository->getWorkingCopy()->checkoutNewBranch('new', self::INITIAL_COMMIT); + $this->fail('libgit2 reads no split index.'); + } catch (UnsupportedOperationException $e) { + $this->assertStringEndsWith('unsupported mandatory extension: \'link\'. LibGit2Driver doesn\'t support it; use CliDriver.', $e->getMessage()); + } + $this->assertFalse($repository->getReferences()->hasBranch('new'), 'No branch is created when the checkout fails'); + } + + public function testAnUnknownExtensionOfTheRepositoryIsUnsupported(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + + $path = self::createFoobarRepository()->getPath(); + // git and libgit2 only read the extensions of a repository of version + // 1, which git 1.8 refuses to configure. + file_put_contents($path.'/config', "[core]\n\trepositoryFormatVersion = 1\n[extensions]\n\tfromTheFuture = true\n", \FILE_APPEND); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('unsupported extension name extensions.fromthefuture. LibGit2Driver doesn\'t support it; use CliDriver.'); + + new Repository($path, driver: new LibGit2Driver()); + } + public function testReftableRepositoriesAreUnsupported(): void { if (!LibGit2Driver::isSupported()) { diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 993b53db..a43dbe31 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -14,10 +14,12 @@ use Gitonomy\Git\Commit; use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Driver\Cli\Parser\LogParser; use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\Log; @@ -127,7 +129,7 @@ public function testParsesCommitsWithAndWithoutGitButlerHeaders(): void EOT; - $parser = new LogParser(); + $parser = new LogParser([]); $parser->parse($logContent); $log = $parser->commits; @@ -222,12 +224,27 @@ public function testLogOfAPathOutsideTheRepositoryThrows(): void { $repository = self::createFoobarRepository(); - // Like git. - $this->expectException(DriverException::class); + // Like git, whose versions word it differently: recent ones name the path twice, then the repository. + if (self::getDriver() instanceof CliDriver) { + $this->expectException(ProcessException::class); + $this->expectExceptionMessageMatches('/^The git command "log" failed \(exit 128\): fatal: (?:\/README\.md: )?\'\/README\.md\' is outside repository(?: at \'.+\')?\.$/'); + } else { + $this->expectException(DriverException::class); + $this->expectExceptionMessageMatches('/^The path "\/README\.md" is outside the repository\.$/'); + } $repository->getLog('master', '/README.md')->getCommits(); } + public function testGetPaths(): void + { + $repository = self::createFoobarRepository(); + + $this->assertSame([], $repository->getLog('master')->getPaths()); + $this->assertSame(['README.md'], $repository->getLog('master', 'README.md')->getPaths(), 'A path alone is a list of one'); + $this->assertSame(['README.md', 'image.jpg'], $repository->getLog('master', ['README.md', 'image.jpg'])->getPaths()); + } + public function testLogOfAnAbsolutePathInTheWorkingTree(): void { $repository = self::createFoobarRepository(false); @@ -249,6 +266,24 @@ public function testLogOnWildcardPath(): void $this->assertSame([$text], self::hashes($repository->getLog('main', 'dir/*.txt'))); } + /** + * Like git, a path names the file of this very name, and the ones it + * matches once its backslash is read as the escape of a wildcard. + */ + public function testLogOfAPathWithABackslash(): void + { + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('Windows has no file name with a backslash.'); + } + + $repository = self::createRepositoryToCommitTo(); + $literal = self::commit($repository, 'back\slash', 'literal'); + $escaped = self::commit($repository, 'backslash', 'escaped'); + self::commit($repository, 'other', 'other'); + + $this->assertSame([$escaped, $literal], self::hashes($repository->getLog('main', 'back\slash'))); + } + public function testLogOfAPathNormalizesIt(): void { $repository = self::createRepositoryToCommitTo(); diff --git a/tests/Gitonomy/Git/Tests/NulByteTest.php b/tests/Gitonomy/Git/Tests/NulByteTest.php index a059d047..f8c3fb9b 100644 --- a/tests/Gitonomy/Git/Tests/NulByteTest.php +++ b/tests/Gitonomy/Git/Tests/NulByteTest.php @@ -58,6 +58,16 @@ public function testADirectoryWithANulByteIsRefused(): void new Repository(self::createTempDir()."\0junk", driver: self::getDriver()); } + public function testACommandWithANulByteIsRefused(): void + { + $this->skipUnlessCliDriver(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"status\000junk" contains a NUL byte, which git doesn\'t accept.'); + + self::createFoobarRepository()->run("status\0junk"); + } + public function testAUrlWithANulByteIsRefused(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index 583f533d..098618e5 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -12,7 +12,6 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\PushReference; use PHPUnit\Framework\Attributes\DataProvider; @@ -47,11 +46,29 @@ public static function provideIsers(): array ]; } + public function testGetFullname(): void + { + $reference = new PushReference(self::createFoobarRepository(), 'refs/heads/master', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT); + + $this->assertSame('refs/heads/master', $reference->getFullname()); + } + + public function testGetRepository(): void + { + $repository = self::createFoobarRepository(); + + $this->assertSame($repository, new PushReference($repository, 'refs/heads/master', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->getRepository()); + } + public function testUnknownBeforeIsAnErrorNotAForcePush(): void { $reference = new PushReference(self::createFoobarRepository(), 'foo', str_repeat('1', 40), self::LONGFILE_COMMIT); - $this->expectException(DriverException::class); + $this->expectDriverException( + 'merge-base', + 'fatal: Not a valid commit name 1111111111111111111111111111111111111111.', + 'Unable to compute the merge bases of "1111111111111111111111111111111111111111" and "'.self::LONGFILE_COMMIT.'": object not found - no match for id (1111111111111111111111111111111111111111).', + ); $reference->isForce(); } diff --git a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php index fb50ec91..23576422 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use PHPUnit\Framework\Attributes\DataProvider; @@ -41,6 +42,16 @@ public function testGetFirstBranchWithNoBranches(): void $this->assertNull($refs->getFirstBranch(), 'getFirstBranch() must not return a non-branch reference'); } + public function testHasRemoteBranch(): void + { + $references = self::createFoobarRepository(false)->getReferences(); + + $this->assertTrue($references->hasRemoteBranch('origin/master')); + $this->assertFalse($references->hasRemoteBranch('origin/missing')); + $this->assertFalse($references->hasRemoteBranch('master'), 'A local branch is no remote one'); + $this->assertFalse(self::createFoobarRepository()->getReferences()->hasRemoteBranch('origin/master'), 'A bare clone has no remote branch'); + } + #[DataProvider('provideBare')] public function testUnknownReference(bool $bare): void { @@ -72,6 +83,29 @@ public function testEmptyRepo(bool $bare): void $this->assertSame([], $refs); } + /** + * It named no reference, instead of no commit. + */ + public function testResolveRefusesAHashEndingWithANewLine(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(ReferenceNotFoundException::class); + + $repository->getReferences()->resolve(self::LONGFILE_COMMIT."\n"); + } + + public function testResolveTakesAnyRevision(): void + { + $references = self::createFoobarRepository()->getReferences(); + $master = $references->getBranch('master'); + + $this->assertContains($master, $references->resolve($master)); + $this->assertSame($references->resolve($master->getCommitHash()), $references->resolve($master)); + $this->assertSame([$master], $references->resolveBranches($master)); + $this->assertSame([], $references->resolveTags($master)); + } + public function testReferencesAreSortedByName(): void { $repository = self::createFoobarRepository(); diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index c9ab4785..a19448bb 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; @@ -57,6 +58,15 @@ public function testEveryReferenceHasAName(): void $this->assertSame('stash', $names['refs/stash']); } + #[DataProvider('provideBare')] + public function testGetLastModification(bool $bare): void + { + $tag = self::createFoobarRepository($bare)->getReferences()->getTag('0.1'); + + $this->assertSame(self::LONGFILE_COMMIT, $tag->getLastModification()->getHash(), 'Without a path, the commit of the reference'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $tag->getLastModification('image.jpg')->getHash()); + } + #[DataProvider('provideBare')] public function testHasBranch(bool $bare): void { @@ -137,6 +147,32 @@ public function testTheTaggerDateIsAtTheOffsetOfTheTagger(): void $this->assertSame(1700000002, $date->getTimestamp()); } + /** + * Like git and libgit2, which read the tags other tools write: with a + * header after the tagger, or without the empty line before the message. + */ + #[DataProvider('provideTagsOfOtherTools')] + public function testATagOfAnotherToolIsReadLikeGit(string $end, string $message): void + { + $repository = self::createFoobarRepository(); + $hash = self::writeObject($repository, 'tag', \sprintf("object %s\ntype commit\ntag other\ntagger A 1700000000 +0000\n%s", self::LONGFILE_COMMIT, $end)); + self::git($repository, 'update-ref', ['refs/tags/other', $hash]); + + $tag = $repository->getReferences()->getTag('other'); + + $this->assertSame('A', $tag->getTaggerName()); + $this->assertSame($message, $tag->getMessage()); + } + + /** + * @return iterable + */ + public static function provideTagsOfOtherTools(): iterable + { + yield 'a header after the tagger' => ["encoding ISO-8859-1\n\nMessage\n", "Message\n"]; + yield 'no message, without the empty line' => ['', '']; + } + #[DataProvider('provideBare')] public function testAnnotatedTagBodyMessageWithBlankLineSeparator(bool $bare): void { @@ -430,6 +466,50 @@ public static function provideWritesOfANameOutsideRefs(): iterable } } + /** + * Like "git update-ref", which writes a tree, or even the tag of a + * commit, under any name but the one of a branch. + * + * @param \Closure(Repository, string, string): void $write + */ + #[DataProvider('provideWritesOfAnObjectThatIsNoCommit')] + public function testABranchOnlyPointsToACommit(\Closure $write, string $revision): void + { + $repository = self::createFoobarRepository(); + $hash = trim(self::git($repository, 'rev-parse', [$revision])); + + try { + $write($repository, 'refs/heads/no-commit', $hash); + $this->fail('A branch only points to a commit.'); + } catch (DriverException $e) { + if (self::getDriver() instanceof LibGit2Driver) { + $this->assertMatchesRegularExpression(\sprintf('/^Unable to write the reference "refs\/heads\/no-commit": the object "%s" is a (?:tree|tag), and a branch only points to a commit\.$/', $hash), $e->getMessage()); + } + } + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertFalse($repository->getReferences()->hasBranch('no-commit')); + + $write($repository, 'refs/tags/no-commit', $hash); + $this->assertSame($hash, trim(self::git($repository, 'rev-parse', ['refs/tags/no-commit']))); + } + + /** + * @return iterable + */ + public static function provideWritesOfAnObjectThatIsNoCommit(): iterable + { + $writes = [ + 'create' => static function (Repository $repository, string $fullname, string $hash): void { $repository->getRepositoryDriver()->createReference($fullname, $hash); }, + 'update' => static function (Repository $repository, string $fullname, string $hash): void { $repository->getRepositoryDriver()->updateReference($fullname, $hash); }, + ]; + + foreach ($writes as $writeName => $write) { + yield $writeName.' to a tree' => [$write, 'master^{tree}']; + yield $writeName.' to an annotated tag' => [$write, 'refs/tags/annotated']; + } + } + /** * Nothing is created through a symbolic reference to a missing branch: * git 2.x and libgit2 refuse it, git 1.x replaces it. diff --git a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php index 4c05b7e3..3b3511d1 100644 --- a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php +++ b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php @@ -12,10 +12,12 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Repository; use Gitonomy\Git\Util\Credentials; use Gitonomy\Git\Util\RemoteUrl; +use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\DataProvider; /** @@ -32,21 +34,26 @@ class RemoteUrlTest extends AbstractTestCase public function testAUrlStartingWithADashIsRefused(\Closure $call): void { $repository = self::createFoobarRepository(false); + $canary = self::createCanary(); $previousCwd = getcwd(); $this->assertIsString($previousCwd); chdir($repository->getPath()); + $exception = null; + try { // A colon before any slash: a URL, never a local path. - $call('--upload-pack=touch pwned;:;git-upload-pack', self::createTempDir().'/clone'); - $this->fail('A leading dash is refused.'); - } catch (InvalidArgumentException $e) { - $this->assertStringContainsString('starts with a dash', $e->getMessage()); + $call('--upload-pack=touch "$GITLIB_CANARY";:;git-upload-pack', self::createTempDir().'/clone'); + } catch (InvalidArgumentException|DriverException $exception) { + // A DriverException: the failure of git, when the URL isn't refused. } finally { chdir($previousCwd); } - $this->assertFileDoesNotExist($repository->getPath().'/pwned'); + // Checked first: a URL that isn't refused may not run its command either. + $this->assertFileDoesNotExist($canary); + $this->assertInstanceOf(InvalidArgumentException::class, $exception, 'A leading dash is refused.'); + $this->assertStringContainsString('starts with a dash', $exception->getMessage()); } /** @@ -97,26 +104,26 @@ public static function provideCalls(): iterable /** * Before 2.14.1 (CVE-2017-1000117), git passes a host starting with a dash * to ssh, which reads it as an option: "-oProxyCommand=..." runs the - * command. Recent versions of OpenSSH refuse such a host. + * command. Recent versions of OpenSSH refuse such a host. The command of + * core.gitProxy gets the port of a git URL the same way. */ #[DataProvider('provideHostsStartingWithADash')] public function testAHostStartingWithADashIsRefused(\Closure $call, string $url): void { - $dir = self::createTempDir(); - $previousCwd = getcwd(); - $this->assertIsString($previousCwd); - chdir($dir); + $canary = self::createCanary(); + + $exception = null; try { - $call($url, $dir.'/clone'); - $this->fail('A host starting with a dash is refused.'); - } catch (InvalidArgumentException $e) { - $this->assertSame(\sprintf('"%s" has a host or a user starting with a dash, which ssh, or the command of core.gitProxy, would read as an option.', Credentials::redact($url)), $e->getMessage()); - } finally { - chdir($previousCwd); + $call($url, self::createTempDir().'/clone'); + } catch (InvalidArgumentException|DriverException $exception) { + // A DriverException: the failure of git, or of ssh, when the host isn't refused. } - $this->assertFileDoesNotExist($dir.'/pwned'); + // Checked first: a URL that isn't refused may not run its command either. + $this->assertFileDoesNotExist($canary); + $this->assertInstanceOf(InvalidArgumentException::class, $exception, 'A host starting with a dash is refused.'); + $this->assertSame(\sprintf('"%s" has a host, a user or a port starting with a dash, which ssh, or the command of core.gitProxy, would read as an option.', Credentials::redact($url)), $exception->getMessage()); } /** @@ -125,7 +132,7 @@ public function testAHostStartingWithADashIsRefused(\Closure $call, string $url) public static function provideHostsStartingWithADash(): iterable { // "${IFS}": the space of a command in a host. - $command = '-oProxyCommand=touch${IFS}pwned'; + $command = '-oProxyCommand=touch${IFS}$GITLIB_CANARY'; $urls = [ 'ssh' => 'ssh://'.$command.'/repository', 'git+ssh' => 'git+ssh://'.$command.'/repository', @@ -135,8 +142,11 @@ public static function provideHostsStartingWithADash(): iterable 'host after a user' => 'ssh://git@'.$command.'/repository', 'address' => 'git@'.$command.':repository', 'address between brackets' => '['.$command.']:repository', - // Passed to the command of the core.gitProxy setting. + // Passed to the command of the core.gitProxy setting, like the port: before 2.14.1, whatever follows the colon. 'git' => 'git://'.$command.'/repository', + 'port' => 'git://example.com:'.$command.'/repository', + 'port after a host between brackets' => 'git://[::1]:'.$command.'/repository', + 'encoded dash of a port' => 'git://example.com:%2D'.substr($command, 1).'/repository', // git decodes a URL before it reads its host. 'encoded dash' => 'ssh://%2D'.substr($command, 1).'/repository', 'encoded dash, lowercase' => 'git+ssh://%2d'.substr($command, 1).'/repository', @@ -166,8 +176,12 @@ public static function provideUrlsGitReachesWithoutRunningACommand(): iterable { yield 'ssh' => ['ssh://git@example.com:2222/repository.git']; yield 'host between brackets' => ['ssh://git@[::1]/repository.git']; + yield 'host between brackets, with a port' => ['ssh://git@[::1]:2222/repository.git']; + yield 'git, with a port' => ['git://example.com:9418/repository.git']; yield 'address' => ['git@example.com:repository.git']; + // git passes it to ssh in the command to run on the host, between quotes. yield 'dash in the path of an address' => ['git@example.com:-repository.git']; + yield 'address between brackets, with a port' => ['[git@example.com:2222]:repository.git']; yield 'dash in the host' => ['ssh://git@my-host.example.com/repository.git']; yield 'encoded character in the user' => ['ssh://first%2Dlast@example.com/repository.git']; // Not reached through ssh: a token may start with a dash. @@ -175,4 +189,28 @@ public static function provideUrlsGitReachesWithoutRunningACommand(): iterable yield 'local path' => ['/path/to/repository.git']; yield 'local path with a colon' => ['./a:b']; } + + #[After] + public function removeCanary(): void + { + putenv('GITLIB_CANARY'); + unset($_SERVER['GITLIB_CANARY']); + } + + /** + * The commands of the URLs create the file the GITLIB_CANARY environment + * variable names, which git and ssh inherit: wherever a driver runs git, + * and without a slash in the URL, which would make a path of it. + * + * @return string The file, which doesn't exist + */ + private static function createCanary(): string + { + $file = self::createTempDir().'/pwned'; + // Symfony Process passes on the variables of getenv() that $_SERVER has too. + putenv('GITLIB_CANARY='.$file); + $_SERVER['GITLIB_CANARY'] = $file; + + return $file; + } } diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 21202642..2b8f42ca 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -124,6 +124,8 @@ public function testOpensACloneWithASeparateGitDir(): void public function testOpensAGitFileWhateverItsLineEnding(string $lineEnding): void { [$dir, $gitDir] = self::cloneWithASeparateGitDir(self::createFoobarRepository()); + // Removed first: Windows hides it, and PHP writes no hidden file. + unlink($dir.'/.git'); file_put_contents($dir.'/.git', 'gitdir: '.$gitDir.$lineEnding); $this->assertSame(realpath($gitDir), new Repository($dir, driver: self::getDriver())->getGitDir()); @@ -444,9 +446,82 @@ public function testCloneBareATagAsBranchDetachesHead(): void $this->assertFileDoesNotExist($repository->getGitDir().'/FETCH_HEAD'); } + /** + * Like git, at the commit of the HEAD of the remote, which none of its + * branches may have. + * + * @param \Closure(string, string): Repository $clone + */ + #[DataProvider('provideBareClones')] + public function testABareCloneOfADetachedHeadDetachesHead(\Closure $clone): void + { + $source = self::createFoobarRepository(false); + self::git($source, 'config', ['user.name', 'Unit Test']); + self::git($source, 'config', ['user.email', 'unit_test@unit-test.com']); + self::git($source, 'checkout', ['-q', '--detach']); + self::git($source, 'commit', ['-q', '--allow-empty', '-m', 'Detached']); + $head = trim(self::git($source, 'rev-parse', ['HEAD'])); + + $repository = $clone($source->getPath(), self::createTempDir()); + + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame($head, $repository->getHeadCommit()->getHash()); + $this->assertFileDoesNotExist($repository->getGitDir().'/FETCH_HEAD'); + } + + /** + * @return iterable + */ + public static function provideBareClones(): iterable + { + yield 'bare clone' => [static fn (string $url, string $path): Repository => Repository::cloneBareFrom($url, $path, driver: self::getDriver())]; + yield 'mirror' => [static fn (string $url, string $path): Repository => Repository::mirrorFrom($url, $path, self::getDriver())]; + } + + /** + * Like git since 2.31, which learns the branch of an empty repository + * from it: LibGit2Driver does from a local one. + * + * @param \Closure(string, string): Repository $clone + * @param list $tracking What the branch tracks, in the configuration of the clone + */ + #[DataProvider('provideClonesOfAnEmptyRepository')] + public function testACloneOfAnEmptyRepositoryIsOnItsBranch(\Closure $clone, array $tracking): void + { + if (self::getDriver() instanceof CliDriver && version_compare(self::gitVersion(), '2.31.0', '<')) { + $this->markTestSkipped('git learns the branch of an empty repository since 2.31.'); + } + + $source = self::createEmptyRepository(); + self::git($source, 'symbolic-ref', ['HEAD', 'refs/heads/trunk']); + + $repository = $clone($source->getGitDir(), self::createTempDir().'/clone'); + + $this->assertSame('refs/heads/trunk', $repository->getRepositoryDriver()->getHeadReference()); + // Without branch.trunk.rebase, which the configuration of the tests makes git add. + $configuration = array_filter(explode("\n", self::git($repository, 'config', ['--local', '--list'])), static fn (string $line): bool => 1 === preg_match('/^branch\.[^.]+\.(?:merge|remote)=/', $line)); + sort($configuration); + $this->assertSame($tracking, $configuration); + } + + /** + * @return iterable}> + */ + public static function provideClonesOfAnEmptyRepository(): iterable + { + yield 'clone' => [static fn (string $url, string $path): Repository => Repository::cloneFrom($url, $path, driver: self::getDriver()), ['branch.trunk.merge=refs/heads/trunk', 'branch.trunk.remote=origin']]; + foreach (self::provideBareClones() as $name => [$clone]) { + yield $name => [$clone, []]; + } + } + public function testCloneOfAMissingBranchThrows(): void { - $this->expectException(DriverException::class); + $this->expectDriverException( + 'clone', + 'fatal: Remote branch missing not found in upstream origin.', + \sprintf('Unable to clone "%s": reference \'refs/remotes/origin/missing\' not found.', self::repositoryUrl()), + ); Repository::cloneFrom(self::repositoryUrl(), self::createTempDir().'/clone', 'missing', self::getDriver()); } @@ -464,9 +539,14 @@ public function testReadingAMovedRepositoryThrows(): void { $dir = self::createTempDir(); $repository = Repository::cloneBareFrom(self::repositoryUrl(), $dir.'/repository', driver: self::getDriver()); + $gitDir = $repository->getGitDir(); rename($dir.'/repository', $dir.'/moved'); + // git doesn't even start, in a working directory that is no more: no ProcessException. $this->expectException(DriverException::class); + $this->expectExceptionMessageMatches('/^'.preg_quote(\sprintf(self::getDriver() instanceof CliDriver + ? 'The git command "show-ref" could not start: The provided cwd "%s" does not exist.' + : 'The repository "%s" does not exist anymore.', $gitDir), '/').'$/'); $repository->getReferences()->getBranches(); } @@ -495,7 +575,11 @@ public function testCloneRefusesADirectoryWithFiles(): void $dir = self::createTempDir(); touch($dir.'/file'); - $this->expectException(DriverException::class); + $this->expectDriverException( + 'clone', + \sprintf('fatal: destination path \'%s\' already exists and is not an empty directory.', $dir), + \sprintf('Unable to clone "%s": "%s" exists and is not an empty directory.', self::repositoryUrl(), $dir), + ); Repository::cloneBareFrom(self::repositoryUrl(), $dir, driver: self::getDriver()); } @@ -648,7 +732,11 @@ public function testCloneFromABranch(): void public function testCloneFromAnUnknownBranchFails(): void { - $this->expectException(DriverException::class); + $this->expectDriverException( + 'clone', + 'fatal: Remote branch unknown not found in upstream origin.', + \sprintf('Remote branch "unknown" not found in "%s".', self::repositoryUrl()), + ); Repository::cloneBareFrom(self::repositoryUrl(), self::createTempDir().'/clone', 'unknown', driver: self::getDriver()); } @@ -730,6 +818,70 @@ public function testNullProcessTimeoutDisablesTheTimeout(): void $this->assertNull($process->getIdleTimeout()); } + public function testAProcessTimeoutOfZeroDisablesTheTimeout(): void + { + $this->skipUnlessCliDriver(); + + $repository = new Repository(self::createFoobarRepository()->getPath(), driver: self::getCliDriver(processTimeout: 0)); + + $this->assertNull($repository->runProcess('rev-parse', ['HEAD'])->getTimeout()); + } + + /** + * Symfony Process refused it when the first git command ran, with its own exception. + */ + public function testANegativeProcessTimeoutIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The timeout of git processes can\'t be negative, got -1.'); + + new CliDriver(processTimeout: -1); + } + + /** + * A translation changes the messages gitlib reads: git gets LANGUAGE=C, + * whatever the one of PHP. + */ + public function testGitWritesItsMessagesInEnglish(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(); + self::git($repository, 'config', ['alias.language', '!echo "$LANGUAGE"']); + $language = getenv('LANGUAGE'); + $server = $_SERVER; + // Symfony Process passes on the variables of getenv() that $_SERVER has too. + putenv('LANGUAGE=fr'); + $_SERVER['LANGUAGE'] = 'fr'; + + try { + $this->assertSame("C\n", $repository->run('language')); + $this->assertSame("de\n", new Repository($repository->getPath(), driver: self::getCliDriver(['LANGUAGE' => 'de']))->run('language'), 'Unless the application sets it'); + } finally { + putenv(false === $language ? 'LANGUAGE' : 'LANGUAGE='.$language); + $_SERVER = $server; + } + } + + public function testAHashEndingWithANewLineIsRefused(): void + { + $repository = self::createFoobarRepository(); + + // An abbreviated hash, for the blob and the tree: "$" matched before the new line of its 40 characters. + foreach ([ + 'commit' => static fn () => $repository->getCommit(self::LONGFILE_COMMIT."\n"), + 'blob' => static fn () => $repository->getBlob("412a0dad051163dcf9f9ce2fc04a141ffcb6947\n"), + 'tree' => static fn () => $repository->getTree(substr($repository->getCommit(self::LONGFILE_COMMIT)->getTreeHash(), 0, 39)."\n"), + ] as $type => $get) { + try { + $get(); + $this->fail(\sprintf('The hash of the %s is refused.', $type)); + } catch (ReferenceNotFoundException $e) { + $this->assertStringContainsString("\n\" is not ", $e->getMessage(), $type); + } + } + } + public function testRunThrowsWhenTheCommandFails(): void { $this->skipUnlessCliDriver(); @@ -787,6 +939,10 @@ public function testGetSizeInBytes(bool $bare): void public function testGetSizeInBytesFollowsNoLink(): void { + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('Windows creates no link to a missing file.'); + } + $repository = self::createEmptyRepository(); $size = $repository->getSizeInBytes(); @@ -835,6 +991,33 @@ public function testGetDescription(bool $bare): void $this->assertSame("Unnamed repository; edit this file 'description' to name the repository.\n", $repository->getDescription()); } + #[DataProvider('provideBare')] + public function testSetDescription(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + + $this->assertSame($repository, $repository->setDescription("The fixture of the tests\n")); + + $this->assertStringEqualsFile($repository->getGitDir().'/description', "The fixture of the tests\n"); + $this->assertSame("The fixture of the tests\n", $repository->getDescription()); + } + + /** + * The description git writes in a new repository is none. + */ + #[DataProvider('provideBare')] + public function testHasDescription(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $this->assertFalse($repository->hasDescription()); + + $repository->setDescription("The fixture of the tests\n"); + $this->assertTrue($repository->hasDescription()); + + unlink($repository->getGitDir().'/description'); + $this->assertFalse($repository->hasDescription(), 'No file, no description'); + } + #[DataProvider('provideBare')] public function testLoggerOk(bool $bare): void { diff --git a/tests/Gitonomy/Git/Tests/RevisionTest.php b/tests/Gitonomy/Git/Tests/RevisionTest.php index 05532b36..373a4956 100644 --- a/tests/Gitonomy/Git/Tests/RevisionTest.php +++ b/tests/Gitonomy/Git/Tests/RevisionTest.php @@ -16,6 +16,8 @@ use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Revision; +use Gitonomy\Git\RevisionList; use PHPUnit\Framework\Attributes\DataProvider; class RevisionTest extends AbstractTestCase @@ -41,6 +43,34 @@ public function testGetFailingReference(bool $bare): void $repository->getRevision('non-existent-commit')->getCommit(); } + /** + * "git rev-parse" prints "^" for it: the commits to leave out of a log. + */ + public function testARevisionToExcludeNamesNoCommit(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The revision "^HEAD" does not exist, or names no commit.'); + + $repository->getRevision('^HEAD')->getCommit(); + } + + /** + * "git rev-parse" takes a full hash at its word. + */ + public function testTheDriverResolvesNoHashWithoutAnObject(): void + { + $driver = self::createFoobarRepository()->getRepositoryDriver(); + $this->assertSame(self::LONGFILE_COMMIT, $driver->resolveRevision(self::LONGFILE_COMMIT)); + $hash = str_repeat('1', 40); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage(\sprintf('The revision "%s" does not exist.', $hash)); + + $driver->resolveRevision($hash); + } + /** * "@" is HEAD for git since 1.8.5, and for libgit2 alone only. */ @@ -104,4 +134,40 @@ public function testGetLog(bool $bare): void $this->assertSame(3, $log->getLimit(), 'Log limit is passed'); $this->assertSame([$revision], $log->getRevisions()?->getAll(), 'Revision is passed'); } + + public function testGetRepository(): void + { + $repository = self::createFoobarRepository(); + + $this->assertSame($repository, $repository->getRevision('master')->getRepository()); + $this->assertSame($repository, new Revision($repository, self::LONGFILE_COMMIT)->getRepository()); + } + + public function testARevisionListCountsItsRevisions(): void + { + $repository = self::createFoobarRepository(); + + $this->assertCount(1, new RevisionList($repository, 'master')); + $this->assertCount(1, new RevisionList($repository, $repository->getRevision('master'))); + $this->assertCount(3, new RevisionList($repository, ['master', self::LONGFILE_COMMIT, $repository->getRevision('0.1')])); + } + + public function testARevisionListIteratesOverItsRevisions(): void + { + $repository = self::createFoobarRepository(); + $tag = $repository->getRevision('0.1'); + + $revisions = iterator_to_array(new RevisionList($repository, ['master', $tag])); + + $this->assertCount(2, $revisions); + $this->assertSame('master', $revisions[0]->getRevision(), 'A name becomes a revision'); + $this->assertSame($tag, $revisions[1]); + } + + public function testARevisionListAsTextArray(): void + { + $repository = self::createFoobarRepository(); + + $this->assertSame(['master', self::LONGFILE_COMMIT, '0.1'], new RevisionList($repository, ['master', $repository->getCommit(self::LONGFILE_COMMIT), $repository->getRevision('0.1')])->getAsTextArray()); + } } diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index 1dab3e00..de78c30e 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -47,6 +47,10 @@ public function testIterateWithNonAsciiNames(bool $bare): void public function testIterateWithSpecialCharactersInNames(): void { + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('Windows has no file name with a backslash, a tab or a quote.'); + } + $repository = self::createFoobarRepository(false); self::git($repository, 'config', ['user.name', 'Unit Test']); self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); @@ -72,6 +76,39 @@ public function testGetSubmodules(bool $bare): void $this->assertSame('160000', $submodules['barbaz']->getMode()); } + #[DataProvider('provideBare')] + public function testASubmoduleHasTheHashOfItsCommit(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $submodule = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree()->getSubmodules()['barbaz']->getObject(); + + $this->assertInstanceOf(Submodule::class, $submodule); + $this->assertSame('5a519c47166f333e32091e166eb55f729106c964', $submodule->getHash()); + } + + #[DataProvider('provideBare')] + public function testGetEntry(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $blob = $tree->getEntry('README.md'); + $this->assertInstanceOf(Blob::class, $blob); + $this->assertSame('07eeea94584fb6f1d8fe55e5b78adc4668c02508', $blob->getHash()); + $this->assertInstanceOf(Submodule::class, $tree->getEntry('barbaz')); + $this->assertInstanceOf(Tree::class, $repository->getCommit(self::PATH_RESOLVING_COMMIT)->getTree()->getEntry('test')); + } + + public function testGetEntryOfAMissingNameThrows(): void + { + $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The tree "c4491f20ce7f24f0b5a344c823afb0dff58449fb" has no entry "nope".'); + + $tree->getEntry('nope'); + } + public function testResolvePathThroughASubmoduleThrows(): void { $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); @@ -139,4 +176,22 @@ public function testResolvePath(bool $bare): void $this->assertArrayHasKey('d', $entries, 'Successfully resolved source folder'); } + + /** + * Old versions of git wrote other modes than 100644 and 100755 for a + * file: "git ls-tree", since 2.17, and libgit2 read them as these two. + */ + public function testTheModeOfAFileOfAnOldTreeIsReadLikeGit(): void + { + $repository = self::createEmptyRepository(); + $blob = self::writeObject($repository, 'blob', "content\n"); + $tree = self::writeObject($repository, 'tree', "100664 file\0".hex2bin($blob)."100775 script\0".hex2bin($blob)); + + $modes = []; + foreach ($repository->getTree($tree) as $name => $entry) { + $modes[$name] = $entry->getMode(); + } + + $this->assertSame(['file' => '100644', 'script' => '100755'], $modes); + } } diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 77971430..b3316f7e 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -14,14 +14,19 @@ use Gitonomy\Git\Diff\File; use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\FastForward; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; +use Gitonomy\Git\WorkingCopy; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; +use Symfony\Component\Process\Process; class WorkingCopyTest extends AbstractTestCase { @@ -369,7 +374,7 @@ public function testMergeOnlyFastForwards(): void $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); - $this->expectException(DriverException::class); + $this->expectDriverException('merge', 'fatal: Not possible to fast-forward, aborting.', 'Unable to fast-forward to "origin/diff-features".'); $wc->merge('origin/diff-features', FastForward::Only); } @@ -511,6 +516,28 @@ public function testUntrackedFilesInNewDirectories(): void $this->assertSame(['newdir/a.txt', 'newdir/sub/b.txt'], $files); } + /** + * "git status -z" gives the name a renamed file had as an entry of its + * own: here, one that reads like the entry of an untracked file. + */ + public function testTheFormerNameOfAStagedRenameIsNotUntracked(): void + { + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('A file name can\'t hold a "?" on Windows.'); + } + + $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().'/?? fake', "foo\n"); + self::git($repository, 'add', ['?? fake']); + self::git($repository, 'commit', ['-q', '-m', 'Add a file']); + self::git($repository, 'mv', ['?? fake', 'renamed']); + file_put_contents($repository->getWorkingDir().'/untracked.txt', 'foo'); + + $this->assertSame(['untracked.txt'], $repository->getWorkingCopy()->getUntrackedFiles()); + } + public function testCheckoutPrefersTheBranchToATagOfTheSameName(): void { $repository = self::createFoobarRepository(false); @@ -641,6 +668,29 @@ public function testCheckoutNewBranchRefusesAnAmbiguousStartPoint(): void $this->assertSame([], $repository->getWorkingCopy()->getDiffPending()->getFiles()); } + /** + * Like "git checkout -b": git runs the hook once the branch is created + * and checked out, and can't delete the branch HEAD points to. + */ + public function testCheckoutNewBranchKeepsTheBranchWhenThePostCheckoutHookFails(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(false); + $repository->getHooks()->set('post-checkout', "#!/bin/sh\nexit 3\n"); + + try { + $repository->getWorkingCopy()->checkoutNewBranch('feature', 'origin/new-feature'); + $this->fail('The hook fails.'); + } catch (ProcessException $e) { + $this->assertStringStartsWith('The git command "checkout" failed (exit ', $e->getMessage()); + } + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame('refs/heads/feature', self::headBranch($repository)->getFullname()); + $this->assertSame(['branch.feature.merge' => 'refs/heads/new-feature', 'branch.feature.remote' => 'origin'], self::branchConfiguration($repository, 'feature')); + } + public function testCheckoutNewBranchWithAnInvalidNameChangesNothing(): void { $repository = self::createFoobarRepository(false); @@ -659,11 +709,15 @@ public function testCheckoutNewBranchWithAnInvalidNameChangesNothing(): void public function testMergeRefusesUnrelatedHistories(): void { + if (self::getDriver() instanceof CliDriver && version_compare(self::gitVersion(), '2.9.0', '<')) { + $this->markTestSkipped('git refuses to merge unrelated histories since 2.9.'); + } + $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); + $this->expectDriverException('merge', 'fatal: refusing to merge unrelated histories.', \sprintf('Refusing to merge "%s", whose history is unrelated.', $orphan)); $repository->getWorkingCopy()->merge($orphan); } @@ -686,6 +740,248 @@ public function testMergeRefusesStagedChanges(): void $this->assertSame($head, $repository->getHeadCommit()->getHash()); } + /** + * libgit2 deletes the directory of a submodule a checkout removes, with + * its repository when it is in it, as "git clone" then "git add" leave + * it: LibGit2Driver refuses the checkout. git keeps the directory. + * + * @param \Closure(WorkingCopy, string): void $checkout + */ + #[DataProvider('provideCheckouts')] + public function testACheckoutRemovingASubmoduleKeepsItsRepository(\Closure $checkout): void + { + $repository = self::createRepositoryWithASubmoduleToRemove(); + + $this->assertSubmoduleIsKept($repository, static fn () => $checkout($repository->getWorkingCopy(), 'removal')); + $this->assertDirectoryExists($repository->getWorkingDir().'/sub/.git'); + } + + public function testAMergeRemovingASubmoduleKeepsItsRepository(): void + { + $repository = self::createRepositoryWithASubmoduleToRemove(); + file_put_contents($repository->getWorkingDir().'/ours', "ours\n"); + self::git($repository, 'add', ['ours']); + self::git($repository, 'commit', ['-q', '-m', 'Ours']); + + $this->assertSubmoduleIsKept($repository, static fn () => $repository->getWorkingCopy()->merge('removal')); + $this->assertDirectoryExists($repository->getWorkingDir().'/sub/.git'); + } + + /** + * The repository of a submodule "git submodule add" checks out is in the + * one of its superproject: libgit2 would still delete the files git + * ignores in its directory. + */ + public function testACheckoutRemovingASubmoduleKeepsItsFiles(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + $repository->getReferences()->createBranch('removal', $repository->getHeadCommit()->getHash()); + // git refuses to clone a local submodule since 2.38.1, unless protocol.file.allow allows it. + new Process([self::gitCommand(), '-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', self::createFoobarRepository()->getGitDir(), 'sub'], $repository->getWorkingDir(), self::gitEnvs())->mustRun(); + self::git($repository, 'commit', ['-q', '-m', 'Add the submodule']); + file_put_contents($repository->getGitDir().'/modules/sub/info/exclude', "ignored\n"); + file_put_contents($repository->getWorkingDir().'/sub/ignored', 'local'); + + $this->assertSubmoduleIsKept($repository, static fn () => $repository->getWorkingCopy()->checkout('removal')); + $this->assertSame('local', file_get_contents($repository->getWorkingDir().'/sub/ignored')); + } + + /** + * A checkout replacing a directory with a file is refused before it + * writes anything, when an untracked file would be left in the directory. + * + * @param \Closure(WorkingCopy, string): void $checkout + */ + #[DataProvider('provideCheckouts')] + public function testARefusedCheckoutWritesNothing(\Closure $checkout): void + { + $repository = self::createRepositoryWithADirectoryBecomingAFile(); + file_put_contents($repository->getWorkingDir().'/dir/untracked', 'untracked'); + + try { + $checkout($repository->getWorkingCopy(), 'feature'); + $this->fail('The checkout should have been refused.'); + } catch (DriverException $e) { + if (self::getDriver() instanceof LibGit2Driver) { + $this->assertStringEndsWith(': it replaces the directory "dir" with a file, but "dir/untracked" would be left in it.', $e->getMessage()); + } + } + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + $this->assertFalse($repository->getReferences()->hasBranch('new')); + $this->assertSame([], $repository->getWorkingCopy()->getDiffPending()->getFiles()); + $this->assertSame(['dir/untracked'], $repository->getWorkingCopy()->getUntrackedFiles()); + } + + /** + * doc/repository.md: LibGit2Driver also refuses the checkout for a file + * git ignores, like git 2.34, where git 2.43 deletes the file. + */ + public function testACheckoutReplacingADirectoryWithAFileDeletesAnIgnoredFileOrWritesNothing(): void + { + $repository = self::createRepositoryWithADirectoryBecomingAFile(); + file_put_contents($repository->getGitDir().'/info/exclude', "ignored\n"); + file_put_contents($repository->getWorkingDir().'/dir/ignored', 'ignored'); + + try { + $repository->getWorkingCopy()->checkout('feature'); + } catch (DriverException $e) { + if (self::getDriver() instanceof LibGit2Driver) { + $this->assertStringEndsWith(': it replaces the directory "dir" with a file, but "dir/ignored" would be left in it.', $e->getMessage()); + } + $this->assertSame('ignored', file_get_contents($repository->getWorkingDir().'/dir/ignored')); + $this->assertSame([], $repository->getWorkingCopy()->getDiffPending()->getFiles()); + + return; + } + + $this->assertNotInstanceOf(LibGit2Driver::class, self::getDriver()); + $this->assertSame('file', file_get_contents($repository->getWorkingDir().'/dir')); + } + + /** + * Like git, which reads the files a sparse checkout leaves out from the + * index. + */ + public function testDiffsLeaveOutTheFilesASparseCheckoutLeavesOut(): void + { + $repository = self::createSparseCheckout(); + $wc = $repository->getWorkingCopy(); + + $this->assertFileDoesNotExist($repository->getWorkingDir().'/AUTHORS'); + $this->assertSame([], $wc->getDiffPending()->getFiles()); + $this->assertSame([], $wc->getDiffStaged()->getFiles()); + $this->assertSame([], $wc->getDiff()->getFiles()); + $this->assertSame([], $wc->getUntrackedFiles()); + } + + /** + * doc/repository.md: libgit2 doesn't read the patterns of a sparse + * checkout. + * + * @param \Closure(WorkingCopy, string): void $checkout + */ + #[DataProvider('provideCheckouts')] + public function testLibGit2DriverChecksNothingOutInASparseCheckout(\Closure $checkout): void + { + $repository = self::createSparseCheckout(); + + try { + $checkout($repository->getWorkingCopy(), 'master~1'); + $this->assertInstanceOf(CliDriver::class, self::getDriver()); + } catch (UnsupportedOperationException $e) { + $this->assertSame(\sprintf('The repository "%s" has a sparse checkout, in which LibGit2Driver checks out and merges nothing; use CliDriver.', $repository->getGitDir()), $e->getMessage()); + $this->assertFalse(new Repository($repository->getPath(), driver: self::getDriver())->getReferences()->hasBranch('new')); + } + + $this->assertFileDoesNotExist($repository->getWorkingDir().'/AUTHORS'); + } + + /** + * @return iterable + */ + public static function provideCheckouts(): iterable + { + yield 'checkout' => [static function (WorkingCopy $wc, string $revision): void { $wc->checkout($revision); }]; + yield 'new branch' => [static function (WorkingCopy $wc, string $revision): void { $wc->checkoutNewBranch('new', $revision); }]; + yield 'fast-forward' => [static function (WorkingCopy $wc, string $revision): void { $wc->merge($revision); }]; + } + + /** + * Runs $checkout, which removes the submodule "sub": LibGit2Driver + * refuses it, and changes nothing. + * + * @param \Closure(): mixed $checkout + */ + private function assertSubmoduleIsKept(Repository $repository, \Closure $checkout): void + { + if (!self::getDriver() instanceof LibGit2Driver) { + $checkout(); + + return; + } + + $head = $repository->getHeadCommit()->getHash(); + + try { + $checkout(); + $this->fail('The checkout would delete the directory of the submodule.'); + } catch (DriverException $e) { + $this->assertStringEndsWith(': it removes the submodule "sub", whose directory would be deleted with all its files.', $e->getMessage()); + } + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + $this->assertSame($head, $repository->getHeadCommit()->getHash()); + $this->assertFalse($repository->getReferences()->hasBranch('new')); + $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); + } + + /** + * @return Repository On the branch "master", which has a submodule "sub" with its repository in its directory; the branch "removal", ahead of it, removes the submodule + */ + private static function createRepositoryWithASubmoduleToRemove(): Repository + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + $tree = $repository->getHeadCommit()->getTreeHash(); + + $submodule = Repository::init($repository->getWorkingDir().'/sub', self::getCliDriver()); + self::git($submodule, 'config', ['user.name', 'Unit Test']); + self::git($submodule, 'config', ['user.email', 'unit_test@unit-test.com']); + self::git($submodule, 'commit', ['-q', '--allow-empty', '-m', 'First']); + self::git($repository, 'update-index', ['--add', '--cacheinfo', '160000', trim(self::git($submodule, 'rev-parse', ['HEAD'])), 'sub']); + self::git($repository, 'commit', ['-q', '-m', 'Add the submodule']); + + self::git($repository, 'update-ref', ['refs/heads/removal', trim(self::git($repository, 'commit-tree', [$tree, '-p', 'HEAD', '-m', 'Remove the submodule']))]); + + return $repository; + } + + /** + * @return Repository On the branch "master", which has a directory "dir"; the branch "feature", ahead of it, replaces it with a file, after it changes a file and adds another + */ + private static function createRepositoryWithADirectoryBecomingAFile(): Repository + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + $dir = $repository->getWorkingDir(); + + mkdir($dir.'/dir'); + file_put_contents($dir.'/dir/tracked', 'tracked'); + self::git($repository, 'add', ['dir']); + self::git($repository, 'commit', ['-q', '-m', 'Directory']); + + self::git($repository, 'checkout', ['-q', '-b', 'feature']); + self::git($repository, 'rm', ['-q', '-r', 'dir']); + file_put_contents($dir.'/dir', 'file'); + file_put_contents($dir.'/AUTHORS', 'changed'); + file_put_contents($dir.'/added', 'added'); + self::git($repository, 'add', ['-A']); + self::git($repository, 'commit', ['-q', '-m', 'File']); + self::git($repository, 'checkout', ['-q', 'master']); + + return $repository; + } + + /** + * @return Repository With the file README.md alone checked out + */ + private static function createSparseCheckout(): Repository + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['core.sparseCheckout', 'true']); + file_put_contents($repository->getGitDir().'/info/sparse-checkout', "/README.md\n"); + self::git($repository, 'read-tree', ['-m', '-u', 'HEAD']); + + return $repository; + } + /** * @return array The configuration of the branch $branch, sorted by key */ diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 00793165..85cee3c6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -19,10 +19,32 @@ // tests. Symfony Process passes on the variables of getenv() that $_SERVER has // too, $_ENV taking precedence. $gitVariables = array_filter(array_keys([...getenv(), ...$_ENV]), static fn (string $name): bool => str_starts_with($name, 'GIT_') && 'GIT_COMMAND' !== $name); + +// HOME is in the working tree: gpg, which git runs to check a signature, would +// create its ".gnupg" directory there. +$gnupgHome = tempnam(sys_get_temp_dir(), 'gitlib_gnupg_') ?: throw new RuntimeException('Unable to create a temporary file.'); +unlink($gnupgHome); +mkdir($gnupgHome, 0o700); +register_shutdown_function(static function () use ($gnupgHome): void { + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($gnupgHome, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + /** @var SplFileInfo $file */ + foreach ($files as $file) { + if ($file->isDir() && !$file->isLink()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + rmdir($gnupgHome); +}); + foreach ([ ...array_fill_keys($gitVariables, null), 'GIT_CONFIG_NOSYSTEM' => '1', + 'GNUPGHOME' => $gnupgHome, 'HOME' => __DIR__.'/fixtures/home', + // The tests read the messages of git, which the language of the developer would translate. + 'LANGUAGE' => 'C', 'XDG_CONFIG_HOME' => null, ] as $name => $value) { if (null === $value) {