diff --git a/.gitattributes b/.gitattributes index 176a458f..e39bddbb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,16 @@ * text=auto +/tests/fixtures/*.bundle binary + +# Left out of the archive Composer installs: only src/, the docs and the license ship. +/.gitattributes export-ignore +/.github export-ignore +/.gitignore export-ignore +/.php-cs-fixer.php export-ignore +/benchmark.php export-ignore +/castor.php export-ignore +/phpstan.neon export-ignore +/phpunit.xml.dist export-ignore +/test-git-versions.php export-ignore +/tests export-ignore +/tools export-ignore +/zensical.toml export-ignore diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..590c9593 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# CONTRIBUTOR COVENANT CODE OF CONDUCT + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +hello@gjcampbell.co.uk. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000..827452f7 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,92 @@ +# CONTRIBUTION GUIDELINES + +Contributions are **welcome** and will be fully **credited**. + +We accept contributions via pull requests on GitHub. Please review these guidelines before continuing. + +## Guidelines + +* Follow the coding standards of PHP-CS-Fixer, configured in `.php-cs-fixer.php` (the Symfony and PhpCsFixer rule sets): `castor cs` applies them. +* Ensure that the current tests pass, and if you've added something new, add the tests where relevant. +* Send a coherent commit history, making sure each commit in your pull request is meaningful. +* You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts. +* If you are changing or adding to the behaviour or public API, you may need to update the docs. +* Please remember that we follow [Semantic Versioning](https://semver.org/). + +You will need [Castor](https://castor.jolicode.com/) to run the tests, fix CS +violations and run the static analysis. See [Castor's documentation](https://castor.jolicode.com/getting-started/installation/) +for installation instructions. + +To install all the dependencies and tools, run: + +```bash +$ castor install +``` + +## Running Tests + +First, install the dependencies using [Composer](https://getcomposer.org/): + +```bash +$ composer install +``` + +Then run [PHPUnit](https://phpunit.de/): + +```bash +$ vendor/bin/phpunit +``` + +* A script `test-git-versions.php` is available in repository to test gitlib against many git versions: `php test-git-versions.php build` compiles them, then `php test-git-versions.php test` runs the suite against each. +* The tests will be automatically run by [GitHub Actions](https://github.com/features/actions) against pull requests. +* Tests run fully offline: no network access is required. + +## Test fixtures + +Most tests run against a fixture repository cloned from `tests/fixtures/foobar.bundle`, +a local git bundle. Using a bundle instead of a network clone keeps the tests fast and +fully offline. + +If you need a new fixture scenario (a specific merge, encoding, or signed-commit shape, +for example), regenerate the bundle with `tests/fixtures/generate-bundle.php`, entirely +from the one already in the repo: + +```bash +$ php tests/fixtures/generate-bundle.php extract +# ... add your commits, branches or tags in the printed directory ... +$ php tests/fixtures/generate-bundle.php build /path/printed/above +``` + +`extract` clones the current bundle to a working directory with every branch checked out +locally, ready to receive new commits. `build` rebuilds `tests/fixtures/foobar.bundle` +from that directory, restricted to the refs listed in `tests/fixtures/bundle-refs.txt` — +add your new branch or tag there first if you introduced one. + +Existing refs must not move, since the tests rely on the commits they point to: add your +commits on new branches or tags, and constants in `AbstractTestCase` for the ones your +tests use. Then add the new refs to `tests/fixtures/bundle-heads.txt` (the output of +`git bundle list-heads tests/fixtures/foobar.bundle`), and run +`tests/fixtures/verify-bundle.sh`. It checks the bundle's integrity, its refs against +`bundle-refs.txt` and `bundle-heads.txt`, and its size, since GitHub renders any change to +this binary file as an opaque diff. If your change intentionally adds a ref or grows the file, update +`tests/fixtures/bundle-refs.txt` or `MAX_SIZE_KB` in that script as part of the same pull +request, so the reason for the change is explicit and reviewable rather than a silent +binary diff. + +## Standard code + +Use PHP-CS-Fixer to make your code compliant with gitlib's coding standards: + +```bash +$ castor cs +``` + +## Static analysis + +Use PHPStan to ensure the code is free of errors: + +```bash +$ castor phpstan +``` + +Both checks run automatically via GitHub Actions against pull requests. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..856fb026 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +tidelift: "packagist/gitonomy/gitlib" diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..eaf2ad22 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,20 @@ +# SECURITY POLICY + +## Supported Versions + +Only 2.x gets security fixes: 1.x is no longer maintained. 1.7.1 has the +security fixes of 2.0 that break nothing of 1.x, and +[its changelog](https://github.com/gitonomy/gitlib/blob/1.x/CHANGELOG.md) lists +the ones it doesn't have. To upgrade, see +[UPGRADE-2.0.md](https://github.com/gitonomy/gitlib/blob/main/UPGRADE-2.0.md). + +After each new major release from 3.0 on, the previous release will be +supported for no less than 12 months, unless explicitly stated otherwise. +This may mean that there are multiple supported versions at any given time. + +## Reporting a Vulnerability + +If you discover a security vulnerability within this package, please send an +email to security@tidelift.com. All security vulnerabilities will be promptly +addressed. Please do not disclose security-related issues publicly until a fix +has been announced. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..29129b89 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,50 @@ +version: 2 + +# At most one pull request per language and per month, for releases a week old at least. Security +# updates come as soon as they are published. +multi-ecosystem-groups: + # The PHP tools, one Composer project per directory: one pull request for all of them. + php: + schedule: + interval: monthly + +updates: + - package-ecosystem: composer + directory: /tools/php-cs-fixer + multi-ecosystem-group: php + patterns: ['*'] + cooldown: + default-days: 7 + + - package-ecosystem: composer + directory: /tools/phpstan + multi-ecosystem-group: php + patterns: ['*'] + cooldown: + default-days: 7 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + cooldown: + default-days: 7 + groups: + github-actions: + patterns: ['*'] + github-actions-security: + applies-to: security-updates + patterns: ['*'] + + - package-ecosystem: pip + directory: /tools/zensical + schedule: + interval: monthly + cooldown: + default-days: 7 + groups: + pip: + patterns: ['*'] + pip-security: + applies-to: security-updates + patterns: ['*'] diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..14513081 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,76 @@ +name: Documentation + +on: + push: + branches: [ main ] + pull_request: + paths: + - .github/workflows/docs.yml + - CHANGELOG.md + - README.md + - UPGRADE-2.0.md + - doc/** + - tools/zensical/** + - zensical.toml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Let a deployment finish: the next one waits. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Build the Documentation + runs-on: ubuntu-24.04 + steps: + - name: Checkout Code + uses: actions/checkout@v7 + with: + # The site is published: keep the token out of the workspace. + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: '3.14' + cache: pip + cache-dependency-path: tools/zensical/requirements.txt + + - name: Install Zensical + run: pip install -r tools/zensical/requirements.txt + + - name: Write the pages + run: python tools/zensical/pages.py + + # Strict, as configured in zensical.toml: a broken link or anchor fails. + - name: Build the site + run: zensical build --clean + + - name: Upload the site + if: github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v5 + with: + path: site + + deploy: + name: Deploy to GitHub Pages + if: github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Setup Pages + uses: actions/configure-pages@v6 + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..c25cb5a1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,202 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + +permissions: + contents: read + +jobs: + verify-fixture: + name: Verify Test Fixture Bundle + runs-on: ubuntu-24.04 + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Verify fixture bundle integrity, refs and size + run: tests/fixtures/verify-bundle.sh + + check-cs: + name: Check Coding Standards + runs-on: ubuntu-24.04 + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.4' + + - name: Setup Castor + uses: castor-php/setup-castor@2a495b8c91f00be6768ad8a040ba8634c797d386 # v1.1.0 + + - name: Run CS check + run: castor cs --dry-run + + check-archive: + name: Check the Archive + runs-on: ubuntu-24.04 + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Ship only the code, the docs and the license + run: diff <(git archive HEAD | tar -t | cut -d/ -f1 | sort -u) <(printf '%s\n' CHANGELOG.md LICENSE README.md UPGRADE-2.0.md composer.json doc src | sort) + + phpstan: + name: Static Analysis + runs-on: ubuntu-24.04 + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.4' + + - name: Setup Castor + uses: castor-php/setup-castor@2a495b8c91f00be6768ad8a040ba8634c797d386 # v1.1.0 + + - name: Install dependencies + run: castor install + + - name: Run PHPStan + run: castor phpstan + + tests: + name: Test PHP ${{ matrix.php }} ${{ matrix.name }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + composer-flags: [''] + name: [''] + include: + - php: '8.4' + composer-flags: '--prefer-lowest' + name: '(prefer lowest dependencies)' + + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: ${{ matrix.php }} + + - name: Setup Problem Matchers + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Validate composer.json + run: composer validate --strict + + - name: Install Composer dependencies + run: | + composer update --prefer-dist --no-interaction ${{ matrix.composer-flags }} + + - name: Execute PHPUnit + run: vendor/bin/phpunit + + tests-git: + name: Test git ${{ matrix.git }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + # The oldest supported git, and the one of Ubuntu 20.04: the other jobs test the latest one. + include: + - git: '1.8.4' + sha256: '5e0efe7cd30e258bc3be9ae023790743974e76eb1b24ecac24de6fc2af2b2786' + - git: '2.25.1' + sha256: 'aaef0cd609a7730cfbae530d7c91214a941d7e26054d2314cc99794880156b33' + + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Cache git + id: cache-git + uses: actions/cache@v6 + with: + path: ~/git + key: git-${{ matrix.git }}-ubuntu-24.04 + + - name: Build git + if: steps.cache-git.outputs.cache-hit != 'true' + run: | + curl -fsSL "https://github.com/git/git/archive/refs/tags/v${{ matrix.git }}.tar.gz" -o git.tar.gz + echo "${{ matrix.sha256 }} git.tar.gz" | sha256sum --check + tar xzf git.tar.gz + make -C "git-${{ matrix.git }}" prefix="$HOME/git" CFLAGS='-std=gnu17 -O2' NO_OPENSSL=1 NO_CURL=1 NO_EXPAT=1 NO_GETTEXT=1 NO_PERL=1 NO_PYTHON=1 NO_TCLTK=1 --jobs="$(nproc)" install + + - name: Setup PHP + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.4' + + - name: Setup Problem Matchers + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install Composer dependencies + run: composer update --prefer-dist --no-interaction + + - name: Execute PHPUnit + run: GIT_COMMAND="$HOME/git/bin/git" vendor/bin/phpunit + + tests-ffi: + name: Test PHP ${{ matrix.php }} (LibGit2Driver) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + env: + # LibGit2Driver only supports libgit2 1.9, which Ubuntu 24.04 doesn't ship. + LIBGIT2_VERSION: '1.9.1' + LIBGIT2_SHA256: '14cab3014b2b7ad75970ff4548e83615f74d719afe00aa479b4a889c1e13fc00' + LIBGIT2_CMAKE_FLAGS: '-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=OFF -DBUILD_CLI=OFF -DUSE_HTTPS=OFF -DUSE_SSH=OFF' + + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Cache libgit2 + id: cache-libgit2 + uses: actions/cache@v6 + with: + path: ~/libgit2 + key: libgit2-${{ env.LIBGIT2_VERSION }}-${{ env.LIBGIT2_CMAKE_FLAGS }}-ubuntu-24.04 + + - name: Build libgit2 + if: steps.cache-libgit2.outputs.cache-hit != 'true' + run: | + curl -fsSL "https://github.com/libgit2/libgit2/archive/refs/tags/v${LIBGIT2_VERSION}.tar.gz" -o libgit2.tar.gz + echo "${LIBGIT2_SHA256} libgit2.tar.gz" | sha256sum --check + tar xzf libgit2.tar.gz + cmake -S "libgit2-${LIBGIT2_VERSION}" -B libgit2-build -DCMAKE_INSTALL_PREFIX="$HOME/libgit2" -DCMAKE_INSTALL_LIBDIR=lib $LIBGIT2_CMAKE_FLAGS + cmake --build libgit2-build --parallel + cmake --install libgit2-build + + - name: Setup PHP + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: ${{ matrix.php }} + extensions: ffi + + - name: Setup Problem Matchers + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install Composer dependencies + run: composer update --prefer-dist --no-interaction + + - name: Execute PHPUnit + run: LD_LIBRARY_PATH="$HOME/libgit2/lib" vendor/bin/phpunit + env: + GITLIB_DRIVER: ffi diff --git a/.gitignore b/.gitignore index abb9c75a..3bac1c17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ -/vendor +/.castor.stub.php +/.php-cs-fixer.cache +/.phpunit.cache /composer.lock /phpunit.xml +/site/ +/var/ +/vendor +/git-builds/ diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 00000000..84e2387c --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,50 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +$fileHeaderComment = <<<'EOF' + This file is part of Gitonomy. + + (c) Alexandre Salomé + (c) Julien DIDIER + + This source file is subject to the MIT license that is bundled + with this source code in the file LICENSE. + EOF; + +$finder = PhpCsFixer\Finder::create() + ->in(__DIR__) + ->append([ + __FILE__, + 'castor.php', + ]) + ->notPath('git-builds') + ->notPath('var') +; + +return new PhpCsFixer\Config() + ->setRiskyAllowed(true) + ->setUnsupportedPhpVersionAllowed(true) + ->setRules([ + '@PHP84Migration' => true, + '@PhpCsFixer' => true, + '@Symfony' => true, + '@Symfony:risky' => true, + 'php_unit_internal_class' => false, // From @PhpCsFixer but we don't want it + 'php_unit_strict' => true, // assertSame() instead of assertEquals() + 'php_unit_test_class_requires_covers' => false, // From @PhpCsFixer but we don't want it + 'phpdoc_add_missing_param_annotation' => false, // From @PhpCsFixer but we don't want it + 'header_comment' => ['header' => $fileHeaderComment], + 'ordered_class_elements' => true, // Symfony(PSR12) override the default value, but we don't want + 'blank_line_before_statement' => true, // Symfony(PSR12) override the default value, but we don't want + ]) + ->setFinder($finder) +; diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 39cdc5c2..00000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: php - -dist: xenial - -php: - - 5.6 - - 7.0 - - 7.1 - - 7.2 - - 7.3 - -install: travis_retry composer install --no-interaction --no-progress --no-suggest - -script: vendor/bin/phpunit diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..11570c08 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,299 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [Unreleased] + +Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). To upgrade, fix +the deprecations of 1.7 first, then see [UPGRADE-2.0.md](UPGRADE-2.0.md). + +### Breaking changes + +- Run every git operation through a driver (`Gitonomy\Git\Driver`): the domain objects call a + `Driver\RepositoryDriverInterface`, typed (hashes, enums, value objects of `Driver\Data`), + instead of building processes and parsing their output. A failing operation always throws, a + missing revision or object a `ReferenceNotFoundException`, a timeout a + `ProcessTimedOutException`; dates are `\DateTimeImmutable`; names in diffs and trees are + UTF-8; references are read again after every write gitlib makes, and peel annotated tags; + the diff of a merge commit is against its first parent; blame lines and logs are typed + objects; the subject of a message is its first paragraph. See + [UPGRADE-2.0.md](UPGRADE-2.0.md) for every change. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Return the dates of commits and tags at the UTC offset of their author, committer or tagger, + like `git log` shows them, instead of in UTC: the instant doesn't change, but `format()` + returns their local time. ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- Require PHP 8.4 or later, Symfony 7.4/8.1+: most classes are `final` (some `readonly`), + and every method has native types. ([#238](https://github.com/gitonomy/gitlib/pull/238)) +- Remove `Gitonomy\Git\Util\StringHelper`, replaced by `symfony/string`, and its configurable + encoding: gitlib reads everything in UTF-8. ([#252](https://github.com/gitonomy/gitlib/pull/252)) +- Require git 1.8.4 or later, the oldest version the test suite passes with: it simplifies the + log of a range filtered by path like the current versions, and `Log::count()` needs git + 1.7.2. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- `Diff::parse()` throws for a diff without its raw information, deprecated since 1.6.0 + ([#229](https://github.com/gitonomy/gitlib/pull/229)): parse the output of + `git diff --raw --patch`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- The constructors of `Commit`, `Tree`, `Blob`, `Revision`, the references and `ReferenceBag` + are internal: get these objects from `Repository`, which returns the same commit, tree or + blob while the application uses it, and reads the references again after its writes. + Extending `Revision` or `Reference` isn't supported anymore. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- `ProcessException` has a message of one line, with the error of git, the first line of its + error output starting with `fatal:` or `error:`, after the warnings: + `The git command "clone" failed (exit 128): fatal: ...`. The command line, the rest of the + error output and the output are no longer in the message: `getErrorOutput()` and + `getOutput()` return the outputs of git. ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- `Tree::resolvePath()` throws an `InvalidArgumentException` for a path through a submodule, + instead of an `Exception\UnexpectedValueException`, which gitlib no longer throws. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- When git can't start, a missing binary for instance, `CliDriver` throws a `DriverException` + instead of a `ProcessException`, with a message of one line, without the command line: + `The git command "clone" could not start: ...`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a name that no revision has, + with both drivers: the name of a remote branch alone, like `feature` for `origin/feature`, + created the local branch. Use `checkoutNewBranch()`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- Remove the API that 1.7 deprecates; see [UPGRADE-2.0.md](UPGRADE-2.0.md): + - `Admin`, replaced by the static methods of `Repository`, and `Repository::cloneTo()`, + replaced by `cloneFrom()` and `cloneBareFrom()`. + - The array of options of `Repository` (`working_dir`, `logger`, `command`, + `environment_variables`, `inherit_environment_variables`, `process_timeout`, `debug`): pass + the working directory and a `CliDriver` instead; `inherit_environment_variables` has no + counterpart, pass the variables git needs instead. `Repository::setLogger()` and + `getLogger()`. + - The `$reload` argument of `Repository::getReferences()`, and the arguments of + `Commit::getIncludingBranches()`. `ReferenceBag::update()` and `Branch::isMergedTo()`, + replaced by `Branch::moveTo()` and `isMergedInto()`. + - `Repository::getDiff()` with one argument, `Log::getDiff()`, `Diff::setRepository()`, + `File::setRepository()`, `addChange()`, `isChangeMode()`, `isDelete()` and `getAnchor()`, + `FileChange::getCount()`, `getLines()`, replaced by `getDiffLines()`, and the `LINE_*` + constants, replaced by `DiffLineType`, and the lines passed to the constructor of + `FileChange` as arrays. + - `Log::setOffset()` and `setLimit()`. + - A range of lines as a string in `Repository::getBlame()`, `Blame::getGroupedLines()` and + `Blame\Line::getLine()`. + - `CommitReference`, `Tree::getCommitReferenceEntries()`, `getEntries()`, `getBlobEntries()` + and `getTreeEntries()`: iterate the tree, or use `getBlobs()`, `getTrees()` and + `getSubmodules()`. + - The branch to create of `WorkingCopy::checkout()`. + - `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, + `PushReference::getReference()`, `getRevision()`, `isCreate()` and `isDelete()`. + - `Repository::getSize()`, in kilobytes: use `getSizeInBytes()`. + +### Features + +- Add an experimental `Driver\Ffi\LibGit2Driver`, running libgit2 1.9 in-process through PHP's + FFI extension, as an alternative to the default `Driver\Cli\CliDriver`: pass it as the driver of + `Repository`. It avoids spawning a `git` process for every operation; see + ["Choosing a driver"](doc/repository.md#choosing-a-driver) for its requirements, its + differences with `CliDriver`, and when it is faster. `benchmark.php`, from a clone of gitlib, + compares both drivers on a repository. With php-fpm, `LibGit2Driver::preload()` keeps libgit2 + loaded from `opcache.preload`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- `Repository` takes the working directory and the driver as arguments: a `Driver\Cli\CliDriver` + by default, configured through its constructor (git binary, environment, timeout and logger, + which gets every command, clones included), or any `Driver\DriverInterface`, to replace it in + tests; see ["Using your own driver"](doc/repository.md#using-your-own-driver). The driver + finds the repository: linked worktrees, submodules, clones with `--separate-git-dir` and + reftable repositories (with `CliDriver`) open, and a directory that isn't a repository + throws right away. ([#253](https://github.com/gitonomy/gitlib/pull/253), + [#261](https://github.com/gitonomy/gitlib/pull/261), fixes #200) +- Add static methods to `Repository` creating repositories, `init()`, `initBare()`, + `cloneFrom()`, `cloneBareFrom()` and `mirrorFrom()`, and checking a repository URL, + `isValidRepository()` and `isValidRepositoryAndBranch()`; see + ["Create and access git repositories"](doc/admin.md). + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Add `Repository::getDiff($from, $to)`, `Repository::getMergeBase()`, + `Repository::getSizeInBytes()` and `WorkingCopy::getDiff()`, + `WorkingCopy::checkoutNewBranch()`, `Branch::moveTo()` and `Branch::isMergedInto()`, + `Log::withOffset()` and `withLimit()`, `Blame::getHunks()` and the line numbers of + `Blame\Line`, `FileChange::getDiffLines()`, which returns `Diff\DiffLine` objects typed by + `Diff\DiffLineType`, iterating a `Tree`, which gives `TreeEntry` objects, + `Tree::getBlobs()`, `getTrees()` and `getSubmodules()`, `Submodule`, + `File::isModeChange()`, `FileChange::getAdditions()` and `getDeletions()`, + `PushReference::getFullname()`, `getRange()`, `isCreation()` and `isDeletion()`, and the SSH + and X.509 signatures of tags. ([#253](https://github.com/gitonomy/gitlib/pull/253), + [#254](https://github.com/gitonomy/gitlib/pull/254), [#263](https://github.com/gitonomy/gitlib/pull/263)) +- Add `WorkingCopy::merge()` to support `git merge`, fast-forwarding or not, with a + `FastForward` enum. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) +- Add `Repository::runProcess()` to expose stderr from successful commands: `run()` only + ever returned stdout, even on success, but some git commands (e.g. `push`) write + meaningful output to stderr even when they succeed. `run()` still returns the standard + output. + ([#247](https://github.com/gitonomy/gitlib/pull/247), fixes #205) +- Support git's mnemonic diff prefixes (`diff.mnemonicPrefix`) in the diff parser: enabling + this option replaces the default `a/`/`b/` prefixes with context-specific ones + (`c/`/`i/`/`o/`/`w/`), which used to raise a `RuntimeException` in tools like GrumPHP that + enable it. ([#245](https://github.com/gitonomy/gitlib/pull/245), fixes #114) +- Support `"copy from"`/`"copy to"` in the diff parser: diffs produced with copy detection + enabled (e.g. `diff.renames = copies`) used to fail to parse. `File` gains a new `isCopy()` + flag, and `isRename()` no longer reports `true` for copies, since a copy's source file + still exists afterward. + ([#244](https://github.com/gitonomy/gitlib/pull/244), fixes #14) + +### Security + +1.7.1 has those that break nothing of 1.x: see +[its changelog](https://github.com/gitonomy/gitlib/blob/1.x/CHANGELOG.md). + +- Refuse a revision, a reference, a hash or a URL starting with a dash, which git read as an + option: `getRevision('--output=/path')`, `WorkingCopy::checkout()` or `merge()` could write + a file, and `Repository::isValidRepository('--upload-pack=...')` run a command. A path may + start with a dash: `init()` and the clones end the options of git with `--`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- Require `symfony/process` 6.4.33, 7.4.5 or 8.1 and later: the older versions escape the + arguments wrongly on Windows (CVE-2024-51736, CVE-2026-24739). + ([#263](https://github.com/gitonomy/gitlib/pull/263)) +- Refuse an `ext::` URL, like `ext::sh -c ...`, whose command git runs, by default before git + 2.12, and an `fd::` URL, which makes git talk through a file descriptor it inherits from PHP, + like the socket of a database, and wait on it. + ([#254](https://github.com/gitonomy/gitlib/pull/254), [#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)) +- 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)) +- 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)) +- Fix `Hooks` writing through a symbolic link to a missing file, out of the hooks directory: + `has()` returned `false` for it, and `set()` followed it. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- Ignore the replacements of objects (`git replace`, `refs/replace/`) with `CliDriver`, like + `LibGit2Driver`: whoever could push such a reference to a repository changed the message of a + commit, or the content of a blob, gitlib read for a hash. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- Ignore `core.fsmonitor` with `CliDriver`: git ran the command the configuration of a + repository names there to read the working tree, and even to diff a commit. Like git with + `--git-dir`, gitlib trusts the repositories it opens: `safe.directory` doesn't apply, see + ["Git configuration"](doc/repository.md#git-configuration). + ([#263](https://github.com/gitonomy/gitlib/pull/263)) +- Fix `WorkingCopy::checkout()` of a path, like `checkout('README.md')`, discarding the changes + of the file: a path is no revision, and throws a `ReferenceNotFoundException`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- Fix moving or deleting a symbolic reference, which changed the branch it points to instead: + `ReferenceBag::delete('refs/remotes/origin/HEAD')` deleted `origin/main`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- Fix `ReferenceBag::delete('HEAD')` deleting `HEAD`, which left no repository: a name outside + `refs/` throws an `InvalidArgumentException`. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) + +### Bug fixes + +- Fix `Commit::getIncludingBranches()` and `Repository::getHead()` throwing for a branch another + process created or checked out since gitlib read the references, and + `getIncludingBranches()` throwing for a regular `refs/remotes/origin/HEAD`, as a mirror leaves + it. ([#263](https://github.com/gitonomy/gitlib/pull/263)) +- Fix `isValidRepository()` and `isValidRepositoryAndBranch()` reading, with `CliDriver`, the + configuration of the repository PHP runs in, like its rewrites of URLs, and returning `true` + for an empty URL there: it throws an `InvalidArgumentException`. + ([#263](https://github.com/gitonomy/gitlib/pull/263)) +- Fix the blame of a file throwing with `CliDriver` after a merge whose sides kept it under two + names: git repeats the path of a commit it blames under both, which gitlib didn't read. + ([#263](https://github.com/gitonomy/gitlib/pull/263)) +- Fix a log throwing with `CliDriver` for a commit whose author or committer git wouldn't + write anymore, like `Name` or no date, as old imports have: it is read like libgit2 + reads it, the epoch in UTC without date. ([#263](https://github.com/gitonomy/gitlib/pull/263)) +- Fix the configuration of the user changing what `CliDriver` reads: `color.ui=always`, + `log.showSignature`, `diff.noprefix`, `diff.external`, `diff.submodule`, textconv filters, + `column.ui`... made reads throw or return wrong data, and `diff.algorithm`, `diff.context`, + `diff.orderFile`, `log.follow` or `branch.sort` changed diffs, logs and the branches of a + commit. gitlib sets them back to git's defaults; see + ["Git configuration"](doc/repository.md#git-configuration). + ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- Fix `Commit::getShortHash()`, `Commit::getDiff()` and `Repository::getDiff()` failing when a + file of the working tree has the name of the revision they read, which git found ambiguous. + ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- Fix logs of thousands of revisions exceeding the limit of the command line: `CliDriver` passes + them to git on its standard input. ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- Fix `Commit::getDiff()` returning an empty diff for a root commit, and throwing for a merge + commit (`CliDriver`). ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `Repository::getDiff()`, `WorkingCopy::getDiffPending()` and `WorkingCopy::getDiffStaged()` + failing to parse the output of git before 2.16, which writes "..." after abbreviated hashes, + and throwing during a conflict. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix the names of files with spaces in diffs, split at the wrong place, the hashes of the files + following a type change, shifted, and hunks of one line, counted as empty. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix annotated tags never resolving to their commit (`ReferenceBag::resolveTags()`, + `Commit::resolveReferences()`), tags created or deleted missing from `getTags()`, and + `getHead()` throwing after a checkout of a new branch: references are read again after + every write gitlib makes. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `createBranch()` and `createTag()` moving an existing reference instead of refusing it, + a branch named "remotes/x" taken for a remote one, and branches whose name ends with "HEAD" + missing. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `isValidRepositoryAndBranch()` matching the end of the name of a branch ("release" for + "feature/release"), and `isValidRepository()` reading a relative path as the name of a remote + of PHP's working directory, or returning `false` when git can't run. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `getTree()` reading the tree of a commit, and arguments holding a NUL byte throwing a + `ValueError`: they throw a `ReferenceNotFoundException` and an `InvalidArgumentException`. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix the log of a merge of an unsigned or SSH-signed tag throwing, or swallowing commits up to + the next PGP signature. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `Commit::getBodyMessage()` losing the second line of a message without an empty line + after its subject, tags without tagger throwing, and SSH-signed tags not seen as signed. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix blame lines numbered from 1 for a range of lines. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `Hooks` failing silently, in a repository without hooks directory, and writing the hooks + of a linked worktree where git doesn't run them. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix the size of a repository (`getSizeInBytes()`, which replaces `getSize()`) throwing for a + link to a missing file in the git directory, like a hook not deployed yet: it no longer follows + links. A file or a directory git deletes + meanwhile, like a lock during a `gc` or the quarantine of a push, no longer makes it throw + either, and one it can't read throws a `RuntimeException` of gitlib, instead of PHP's. + ([#254](https://github.com/gitonomy/gitlib/pull/254), [#263](https://github.com/gitonomy/gitlib/pull/263)) +- Fix blame lines starting with a TAB character (`Blame\Line::getContent()`): code removing it + must stop doing so. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix reading a commit whose `encoding` header isn't UTF-8, which threw: its message, author and + committer are now re-encoded to UTF-8, like `git log` does. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix file names holding non-ASCII or special characters (tab, quote, backslash) in trees + (iterating a `Tree`), and special characters in diffs, which kept git's C-style quoting. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix relative paths not resolving against the repository in `Repository::run()`: the git + process spawned by `run()` never had its working directory set, so commands such as + `apply` resolved paths against the calling script's cwd instead of `--work-tree`, failing + when run from outside the repository directory. + ([#249](https://github.com/gitonomy/gitlib/pull/249), fixes #67) +- Fix diff parsing for dirty submodules without an index line: a submodule whose working + tree changed but still points at the same commit is reported by git without an `index ` + line, which made the parser choke on the next hunk. + ([#246](https://github.com/gitonomy/gitlib/pull/246), fixes #165) +- Fix bugs and hardening issues found by a code audit: + ([#239](https://github.com/gitonomy/gitlib/pull/239)) + - `ReferenceBag::getFirstBranch()` could return a non-branch reference, or throw, when no + branch existed. + - `Tag::getBodyMessage()` dropped or truncated a line for annotated tags using the + standard `subject\n\nbody` convention. + - `Hooks::set()` created hook scripts world-writable (`0777`); they are now created + `0700`. + +### Minor + +- CI tests git 1.8.4 and 2.25.1, analyses the tests and the libgit2 driver with PHPStan, + without baseline, checks the archive Composer installs, pins the third-party actions by + commit, and the suite deletes what it creates, in any order. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- The tests run with a git configuration of their own, whatever the developer's + (`tests/fixtures/home`), which sets what gitlib sets back to git's defaults, and without the + variables of git of their environment: run from a hook, they wrote the repository of the hook + (`GIT_DIR`, `GIT_INDEX_FILE`...). ([#254](https://github.com/gitonomy/gitlib/pull/254)) +- Add a regression test for reading a blob's content in a tree that also contains a + submodule. ([#243](https://github.com/gitonomy/gitlib/pull/243)) +- Replace the network-dependent test fixture (cloned from GitHub on every run) with a local, + audited git bundle, plus a Console tool to regenerate it. + ([#241](https://github.com/gitonomy/gitlib/pull/241), + [#242](https://github.com/gitonomy/gitlib/pull/242)) +- CI: bump `actions/checkout` to v7 to fix a Node 20 deprecation warning. + ([#240](https://github.com/gitonomy/gitlib/pull/240)) + +## [v1.6.0] - 2025-12-09 + +_Earlier versions are not documented here. See the +[GitHub releases](https://github.com/gitonomy/gitlib/releases) and +[commit history](https://github.com/gitonomy/gitlib/commits/v1.6.0) for prior changes._ + +[Unreleased]: https://github.com/gitonomy/gitlib/compare/v1.6.0...HEAD +[v1.6.0]: https://github.com/gitonomy/gitlib/releases/tag/v1.6.0 diff --git a/README.md b/README.md index fb03092d..7499784a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,96 @@ -Git lib for Gitonomy -==================== +gitlib +====== -[![Build Status](https://secure.travis-ci.org/gitonomy/gitlib.png)](https://travis-ci.org/gitonomy/gitlib) +Access git repositories from PHP 8.4+, through the `git` binary or libgit2. -This library provides methods to access Git repository from PHP. +```php +use Gitonomy\Git\Repository; -It makes shell calls, which makes it less performant than any solution. +$repository = new Repository('/path/to/repository'); -Anyway, it's convenient and don't need to build anything to use it. That's how we love it. +// The last 5 commits of main that changed src/ +foreach ($repository->getLog('main', 'src', limit: 5) as $commit) { + echo $commit->getShortHash().' '.$commit->getSubjectMessage().PHP_EOL; +} -*Documentation*: http://gitonomy.com/doc/gitlib/master/ +// The files the last commit changed +foreach ($repository->getHeadCommit()->getDiff()->getFiles() as $file) { + echo $file->getName().': +'.$file->getAdditions().' -'.$file->getDeletions().PHP_EOL; +} + +// Who wrote the first lines of README.md +foreach ($repository->getBlame('main', 'README.md', 1, 3)->getLines() as $line) { + echo $line->getCommit()->getAuthorName().': '.$line->getContent().PHP_EOL; +} +``` + +The documentation is at **[gitonomy.github.io/gitlib](https://gitonomy.github.io/gitlib/)**. + +What it does +------------ + +- **Open** a bare repository, a working tree, a linked worktree or a submodule, or + **create** one: init, clone, mirror. See [Opening a repository](doc/repository.md) and + [Creating repositories](doc/admin.md). +- **Read** commits, logs, trees, blobs, diffs, blames, branches and tags, as PHP objects. +- **Change** the working copy: checkout, new branches, merges, and the diffs of what is + pending or staged. See [Working copy](doc/workingcopy.md). +- **Manage** the hooks of a repository: see [Hooks](doc/hooks.md). +- **Run** any other git command: see + [Running git commands](doc/repository.md#running-git-commands). + +Two drivers +----------- + +`Repository` runs git through a driver, its last argument: + +- `CliDriver`, the default, runs the `git` binary, 1.8.4 or later, and needs nothing else; +- `LibGit2Driver`, experimental, runs [libgit2](https://libgit2.org/) 1.9 in-process, through + PHP's `FFI` extension. It is faster for many small reads in a long-running process, and + slower on long histories, like blames: see + ["Choosing a driver"](doc/repository.md#choosing-a-driver). + +```php +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository', driver: new LibGit2Driver()); +``` + +Installation +------------ + +```bash +composer require gitonomy/gitlib +``` + +Upgrading from 1.x? Read [UPGRADE-2.0.md](UPGRADE-2.0.md) and the [changelog](CHANGELOG.md). + +Documentation +------------- + +- [Opening a repository](doc/repository.md): paths, drivers, errors, git configuration +- [Creating repositories](doc/admin.md) +- [Revisions](doc/revision.md) +- [Commits](doc/commit.md) +- [Log](doc/log.md) +- [Trees](doc/tree.md) +- [Blobs](doc/blob.md) +- [Diffs](doc/diff.md) +- [Blame](doc/blame.md) +- [Tags and branches](doc/references.md) +- [Branches](doc/branch.md) +- [Working copy](doc/workingcopy.md) +- [Hooks](doc/hooks.md) + +For Enterprise +-------------- + +Available as part of the Tidelift Subscription + +The maintainers of `gitonomy/gitlib` and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/packagist-gitonomy-gitlib?utm_source=packagist-gitonomy-gitlib&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) + +License +------- + +gitlib is released under the [MIT license](https://github.com/gitonomy/gitlib/blob/main/LICENSE). diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md new file mode 100644 index 00000000..2f95c00e --- /dev/null +++ b/UPGRADE-2.0.md @@ -0,0 +1,728 @@ +UPGRADE FROM 1.x to 2.0 +======================= + +gitlib 2.0 runs every git operation through a driver: `CliDriver`, the +default, runs the `git` binary like 1.x did, and the experimental +`LibGit2Driver` runs libgit2 in-process (see +["Choosing a driver"](doc/repository.md#choosing-a-driver)). + +2.0 removes the API of 1.x that it replaces. Upgrade to 1.7.1 first, which +requires PHP 8.1: it has the replacements, and each call to the API that +2.0 removes triggers a deprecation (`E_USER_DEPRECATED`) naming its +replacement. Log the deprecations of your application to find them, and fix +them before upgrading to 2.0 (see +[Supporting 1.x and 2.x](#supporting-1x-and-2x)). The other changes, listed +below too, break code that relies on them. + +Requirements +------------ + +* PHP 8.4 or later is required. +* git 1.8.4 or later is required by `CliDriver`, the default driver: before, + git simplifies the log of a range filtered by path differently, and it + can't count commits (`Log::count()`) before 1.7.2. +* `symfony/process` must be `^6.4.33`, `^7.4.5` or `^8.1`: the older + versions escape the arguments wrongly on Windows (CVE-2024-51736, + CVE-2026-24739). +* `symfony/string` `^7.4.6` or `^8.0.6`, and `psr/log` `^1.1`, `^2.0` or + `^3.0` are new dependencies. +* Repositories using SHA-256 hashes are refused, with an + `UnsupportedOperationException`, by both drivers. + +Supporting 1.x and 2.x +---------------------- + +gitlib 1.7 has the API of 2.0 that can be added without breaking 1.x, and +deprecates what 2.0 removes or breaks: `new Repository($dir, $workingDir, +$driver)` with a `CliDriver` of the same arguments, the static methods of +`Repository` creating repositories, `Diff::parse($rawDiff, $repository)`, +`Repository::getDiff($from, $to)`, `FileChange::getDiffLines()`, iterating +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 +from PHP 8.4, with the same code. + +Before: + +```php +$repository = new Repository($gitDir, ['working_dir' => $workingDir, 'logger' => $logger]); +$diff = Diff::parse($rawDiff); +$diff->setRepository($repository); +``` + +After, with 1.7 and 2.0: + +```php +use Gitonomy\Git\Driver\Cli\CliDriver; + +$repository = new Repository($gitDir, $workingDir, new CliDriver(logger: $logger)); +$diff = Diff::parse($rawDiff, $repository); +``` + +1.7 breaks nothing of 1.x, so: + +* Pass by position the arguments whose name changes in 2.0 (see + [Extending gitlib](#extending-gitlib)): 1.7 keeps the names of 1.x. So + do the arguments 1.7 adds to `Repository::getDiff()`, `getBlame()`, + `Diff::parse()`, `Diff::fromArray()` and `File::fromArray()`: their + signature leaves them out, not to break the classes overriding these + methods. +* `ReferenceBag::createBranch()` and `createTag()` keep a revision name as + the hash of the reference they return, like 1.x: pass a `Revision` to + get the hash of its commit with both versions. +* The submodules of trees are `CommitReference` objects, which extend + `Submodule` in 1.7: check them with `instanceof Submodule`. +* `File::toArray()` has no `is_copy` key in 1.7. + +What 1.7 can't bring, as it would break 1.x, behaves differently with +both versions: + +* With a range of lines, `Blame::getLines()` and `getLine()` number the + lines from 1 in 1.7, like the file in 2.0: use `Line::getNumber()`, or + `Blame::getHunks()`, numbered like the file in 1.7 too. +* 1.7 doesn't read the references again after a write: open a new + `Repository` to read them, with both versions. `getReferences(true)` is + deprecated in 1.7, and 2.0 removes its argument. +* The classes `final` in 2.0 are only `@final` in 1.7: see + [Testing code using gitlib](#testing-code-using-gitlib). +* The other changes listed below: dates, the stricter parser of diffs, + the directory git runs in, the exceptions... + +Opening a repository +-------------------- + +* The second argument of `Repository` is the working directory, when the + git directory is kept apart from it, instead of an array of options: its + `working_dir` option is this argument, its `logger`, `command`, + `environment_variables` and `process_timeout` options configure a + `Gitonomy\Git\Driver\Cli\CliDriver`, which `Repository` takes as its last + argument, and its `debug` option is removed: a failing git operation + always throws, see [Errors](#errors). +* The `inherit_environment_variables` option has no counterpart in + `CliDriver`: git inherits the environment variables of PHP anyway, and + `$_SERVER` holds, under php-fpm, the headers and the credentials of the + request. Pass the variables git needs to the `environmentVariables` + argument instead. + + git inherits `$_ENV`, and the variables of `getenv()` that `$_SERVER` + holds too, like Symfony Process passes them: a variable an application + only writes in `$_SERVER`, without `putenv()` nor `$_ENV`, which the + option passed, no longer reaches git. A command line application + relying on it passes `$_SERVER` itself: + + ```php + $driver = new CliDriver(environmentVariables: array_filter($_SERVER, is_string(...))); + ``` + + Before: + + ```php + $repository = new Repository($path, [ + 'command' => '/usr/bin/git', + 'logger' => $logger, + 'process_timeout' => 60, + ]); + ``` + + After: + + ```php + use Gitonomy\Git\Driver\Cli\CliDriver; + use Gitonomy\Git\Repository; + + $repository = new Repository($path, driver: new CliDriver(command: '/usr/bin/git', processTimeout: 60, logger: $logger)); + ``` + +* The logger is an argument of `CliDriver`, which logs every git command it + runs, cloning and initializing included: `Repository::setLogger()` and + `Repository::getLogger()` are removed. `LibGit2Driver` runs no command, + and logs nothing. +* `$path` may be a linked worktree, a submodule or a clone with + `--separate-git-dir`, whose `.git` is a file. +* A directory that isn't a repository throws an `InvalidArgumentException` + right away, instead of a `ProcessException` at the first operation. + +Creating repositories +--------------------- + +* `Admin` and `Repository::cloneTo()` are removed: use the static methods + of `Repository`. They take + the URL before the path, have a method for each kind of repository + instead of the `$bare` argument, whose default was a bare repository, and + take the driver as last argument: + + | 1.x | 2.0 | + |-----------------------------------------------------|--------------------------------------------------------------| + | `Admin::init($path)` | `Repository::initBare($path)` | + | `Admin::init($path, false)` | `Repository::init($path)` | + | `Admin::cloneTo($path, $url)` | `Repository::cloneBareFrom($url, $path)` | + | `Admin::cloneTo($path, $url, false)` | `Repository::cloneFrom($url, $path)` | + | `Admin::cloneBranchTo($path, $url, $branch)` | `Repository::cloneBareFrom($url, $path, $branch)` | + | `Admin::cloneBranchTo($path, $url, $branch, false)` | `Repository::cloneFrom($url, $path, $branch)` | + | `Admin::mirrorTo($path, $url)` | `Repository::mirrorFrom($url, $path)` | + | `Admin::isValidRepository($url)` | `Repository::isValidRepository($url)` | + | `Admin::isValidRepositoryAndBranch($url, $branch)` | `Repository::isValidRepositoryAndBranch($url, $branch)` | + | `$repository->cloneTo($path)` | `Repository::cloneBareFrom($repository->getGitDir(), $path)` | + | `$repository->cloneTo($path, false)` | `Repository::cloneFrom($repository->getGitDir(), $path)` | + +* `isValidRepositoryAndBranch()` checks the exact name of the branch: + `release` no longer matches `feature/release`, nor `rel*` `release`. +* `isValidRepository()` and `isValidRepositoryAndBranch()` read a relative + path as a path, never as the name of a remote of PHP's working directory, + and throw a `DriverException` when git can't start, instead of returning + `false`. + + Before: + + ```php + $repository = Admin::cloneTo($path, $url, false, ['process_timeout' => 60]); + ``` + + After: + + ```php + use Gitonomy\Git\Driver\Cli\CliDriver; + use Gitonomy\Git\Repository; + + $repository = Repository::cloneFrom($url, $path, driver: new CliDriver(processTimeout: 60)); + ``` + +* `Repository::cloneTo()` cloned with the driver of the repository: pass it + to `cloneFrom()` or `cloneBareFrom()` as well. +* `Admin::cloneRepository()` has no replacement for the arguments of + `git clone` other than `--bare`, `--branch` and `--mirror`: run + `git clone` yourself, then open the repository with + `new Repository($path)`. + +References +---------- + +* `Repository::getReferences()` reads the references again after every + write gitlib makes (creating or deleting a reference, a checkout, a merge, + `run()`...), and takes no argument: open a new `Repository` to see the + writes of other processes. A `Reference` object keeps the commit it + pointed to when it was read. +* `Reference::getCommitHash()` returns the commit an annotated tag points + to, instead of the hash of the tag object: `ReferenceBag::resolve()`, + `resolveTags()` and `Commit::resolveReferences()` find annotated tags. +* `ReferenceBag::createBranch()` and `createTag()` throw an + `InvalidArgumentException` when the reference exists, instead of moving + it, and take a `Revision` or a revision name as second argument, instead + of a commit hash only. +* `ReferenceBag::update()` is removed: use `Branch::moveTo()`. + + Before: + + ```php + $repository->getReferences()->update(new Branch($repository, 'refs/heads/main', $hash)); + ``` + + After: + + ```php + $repository->getReferences()->getBranch('main')->moveTo($hash); + ``` + +* `Branch::isMergedTo()` is removed: use `isMergedInto()`, which takes + the destination as a `Revision` or a revision name, without a default + (`'master'` before) nor `$compareOnlyWithRemote`. +* `Commit::getIncludingBranches()` takes no argument: it returns the local + and remote branches, which `Branch::isLocal()` and `isRemote()` filter. + + Before: + + ```php + $branches = $commit->getIncludingBranches(true, false); + ``` + + After: + + ```php + use Gitonomy\Git\Reference\Branch; + + $branches = array_filter($commit->getIncludingBranches(), static fn (Branch $branch): bool => $branch->isLocal()); + ``` + +* `ReferenceBag::getFirstBranch()` returns `null` instead of `false` when + there is no branch, and only branches. +* `Repository::getHead()` returns a `Reference\Branch` or a `Commit`. + `HEAD` pointing to another reference, like a tag, which git allows, + throws a `ReferenceNotFoundException`, where 1.x returned the reference: + `getHeadCommit()` still returns its commit. +* The getters of `Reference\Tag` (`getTaggerName()`, `getTaggerEmail()`, + `getTaggerDate()`, `getMessage()`, `getSubjectMessage()`, + `getBodyMessage()`, `getGpgSignature()`) return `null` instead of `false` + for a lightweight tag. The tagger is `null` for an annotated tag without + one too, where gitlib threw. `getGpgSignature()` returns `null` for an + unsigned tag instead of throwing, and finds X.509 and SSH signatures too. +* A branch whose name ends with `HEAD` is listed: only the `HEAD` of a + remote is left out. +* Moving or deleting a symbolic reference, like `refs/remotes/origin/HEAD`, + changes it, instead of the branch it points to. +* `ReferenceBag::delete()` throws an `InvalidArgumentException` for a name + outside `refs/`: `HEAD`, which `CliDriver` deleted, leaving no + repository, or a short name, like `master`, which did nothing. + +History +------- + +* `Log::setOffset()` and `setLimit()` are removed: `withOffset()` and + `withLimit()` return another `Log`. +* `count($log)` returns the number of commits the log iterates, after its + offset and up to its limit; `countCommits()` still counts them all. +* A negative offset or limit throws an `InvalidArgumentException`. +* The revisions of a log are names, ranges (`a..b`, `a...b`), names to + exclude (`^a`) or `--all`: any other option of `git log` throws an + `InvalidArgumentException`, instead of being passed to git. An unknown + revision throws a `ReferenceNotFoundException` naming it. +* `Log::getDiff()` is removed: it ignored the paths of the log. Use + `Repository::getDiff()`. + +Diffs +----- + +* `Repository::getDiff()` takes the commits to compare: `$from` and `$to`, + both required. + + | 1.x | 2.0 | + |------------------------------------|------------------------------------------------------------------| + | `$repository->getDiff('a..b')` | `$repository->getDiff('a', 'b')` | + | `$repository->getDiff(['a', 'b'])` | `$repository->getDiff('a', 'b')` | + | `$repository->getDiff('a...b')` | `$repository->getDiff($repository->getMergeBase('a', 'b'), 'b')` | + | `$repository->getDiff('a')` | `$repository->getWorkingCopy()->getDiff('a')` | + +* The diff of a merge commit, `Commit::getDiff()`, is against its first + parent, like GitHub shows it, with both drivers. +* `Diff`, `Diff\File` and `Diff\FileChange` get their repository and their + changes when constructed: `Diff::setRepository()`, + `File::setRepository()` and `File::addChange()` are removed. + `Diff::parse()` and `fromArray()` take the repository to read blobs from. +* `FileChange::getDiffLines()` returns the lines of a hunk as + `Diff\DiffLine` objects, with `getType()`, a `Diff\DiffLineType`, and + `getContent()`: `FileChange::getLines()`, which returns them as arrays, + is removed, as the `FileChange::LINE_*` constants and + `FileChange::getCount()`, replaced by `getAdditions()` and + `getDeletions()`. The constructor of `FileChange` takes `DiffLine` + objects, instead of arrays. + + Before: + + ```php + foreach ($change->getLines() as [$type, $content]) { + if (FileChange::LINE_ADD === $type) { + // ... + } + } + ``` + + After, with 1.7 and 2.0: + + ```php + use Gitonomy\Git\Diff\DiffLineType; + + foreach ($change->getDiffLines() as $line) { + if (DiffLineType::Addition === $line->getType()) { + // ... $line->getContent() + } + } + ``` + +* `File::isChangeMode()`, `isDelete()` and `getAnchor()` are removed: + use `isModeChange()`, `isDeletion()`, and + `substr($file->getNewIndex(), 0, 12)`. +* `File::getOldIndex()` and `getNewIndex()` return `null` for a missing + hash, never an empty string; `getName()` returns a `string`. +* `File::getOldBlob()` and `getNewBlob()` throw a `LogicException` without + repository, and a `RuntimeException` without hash. +* `Diff::parse()` throws an `InvalidArgumentException` for a diff without + its raw information, deprecated since 1.6.0: parse the output of + `git diff --raw --patch`, with `-z` or not. It reads the names of the + files from the raw information: names with spaces are no longer split at + the wrong place. +* It reads the status of a file, a creation, a modification, a deletion, + a rename..., from its raw line (`A`, `M`, `D`, `R100`...), instead of + the headers of the patch (`new file mode`...): a diff written by hand + where they disagree gives another result. Like git, the raw line + separates the status and the name with a TAB: spaces throw a + `RuntimeException`. Generate the diffs of your tests with git, with + `git diff --raw --patch --full-index`, instead of writing them. +* During a merge conflict, `WorkingCopy::getDiffPending()` and + `getDiffStaged()` leave the unmerged files out, where they threw, and + `WorkingCopy::getDiff()` compares them, conflict markers included, to the + revision, like `git diff HEAD`. + +Blame +----- + +* `Repository::getBlame()` takes the first and the last lines to blame, + instead of a range as a string (`'3,5'`, `'3,+3'` or a regular + expression). +* `Blame::getLines()` and `getLine()` number the lines like the file, from + the first blamed line, instead of from 1. +* `Blame\Line::getNumber()` and `getOriginalNumber()` return the number of + the line in the file and in the commit that changed it, as integers: + `Line::getLine()` is removed. +* `Blame::getGroupedLines()` is removed: `getHunks()` returns + `Blame\Hunk` objects. + +Trees +----- + +* Iterating a `Tree` gives its entries by name, `TreeEntry` objects with + `getName()`, `getMode()` and `getObject()`; `getBlobs()`, `getTrees()` + and `getSubmodules()` return them by kind. `getEntries()`, + `getBlobEntries()` and `getTreeEntries()`, which return them as arrays + (`[$mode, $object]`), are removed. + + Before: + + ```php + foreach ($tree->getEntries() as $name => [$mode, $object]) { + // ... + } + ``` + + After, with 1.7 and 2.0: + + ```php + foreach ($tree as $name => $entry) { + // ... $entry->getMode(), $entry->getObject() + } + ``` + +* `CommitReference` is removed: it is `Submodule`. So is + `Tree::getCommitReferenceEntries()`: use `getSubmodules()`. +* `Tree::resolvePath()` throws an `InvalidArgumentException` explaining + why a path doesn't resolve, a path through a submodule included, which + threw an `UnexpectedValueException`, and resolves `/` to the tree. + +Working copy +------------ + +* `WorkingCopy::checkout()` takes a `Revision` or a revision name: a local + `Branch` object attaches `HEAD` to the branch, instead of detaching it. + Its second argument, a branch to create, is removed: use + `checkoutNewBranch($branch, $startPoint)`. +* `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a + name that no revision has, with both drivers: the name of a remote + branch alone, like `feature` for `origin/feature`, created the local + branch, and a path, like `README.md`, discarded its changes. + + Before: + + ```php + $workingCopy->checkout('feature'); + ``` + + After: + + ```php + $workingCopy->checkoutNewBranch('feature', 'origin/feature'); + ``` + +Commits, trees and blobs +------------------------ + +* `Repository::getCommit()` takes a full hash. `getTree()` and `getBlob()` + take a hash, abbreviated or not, instead of any revision: use + `getRevision($name)->getCommit()` for a name. +* Reading an object of another type throws a `ReferenceNotFoundException` + ("The object ... is not a tree."): `getTree()` read the tree of a commit, + and `getBlob()` threw a `ProcessException`. +* The subject of a commit or a tag is its first paragraph, on one line, and + its body what follows, like `git log --format=%s` and `%b`: a message + without an empty line after its first line has no body, where its second + line was lost. +* `Commit::getShortMessage()` and `getFixedShortHash()` are removed: use + `symfony/string` (`u($commit->getSubjectMessage())->truncate(50, '...')`) + and `substr()`. +* `Blob::getMimetype()` returns the mimetype alone (`text/plain`, instead + of `text/plain; charset=us-ascii`), and throws instead of returning + `false`. +* `Repository` keeps the objects it returns only while your application + uses them: memory no longer grows with every commit read. + +Errors +------ + +* A failing git operation always throws, a + `Gitonomy\Git\Exception\ProcessException` with `CliDriver`. Nothing + returns `null` or empty data instead anymore: `Repository::run()` returns + a `string`, and `Repository::getHead()` and `Repository::getHeadCommit()` + never return `null`. + + Before: + + ```php + $repository = new Repository($path, ['debug' => false]); + + if (null === $output = $repository->run('fetch', ['--all'])) { + // handle the failure + } + ``` + + After: + + ```php + $repository = new Repository($path); + + try { + $output = $repository->run('fetch', ['--all']); + } catch (ProcessException $e) { + // handle the failure + } + ``` + +* Failures are no longer logged at the `error` level: the exception reports + them, and the logger gets their error output at the `debug` level. Alerts + based on `error` logs must rely on exceptions instead. +* A missing revision, reference or object throws a + `ReferenceNotFoundException`, with both drivers: it extends + `Gitonomy\Git\Exception\InvalidArgumentException`, still an + `\InvalidArgumentException`, and takes a whole message instead of a name. +* A timeout throws a `Gitonomy\Git\Exception\ProcessTimedOutException`, + and the other failures of Symfony Process a `DriverException`: no + exception of Symfony or of PHP leaks anymore, not even as the previous + exception, as they hold the command line. +* `ProcessException` extends the new + `Gitonomy\Git\Exception\DriverException`, itself a `RuntimeException`: + catching `RuntimeException` or `GitExceptionInterface` still works. +* An unknown revision throws a `ReferenceNotFoundException`, an + `InvalidArgumentException`, where `Repository::getDiff()`, `getBlame()` + or `WorkingCopy::checkout()` threw a `ProcessException`: a + `catch (RuntimeException)` doesn't catch it anymore. Catch + `GitExceptionInterface`, which every exception of gitlib implements. +* The message of a `ProcessException` is one line, with the error of git: + the first line of its error output starting with `fatal:` or `error:`, + after the warnings, or its first line: `The git command "clone" failed + (exit 128): fatal: ...`, or `The git command "diff" failed (exit 1).` + when git writes no error, but for the conflict of a merge, which git + writes on its output (`CONFLICT (content): ...`). The command line, the + rest of the error output and the output are no longer in the message: + `getErrorOutput()` and `getOutput()` return the outputs of git. Its + constructor takes the git command as second argument, optional, and + reads the exit code and the outputs of the `Process`: a test double of + `Process` must return them. +* When git can't start, a missing binary for instance, `CliDriver` throws + a `DriverException` instead of a `ProcessException`, with a message of + one line, without the command line: `The git command "clone" could not + start: ...`. +* These methods throw instead of returning a fallback value: + * `Commit::getIncludingBranches()` threw nothing and returned `[]` when git + failed; + * `ReferenceBag` (`Repository::getReferences()`) only returns an empty bag + for a repository without references, and throws for any other failure. +* gitlib no longer throws `Gitonomy\Git\Exception\UnexpectedValueException`: + catch `InvalidArgumentException` for `Tree::resolvePath()`. +* `Commit::getShortHash()` throws a `ReferenceNotFoundException` for an + unknown commit, instead of a `ProcessException`. +* `Hooks` throws a `RuntimeException` when a hook exists already, or can't + 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`. +* An argument holding a NUL byte throws an `InvalidArgumentException`, + where PHP threw a `ValueError`: git and libgit2 read their arguments as C + strings, which end at the first NUL byte. +* A revision, a reference, a hash or a URL starting with a dash throws an + `InvalidArgumentException`, with both drivers: git read it as an option, + `getRevision('--output=/path')` writing a file, or + `isValidRepository('--upload-pack=...')` running a command. A path may + start with a dash. +* An empty URL throws an `InvalidArgumentException`, with both drivers. +* An `ext::` URL throws an `InvalidArgumentException`, with both drivers: + git runs its command, by default before git 2.12. So does an `fd::` URL, + which makes git talk through a file descriptor it inherits from PHP. +* An ssh URL whose host or user starts with a dash, even encoded (`%2D`), + like `ssh://-oProxyCommand=.../repository` or + `ssh://-oProxyCommand=...@host/path`, or a `git://` URL whose host does, + throws an `InvalidArgumentException`, with both drivers: git before 2.14.1 + passes it to ssh, or to the command of `core.gitProxy`, which reads it as + an option. +* `Hooks` throws an `InvalidArgumentException` for a name that isn't the one + of a file of the hooks directory, like `../config` or `.hidden`. +* The messages of exceptions, their traces (`#[\SensitiveParameter]`) and + the logs hide the credentials of URLs + (`https://***@example.com/repository.git`). `getErrorOutput()` and + `getOutput()` of `ProcessException` don't. +* Every message is a sentence ending with a dot. + +Git configuration +----------------- + +* `CliDriver` sets back to git's defaults the configuration of the user that + changes what git prints, what a read returns, or the upstream of a new + branch: colors, columns, `branch.autoSetupMerge`, + `branch.autoSetupRebase`, `branch.sort`, `log.follow`, + `log.showSignature`, and for diffs `diff.algorithm`, `diff.context`, + `diff.external`, `diff.orderFile`, `diff.submodule`, the prefixes, the + textconv filters... Before, they made reads throw or return other data: a + log of a path following its renames with `log.follow`, for instance. See + ["Git configuration"](doc/repository.md#git-configuration). +* `CliDriver` ignores the replacements of objects (`git replace`, + `refs/replace/`), like `LibGit2Driver`: `run()` and `runProcess()` too. + Run git yourself to read them. +* `CliDriver` ignores `core.fsmonitor`, whose command git runs to read the + working tree: `run()` and `runProcess()` too. + +Dates +----- + +* `Commit::getAuthorDate()`, `Commit::getCommitterDate()` and + `Reference\Tag::getTaggerDate()` return a `\DateTimeImmutable` instead of + a `\DateTime`. Code that modifies the returned date must use the returned + value of `modify()`, `setTimezone()`... +* These dates are at the UTC offset of their author, committer or tagger, + like `git log` shows them (`2016-08-17T12:00:00+02:00`), instead of in + UTC. The instant doesn't change: comparisons and `getTimestamp()` return + the same results, but `format()` returns the local time of the author, + committer or tagger. To keep UTC, convert the date: + + Before: + + ```php + $date = $commit->getAuthorDate()->format('Y-m-d H:i'); + ``` + + After: + + ```php + $date = $commit->getAuthorDate()->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i'); + ``` + +Extending gitlib +---------------- + +* These classes are `final`: `Blame`, `Blob`, `Commit`, `Log`, + `Parser\DiffParser`, `PushReference`, + `Reference\Branch`, `Reference\Stash`, `Reference\Tag`, `ReferenceBag`, + `Repository`, `Tree`, and the exceptions `LogicException`, + `ProcessException`, `ReferenceNotFoundException` and + `UnexpectedValueException`. +* These classes are `final readonly`: `Blame\Line`, `Diff\Diff`, + `Diff\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. +* The constructors of `Commit`, `Tree`, `Blob`, `Revision`, `Reference`, + `Reference\Tag` and `ReferenceBag` are internal: get these objects from + `Repository` (`getCommit()`, `getTree()`, `getBlob()`, `getRevision()`, + `getReferences()`), which returns the same commit, tree or blob while your + application uses it, and reads the references again after its writes. + The constructor of `Reference` requires the commit hash. Extending + `Revision` or `Reference` isn't supported anymore, and won't be possible + in 3.0: `Reference` declares `getName()`, abstract. +* With named arguments, some parameters are renamed: + `Repository::__construct()` `$options` (`$workingDir`), `getBlame()` + `$lineRange` (`$firstLine`), `getDiff()` `$revisions` (`$from`), + `ReferenceBag::createBranch()` and `createTag()` `$commitHash` + (`$revision`), `resolve()`, `resolveBranches()` and `resolveTags()` + `$hash` (`$commit`), and `PushReference::__construct()` `$reference` + (`$fullname`). +* To change how gitlib runs git, for example in tests, implement + `Gitonomy\Git\Driver\DriverInterface` and pass it as the driver of + `Repository`, instead of extending `Repository`: see + ["Using your own driver"](doc/repository.md#using-your-own-driver). + gitlib may add methods to the interfaces of the drivers in a minor + version of 2.x. +* `Commit::__construct()` takes a `Gitonomy\Git\Driver\Data\CommitData` + instead of an array, and `Commit::setData()` is internal. +* `Gitonomy\Git\Util\StringHelper` is removed, with its configurable + encoding: use `symfony/string`. Logs are always read in UTF-8. +* The parsers of the git output moved to `Gitonomy\Git\Driver\Cli\Parser`, + and are `final` 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. + +Testing code using gitlib +------------------------- + +The classes `final` in 2.0 can't be doubled: `createMock()` of PHPUnit, +Prophecy and Mockery refuse them ("Class "Gitonomy\Git\WorkingCopy" is +declared "final" and cannot be doubled", "could not reflect class +Gitonomy\Git\WorkingCopy as it is marked final"). Instead, from the +simplest: + +1. Construct the value objects: `Diff`, `Diff\File`, `Diff\FileChange` + and `Diff\DiffLine` have public constructors. `new Diff([new File(...)], + '')` works with 1.x too. +2. Parse the real output of git: `Diff::parse()` of + `git diff --raw --patch --full-index` (see [Diffs](#diffs)). +3. Use a real repository, in a temporary directory: + `Repository::init($path)`, or `git init` with 1.x before 1.7. A + `WorkingCopy` needs one. +4. Implement `Gitonomy\Git\Driver\DriverInterface`, and pass it as the + driver of `Repository`: see + ["Using your own driver"](doc/repository.md#using-your-own-driver). + +Data returned +------------- + +* File names in diffs (`Diff\File`) and tree entries (`Tree`) are returned + in UTF-8, instead of git's quoted form (`"caf\303\251.txt"`) for names + with non-ASCII or special characters. +* `Blame\Line::getContent()` no longer starts with a TAB. +* `Commit::getDiff()` lists the files of a root commit, instead of + returning an empty diff. +* A commit read through `Repository::getCommit()` has its message as + `git log` prints it, like a commit of a `Log` already had: without blank + lines around it, nor whitespace at the end of its lines. It also comes + re-encoded to UTF-8 when the commit has an `encoding` header, instead of + throwing. +* `Reference\Tag::isSigned()` returns `false` for a lightweight tag. +* `PushReference::isForce()` returns `true` for histories without a common + ancestor, where the constructor threw. `PushReference` runs git when + `isForce()` needs it, not when constructed. `getReference()`, + `getRevision()`, `isCreate()` and `isDelete()` are removed: use + `getFullname()`, `getRange()`, `isCreation()` and `isDeletion()`, named + like the ones of `Diff\File`. The `$reference` argument of its + constructor is named `$fullname`. +* `Blob::getContent()` only reads blobs, where `git cat-file -p` also + printed trees and commits. +* `WorkingCopy::getUntrackedFiles()` and `RevisionList::getAll()` return + lists, without gaps in their keys. +* `Diff\File::toArray()` has a new `is_copy` key. +* `Diff::getRawDiff()` of a diff gitlib reads returns the output of + `git diff --raw -z --patch`, NUL bytes included, with the options gitlib + sets, instead of the text of `--raw --patch`; with `LibGit2Driver`, an + empty string. + +Other changes +------------- + +* `Repository::run()` and `runProcess()` run git in the working directory + of the repository, or its git directory when it is bare, instead of the + current directory of PHP. Relative paths, in the arguments of `run()` + and in the paths of a log, are relative to the repository. When PHP runs + in a subdirectory of the repository, the output of the commands relative + to the current directory changes too, and so does their default scope: + + | `run()` in `repository/sub` | 1.x | 2.0 | + |------------------------------|-------------------|-------------------------------| + | `ls-files` | `in-sub.txt` | `root.txt`, `sub/in-sub.txt` | + | `status --short` | ` M ../root.txt` | ` M root.txt` | + | `rev-parse --show-prefix` | `sub/` | (empty) | + + Nothing changes when PHP runs out of the repository. +* `Hooks::set()` makes hooks executable by their owner only (`0700`, + instead of `0777`): change their permissions if another user runs them. +* `Repository::shell()` is removed: it ignored the driver. Run + `git shell` with Symfony Process instead. +* `Hooks::has()` returns `true` for a symbolic link to a missing file: + `get()` throws a `RuntimeException`, as it can't read it, `set()` and + `setSymlink()` refuse it, as a hook that exists already, and `remove()` + removes the link. +* `Repository::getSize()` is removed: `getSizeInBytes()` returns bytes, + instead of kilobytes. Neither follows links: a link in the git directory, + to a hook not deployed yet for instance, counts for nothing, where + `getSize()` counted the file it points to, and threw for a missing one. diff --git a/benchmark.php b/benchmark.php new file mode 100644 index 00000000..eb610af9 --- /dev/null +++ b/benchmark.php @@ -0,0 +1,158 @@ +#!/usr/bin/env php + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +require __DIR__.'/vendor/autoload.php'; + +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Repository; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\SingleCommandApplication; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Process\Exception\ProcessTimedOutException; +use Symfony\Component\Process\Process; + +/** + * @return array The operations to compare + */ +function operations(string $head, string $commit, string $file): array +{ + return [ + 'Resolve HEAD' => static fn (Repository $repository) => $repository->getHeadCommit()->getHash(), + 'Read a commit' => static fn (Repository $repository) => $repository->getCommit($commit)->getMessage(), + 'Log 100 commits' => static fn (Repository $repository) => $repository->getLog($head, null, 0, 100)->getCommits(), + 'Log 100 commits of a directory' => static fn (Repository $repository) => $repository->getLog($head, dirname($file), 0, 100)->getCommits(), + 'Last commit of a file' => static fn (Repository $repository) => $repository->getCommit($head)->getLastModification($file)->getHash(), + 'Count the commits of HEAD' => static fn (Repository $repository) => $repository->getLog($head)->countCommits(), + 'List the branches' => static fn (Repository $repository) => $repository->getReferences()->getBranches(), + 'Read a file' => static fn (Repository $repository) => $repository->getCommit($head)->getTree()->resolvePath($file)->getContent(), + 'Diff a commit' => static fn (Repository $repository) => $repository->getCommit($commit)->getDiff()->getFiles(), + 'List the branches including a commit' => static fn (Repository $repository) => $repository->getCommit($commit)->getIncludingBranches(), + 'Blame a file' => static fn (Repository $repository) => $repository->getBlame($head, $file)->getLines(), + ]; +} + +/** + * @param list $values + */ +function median(array $values): float +{ + sort($values); + $middle = intdiv(count($values), 2); + + return count($values) % 2 ? $values[$middle] : ($values[$middle - 1] + $values[$middle]) / 2; +} + +// One measure, in the process measure() starts. +if (false !== $measure = getenv('GITLIB_BENCHMARK_MEASURE')) { + /** @var class-string $driver */ + [$path, $driver, $operation, $head, $commit, $file, $iterations] = json_decode($measure, true, flags: \JSON_THROW_ON_ERROR); + $run = operations($head, $commit, $file)[$operation]; + + // Warms up, and fails before timing anything when the operation throws. + $run(new Repository($path, driver: new $driver())); + + $durations = []; + for ($i = 0; $i < $iterations; ++$i) { + // A new Repository every time, like a web request would open it. + $start = hrtime(true); + $run(new Repository($path, driver: new $driver())); + $durations[] = hrtime(true) - $start; + } + + echo median($durations) / 1e6; + + exit(0); +} + +/** + * Measures an operation in a process of its own: libgit2's caches don't + * carry over from one operation to the next, and an operation that takes too + * long can be stopped. + * + * @param class-string $driver + * + * @return float|null The median duration, in milliseconds, or null when the measure took more than $timeout seconds + */ +function measure(string $path, string $driver, string $operation, string $head, string $commit, string $file, int $iterations, int $timeout): ?float +{ + $process = new Process([\PHP_BINARY, __FILE__], env: ['GITLIB_BENCHMARK_MEASURE' => json_encode([$path, $driver, $operation, $head, $commit, $file, $iterations])], timeout: $timeout); + + try { + return (float) $process->mustRun()->getOutput(); + } catch (ProcessTimedOutException) { + return null; + } +} + +/** + * Times the loading of libgit2, in new PHP processes: a process pays it once. + * + * @return float The median duration, in milliseconds + */ +function measureLibraryLoading(int $iterations): float +{ + $code = <<<'PHP' + require $argv[1]; + $start = hrtime(true); + new Gitonomy\Git\Driver\Ffi\LibGit2Driver(); + echo hrtime(true) - $start; + PHP; + + $durations = []; + for ($i = 0; $i < $iterations; ++$i) { + $durations[] = (int) new Process([\PHP_BINARY, '-r', $code, __DIR__.'/vendor/autoload.php'])->mustRun()->getOutput(); + } + + return median($durations) / 1e6; +} + +new SingleCommandApplication() + ->setName('Compare CliDriver and LibGit2Driver on read operations') + ->addArgument('repository', InputArgument::REQUIRED, 'The repository to read') + ->addArgument('file', InputArgument::REQUIRED, 'A file of HEAD, to log, read and blame') + ->addOption('iterations', null, InputOption::VALUE_REQUIRED, 'The number of timed runs of each operation, of which the median is kept', '10') + ->addOption('timeout', null, InputOption::VALUE_REQUIRED, 'The time after which the measure of an operation is stopped, in seconds', '60') + ->setCode(static function (InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + $path = $input->getArgument('repository'); + $file = $input->getArgument('file'); + $iterations = (int) $input->getOption('iterations'); + $timeout = (int) $input->getOption('timeout'); + + $repository = new Repository($path); + $head = $repository->getHeadCommit()->getHash(); + // The last commit that changed $file: its diff isn't empty. + $commit = $repository->getHeadCommit()->getLastModification($file)->getHash(); + + $io->title(sprintf('%s (HEAD: %s, %d commits, %d references)', $path, $head, $repository->getLog($head)->countCommits(), count($repository->getReferences()))); + $io->writeln(sprintf('PHP %s, %s, median of %d runs.', \PHP_VERSION, trim(new Process(['git', '--version'])->mustRun()->getOutput()), $iterations)); + + $format = static fn (?float $duration): string => null === $duration ? sprintf('> %d s', $timeout) : sprintf('%.1f ms', $duration); + $rows = [['Load libgit2 (once per process)', '-', $format(measureLibraryLoading($iterations)), '-']]; + foreach (array_keys(operations($head, $commit, $file)) as $operation) { + $cli = measure($path, CliDriver::class, $operation, $head, $commit, $file, $iterations, $timeout); + $libGit2 = measure($path, LibGit2Driver::class, $operation, $head, $commit, $file, $iterations, $timeout); + $rows[] = [$operation, $format($cli), $format($libGit2), null === $cli || null === $libGit2 ? '-' : sprintf('%.1f', $cli / $libGit2)]; + } + + $io->table(['Operation', 'CliDriver', 'LibGit2Driver', 'Ratio'], $rows); + + return 0; + }) + ->run() +; diff --git a/castor.php b/castor.php new file mode 100644 index 00000000..61fc7a88 --- /dev/null +++ b/castor.php @@ -0,0 +1,37 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +use Castor\Attribute\AsRawTokens; +use Castor\Attribute\AsTask; + +use function Castor\guard_min_version; +use function Castor\import; +use function Castor\run; + +guard_min_version('1.0.0'); + +import(__DIR__.'/tools/php-cs-fixer/castor.php'); +import(__DIR__.'/tools/phpstan/castor.php'); + +#[AsTask(description: 'Install dependencies')] +function install(): void +{ + run(['composer', 'install']); + qa\cs\install(); + qa\phpstan\install(); +} + +#[AsTask(description: 'Run PHPUnit', ignoreValidationErrors: true)] +function phpunit(#[AsRawTokens] array $rawTokens): void +{ + run(['vendor/bin/phpunit', ...$rawTokens]); +} diff --git a/composer.json b/composer.json index ae72c6d2..4900ca6b 100644 --- a/composer.json +++ b/composer.json @@ -4,17 +4,26 @@ "license": "MIT", "authors": [ { - "name": "Alexandre Salomé", - "email": "alexandre.salome@gmail.com", - "homepage": "http://alexandre-salome.fr" + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" }, { - "name": "Julien DIDIER", + "name": "Julien Didier", "email": "genzo.wm@gmail.com", - "homepage": "http://www.jdidier.net" + "homepage": "https://github.com/juliendidier" + }, + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info", + "homepage": "https://github.com/lyrixx" + }, + { + "name": "Alexandre Salomé", + "email": "alexandre.salome@gmail.com", + "homepage": "https://github.com/alexandresalome" } ], - "homepage": "http://gitonomy.com", "autoload": { "psr-4": { "Gitonomy\\Git\\": "src/Gitonomy/Git/" @@ -26,19 +35,29 @@ } }, "require": { - "php": "^5.6 || ^7.0", - "symfony/process": "^3.4|^4.0" + "php": "^8.4", + "ext-pcre": "*", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "symfony/process": "^6.4.33 || ^7.4.5 || ^8.1", + "symfony/string": "^7.4.6 || ^8.0.6" }, "require-dev": { - "phpunit/phpunit": "^5.7|^6.5", - "psr/log": "^1.0" + "ext-fileinfo": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^12.0", + "symfony/console": "^8.1" }, "suggest": { - "psr/log": "Required to use loggers for reporting of execution" + "ext-ffi": "To use the experimental LibGit2Driver (in-process libgit2) instead of the default CliDriver — also requires libgit2 1.9 to be installed on the system.", + "ext-fileinfo": "To read the mimetype of a blob with Blob::getMimetype()." + }, + "config": { + "preferred-install": "dist", + "sort-packages": true }, "extra": { "branch-alias": { - "dev-master": "1.1-dev" + "dev-main": "2.0.x-dev" } } } diff --git a/doc/admin.md b/doc/admin.md new file mode 100644 index 00000000..9237bed0 --- /dev/null +++ b/doc/admin.md @@ -0,0 +1,153 @@ +Create and access git repositories +================================== + +The static methods of `Repository` create new repositories, and return them. +They replace `Admin`, removed in 2.0: see +[UPGRADE-2.0.md](../UPGRADE-2.0.md#creating-repositories). + +To open a repository that exists already, use `new Repository($path)`: see +[Repository](repository.md). + +Create a repository +------------------- + +```php +use Gitonomy\Git\Repository; + +// Initialize a repository with a working directory +$repository = Repository::init('/path/to/repository'); + +// Initialize a bare repository +$repository = Repository::initBare('/path/to/repository'); +``` + +Cloning repositories +-------------------- + +You can clone a repository from an URL by doing: + +```php +// Clone with a working directory +$repository = Repository::cloneFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib'); + +// Clone to a bare repository +$repository = Repository::cloneBareFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib'); +``` + +You can also clone a repository and point it to a specific branch. With a +working directory, this branch will be checked out: + +```php +$repository = Repository::cloneFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib', 'a-branch'); +``` + +Like `git clone --branch`, the branch may also be a tag: the clone checks +its commit out, `HEAD` detached. + +Like `git clone`, the methods refuse a directory with files in it. When a +clone fails, they remove what they created. + +To clone a `Repository` object, use its git directory as URL: + +```php +$new = Repository::cloneBareFrom($repository->getGitDir(), '/tmp/clone'); +``` + +These methods support no other argument of `git clone`. With `CliDriver`, +run `git clone` yourself, then open the repository: + +```php +use Symfony\Component\Process\Process; + +new Process(['git', 'clone', '--depth', '1', $url, '/tmp/gitlib'])->mustRun(); + +$repository = new Repository('/tmp/gitlib'); +``` + +Mirror a repository +------------------- + +If you want to mirror fully a repository and all references, use the +`mirrorFrom` method. It clones into a bare repository, set up to mirror the +remote when fetching, like `git clone --mirror`: + +```php +$mirror = Repository::mirrorFrom('https://github.com/gitonomy/gitlib.git', '/tmp/mirror'); +``` + +Check a repository URL +---------------------- + +You can check that a repository exists, and that it has a branch, without +cloning it: + +```php +Repository::isValidRepository('https://github.com/gitonomy/gitlib.git'); // true +Repository::isValidRepositoryAndBranch('https://github.com/gitonomy/gitlib.git', 'main'); // true +``` + +They return `false` as well for a repository that can't be reached, or that +refuses access. The branch must have this name exactly: `release` doesn't +name `feature/release`. A relative path is the path of a repository, never +the name of a remote. + +Errors +------ + +When git fails (an unknown branch, a directory with files, a network +error...), the methods creating repositories throw a +`Gitonomy\Git\Exception\DriverException`: a `ProcessException`, with git's +error output, when `CliDriver` runs git. With `CliDriver`, every method, +`isValidRepository()` included, throws a `DriverException` when git can't +start, like a missing binary. See [Errors](repository.md#errors). + +A URL that makes git run a command, or use a file of PHP, throws an +`InvalidArgumentException`, with both drivers: + +- an empty URL, which would make git read the repository PHP runs in; +- a URL starting with a dash, which git would read as an option, like + `--upload-pack=...`; a path may start with one; +- an `ext::` URL, like `ext::sh -c ...`, whose command git runs, unless its + `protocol.allow` setting forbids it, as it does by default since git 2.12; +- an `fd::` URL, like `fd::7`, which makes git talk through a file + descriptor it inherits from PHP, like the socket of a database: git waits + on it until it times out; +- an ssh URL whose host or user starts with a dash, even encoded (`%2D`), + like `ssh://-oProxyCommand=.../repository` or + `ssh://-oProxyCommand=...@host/repository`: before 2.14.1, git passes it + to ssh, which reads it as an option and runs the command. + +The message of an exception hides the credentials of the URL +(`https://***@example.com/repository.git`). gitlib doesn't check the URLs +any further: a path, for instance, names a repository of the server. + +Driver and logger +----------------- + +All these methods take the driver running git as their last argument, see +["Choosing a driver"](repository.md#choosing-a-driver). The repositories they +return use it too: + +```php +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Repository; + +$repository = Repository::initBare('/path/to/repository', driver: new LibGit2Driver()); +``` + +They take no logger: the logger is an argument of `CliDriver`, which logs the +commands initializing and cloning repositories too. See +[Logger](repository.md#logger): + +```php +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; + +$repository = Repository::cloneFrom($url, '/tmp/gitlib', driver: new CliDriver(logger: $logger)); +``` + +### References + +- +- +- diff --git a/doc/api/admin.rst b/doc/api/admin.rst deleted file mode 100644 index f1ec8b66..00000000 --- a/doc/api/admin.rst +++ /dev/null @@ -1,76 +0,0 @@ -Create and access git repositories -================================== - -gitlib provides methods to initialize new repositories. - -Create a repository -------------------- - -To initialize a new repository, use method ``Admin::init``. - -.. code-block:: php - - // Initialize a bare repository - $repository = Gitonomy\Git\Admin::init('/path/to/repository'); - - // Initialize a non-bare repository - $repository = Gitonomy\Git\Admin::init('/path/to/repository', false); - -Default behavior is to create a bare repository. If you want to initialize a -repository with a working copy,pass ``false`` as third argument of Repository -constructor. - -Cloning repositories --------------------- - -You can clone a repository from an URL by doing: - -.. code-block:: php - - // Clone to a bare repository - $repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git'); - - // Clone to a non-bare repository - $repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', false); - -Default behavior is to clone in a bare repository. - -You can also clone a repository and point it to a specific branch. In a non-bare repository, this branch will be checked out: - -.. code-block:: php - - // Clone to a bare repository - $repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch'); - - // Clone to a non-bare repository - $repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch' false); - -Clone a Repository object -------------------------- - -If you already have a Repository instance and want to clone it, you can use this shortcut: - -.. code-block:: php - - $new = $repository->cloneTo('/tmp/clone'); - -Mirror a repository -------------------- - -If you want to mirror fully a repository and all references, use the ``mirrorTo`` method. This method -takes only two arguments, where to mirror and what to mirror: - -.. code-block:: php - - // Mirror to a bare repository - $mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git'); - - // Mirror to a non-bare repository - $mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git', false); - - -References -:::::::::: - -* http://linux.die.net/man/1/git-init -* http://linux.die.net/man/1/git-clone diff --git a/doc/api/blame.rst b/doc/api/blame.rst deleted file mode 100644 index 7fcbdcdc..00000000 --- a/doc/api/blame.rst +++ /dev/null @@ -1,54 +0,0 @@ -Blaming files -============= - -Line-per-line iteration ------------------------ - -To iterate on lines of a blame: - -.. code-block:: php - - $blame = $repository->getBlame('master', 'README.md'); - - foreach ($blame->getLines() as $lineNumber => $line) { - $commit = $line->getCommit(); - echo $lineNumber.': '.$line->getContent()." - ".$commit->getAuthorName()."\n"; - } - -The *getLines* method returns an array indexed starting from 1. - -As you can see, you can access the commit object related to the line you are iterating on. - -If you want to access directly a line: - -.. code-block:: php - - $line = $blame->getLine(32); - -The Line object ---------------- - -LineObject represents an item of the blame file. It is composed of those informations: - -.. code-block:: php - - $line->getCommit(); // returns a Commit - $line->getContent(); // returns text - - // you can access author from commmit: - $author = $line->getCommit()->getAuthorName(); - -Group reading by commit ------------------------ - -If you plan to display it, you'll probably need a version where lines from same commit are grouped. - -To do so, use the *getGroupedLines* method that will return an array like this: - -.. code-block:: php - - $blame = array( - array(Commit, array(1 => Line, 2 => Line, 3 => Line)), - array(Commit, array(4 => Line)), - array(Commit, array(5 => Line, 6 => Line)) - ) diff --git a/doc/api/blob.rst b/doc/api/blob.rst deleted file mode 100644 index 78d36c25..00000000 --- a/doc/api/blob.rst +++ /dev/null @@ -1,44 +0,0 @@ -Blob -==== - -In git, a blob represents a file content. You can't access the file name -directly from the *Blob* object; the filename information is stored within -the tree, not in the blob. - -It means that for git, two files with different names but same content will -have the same hash. - -To access a repository *Blob*, you need the hash identifier: - -.. code-block:: php - - $repository = new Gitonomy\Git\Repository('/path/to/repository'); - $blob = $repository->getBlob('a7c8d2b4'); - -Get content ------------ - -To get content from a *Blob* object: - -.. code-block:: php - - echo $blob->getContent(); - -File informations ------------------ - -To get mimetype of a *Blob* object using finfo extension: - -.. code-block:: php - - echo $blob->getMimetype(); - -You can also test if *Blob* is a text of a binary file: - -.. code-block:: php - - if ($blob->isText()) { - echo $blob->getContent(), "\n"; - } elseif ($blob->isBinary()) { - echo "File is binary\n"; - } diff --git a/doc/api/branch.rst b/doc/api/branch.rst deleted file mode 100644 index c7c0c408..00000000 --- a/doc/api/branch.rst +++ /dev/null @@ -1,16 +0,0 @@ -Branch -====== - -To access a *Branch*, starting from a repository object: - -.. code-block:: php - - $repository = new Gitonomy\Git\Repository('/path/to/repository'); - $branch = $repository->getReferences()->getBranch('master'); - -You can check is the branch is a local or remote one: - -.. code-block:: php - - $branch->isLocal(); - $branch->isRemote(); diff --git a/doc/api/commit.rst b/doc/api/commit.rst deleted file mode 100644 index d11ea810..00000000 --- a/doc/api/commit.rst +++ /dev/null @@ -1,168 +0,0 @@ -Commit -====== - -To access a *Commit*, starting from a repository object: - -.. code-block:: php - - $repository = new Gitonomy\Git\Repository('/path/to/repository'); - $commit = $repository->getCommit('a7c8d2b4'); - -Browsing parents ----------------- - -A *Commit* can have a natural number of parents: - -* **no parent**: it's an initial commit, the root of a tree -* **one parent**: it means it's not a merge, just a regular commit -* **many parents**: it's a merge-commit - -You have 2 methods available for accessing parents: - -.. code-block:: php - - // Access parent hashes - $hashes = $commit->getParentHashes(); - - // Access parent commit objects - $commits = $commit->getParents(); - -For example, if you want to display all parents, starting from a commit: - -.. code-block:: php - - function displayLog(Gitonomy\Git\Commit $commit) { - echo '- '.$commit->getShortMessage()."\n"; - foreach ($commit->getParents() as $parent) { - displayLog($parent); - } - } - -Notice that this function will first display all commits from first merged -branch and then display all commits from next branch, and so on. - -Accessing tree --------------- - -The tree object contains the reference to the files associated to a given -commit. Every commit has one and only one tree, referencing all files and -folders of a given state for a project. For more informations about the tree, -see the chapter dedicated to it. - -To access a tree starting from a commit: - -.. code-block:: php - - // Returns the tree hash - $tree = $commit->getTreeHash(); - - // Returns the tree object - $tree = $commit->getTree(); - -Author & Committer informations -------------------------------- - -Each commit has two authoring informations: an author and a committer. The -author is the creator of the modification, authoring a modification in the -repository. The committer is responsible of introducing this modification to -the repository. - -You can access informations from author and committer using those methods: - -.. code-block:: php - - // Author - $commit->getAuthorName(); - $commit->getAuthorEmail(); - $commit->getAuthorDate(); // returns a DateTime object - - // Committer - $commit->getCommitterName(); - $commit->getCommitterEmail(); - $commit->getCommitterDate(); // returns a DateTime object - -Commit message and short message --------------------------------- - -Each commit also has a message, associated to the modification. This message -can be multilined. - -To access the message, you can use the *getMessage* method: - -.. code-block:: php - - $commit->getMessage(); - -For your convenience, this library provides a shortcut method to keep only the -first line or first 50 characters if the first line is too long: - -.. code-block:: php - - $commit->getShortMessage(); - -You can customize it like this: - -.. code-block:: php - - $commit->getShortMessage(45, true, '.'); - -* The first parameter is the max length of the message. -* The second parameter determine if the last word should be cut or preserved -* The third parameter is the separator - -There are also two other methods for your convenience: - -.. code-block:: php - - // The first line - $commit->getSubjectMessage(); - - // The body (rest of the message) - $commit->getBodyMessage(); - -Diff of a commit ----------------- - -You can check the modifications introduced by a commit using the *getDiff* -method. When you request a diff for a commit, depending of the number of -parents, the strategy will be different: - -* If you have *no parent*, the diff will be the content of the tree -* If you only have *one parent*, the diff will be between the commit and his - parent -* If you have *multiple parents*, the diff will be the difference between the - commit and the first common ancestor of all parents - -For more informations about the diff API, read the related chapter. - -To access the *Diff* object of a commit, use the method *getDiff*: - -.. code-block:: php - - $diff = $commit->getDiff(); - -Last modification of a file ---------------------------- - -To know the last modification of a file, you can use the *getLastModification* -method on a commit. - -Here is a very straightforward example: - -.. code-block:: php - - $last = $commit->getLastModification('README'); - - echo "Last README modification:\n"; - echo" Author: ".$last->getAuthorName()."\n"; - echo" Date: ".$last->getAuthorDate()->format('d/m/Y')."\n"; - echo" Message: ".$last->getMessage(); - -Find every branches containing a commit ---------------------------------------- - -.. code-block:: php - - $branches = $commit->getIncludingBranches($includeLocalBranches, $includeRemoteBranches); - $localBranches = $commit->getIncludingBranches(true, false); - $remoteBranches = $commit->getIncludingBranches(false, true); diff --git a/doc/api/diff.rst b/doc/api/diff.rst deleted file mode 100644 index badd0776..00000000 --- a/doc/api/diff.rst +++ /dev/null @@ -1,102 +0,0 @@ -Computing diff -============== - -Even if git is a diff-less storage engine, it's possible to compute them. - -To compute a diff in git, you need to specify a *revision*. This revision can -be a commit (*2bc7a8*) or a range (*2bc7a8..ff4c21b*). - -For more informations about git revisions: *man gitrevisions*. - -When you have decided the revision you want and have your *Repository* object, -you can call the *getDiff* method on the repository: - -.. code-block:: php - - $diff = $repository->getDiff('master@{2 days ago}..master'); - -You can also access it from a *Log* object: - -.. code-block:: php - - $log = $repository->getLog('master@{2 days ago}..master'); - $diff = $log->getDiff(); - -Iterating a diff ----------------- - -When you have a *Diff* object, you can iterate over files using method -*getFiles()*. This method returns a list of *File* objects, who represents the -modifications for a single file. - -.. code-block:: php - - $files = $diff->getFiles(); - echo sprintf("%s files modified", count($files)); - - foreach ($files as $fileDiff) { - echo sprintf("Old name: (%s) %s\n", $fileDiff->getOldMode(), $fileDiff->getOldName()); - echo sprintf("New name: (%s) %s\n", $fileDiff->getNewMode(), $fileDiff->getNewName()); - } - -The File object ---------------- - -Here is an exhaustive list of the *File* class methods: - -.. code-block:: php - - $file->getOldName(); - $file->getNewName(); - $file->getOldDiff(); - $file->getNewDiff(); - - $file->isCreation(); - $file->isDeletion(); - $file->isModification(); - - $file->isRename(); - $file->isChangeMode(); - - $file->getAdditions(); // Number of added lines - $file->getDeletions(); // Number of deleted lines - - $file->isBinary(); // Binary files have no "lines" - - $file->getChanges(); // See next chapter - -The FileChange object ---------------------- - -.. note:: - - This part of API is not very clean, very consistent. If you have any idea - or suggestion on how to enhance this, your comment would be appreciated. - -A *File* object is composed of many changes. For each of those changes, -a *FileChange* object is associated. - -To access changes from a file, use the *getChanges* method: - -.. code-block:: php - - $changes = $file->getChanges(); - foreach ($changes as $change) { - foreach ($lines as $data) { - list ($type, $line) = $data; - if ($type === FileChange::LINE_CONTEXT) { - echo ' '.$line."\n"; - } elseif ($type === FileChange::LINE_ADD) { - echo '+'.$line."\n"; - } else { - echo '-'.$line."\n"; - } - } - } - -To get line numbers, use the range methods: - -.. code-block:: php - - echo sprintf("Previously from line %s to %s\n", $change->getOldRangeStart(), $change->getOldRangeEnd()); - echo sprintf("Now from line %s to %s\n", $change->getNewRangeStart(), $change->getNewRangeEnd()); diff --git a/doc/api/hooks.rst b/doc/api/hooks.rst deleted file mode 100644 index 20c4abec..00000000 --- a/doc/api/hooks.rst +++ /dev/null @@ -1,76 +0,0 @@ -Hooks -===== - -It's possible to define custom hooks on any repository with git. Those hooks -are located in the *.git/hooks* folder. - -Those files need to be executable. For convenience, gitlib will set them to -*777*. - -With *gitlib*, you can manage hooks over a repository using the *Hooks* object. - -To access it from a repository, use the *getHooks* method on a *Repository* -object: - -.. code-block:: php - - $hooks = $repository->getHooks(); - -Reading hooks -------------- - -To read the content of a hook, use the *get* method like this: - -.. code-block:: php - - $content = $hooks->get('pre-receive'); // returns a string - -If the hook does not exist, an exception will be thrown (*InvalidArgumentException*). - -You can test if a hook is present using the method *has*: - -.. code-block:: php - - $hooks->has('pre-receive'); // a boolean indicating presence - -Inserting hooks ---------------- - -You can modify a hook in two different ways: creating a new file or using a symlink. - -To create the hook using a symlink: - -.. code-block:: php - - $hooks->setSymlink('pre-receive', '/path/to/file-to-link'); - -If the hook already exist, a *LogicException* will be thrown. If an error occured -during symlink creation, a *RuntimeException* will be thrown. - -If you want to directly create a new file in hooks directory, use the -method *set*. This method will create a new file, put content in it and make it -executable: - -.. code-block:: php - - $content = <<set('pre-receive', $content); - -If the hook already exists, a *LogicException* will be thrown. - -Removing hooks --------------- - -To remove a hook from a repository, use the function *remove*: - -.. code-block:: php - - $hooks->remove('pre-receive'); diff --git a/doc/api/log.rst b/doc/api/log.rst deleted file mode 100644 index 5a795b9e..00000000 --- a/doc/api/log.rst +++ /dev/null @@ -1,54 +0,0 @@ -Getting log history -=================== - -Crawling manually commits and parents to browse history is surely a good -solution. But when it comes to ordering them or aggregate them from multiple -branches, we tend to use ``git log``. - -To get a *Log* object from a repository: - -.. code-block:: php - - $log = $repository->getLog(); - -You can pass four arguments to *getLog* method: - -.. code-block:: php - - // Global log for repository - $log = $repository->getLog(); - - // Log for master branch - $log = $repository->getLog('master'); - - // Returns last 10 commits on README file - $log = $repository->getLog('master', 'README', 0, 10); - - // Returns last 10 commits on README or UPGRADE files - $log = $repository->getLog('master', array('README', 'UPGRADE'), 0, 10); - -Counting --------- - -If you want to count overall commits, without offset or limit, use the *countCommits* method: - -.. code-block:: php - - echo sprintf("This log contains %s commits\n", $log->countCommits()); - - // Countable interface - echo sprintf("This log contains %s commits\n", count($log)); - -Offset and limit ----------------- - -Use those methods: - -.. code-block:: php - - $log->setOffset(32); - $log->setLimit(40); - - // or read it: - $log->getOffset(); - $log->getLimit(); diff --git a/doc/api/references.rst b/doc/api/references.rst deleted file mode 100644 index 5b7fa975..00000000 --- a/doc/api/references.rst +++ /dev/null @@ -1,91 +0,0 @@ -Tags and branches -================= - -Accessing tags and branches ---------------------------- - -With *gitlib*, you can access them via the *ReferenceBag* object. To get this -object from a *Repository*, use the *getReferences* method: - -.. code-block:: php - - $references = $repository->getReferences(); - -First, you can test existence of tags and branches like this: - -.. code-block:: php - - if ($references->hasBranch('master') && $references->hasTag('0.1')) { - echo "Good start!"; - } - -If you want to access all branches or all tags: - -.. code-block:: php - - $branches = $references->getBranches(); - $localBranches = $references->getLocalBranches(); - $remoteBranches = $references->getRemoteBranches(); - $tags = $references->getTags(); - $all = $references->getAll(); - -To get a given branch or tag, call *getBranch* or *getTag* on the -*ReferenceBag*. Those methods return *Branch* and *Tag* objects: - -.. code-block:: php - - $master = $references->getBranch('master'); - $feat123 = $references->getLocalBranch('feat123'); - $feat456 = $references->getRemoteBranch('origin/feat456'); - $v0_1 = $references->getTag('0.1'); - -If the reference cannot be resolved, a *ReferenceNotFoundException* will be -thrown. - -On each of those objects, you can access those informations: - -.. code-block:: php - - // Get the associated commit - $commit = $master->getCommit(); - - // Get the commit hash - $hash = $master->getCommitHash(); - - // Get the last modification - $lastModification = $master->getLastModification(); - -Create and delete reference ---------------------------- - -You can create new tags and branches on repository, using helper methods -on ReferenceBag object: - -.. code-block:: php - - // create a branch - $references = $repository->getReferences(); - $branch = $references->createBranch('foobar', 'a8b7e4...'); // commit to reference - - // create a tag - $references = $repository->getReferences(); - $tag = $references->createTag('0.3', 'a8b7e4...'); // commit to reference - - // delete a branch or a tag - $branch->delete(); - -Resolution from a commit ------------------------- - -To resolve a branch or a commit from a commit, you can use the *resolveTags* -and *resolveBranches* methods on it: - -.. code-block:: php - - $branches = $references->resolveBranches($commit); - $tags = $references->resolveTags($commit); - - // Resolve branches and tags - $all = $references->resolve($commit); - -You can pass a *Commit* object or a hash to the method, gitlib will handle it. diff --git a/doc/api/repository.rst b/doc/api/repository.rst deleted file mode 100644 index 53fa07a3..00000000 --- a/doc/api/repository.rst +++ /dev/null @@ -1,135 +0,0 @@ -Repository methods -================== - -Creating a *Repository* object is possible, providing a *path* argument to the -constructor: - -.. code-block:: php - - $repository = new Repository('/path/to/repo'); - -Repository options ------------------- - -The constructor of Repository takes an additional parameter: ``$options``. -This parameter can be used used to tune behavior of library. - -Available options are: - -* **debug** (default: true): Enables exception when edge cases are met -* **environment_variables**: (default: none) An array of environment variables to be set in sub-process -* **logger**: (default: none) Logger to use for reporting of execution (a ``Psr\Log\LoggerInterface``) -* **command**: (default: ``git``) Specify command to execute to run git -* **working_dir**: If you are using multiple working directories, this option is for you - -An example: - -.. code-block:: php - - $repository = new Repository('/path/to/repo', array( - 'debug' => true, - 'logger' => new Monolog\Logger() - )); - -Test if a repository is bare ----------------------------- - -On a *Repository* object, you can call method *isBare* to test if your repository is bare or not: - -.. code-block:: php - - $repository->isBare(); - -Compute size of a repository ----------------------------- - -To know how much size a repository is using on your drive, you can use ``getSize`` method on a *Repository* object. - -.. warning:: This command was only tested with linux. - -The returned size is in kilobytes: - -.. code-block:: php - - $size = $repository->getSize(); - - echo "Your repository size is ".$size."KB"; - -Access HEAD ------------ - -``HEAD`` represents in git the version you are working on (in working tree). -Your ``HEAD`` can be attached (using a reference) or detached (using a commit). - -.. code-block:: php - - $head = $repository->getHead(); // Commit or Reference - $head = $repository->getHeadCommit(); // Commit - - if ($repository->isHeadDetached()) { - echo "Sorry man\n"; - } - -Options for repository ----------------------- - -Logger -...... - -If you are developing, you may appreciate to have a logger inside repository, telling you every executed command. - -You call method ``setLogger`` as an option on repository creation: - -.. code-block:: php - - $repository->setLogger(new Monolog\Logger('repository')); - - $repository->run('fetch', array('--all')); - -You can also specify as an option on repository creation: - - $logger = new Monolog\Logger('repository'); - $repository = new Repository('/path/foo', array('logger' => $logger)); - - $repository->run('fetch', array('--all')); - -This will output: - -.. code-block:: text - - info run command: fetch "--all" - debug last command (fetch) duration: 23.24ms - debug last command (fetch) return code: 0 - debug last command (fetch) output: Fetching origin - -Disable debug-mode -.................. - -Gitlib throws an exception when something seems wrong. If a ``git` command returns a non-zero result, it will stop execution and throw an ``RuntimeException``. - -If you want to prevent this, set ``debug`` option to ``false``. This will make Repository log errors and return empty data instead of throwing exceptions. - -.. code-block:: php - - $repository = new Repository('/tmp/foo', array('debug' => false, 'logger' => $logger)); - -.. note:: if you plan to disable debug, you should rely on logger to keep a trace of edge failing cases. - -Specify git command to use -.......................... - -You can pass option ``command`` to specify which command to use to run git calls. If you have a git binary -located somewhere else, use this option to specify to gitlib path to your git binary: - -.. code-block:: php - - $repository = new Gitonomy\Git\Repository('/tmp/foo', array('command' => '/home/alice/bin/git')); - -Environment variables -..................... - -Now you want to set environment variables to use to run ``git`` commands. It might be useful. - -.. code-block:: php - - $repository = new Gitonomy\Git\Repository('/tmp/foo', array('environment_variables' => array('GIT_'))) diff --git a/doc/api/revision.rst b/doc/api/revision.rst deleted file mode 100644 index 8ffc3289..00000000 --- a/doc/api/revision.rst +++ /dev/null @@ -1,28 +0,0 @@ -Revision -======== - -To get a revision from a *Repository* object: - -.. code-block:: php - - $revision = $repository->getRevision('master@{2 days ago}'); - -Getting the log ---------------- - -You can access a *Log* object starting from a revision using the *getLog* -method. This method takes two parameters: *offset* and *limit*: - -.. code-block:: php - - // Returns 100 lasts commits - $log = $revision->getLog(null, 100); - -Resolve a revision ------------------- - -To resolve a revision to a commit: - -.. code-block:: php - - $commit = $revision->getCommit(); diff --git a/doc/api/tree.rst b/doc/api/tree.rst deleted file mode 100644 index 4641a9f3..00000000 --- a/doc/api/tree.rst +++ /dev/null @@ -1,54 +0,0 @@ -Tree and files -============== - -To organize folders, git uses trees. In gitlib, those trees are represented -via *Tree* object. - -To get the root tree associated to a commit, use the *getTree* method on the -commit object: - -.. code-block:: php - - $tree = $commit->getTree(); - -This tree is the entry point of all of your files. - -The main method for a tree is the *getEntries* method. This method will -return an array, indexed by name. Each of those elements will be the entry mode -and the entry object. - -Let's understand how it works with a concrete example: - -.. code-block:: php - - function displayTree(Tree $tree, $indent = 0) - { - $indent = str_repeat(' ', $indent); - foreach ($tree->getEntries() as $name => $data) { - list($mode, $entry) = $data; - if ($entry instanceof Tree) { - echo $indent.$name."/\n"; - displayTree($tree, $indent + 1); - } else { - echo $indent.$name."\n"; - } - } - } - - displayTree($commit->getTree()); - -This method will recursively display all entries of a tree. - -Resolve a path --------------- - -To access directly a sub-file, the easier is probably to use the *resolvePath* -method. - -An example: - -.. code-block:: php - - $source = $tree->resolvePath('src/Gitonomy/Git'); - - $source instanceof Tree; diff --git a/doc/api/workingcopy.rst b/doc/api/workingcopy.rst deleted file mode 100644 index 4b652230..00000000 --- a/doc/api/workingcopy.rst +++ /dev/null @@ -1,45 +0,0 @@ -Working copy -============ - -Working copy is the folder associated to a git repository. In *gitlib*, you -can access this object using the *getWorkingCopy* on a *Repository* object: - -.. code-block:: php - - $repo = new Repository('/path/to/working-dir'); - $wc = $repo->getWorkingCopy(); - -Checkout a revision -------------------- - -You can checkout any revision using *checkout* method. You can also pass a -second argument, which will be passed as argument with ``-b``: - -.. code-block:: php - - // git checkout master - $wc->checkout('master'); - - // git checkout origin/master -b master - $wc->checkout('origin/master', 'master'); - -You can also pass a *Reference* or a *Commit*. - -Staged modifications --------------------- - -You can get a diff of modifications pending in staging area. To get the ``Diff`` object, -call method ``getDiffStaged()``: - -.. code-block:: php - - $diff = $wc->getDiffStaged(); - -Pending modifications ---------------------- - -You can get pending modifications on tracked files by calling method ``getDiffPending()``: - -.. code-block:: php - - $diff = $wc->getDiffPending(); diff --git a/doc/blame.md b/doc/blame.md new file mode 100644 index 00000000..b9299ebe --- /dev/null +++ b/doc/blame.md @@ -0,0 +1,82 @@ +Blaming files +============= + +`Repository::getBlame()` returns, for each line of a file, the commit that +last changed it, like `git blame`. It takes a revision (a name or a +`Revision` object) and the path of the file, relative to the root of the +repository: + +```php +$blame = $repository->getBlame('master', 'README.md'); +``` + +To blame some lines only, pass the first and the last ones, numbered from +1: + +```php +// Lines 2 to 3 +$blame = $repository->getBlame('master', 'README.md', 2, 3); + +// From line 2 to the end of the file +$blame = $repository->getBlame('master', 'README.md', 2); +``` + +Like git, the blame stops at the end of the file, and a first line after it +throws a `DriverException`. A last line before the first one blames the lines +in order, like `git blame -L 5,3`. + +Line-per-line iteration +----------------------- + +`getLines()` returns the lines of the blame, `Blame\Line` objects, keyed by +their number in the file: + +```php +foreach ($blame->getLines() as $number => $line) { + echo $number.': '.$line->getContent().' - '.$line->getCommit()->getAuthorName().PHP_EOL; +} +``` + +With a range, the keys start at its first line, not at 1. + +To get a line by its number in the file: + +```php +$line = $blame->getLine(2); +``` + +It throws an `InvalidArgumentException` when the blame doesn't have this +line. `count($blame)` returns the number of lines blamed. + +The Line object +--------------- + +A `Blame\Line` is a line of the blamed file: + +```php +$line->getNumber(); // Its number in the blamed file +$line->getOriginalNumber(); // Its number in the file of the commit that last changed it +$line->getContent(); // Its text, without its line break +$line->getCommit(); // The commit that last changed it + +// The author of the commit +$author = $line->getCommit()->getAuthorName(); +``` + +Group reading by commit +----------------------- + +To display a blame, you'll probably group the consecutive lines last +changed by the same commit. `getHunks()` returns these groups, `Blame\Hunk` +objects, with their commit and their lines, keyed by their number in the +file: + +```php +foreach ($blame->getHunks() as $hunk) { + echo $hunk->getCommit()->getShortHash().' '.$hunk->getCommit()->getSubjectMessage().PHP_EOL; + + foreach ($hunk->getLines() as $number => $line) { + echo ' '.$number.': '.$line->getContent().PHP_EOL; + } +} +``` diff --git a/doc/blob.md b/doc/blob.md new file mode 100644 index 00000000..52e7815c --- /dev/null +++ b/doc/blob.md @@ -0,0 +1,58 @@ +Blob +==== + +In git, a blob is the content of a file. A `Blob` object doesn't know the +name of its file: names are stored in trees, not in blobs. Two files with +different names but the same content have the same blob. + +To get the blob of a file, resolve its path in the tree of a commit (see +[tree.md](tree.md)): + +```php +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository'); +$blob = $repository->getRevision('master')->getCommit()->getTree()->resolvePath('README.md'); +``` + +`Repository::getBlob()` takes the hash of a blob, abbreviated or not: + +```php +$blob = $repository->getBlob('07eeea9'); + +echo $blob->getHash().PHP_EOL; // "07eeea94584fb6f1d8fe55e5b78adc4668c02508", always the full hash +``` + +It throws a `ReferenceNotFoundException` for anything else than a hash, of +4 characters at least, and for an abbreviated hash naming nothing. + +Get content +----------- + +To get the content of a `Blob` object: + +```php +echo $blob->getContent(); +``` + +File informations +----------------- + +`getMimetype()` returns the mimetype of the content, alone, like +`text/plain` or `image/jpeg`. It needs the `fileinfo` extension of PHP, and +throws a `RuntimeException` when it can't read the mimetype: + +```php +echo $blob->getMimetype().PHP_EOL; +``` + +`isText()` and `isBinary()` tell if the blob is a text or a binary file. +They check its first 8000 bytes for a NUL character, like git does: + +```php +if ($blob->isText()) { + echo $blob->getContent().PHP_EOL; +} elseif ($blob->isBinary()) { + echo 'File is binary'.PHP_EOL; +} +``` diff --git a/doc/branch.md b/doc/branch.md new file mode 100644 index 00000000..64afd16c --- /dev/null +++ b/doc/branch.md @@ -0,0 +1,65 @@ +Branch +====== + +To access a `Branch`, starting from a repository object: + +```php +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository'); +$branch = $repository->getReferences()->getBranch('master'); +$remoteBranch = $repository->getReferences()->getRemoteBranch('origin/master'); +``` + +See [references.md](references.md) to list, create and delete branches. + +You can check if the branch is a local or a remote one: + +```php +$branch->isLocal(); // A branch of refs/heads/ +$branch->isRemote(); // A branch of refs/remotes/, like origin/master + +$branch->getName(); // "master", or "origin/master" for a remote branch +``` + +Like every reference, a branch gives its commit: + +```php +$commit = $branch->getCommit(); +$hash = $branch->getCommitHash(); +``` + +Moving a branch +--------------- + +`moveTo()` points the branch to a revision: a name, a hash, or a `Revision` +object. It returns the branch, read again: the object you called it on +keeps the commit it pointed to. + +```php +$branch = $branch->moveTo('master~2'); +``` + +Like `git update-ref`, it doesn't touch the working tree, even for the +branch checked out. + +Merged branches +--------------- + +`isMergedInto()` tells if the history of a revision (a name, a hash, or a +`Revision` object) contains the branch: + +```php +if ($branch->isMergedInto('master')) { + echo $branch->getName().' is merged into master'.PHP_EOL; +} +``` + +Branches containing a commit +---------------------------- + +To find the branches whose history contains a commit, use +`Commit::getIncludingBranches()`: see +[commit.md](commit.md#branches-containing-a-commit). + +To check a branch out, see [workingcopy.md](workingcopy.md). diff --git a/doc/commit.md b/doc/commit.md new file mode 100644 index 00000000..82f0c15c --- /dev/null +++ b/doc/commit.md @@ -0,0 +1,193 @@ +Commit +====== + +To get a `Commit`, resolve a revision: a branch, a tag, `HEAD`, `master~2`, +an abbreviated hash... + +```php +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository'); +$commit = $repository->getRevision('master')->getCommit(); +``` + +`Repository::getCommit()` takes the full hash of a commit, its 40 +characters. It throws a `ReferenceNotFoundException` for anything else, an +abbreviated hash included: + +```php +$commit = $repository->getCommit('7e9ba14939b0ce67ab95d83e03bd64710db91ef4'); +``` + +The commit is read when you first need it: a hash naming no commit throws a +`ReferenceNotFoundException` then. + +Hashes +------ + +```php +$commit->getHash(); // "7e9ba14939b0ce67ab95d83e03bd64710db91ef4" +$commit->getShortHash(); // "7e9ba14", abbreviated and unique in the repository +``` + +Browsing parents +---------------- + +A commit has: + +- **no parent**: it's a root commit, the first one of a history; +- **one parent**: it's a regular commit; +- **several parents**: it's a merge commit. Its first parent is the + commit the other ones were merged into. + +```php +// The hashes of the parents +$hashes = $commit->getParentHashes(); + +// The parents, Commit objects +$parents = $commit->getParents(); +``` + +For example, to display the first parents of a commit, like +`git log --first-parent`: + +```php +for ($current = $commit; null !== $current; $current = $current->getParents()[0] ?? null) { + echo $current->getShortHash().' '.$current->getSubjectMessage().PHP_EOL; +} +``` + +To list a history in order, with every parent, use a log: see +[log.md](log.md). + +Accessing the tree +------------------ + +The tree of a commit holds its files and folders, as the commit recorded +them. Every commit has exactly one tree. See [tree.md](tree.md) to browse +it. + +```php +// The hash of the tree +$hash = $commit->getTreeHash(); + +// The Tree object +$tree = $commit->getTree(); +``` + +Author and committer +-------------------- + +A commit has an author and a committer. The author wrote the change. The +committer recorded it in the repository: they can differ when a commit is +rebased, cherry-picked or applied from a patch. + +```php +// Author +$commit->getAuthorName(); +$commit->getAuthorEmail(); +$commit->getAuthorDate(); // A DateTimeImmutable + +// Committer +$commit->getCommitterName(); +$commit->getCommitterEmail(); +$commit->getCommitterDate(); // A DateTimeImmutable +``` + +Like `git log`, a date is at the UTC offset of its author or committer: +`2012-12-31T15:21:03+01:00` for a commit made in Paris in winter. Use +`setTimezone()` to show it in another timezone. Comparing dates doesn't +depend on their offsets. An offset of more than 14 hours or 59 minutes, +which no timezone has, gives UTC. + +Message +------- + +The message of a commit can have several lines. Like git, gitlib splits it +in a subject and a body: + +```php +// The whole message +$commit->getMessage(); + +// The first paragraph, on one line, like "git log --format=%s" +$commit->getSubjectMessage(); + +// What follows the first paragraph and its empty lines, like "git log --format=%b" +$commit->getBodyMessage(); +``` + +For a message `"Fix the parser\nof dates\n\nIt failed on leap years.\n"`, +the subject is `"Fix the parser of dates"`, and the body +`"It failed on leap years.\n"`. A message without an empty line has no +body. + +To truncate the subject, use `symfony/string`, a dependency of gitlib: + +```php +use function Symfony\Component\String\u; + +echo u($commit->getSubjectMessage())->truncate(50, '...').PHP_EOL; +``` + +Diff of a commit +---------------- + +`getDiff()` returns the changes a commit made: + +- against its first parent, for a regular commit or a merge commit. For a + merge, these are the changes it brought into the branch it was merged + into, like GitHub shows a merge commit; +- against nothing, for a root commit: every file is a creation. + +```php +$diff = $commit->getDiff(); +``` + +To compare any two commits, use `Repository::getDiff($from, $to)`. See +[diff.md](diff.md) for the diff API. + +Last modification of a file +--------------------------- + +`getLastModification()` returns the last commit, in the history of this +one, that changed a path. It throws a `ReferenceNotFoundException` when no +commit changed it. The path is relative to the root of the repository. + +```php +$last = $commit->getLastModification('README.md'); + +echo 'Last README.md modification'.PHP_EOL; +echo ' Author: '.$last->getAuthorName().PHP_EOL; +echo ' Date: '.$last->getAuthorDate()->format('d/m/Y').PHP_EOL; +echo ' Message: '.$last->getSubjectMessage().PHP_EOL; +``` + +References pointing to a commit +------------------------------- + +`resolveReferences()` returns the branches and tags pointing to the commit, +annotated tags included: + +```php +foreach ($commit->resolveReferences() as $reference) { + echo $reference->getFullname().PHP_EOL; // "refs/heads/master", "refs/tags/1.0"... +} +``` + +See [references.md](references.md#resolution-from-a-commit). + +Branches containing a commit +---------------------------- + +`getIncludingBranches()` returns the local and remote branches whose +history contains the commit, as `Branch` objects. Filter them with +`isLocal()` or `isRemote()`: + +```php +use Gitonomy\Git\Reference\Branch; + +$branches = $commit->getIncludingBranches(); +$localBranches = array_filter($branches, static fn (Branch $branch): bool => $branch->isLocal()); +$remoteBranches = array_filter($branches, static fn (Branch $branch): bool => $branch->isRemote()); +``` diff --git a/doc/debug.rst b/doc/debug.rst deleted file mode 100644 index 704fd353..00000000 --- a/doc/debug.rst +++ /dev/null @@ -1,22 +0,0 @@ -Debug-mode -========== - -gitlib offers a debug mode, to make you see edge-cases of your usage. This is called -debug-mode. - -Debug-mode is enabled by default. If you disable it, gitlib will behave differently: - -* when an error is met during execution, gitlib will try to minimize it, to not block - execution flow. Errors will still be reporter in logger. -* logs will be more verbose. They will contain every output, every return code, every - possible information to ease debugging. - -If you want to disable exceptions and try to minimize as much as possible errors, pass -``false`` when construction a repository: - -.. code-block:: php - - $repository = new Gitonomy\Git\Repository($path'/tmp/repo', $debug = false) - -``$debug`` argument should be available in every method you can use to create a -repository. diff --git a/doc/development.rst b/doc/development.rst deleted file mode 100644 index 986b0868..00000000 --- a/doc/development.rst +++ /dev/null @@ -1,24 +0,0 @@ -Developing gitlib -================= - -If you plan to contribute to gitlib, here are few things you should know: - -Documentation generation -:::::::::::::::::::::::: - -Documentation is generated using Sphinx (restructured text). Configuration file -is located in https://github.com/gitonomy/website/blob/master/bin/conf.py - -You will need to fetch vendor modules for PHP blocks especially. If you really -want to generate it, install the website project locally and hack into it. - -Test against different git versions -::::::::::::::::::::::::::::::::::: - -A script ``test-git-version.sh`` is available in repository to test gitlib against -many git versions. - -This script is not usable on Travis-CI, they would hate me for this. It creates -a local cache to avoid fetching from Github and compiling if already compiled. - -Use it at your own risk, it's still under experiment. diff --git a/doc/diff.md b/doc/diff.md new file mode 100644 index 00000000..55b25113 --- /dev/null +++ b/doc/diff.md @@ -0,0 +1,198 @@ +Computing diffs +=============== + +Git stores snapshots of files, not diffs: gitlib computes them, like +`git diff`. + +Comparing two commits +--------------------- + +`Repository::getDiff($from, $to)` returns the changes from the commit +`$from` to the commit `$to`. Each one is a revision name (a branch, a tag, a +hash, `master~2`...) or a `Revision` object (a `Commit`, a `Branch`...): + +```php +$diff = $repository->getDiff('0.1', 'master'); +``` + +Like `git diff 0.1 master`, it compares the files of both commits. To compare +a branch with the commit it forked from, like `git diff master...feature`, +start from their best common ancestor: + +```php +$diff = $repository->getDiff($repository->getMergeBase('master', 'feature'), 'feature'); +``` + +Other diffs +----------- + +- `Commit::getDiff()` returns the changes of a commit, against its first + parent: see [commit.md](commit.md#diff-of-a-commit). +- `WorkingCopy::getDiff($revision)` returns the changes of the working + tree, staged or not, since a revision (`HEAD` by default). +- `WorkingCopy::getDiffPending()` returns the changes of the working tree + not staged yet. +- `WorkingCopy::getDiffStaged()` returns the changes staged in the index. + +See [workingcopy.md](workingcopy.md) for the working copy. + +Every diff detects renames, like `git diff -M`. `LibGit2Driver` computes +the similarity of files like libgit2 does: it can find a rename where git +finds a deletion and a creation (see +[Choosing a driver](repository.md#choosing-a-driver)). During a merge +conflict, `WorkingCopy::getDiffStaged()` and `getDiffPending()` leave the +unmerged files out, and `WorkingCopy::getDiff()` compares them, conflict +markers included, to the revision, like `git diff HEAD`. + +Iterating a diff +---------------- + +`getFiles()` returns the files of a diff, `File` objects: + +```php +$files = $diff->getFiles(); +echo count($files).' files changed'.PHP_EOL; + +foreach ($files as $file) { + echo $file->getName().': +'.$file->getAdditions().' -'.$file->getDeletions().PHP_EOL; +} +``` + +The File object +--------------- + +A `File` tells what happened to a file: + +```php +$file->isCreation(); // There is no old file +$file->isDeletion(); // There is no new file +$file->isModification(); // There are both: a rename, a copy or a mode change included +$file->isRename(); +$file->isCopy(); // Only for a diff detecting copies, like "git diff -C" +$file->isModeChange(); // From "100644" to "100755", for example +$file->isBinary(); // A binary file has no changes +``` + +A type change, like a file replaced by a symbolic link, is a deletion +followed by a creation, like git shows it. + +Its names, modes and blob hashes: + +```php +$file->getName(); // The new name, or the old one for a deletion +$file->getOldName(); // null for a creation +$file->getNewName(); // null for a deletion + +$file->getOldMode(); // "100644", "100755", "120000" (a symbolic link)... null for a creation +$file->getNewMode(); // null for a deletion + +$file->getOldIndex(); // The hash of the old blob, null for a creation +$file->getNewIndex(); // The hash of the new blob, null for a deletion +``` + +`getOldBlob()` and `getNewBlob()` return the `Blob` objects, to read the +content of the file before and after (see [blob.md](blob.md)): + +```php +if (!$file->isCreation() && !$file->isBinary()) { + $before = $file->getOldBlob()->getContent(); +} +``` + +They throw a `LogicException` when there is no such file (`getOldBlob()` +for a creation, `getNewBlob()` for a deletion), or when the diff has no +repository. The diffs gitlib returns have one: see +[Parsing a diff](#parsing-a-diff) for the others. They throw a +`RuntimeException` when the diff doesn't know the hash of the blob. + +Two cases have no blob in the repository, and reading the content of their +`Blob` throws a `ReferenceNotFoundException`: + +- a submodule, of mode `160000`: its hashes are commits of the submodule; +- a file of the working tree not staged yet: read it from the working + tree instead. + +The number of lines added and deleted, and the changes themselves: + +```php +$file->getAdditions(); +$file->getDeletions(); + +$file->getChanges(); // FileChange objects: see below +``` + +The FileChange object +--------------------- + +The changes of a file are its hunks, the blocks starting with `@@` in a +patch. Each one is a `FileChange` object: `getDiffLines()` returns the lines +of the hunk, `DiffLine` objects: + +```php +use Gitonomy\Git\Diff\DiffLineType; + +foreach ($file->getChanges() as $change) { + echo sprintf('@@ -%d,%d +%d,%d @@', $change->getRangeOldStart(), $change->getRangeOldCount(), $change->getRangeNewStart(), $change->getRangeNewCount()).PHP_EOL; + + foreach ($change->getDiffLines() as $line) { + echo match ($line->getType()) { + DiffLineType::Addition => '+', + DiffLineType::Context => ' ', + DiffLineType::Deletion => '-', + }.$line->getContent().PHP_EOL; + } +} +``` + +- `getRangeOldStart()` and `getRangeOldCount()` are the first line of the + hunk in the old file, and its number of lines there; +- `getRangeNewStart()` and `getRangeNewCount()` are the same in the new + file; +- `DiffLine::getType()` returns a `DiffLineType`: `Addition`, `Context` or + `Deletion`; +- `DiffLine::getContent()` returns the line, without its prefix (`+`, + `-` or a space) nor its line break. + +`getAdditions()` and `getDeletions()` count the lines the hunk adds and +deletes. + +Parsing a diff +-------------- + +`Diff::parse()` reads the output of `git diff --raw --patch`, with `-z` or +not. The raw information gives the names, modes, hashes and status of the +files: a patch without it throws an `InvalidArgumentException`. What the +parser doesn't know throws a `RuntimeException`, like the log of a submodule +that `git diff` writes instead of its patch, when the configuration sets +`diff.submodule`: pass `--submodule=short`. Pass the repository to read the +blobs of the files. Here, the output comes from `Repository::run()`, which +needs `CliDriver`: + +```php +use Gitonomy\Git\Diff\Diff; + +$output = $repository->run('diff', ['--raw', '--patch', '--submodule=short', '0.1', 'master']); +$diff = Diff::parse($output, $repository); +``` + +`getRawDiff()` returns the output a diff was parsed from: for a diff gitlib +reads with `CliDriver`, the one of `git diff --raw -z --patch`, NUL bytes +included, with the options gitlib sets. It is empty for the diffs of +`LibGit2Driver`, which runs no git command. + +Storing a diff +-------------- + +`toArray()` exports a diff as an array of scalars, to store it in a cache +or as JSON. `Diff::fromArray()` reads it back, with the repository to read +the blobs from: + +```php +use Gitonomy\Git\Diff\Diff; + +$array = $diff->toArray(); + +$diff = Diff::fromArray($array, $repository); +``` + +The arrays of 1.x are read too. diff --git a/doc/hooks.md b/doc/hooks.md new file mode 100644 index 00000000..06ccd801 --- /dev/null +++ b/doc/hooks.md @@ -0,0 +1,100 @@ +Hooks +===== + +It's possible to define custom hooks on any repository with git. Those +hooks are located in the *hooks* folder of the git directory: *.git/hooks*, +or *hooks* in a bare repository. A linked worktree (`git worktree add`) runs +the hooks of the repository it belongs to. gitlib ignores the `core.hooksPath` +setting of git. + +Those files need to be executable: gitlib makes them executable by their +owner only (*0700*). If another user runs them (a git daemon, a web +server...), change their permissions yourself. + +A hook is a file of the hooks directory: its name, like `pre-receive`, is +made of letters, digits, `-`, `_` and `.`, and doesn't start with a dot. Any +other name, like `../config`, throws a +`Gitonomy\Git\Exception\InvalidArgumentException`. + +git runs them, when it receives a push for instance, or when `CliDriver` +checks out or merges. `LibGit2Driver` doesn't run them (see +["Choosing a driver"](repository.md#choosing-a-driver)). + +With *gitlib*, you can manage hooks over a repository using the *Hooks* +object. + +To access it from a repository, use the *getHooks* method on a +*Repository* object: + +```php +$hooks = $repository->getHooks(); +``` + +Reading hooks +------------- + +To read the content of a hook, use the *get* method like this: + +```php +$content = $hooks->get('pre-receive'); // returns a string +``` + +If the hook does not exist, a `Gitonomy\Git\Exception\InvalidArgumentException` +is thrown, and a `Gitonomy\Git\Exception\RuntimeException` if it can't be +read. + +You can test if a hook is present using the method *has*: + +```php +$hooks->has('pre-receive'); // a boolean indicating presence +``` + +A symbolic link is a hook, even when the file it points to doesn't exist +yet: *get* can't read it, and *set* doesn't write through it. + +Inserting hooks +--------------- + +You can modify a hook in two different ways: creating a new file or +using a symlink. + +To create the hook using a symlink: + +```php +$hooks->setSymlink('pre-receive', '/path/to/file-to-link'); +``` + +If you want to directly create a new file in hooks directory, use the +method *set*. This method will create a new file, put content in it and +make it executable: + +```php +$content = <<set('pre-receive', $content); +``` + +Both methods create the hooks directory when the repository has none, like +one initialized with an empty template. They throw a +`Gitonomy\Git\Exception\RuntimeException` if the hook already exists, or if +it can't be created. + +Removing hooks +-------------- + +To remove a hook from a repository, use the function *remove*: + +```php +$hooks->remove('pre-receive'); +``` + +It throws a `Gitonomy\Git\Exception\InvalidArgumentException` if the hook +does not exist, like *get*, and a `Gitonomy\Git\Exception\RuntimeException` +if it can't be removed. diff --git a/doc/index.rst b/doc/index.rst deleted file mode 100644 index 919e91c5..00000000 --- a/doc/index.rst +++ /dev/null @@ -1,59 +0,0 @@ -gitlib - library to manipulate git -================================== - -gitlib requires PHP 5.3 and class autoloading (PSR-0) to work properly. Internally, it relies on ``git`` method calls -to fetch informations from repository. - -.. code-block:: php - - use Gitonomy\Git\Repository; - - $repository = new Repository('/path/to/repository'); - - foreach ($repository->getReferences()->getBranches() as $branch) { - echo "- ".$branch->getName(); - } - - $repository->run('fetch', array('--all')); - - -Reference ---------- - -.. toctree:: - :maxdepth: 1 - - api/admin - api/repository - api/hooks - api/workingcopy - api/commit - api/blame - api/blob - api/branch - api/tree - api/log - api/diff - api/references - api/revision - - -Documentation -------------- - -.. toctree:: - :maxdepth: 2 - - installation - debug - development - -Missing features ----------------- - -Some major features are still missing from gitlib: - -* Remotes -* Submodules - -If you want to run git commands on repository, call method ``Repository::run`` with method and arguments. diff --git a/doc/installation.rst b/doc/installation.rst deleted file mode 100644 index 06d36d21..00000000 --- a/doc/installation.rst +++ /dev/null @@ -1,20 +0,0 @@ -Installation of gitlib -====================== - -Autoloading -::::::::::: - -gitlib relies on class autoloading. It does not require any additional setup. - -Using composer -:::::::::::::: - -Edit your ``composer.json`` file and add ``gitonomy/gitlib`` in section ``require``: - -.. code-block:: json - - { - "require": { - "gitonomy/gitlib": "^1.0" - } - } diff --git a/doc/log.md b/doc/log.md new file mode 100644 index 00000000..86b73329 --- /dev/null +++ b/doc/log.md @@ -0,0 +1,133 @@ +Getting log history +=================== + +Walking the parents of commits browses a history. To order the commits, or +to gather the history of several branches, use a log, like `git log`. + +To get a `Log` object from a repository: + +```php +$log = $repository->getLog(); +``` + +`getLog()` takes four arguments, all optional: the revisions, the paths, +the offset and the limit. + +```php +// The history of every reference +$log = $repository->getLog(); + +// The history of the master branch +$log = $repository->getLog('master'); + +// The last 10 commits of master changing the README.md file +$log = $repository->getLog('master', 'README.md', 0, 10); + +// The last 10 commits of master changing README or README.md +$log = $repository->getLog('master', ['README', 'README.md'], 0, 10); +``` + +A `Revision` object, like a `Commit` or a `Branch`, has a shortcut, taking +the paths, the offset and the limit: + +```php +$log = $repository->getRevision('master')->getLog('README.md', 0, 10); +``` + +Revisions +--------- + +The revisions are a string, a `Revision` object, or an array of them. Each +one can be: + +- a name: a branch, a tag, a hash, `HEAD~3`..., for its history; +- a range, `a..b`: the history of `b` without the history of `a`; +- a symmetric range, `a...b`: the commits in the history of `a` or `b`, + but not in both; +- a name to exclude, `^a`: the history of the other revisions, without the + history of `a`; +- `--all`: every reference and `HEAD`, like no revision at all. + +An empty end of a range is `HEAD`: `..master` is `HEAD..master`. + +```php +// The commits of master since the tag 0.1 +$log = $repository->getLog('0.1..master'); + +// The same +$log = $repository->getLog(['master', '^0.1']); + +// The commits of feature and master since they diverged +$log = $repository->getLog('feature...master'); +``` + +A revision naming no commit throws a `ReferenceNotFoundException` naming +it. Other options of `git log` are not supported: they throw an +`InvalidArgumentException`. + +Paths +----- + +The paths filter the log: it lists the commits changing them. They are +relative to the root of the repository, and support the wildcards of git: + +```php +// The commits changing a PHP file, in any folder +$log = $repository->getLog('master', '*.php'); + +// The commits changing a file in the src folder +$log = $repository->getLog('master', 'src'); +``` + +With `CliDriver`, they support the magic of git's pathspecs too, like +`:(exclude)src`, which git knows since 1.9. `LibGit2Driver` doesn't: a path +starting with `:` throws an `UnsupportedOperationException`. + +Iterating +--------- + +A log is iterable: + +```php +foreach ($log as $commit) { + echo $commit->getShortHash().' '.$commit->getSubjectMessage().PHP_EOL; +} +``` + +`getCommits()` returns them as an array. `getSingleCommit()` returns the +first commit of the log, and throws a `ReferenceNotFoundException` when the +log is empty: + +```php +$commits = $log->getCommits(); + +$lastChange = $repository->getLog('master', 'README.md')->getSingleCommit(); +``` + +Counting +-------- + +`count($log)` returns the number of commits the log iterates, after its +offset and up to its limit. To count every commit of the log, without offset +nor limit, use `countCommits()`: + +```php +echo sprintf('This page has %d commits%s', count($log), PHP_EOL); +echo sprintf('This log has %d commits%s', $log->countCommits(), PHP_EOL); +``` + +Offset and limit +---------------- + +A log is immutable: `withOffset()` and `withLimit()` return another log. + +```php +$log = $repository->getLog('master'); + +$firstPage = $log->withLimit(10); +$secondPage = $firstPage->withOffset(10); + +// To read them +$secondPage->getOffset(); // 10 +$secondPage->getLimit(); // 10 +``` diff --git a/doc/references.md b/doc/references.md new file mode 100644 index 00000000..1d891383 --- /dev/null +++ b/doc/references.md @@ -0,0 +1,157 @@ +Tags and branches +================= + +Accessing tags and branches +--------------------------- + +Branches and tags are references. To get them, call `getReferences()` on a +`Repository`. It returns a `ReferenceBag` object: + +```php +$references = $repository->getReferences(); +``` + +The bag reads the references again after every write gitlib makes: creating +or deleting a reference, a checkout, a merge, `Repository::run()`... To see +the writes of other processes, open a new `Repository`. + +First, you can test the existence of tags and branches like this: + +```php +if ($references->hasBranch('master') && $references->hasTag('0.1')) { + echo 'Good start!'.PHP_EOL; +} + +$references->hasRemoteBranch('origin/master'); +$references->hasBranches(); // A branch at least, local or remote +$references->has('refs/heads/master'); // A reference, by its full name +``` + +To get all branches or all tags, sorted by full name (local branches come +before remote ones): + +```php +$branches = $references->getBranches(); // Branch objects, local and remote +$localBranches = $references->getLocalBranches(); +$remoteBranches = $references->getRemoteBranches(); +$tags = $references->getTags(); // Tag objects +$all = $references->getAll(); // Every reference, keyed by its full name +``` + +The bag is also iterable and countable, like `getAll()`. The default branch +of a remote (`refs/remotes/origin/HEAD`) is left out, and the stash is a +`Reference\Stash` object. + +To get a given branch or tag, call `getBranch()`, `getRemoteBranch()` or +`getTag()` on the `ReferenceBag`. They return `Branch` and `Tag` objects: + +```php +$master = $references->getBranch('master'); +$feature = $references->getRemoteBranch('origin/feature'); +$v0_1 = $references->getTag('0.1'); + +// A reference by its full name +$reference = $references->get('refs/heads/master'); +``` + +If the reference doesn't exist, they throw a `ReferenceNotFoundException`. +`getFirstBranch()` returns the first branch of `getBranches()`, or `null` +when there is none. + +Every reference is a `Revision`. On each one, you can access: + +```php +// The name: "master", "origin/feature" for a remote branch, "0.1" for a tag +$name = $master->getName(); + +// The full name: "refs/heads/master", "refs/remotes/origin/feature", "refs/tags/0.1" +$fullname = $master->getFullname(); + +// The commit it points to +$commit = $master->getCommit(); +$hash = $master->getCommitHash(); + +// The last commit changing a path, in its history +$lastModification = $master->getLastModification('README.md'); +``` + +`getCommitHash()` is the commit the reference points to: for an annotated +tag, the commit the tag object points to. A tag may point to a tree or a blob +instead: `getCommitHash()` is then its hash, and reading the `Commit` of +`getCommit()` throws a `ReferenceNotFoundException`. A reference object keeps +the commit it pointed to when it was read: get it again from the bag after a +change. + +Create and delete references +---------------------------- + +You can create new tags and branches, using methods of the `ReferenceBag`. +They take the revision to point to: a name, a hash, or a `Revision` object +like a `Commit` or a `Branch`. + +```php +// Create a branch +$branch = $references->createBranch('foobar', 'master'); + +// Create a lightweight tag +$tag = $references->createTag('0.3', $repository->getHeadCommit()); + +// Delete a branch or a tag +$branch->delete(); +$tag->delete(); +``` + +`ReferenceBag::delete()` takes the full name of a reference under `refs/`, +like `refs/heads/foobar`, and does nothing when it doesn't exist, like +`git update-ref -d`. Another name, like `HEAD` or `foobar`, throws an +`InvalidArgumentException`. + +`createBranch()` and `createTag()` throw an `InvalidArgumentException` when +the reference exists: they never move it. To move a branch, use +`Branch::moveTo()`: see [branch.md](branch.md). + +Tags +---- + +A tag is lightweight, a name pointing to a commit, or annotated: a tag +object, with a tagger, a message, and maybe a signature. + +```php +$tag = $references->getTag('1.0'); + +if ($tag->isAnnotated()) { + echo $tag->getTaggerName().' <'.$tag->getTaggerEmail().'>'.PHP_EOL; + echo $tag->getTaggerDate()?->format('Y-m-d').PHP_EOL; + echo $tag->getSubjectMessage().PHP_EOL; +} +``` + +- `getTaggerName()`, `getTaggerEmail()` and `getTaggerDate()` (a + `DateTimeImmutable`, at the UTC offset of the tagger, like the dates of + a [commit](commit.md#author-and-committer)) return `null` for a + lightweight tag, and for an annotated tag without tagger; +- `getMessage()` returns the message, without the signature, `null` for a + lightweight tag; +- `getSubjectMessage()` and `getBodyMessage()` split the message like + for a commit (see [commit.md](commit.md#message)), `null` for a + lightweight tag; +- `isSigned()` tells if the tag is signed, with GPG, X.509 or SSH; +- `getGpgSignature()` returns the signature, without its armor lines, + `null` for an unsigned or lightweight tag. + +Resolution from a commit +------------------------ + +To find the branches or the tags pointing to a commit, use the +`resolveBranches()` and `resolveTags()` methods: + +```php +$branches = $references->resolveBranches($commit); +$tags = $references->resolveTags($commit); // Annotated tags included + +// Resolve branches and tags +$all = $references->resolve($commit); +``` + +You can pass a `Commit` object, a hash or any revision name. +`Commit::resolveReferences()` is a shortcut for `resolve()`. diff --git a/doc/repository.md b/doc/repository.md new file mode 100644 index 00000000..de78ef81 --- /dev/null +++ b/doc/repository.md @@ -0,0 +1,578 @@ +Repository methods +================== + +A `Repository` object opens a repository on the disk. It is the entry point +to everything else: commits, references, working copy... + +To create a repository, or to clone one, see +[Creating repositories](admin.md). + +Opening a repository +-------------------- + +Give the path of the repository to the constructor: + +```php +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repo'); +``` + +The path may be: + +- a bare repository; +- a working tree, with its `.git` directory; +- a working tree whose `.git` is a file pointing to its git directory: a + linked worktree (`git worktree add`), a submodule, or a clone with + `--separate-git-dir`. + +The constructor takes two more arguments: + +```php +public function __construct(string $dir, ?string $workingDir = null, DriverInterface $driver = new CliDriver()) +``` + +- `$workingDir`: the working tree, when `$dir` is a git directory kept apart + from it, like `git --git-dir=$dir --work-tree=$workingDir`; +- `$driver`: the backend running git. See + ["Choosing a driver"](#choosing-a-driver), + ["Configuring `CliDriver`"](#configuring-clidriver) and + ["Logger"](#logger). + +```php +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/srv/project.git', '/srv/project', new CliDriver(processTimeout: 60)); +``` + +A directory that doesn't exist, or that isn't a repository, throws an +`InvalidArgumentException`. + +gitlib doesn't support the repositories using SHA-256 hashes yet: opening one +throws an `UnsupportedOperationException`. + +Paths +----- + +```php +$repository->isBare(); // true for a bare repository +$repository->getGitDir(); // the git directory, "/path/to/repo/.git" +$repository->getWorkingDir(); // the working tree, "/path/to/repo", or null for a bare repository +$repository->getPath(); // the working tree, or the git directory of a bare repository +``` + +Access HEAD +----------- + +`HEAD` represents in git the version you are working on (in working +tree). Your `HEAD` can be attached (pointing to a branch) or detached +(pointing to a commit). + +```php +use Gitonomy\Git\Reference\Branch; + +$head = $repository->getHead(); // the Branch HEAD points to, or the Commit when it is detached +$commit = $repository->getHeadCommit(); // the Commit, in both cases + +if ($head instanceof Branch) { + echo 'On branch '.$head->getName().PHP_EOL; +} else { + echo 'HEAD detached at '.$commit->getShortHash().PHP_EOL; +} +``` + +`isHeadAttached()` and `isHeadDetached()` tell it too. + +In a repository without commits yet, `HEAD` points to a branch that doesn't +exist yet: `getHead()` and `getHeadCommit()` throw a +`ReferenceNotFoundException`, and `isHeadAttached()` returns `true`. +`getHead()` throws one too when `HEAD` points to a reference that isn't a +branch, like a tag, which git allows. + +Commits, trees and blobs +------------------------ + +```php +// A revision: a branch, a tag, a hash, "HEAD~2"... +$commit = $repository->getRevision('master')->getCommit(); + +// A full hash only +$commit = $repository->getCommit('7e9ba14939b0ce67ab95d83e03bd64710db91ef4'); + +// A hash, abbreviated or not +$tree = $repository->getTree('56953e3'); +$blob = $repository->getBlob('412a0dad'); +``` + +`getCommit()` only takes a full hash: anything else throws a +`ReferenceNotFoundException`. Use `getRevision($name)->getCommit()` for a +name or an abbreviated hash, see [Revision](revision.md). `getTree()` and +`getBlob()` take a hash, abbreviated or not, and throw for anything else: to +read a file of a commit, go through its tree, see [Tree](tree.md). + +An object that doesn't exist throws a `ReferenceNotFoundException`, at the +latest when its data is read. + +The repository returns the same object for a hash, as long as your +application uses it: + +```php +$repository->getCommit($hash) === $repository->getCommit($hash); // true +``` + +It keeps no reference to the objects your application doesn't use anymore: +reading a long history doesn't fill the memory. + +`getMergeBase()` returns the best common ancestor of two revisions, the +commit `git diff a...b` compares with, or `null` when they have no common +history: + +```php +$base = $repository->getMergeBase('master', 'feature'); // Commit|null +``` + +References +---------- + +`getReferences()` returns the branches, tags... of the repository: see +[References](references.md). + +```php +$references = $repository->getReferences(); +``` + +gitlib reads them again after every write it makes: creating or deleting a +reference, a checkout, a merge, `run()`... It doesn't see the writes of other +processes (a push to the repository, another worker...): open a new +`Repository` to see them. + +Description +----------- + +```php +$repository->getDescription(); // the content of the "description" file of the git directory +$repository->hasDescription(); // false while it is git's default one +$repository->setDescription('My project'); +``` + +Compute size of a repository +---------------------------- + +`getSizeInBytes()` returns the size of the files of the git directory, +working tree excluded, in bytes. It doesn't follow links, and leaves out a +file or a directory git deletes meanwhile, like a lock during a `gc` or the +quarantine of a push. One it can't read throws a `RuntimeException`: + +```php +$size = $repository->getSizeInBytes(); + +echo 'Your repository size is '.round($size / 1000).' KB'; +``` + +Running git commands +-------------------- + +With `CliDriver`, `run()` runs any git command on the repository, and returns +its standard output: + +```php +$output = $repository->run('log', ['--oneline', '-n', '5']); +``` + +Some commands, like `push`, write their meaningful output to the error +output, even when they succeed. `runProcess()` returns the Symfony `Process`, +already run: + +```php +$process = $repository->runProcess('push', ['origin', 'main']); + +echo $process->getErrorOutput(); +``` + +Both throw a `ProcessException` when git fails, a `DriverException` when it +can't start, a `ProcessTimedOutException` when it runs longer than +[the timeout](#configuring-clidriver), and an `UnsupportedOperationException` +with another driver than `CliDriver`. The relative paths of the arguments are +relative to the repository. The arguments go to git as they are: a value from +your users starting with a dash is an option for git, unless `--` comes +before it. gitlib reads the references again after the command, which may +have written them. + +Errors +------ + +Gitlib throws an exception when an operation fails. It never returns `null` +or empty data instead. Every exception of gitlib implements +`Gitonomy\Git\Exception\GitExceptionInterface`, and lives in the +`Gitonomy\Git\Exception` namespace: + +- `ReferenceNotFoundException`, an `InvalidArgumentException`: a revision, + a reference or an object doesn't exist (`getRevision('unknown')->getCommit()`, + `getReferences()->getBranch('unknown')`...). +- `DriverException`, a `RuntimeException`: git reports a failure, like a + merge conflict or a clone of an unknown repository, or can't start, like + a missing binary (`The git command "clone" could not start: ...`). With + `CliDriver`, it is mostly a `ProcessException`, whose message has the + error of git, the first `fatal:` or `error:` line of its error output + (`The git command "clone" failed (exit 128): fatal: ...`), and whose + `getErrorOutput()` and `getOutput()` return the output of git, or a + `ProcessTimedOutException`, when git runs longer than + [the timeout](#configuring-clidriver). +- `UnsupportedOperationException`, a `RuntimeException`: the driver can't + perform the operation at all, where another driver may: `run()`, cloning + a bundle or opening a reftable repository with `LibGit2Driver`, libgit2 + that isn't installed... +- `InvalidArgumentException`: an argument gitlib doesn't support, like an + option of `git log` in the revisions of a log, a string holding a NUL + byte, which git doesn't accept, a revision, a reference, a hash or a URL + 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`. +- `RuntimeException`: any other failure, like a hook that exists already + (`Hooks::set()`), a file of the git directory that can't be written, or + an output of git that gitlib can't read. `DriverException` and + `UnsupportedOperationException` extend it. +- `LogicException`: a call that makes no sense, like the working copy of a + bare repository. + +```php +use Gitonomy\Git\Exception\ReferenceNotFoundException; + +try { + $commit = $repository->getRevision($name)->getCommit(); +} catch (ReferenceNotFoundException) { + echo 'No commit named '.$name.PHP_EOL; +} +``` + +The message of an exception hides the credentials of the URLs it holds +(`https://***@example.com/repository.git`): `getErrorOutput()` and +`getOutput()` of a `ProcessException` return the output of git as it is. +A `git://` URL takes no credentials: git quotes one without its scheme in +its errors, so a password put there isn't hidden. + +Choosing a driver +----------------- + +By default, gitlib spawns a `git` process for every operation, with +`CliDriver`, which requires git 1.8.4 or later. You can instead run +[libgit2](https://libgit2.org/) in-process, through PHP's `FFI` extension, +by passing a `LibGit2Driver`: + +```php +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repo', driver: new LibGit2Driver()); +``` + +> **warning** +> +> `LibGit2Driver` is experimental: it isn't covered by the backward +> compatibility promise, and can change in a minor version. It reproduces +> git's behavior with libgit2, and may still differ from git in cases its +> tests don't cover. + +A driver holds no state of a repository: create it once, and pass it to +every `Repository` you open or create, as a service of your container for +instance. + +`LibGit2Driver` avoids spawning a process for every operation. It requires +the `ext-ffi` PHP extension and libgit2 1.9, built for SHA-1 only: libgit2 +changes its binary interface with every minor version, and its build +supporting SHA-256 changes its structures, so any other build is refused. If +either is missing, constructing the `LibGit2Driver` throws an +`UnsupportedOperationException` right away — there is no silent fallback to +`CliDriver`. You can check availability upfront: + +```php +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; + +$driver = LibGit2Driver::isSupported() ? new LibGit2Driver() : new CliDriver(); +``` + +If libgit2 isn't discoverable under one of the usual shared-library names +(`libgit2.so.1.9`, `libgit2.so`, `libgit2.dylib`, `git2.dll`...), pass its +path to the driver: + +```php +$driver = new LibGit2Driver('/opt/libgit2/lib/libgit2.so.1.9'); +``` + +`isSupported()` only looks for the usual names: to check a path, construct +the driver and catch the exception instead: + +```php +use Gitonomy\Git\Exception\UnsupportedOperationException; + +try { + $driver = new LibGit2Driver('/opt/libgit2/lib/libgit2.so.1.9'); +} catch (UnsupportedOperationException) { + $driver = new CliDriver(); +} +``` + +Differences with `CliDriver`: + +- `Repository::run()` and `Repository::runProcess()` throw an + `UnsupportedOperationException`: there is no `git` process to run. +- Nothing is logged, and nothing times out, not even network operations. +- SSH and git's credential helpers aren't supported: clone a private + repository through HTTPS, with the credentials in the URL + (`https://user:token@example.com/repo.git`). +- Bundles (`.bundle` files) can't be cloned, and repositories storing + their references in the reftable format can't be opened: both throw an + `UnsupportedOperationException`. +- The paths of a log don't support the magic of git's pathspecs + (`:(exclude)src`, `:/src`...): a path starting with `:` throws an + `UnsupportedOperationException`. +- Neither hooks nor external filters (Git LFS...) are run: a checkout or a + merge writes the files without them. +- Like git, `LibGit2Driver` doesn't read `/etc/gitconfig` when the + `GIT_CONFIG_NOSYSTEM` environment variable is true, but it doesn't read + `/etc/gitattributes` either, which git only skips with + `GIT_ATTR_NOSYSTEM`. +- `Diff::getRawDiff()` returns an empty string. +- Short hashes (`Commit::getShortHash()`) have 7 characters, more when 7 + are ambiguous: git adapts their length to the size of the repository. +- `WorkingCopy::merge()` only supports fast-forward and conflict-free + merges: a conflict throws instead of leaving conflict markers in the + working copy. A merge commit is never signed, whatever + `commit.gpgSign`, and its message never lists the merged commits, + whatever `merge.log`, nor has the output of gpg checking a signed tag. +- 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 + differently, and it ignores `blame.ignoreRevsFile`. +- Rename detection in diffs may pair files differently. +- Network operations go through the proxy of `https_proxy`, or of + `http.proxy`, for HTTPS only: libgit2 1.9 uses no proxy for a plain + `http://` URL. +- With php-fpm, libgit2 must be preloaded: see + ["With php-fpm"](#with-php-fpm). + +These limits apply to both drivers, so that they behave the same: + +- The revisions of a log are names, ranges (`a..b`, `a...b`), names to + exclude (`^a`) or `--all`: any other option of `git log` throws an + `InvalidArgumentException`. +- A blame takes the numbers of its first and last lines, none of the other + forms of `git blame -L`. + +### With php-fpm + +Call `LibGit2Driver::preload()` from the +[`opcache.preload`](https://www.php.net/manual/en/opcache.preloading.php) +script, with the library path you give to `LibGit2Driver`, if any: + +```php +// preload.php +require __DIR__.'/vendor/autoload.php'; + +Gitonomy\Git\Driver\Ffi\LibGit2Driver::preload(); +``` + +libgit2 then stays loaded and initialized for the lifetime of php-fpm, +instead of every request loading it again (about 10 ms, see below), and the +driver works with the default `ffi.enable` setting, `preload`: outside the +CLI, only preloaded code can use FFI. + +Without preloading, `LibGit2Driver` throws an `UnsupportedOperationException` +unless `ffi.enable` is `true`, which lets any code of the application call C +functions. Even then, don't: every request initializes libgit2 again, and +leaks its global state, about 700 KB per request. + +### Performance + +`CliDriver` starts a `git` process for every operation, which costs a few +milliseconds each time. `LibGit2Driver` calls libgit2 directly, once +loaded. Which one is faster depends on the operation and on the size of the +history: + +| Operation (ms) | gitlib `CliDriver` | gitlib `LibGit2Driver` | symfony `CliDriver` | symfony `LibGit2Driver` | +|--------------------------------------|-------------------:|-----------------------:|--------------------:|------------------------:| +| Load libgit2, once per process | - | 10.5 | - | 10.5 | +| Resolve HEAD | 3.6 | 0.6 | 9.3 | 4.5 | +| Read a commit | 3.1 | 0.3 | 4.0 | 0.3 | +| Read a file | 18.5 | 0.7 | 24.4 | 1.0 | +| List the branches | 3.0 | 0.6 | 10.0 | 4.4 | +| Log 100 commits | 9.9 | 4.0 | 11.3 | 3.8 | +| Last commit of a file | 3.7 | 0.9 | 7.9 | 5.1 | +| Log 100 commits of a directory | 13.8 | 11.0 | 95 | 262 | +| Count the commits of HEAD | 7.3 | 5.1 | 43 | 1,332 | +| List the branches including a commit | 8.4 | 3.3 | 16 | 912 | +| Diff a commit | 23.3 | 17.4 | 5.1 | 18.4 | +| Blame a file | 23 | 78 | 613 | about 580,000 | + +These are medians of 10 runs, each on a new `Repository`, as a web request +would open it. gitlib is this repository (350 commits), with +`src/Gitonomy/Git/Repository.php` as the file. symfony/symfony has 80,000 +commits, 910 references and a commit-graph (which `git gc` writes by +default), with `src/Symfony/Component/Process/Process.php` (1,700 lines, 750 +commits) as the file. Measured with PHP 8.5, git 2.53 and libgit2 1.9.1 on +an AMD Ryzen AI 9 HX 370. + +- `LibGit2Driver` reads objects (commits, files, references) 2 to 28 + times faster: no process to start. +- Loading libgit2 costs about 10 ms, mostly libgit2 setting up OpenSSL, + which reads the system's CA certificates. A command or a worker pays it + once. PHP unloads libgit2 at the end of every request: with php-fpm, each + request pays it again, unless + [libgit2 is preloaded](#with-php-fpm), which brings it down to 0.1 ms. +- libgit2 doesn't use git's commit-graph, and caches objects per + repository only: walking a long history (counting commits, finding the + branches including a commit, logging a path) is up to 60 times slower. +- libgit2's blame is much slower than git's on a long history: about 10 + minutes instead of 0.6 s for symfony's `Process.php`. It diffs trees for + every commit it goes through, where git compares the hash of the file + ([libgit2#3027](https://github.com/libgit2/libgit2/issues/3027)). + +Keep the default `CliDriver`, unless your application runs many small reads +in a long-running process, and check with your own repository, from a clone +of gitlib (Composer doesn't install `benchmark.php`, nor `symfony/console`, +which it requires): + +```bash +git clone https://github.com/gitonomy/gitlib.git +cd gitlib +composer install +php benchmark.php /path/to/repository path/to/a/file +``` + +Configuring `CliDriver` +----------------------- + +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; +- `processTimeout`: the timeout of every git process, in seconds, for its + whole duration and without output, `3600` by default, `null` for none. A + git process running longer throws a `ProcessTimedOutException`; +- `logger`: see ["Logger"](#logger). + +```php +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/tmp/foo', driver: new CliDriver( + command: '/home/alice/bin/git', + environmentVariables: ['GIT_SSH_COMMAND' => 'ssh -i /home/alice/.ssh/deploy'], + processTimeout: 60, +)); +``` + +Git configuration +----------------- + +git reads the configuration of the user running PHP (`~/.gitconfig`, +`/etc/gitconfig`...) and of the repository. Where it changes what git +prints, which gitlib reads, what a read returns, or the branches gitlib +creates, `CliDriver` sets it back to git's defaults: + +- colors and columns; +- `branch.autoSetupMerge` and `branch.autoSetupRebase`: like with + `LibGit2Driver`, a branch `WorkingCopy::checkoutNewBranch()` creates from + a remote branch tracks it, without rebasing; +- `branch.sort`, `log.follow` and `log.showSignature`; +- `core.fsmonitor`, whose command git runs to read the working tree, and + even to diff a commit; +- for diffs, `diff.algorithm`, `diff.context`, `diff.external`, + `diff.indentHeuristic`, `diff.interHunkContext`, `diff.orderFile`, + `diff.submodule`, `diff.suppressBlankEmpty`, the prefixes + (`diff.noprefix`, `diff.mnemonicPrefix`...), and the textconv filters, of + blames too. + +Like libgit2, `CliDriver` reads the objects as they are, ignoring their +replacements (`git replace`, `refs/replace/`): whoever can push such a +reference to a repository would change what your application shows for a +hash. + +The rest of the configuration applies, like with git: the identity, the +credentials and the rewrites of URLs, line endings, hooks, `merge.log`, +`blame.ignoreRevsFile`, or `core.abbrev`, the length of short hashes, with +`LibGit2Driver` too. + +`safe.directory` doesn't apply: `CliDriver` runs git with `--git-dir`, which +git trusts, whoever owns the repository. Like git, gitlib then runs the +commands the configuration of the repository names: its hooks, and the +filters of its `.gitattributes`, when it checks files out, merges, or reads +the working tree. Only open a repository whose `.git` directory you trust: +not one another user of the machine owns, nor one extracted from an archive +your users send. `LibGit2Driver` runs neither hooks nor external filters. + +`run()` and `runProcess()` get `branch.autoSetupMerge`, +`branch.autoSetupRebase`, `branch.sort`, `core.fsmonitor`, `log.follow`, +`log.showSignature`, `diff.indentHeuristic` and `diff.suppressBlankEmpty` set +back too, and ignore the replacements, but not the rest, which gitlib sets with +the options of the commands it runs: pass `--no-color` or `--no-ext-diff` +yourself. + +Logger +------ + +`CliDriver` takes a PSR-3 logger, and logs every git command it runs, +initializing and cloning repositories included: + +```php +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/foo', driver: new CliDriver(logger: new Monolog\Logger('git'))); + +$repository->run('fetch', ['--all']); +``` + +This will output: + +``` +info run command: fetch "--all" +debug last command (fetch) duration: 23.24ms +debug last command (fetch) return code: 0 +debug last command (fetch) output: Fetching origin +``` + +A failing command also logs its error output, at the `debug` level: the +exception it throws reports the failure. The logs hide the credentials of +URLs, like exceptions. `LibGit2Driver` logs nothing. + +Using your own driver +--------------------- + +`$driver` accepts any `Gitonomy\Git\Driver\DriverInterface` implementation. +Its `open($dir, $workingDir)` method returns the +`Gitonomy\Git\Driver\RepositoryDriverInterface` the repository runs its +operations with: `getGitDir()`, `getWorkingDir()`, `getHeadReference()`, +`getReferences()`, `getCommitData()`... In tests, a stub of both lets you +exercise code built on gitlib without any git repository: + +```php +use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Repository; + +$repositoryDriver = $this->createStub(RepositoryDriverInterface::class); +$repositoryDriver->method('getGitDir')->willReturn('/path/to/repo.git'); +$repositoryDriver->method('getReferences')->willReturn([new ReferenceData('refs/heads/main', $hash)]); + +$driver = $this->createStub(DriverInterface::class); +$driver->method('open')->willReturn($repositoryDriver); + +$repository = new Repository('/path/to/repo.git', driver: $driver); +$repository->getReferences()->getBranch('main')->getCommitHash(); // $hash +``` + +The other methods of `DriverInterface` create repositories: `init()`, +`initBare()`, `cloneFrom()`, `cloneBareFrom()`, `mirrorFrom()`, +`isValidRepository()` and `isValidRepositoryAndBranch()`. + +gitlib may add methods to both interfaces in a minor version of 2.x: the +promise of backward compatibility covers the code calling them, not the +drivers implementing them, like a stub that implements every method. diff --git a/doc/revision.md b/doc/revision.md new file mode 100644 index 00000000..39848cf9 --- /dev/null +++ b/doc/revision.md @@ -0,0 +1,51 @@ +Revision +======== + +A revision is a name git resolves to a commit: a branch, a tag, a hash, +abbreviated or not, `HEAD~2`, `master@{2 days ago}`... + +To get a revision from a *Repository* object: + +```php +$revision = $repository->getRevision('master@{2 days ago}'); +``` + +`Commit` and `Reference` (a branch, a tag) are revisions too: you can pass +them wherever gitlib takes a revision. + +Resolve a revision +------------------ + +To resolve a revision to a commit: + +```php +$commit = $revision->getCommit(); +``` + +It throws a `Gitonomy\Git\Exception\ReferenceNotFoundException` when the +revision names no commit. A tag, annotated or not, resolves to the commit it +points to. + +`Repository::getCommit()` only takes a full hash: use +`getRevision($name)->getCommit()` for anything else. + +Getting the log +--------------- + +You can access a *Log* object starting from a revision using the +*getLog* method. This method takes three parameters: *paths*, *offset* and +*limit*: + +```php +// Returns the 100 last commits +$log = $revision->getLog(limit: 100); + +// Returns the 100 last commits changing README.md +$log = $revision->getLog('README.md', limit: 100); + +// Skips the 100 last commits +$log = $revision->getLog(null, 100); +``` + +The first argument filters on paths: `getLog(null, 100)` is an offset, not a +limit. See [Log](log.md). diff --git a/doc/tree.md b/doc/tree.md new file mode 100644 index 00000000..9a671544 --- /dev/null +++ b/doc/tree.md @@ -0,0 +1,100 @@ +Tree and files +============== + +Git stores folders as trees. In gitlib, a tree is a `Tree` object. + +The root tree of a commit is the entry point of all its files: + +```php +$tree = $commit->getTree(); +``` + +`Repository::getTree()` takes the hash of a tree, abbreviated or not. + +Entries of a tree +----------------- + +Iterating a tree gives its entries, keyed by their name. Each one is a +`TreeEntry` object, with the name of the entry, its mode, and its object: a +`Blob` for a file (see [blob.md](blob.md)), a `Tree` for a folder, or a +`Submodule`. + +```php +foreach ($tree as $name => $entry) { + $entry->getName(); // The name of the file or the folder, the same as $name + $entry->getMode(); // The mode git stores: "100644", "100755" (executable), "120000" (symbolic link), "040000" (tree), "160000" (submodule) + $entry->getObject(); // A Blob, a Tree or a Submodule +} +``` + +To keep one kind of entries only, also keyed by their name: + +```php +$files = $tree->getBlobs(); +$submodules = $tree->getSubmodules(); +$folders = $tree->getTrees(); +``` + +To get the object of an entry by its name: + +```php +$readme = $tree->getEntry('README.md'); // A Blob +``` + +It throws an `InvalidArgumentException` when the tree has no such entry. + +Here is a function displaying a tree and its folders, recursively: + +```php +use Gitonomy\Git\Submodule; +use Gitonomy\Git\Tree; + +function displayTree(Tree $tree, int $depth = 0): void +{ + foreach ($tree as $name => $entry) { + $object = $entry->getObject(); + + echo str_repeat(' ', $depth).match (true) { + $object instanceof Tree => $name.'/', + $object instanceof Submodule => $name.' @ '.$object->getHash(), + default => $name, + }.PHP_EOL; + + if ($object instanceof Tree) { + displayTree($object, $depth + 1); + } + } +} + +displayTree($commit->getTree()); +``` + +Submodules +---------- + +A submodule is a commit of another repository. Its `Submodule` object only +holds the hash of this commit: gitlib doesn't read the submodule. Open it as +a repository, from its folder in the working tree, to read it. + +```php +foreach ($tree->getSubmodules() as $name => $entry) { + echo $name.' is at '.$entry->getObject()->getHash().PHP_EOL; +} +``` + +Resolve a path +-------------- + +To access a file or a folder from its path, use `resolvePath()`. It returns +a `Blob`, a `Tree` or a `Submodule`: + +```php +$folder = $tree->resolvePath('src/Gitonomy/Git'); // A Tree +$file = $tree->resolvePath('src/Gitonomy/Git/Repository.php'); // A Blob +``` + +- The path is relative to the tree. +- `""` and `"/"` resolve to the tree itself. Leading, trailing and + repeated slashes are ignored. +- A path that doesn't exist throws an `InvalidArgumentException`, as a + path going through a file or a submodule (`README.md/foo`). diff --git a/doc/workingcopy.md b/doc/workingcopy.md new file mode 100644 index 00000000..8e84c3df --- /dev/null +++ b/doc/workingcopy.md @@ -0,0 +1,170 @@ +Working copy +============ + +Working copy is the folder associated to a git repository. In *gitlib*, +you can access this object using the *getWorkingCopy* on a *Repository* +object: + +```php +use Gitonomy\Git\Repository; + +$repo = new Repository('/path/to/working-dir'); +$wc = $repo->getWorkingCopy(); +``` + +A bare repository has no working copy: `getWorkingCopy()` throws a +`LogicException`. + +Checkout a revision +------------------- + +You can checkout any revision using *checkout* method. Like `git checkout`, +it attaches `HEAD` to a local branch, or to `@{-1}`, the branch checked out +before (`@{-2}` the one before...), leaves it as it is for `HEAD`, and +detaches it on anything else: a tag, a commit, a remote branch... + +```php +// git checkout master +$wc->checkout('master'); + +// git checkout v1.0 (HEAD is detached) +$wc->checkout('v1.0'); + +// git checkout @{-1}: the branch checked out before +$wc->checkout('@{-1}'); +``` + +You can also pass a *Revision*: a *Commit*, a *Reference*... A local *Branch* +object attaches `HEAD` to the branch. + +```php +$wc->checkout($repo->getReferences()->getBranch('master')); +``` + +A name that no revision has throws a `ReferenceNotFoundException`, with both +drivers, unlike `git checkout`: a path, like `README.md`, whose changes git +would discard, or the name of a remote branch alone, like `feature` for +`origin/feature`, whose local branch git would create. Create it with +*checkoutNewBranch*. + +To create a branch and check it out, use *checkoutNewBranch*. Its second +argument is the revision to start the branch from, `HEAD` by default: + +```php +// git checkout -b feature +$wc->checkoutNewBranch('feature'); + +// git checkout -b develop origin/develop +$wc->checkoutNewBranch('develop', 'origin/develop'); +``` + +Like with `git checkout -b`, a branch created from a remote branch, like +`origin/develop`, tracks it: `git pull` and `git push` know its upstream +branch. One created from a local branch, a tag or a commit tracks none. +gitlib does so whatever the configuration (`branch.autoSetupMerge`), with +both drivers. Like git, *checkoutNewBranch* refuses a start point several +references have the name of, like `origin/develop` when a local branch has +this name too, with a `DriverException`: use its full name, +`refs/remotes/origin/develop`. + +Like `git checkout`, both methods keep the local changes, and refuse to +overwrite the ones the checkout would change: they throw a +`DriverException`. *checkoutNewBranch* then creates no branch. + +Merge a revision +---------------- + +You can merge any revision into the current branch using the *merge* method. +A second argument, a `FastForward`, tells whether the merge fast-forwards +the branch when its history allows it: + +```php +use Gitonomy\Git\FastForward; + +// git merge origin/develop +$wc->merge('origin/develop'); + +// git merge --no-ff origin/develop +$wc->merge('origin/develop', FastForward::Never); + +// git merge --ff-only origin/develop +$wc->merge('origin/develop', FastForward::Only); +``` + +`FastForward::Allow`, the default, fast-forwards when possible and creates a +merge commit otherwise. `FastForward::Never` always creates a merge commit. +`FastForward::Only` throws instead of creating one. + +You can also pass a *Revision*. Like with *checkout*, a name that no +revision has throws a `ReferenceNotFoundException`. A `DriverException` is +thrown if the merge fails: a conflict, staged changes, a revision whose +history is unrelated to the branch... + +With `CliDriver`, a conflict leaves the working copy merging, with conflict +markers in the files, like `git merge`. To cancel the merge: + +```php +$repo->run('merge', ['--abort']); +``` + +> **note** +> +> With `LibGit2Driver` (see [Choosing a driver](repository.md#choosing-a-driver)), +> `merge()` only supports fast-forward and conflict-free merges: a conflict +> throws without changing anything, instead of leaving conflict markers in +> the working copy. + +Modifications since a revision +------------------------------ + +You can get the modifications of the working copy, staged or not, since a +revision, `HEAD` by default: + +```php +// git diff HEAD +$diff = $wc->getDiff(); + +// git diff master +$diff = $wc->getDiff('master'); +``` + +Staged modifications +-------------------- + +You can get a diff of modifications pending in staging area. To get the +`Diff` object, call method `getDiffStaged()`: + +```php +$diff = $wc->getDiffStaged(); +``` + +Pending modifications +--------------------- + +You can get pending modifications on tracked files, not staged yet, by +calling method `getDiffPending()`: + +```php +$diff = $wc->getDiffPending(); +``` + +Like with git 2.19 and later, a file added with `git add -N` (intent to add) +is no staged change: `getDiffPending()` lists it as created. With +`CliDriver`, older versions of git list it as an empty staged file. + +During a merge conflict, `getDiffStaged()` and `getDiffPending()` leave the +unmerged files out, and `getDiff()` compares them, conflict markers included, +to the revision, like `git diff HEAD`. + +See [Diff](diff.md) to read these diffs. + +Untracked files +--------------- + +To list the files git doesn't track, ignored files excluded, call +`getUntrackedFiles()`. It returns their paths, relative to the root of the +working copy: + +```php +$files = $wc->getUntrackedFiles(); // ['new-file.txt', 'new-directory/other-file.txt'] +``` diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..781747b6 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,23 @@ +includes: + - tools/phpstan/vendor/phpstan/phpstan-phpunit/extension.neon + - tools/phpstan/vendor/phpstan/phpstan-phpunit/rules.neon + +parameters: + level: 9 + paths: + - src + - tests/Gitonomy + - tests/bootstrap.php + tmpDir: 'var/phpstan/tmp' + inferPrivatePropertyTypeFromConstructor: true + ignoreErrors: + # PHP FFI is dynamic by design: `$ffi->some_c_function()` and + # `$cdata->some_field` are resolved at runtime against the cdef loaded + # into that FFI instance, which PHPStan can't see. The tests run with + # GITLIB_DRIVER=ffi check them instead. + - + identifier: method.notFound + path: src/Gitonomy/Git/Driver/Ffi/* + - + identifier: property.notFound + path: src/Gitonomy/Git/Driver/Ffi/* diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 36964385..be4cabfb 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,21 +1,27 @@ - - - + - tests/Gitonomy/Git/Tests + ./tests/Gitonomy/Git/Tests - + + + + ./src/Gitonomy/Git + + diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php deleted file mode 100644 index 7b64cd77..00000000 --- a/src/Gitonomy/Git/Admin.php +++ /dev/null @@ -1,164 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git; - -use Gitonomy\Git\Exception\RuntimeException; -use Symfony\Component\Process\Process; - -/** - * Administration class for Git repositories. - * - * @author Alexandre Salomé - */ -class Admin -{ - /** - * Initializes a repository and returns the instance. - * - * @param string $path path to the repository - * @param bool $bare indicate to create a bare repository - * @param array $options options for Repository creation - * - * @throws RuntimeException Directory exists or not writable (only if debug=true) - * - * @return Repository - */ - public static function init($path, $bare = true, array $options = []) - { - $process = static::getProcess('init', array_merge(['-q'], $bare ? ['--bare'] : [], [$path]), $options); - - $process->run(); - - if (!$process->isSuccessFul()) { - throw new RuntimeException(sprintf("Error on repository initialization, command wasn't successful (%s). Error output:\n%s", $process->getCommandLine(), $process->getErrorOutput())); - } - - return new Repository($path, $options); - } - - /** - * Checks the validity of a git repository url without cloning it. - * - * This will use the `ls-remote` command of git against the given url. - * Usually, this command returns 0 when successful, and 128 when the - * repository is not found. - * - * @param string $url url of repository to check - * @param array $options options for Repository creation - * - * @return bool true if url is valid - */ - public static function isValidRepository($url, array $options = []) - { - $process = static::getProcess('ls-remote', [$url], $options); - - $process->run(); - - return $process->isSuccessFul(); - } - - /** - * Clone a repository to a local path. - * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param bool $bare indicates if repository should be bare or have a working copy - * @param array $options options for Repository creation - * - * @return Repository - */ - public static function cloneTo($path, $url, $bare = true, array $options = []) - { - $args = $bare ? ['--bare'] : []; - - return static::cloneRepository($path, $url, $args, $options); - } - - /** - * Clone a repository branch to a local path. - * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param string $branch branch to clone - * @param bool $bare indicates if repository should be bare or have a working copy - * @param array $options options for Repository creation - * - * @return Repository - */ - public static function cloneBranchTo($path, $url, $branch, $bare = true, $options = []) - { - $args = ['--branch', $branch]; - if ($bare) { - $args[] = '--bare'; - } - - return static::cloneRepository($path, $url, $args, $options); - } - - /** - * Mirrors a repository (fetch all revisions, not only branches). - * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param array $options options for Repository creation - * - * @return Repository - */ - public static function mirrorTo($path, $url, array $options = []) - { - return static::cloneRepository($path, $url, ['--mirror'], $options); - } - - /** - * Internal method to launch effective ``git clone`` command. - * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param array $args arguments to be added to the command-line - * @param array $options options for Repository creation - * - * @return Repository - */ - public static function cloneRepository($path, $url, array $args = [], array $options = []) - { - $process = static::getProcess('clone', array_merge(['-q'], $args, [$url, $path]), $options); - - $process->run(); - - if (!$process->isSuccessFul()) { - throw new RuntimeException(sprintf('Error while initializing repository: %s', $process->getErrorOutput())); - } - - return new Repository($path, $options); - } - - /** - * This internal method is used to create a process object. - */ - private static function getProcess($command, array $args = [], array $options = []) - { - $is_windows = defined('PHP_WINDOWS_VERSION_BUILD'); - $options = array_merge([ - 'environment_variables' => $is_windows ? ['PATH' => getenv('PATH')] : [], - 'command' => 'git', - 'process_timeout' => 3600, - ], $options); - - $process = new Process(array_merge([$options['command'], $command], $args)); - $process->setEnv($options['environment_variables']); - $process->setTimeout($options['process_timeout']); - $process->setIdleTimeout($options['process_timeout']); - - return $process; - } -} diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index 17d8bfe7..b935ecfd 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -1,6 +1,6 @@ @@ -12,135 +12,101 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Blame\Hunk; use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Parser\BlameParser; /** * @author Alexandre Salomé */ -class Blame implements \Countable +final class Blame implements \Countable { /** - * @var Repository + * @var array|null By number */ - protected $repository; + private ?array $lines = null; - /** - * @var Revision - */ - protected $revision; + private readonly ?int $firstLine; - /** - * @var string - */ - protected $file; - - /** - * @var string|null - */ - protected $lineRange; + private readonly ?int $lastLine; /** - * @var array|null + * Like "git blame -L 5,3", a last line before the first one blames the lines 3 to 5. + * + * @param string $file the file the blame is about + * @param int|null $firstLine The first line to blame, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null + * + * @throws InvalidArgumentException An invalid range of lines */ - protected $lines; + public function __construct( + private readonly Repository $repository, + private readonly Revision $revision, + private readonly string $file, + ?int $firstLine = null, + ?int $lastLine = null, + ) { + if ((null !== $firstLine && $firstLine < 1) || (null !== $lastLine && $lastLine < 1)) { + throw new InvalidArgumentException(\sprintf('Invalid range of lines "%s,%s": the lines are numbered from 1.', $firstLine, $lastLine)); + } - /** - * @param string $lineRange Argument to pass to git blame (-L). - * Can be a line range (40,60 or 40,+21) - * or a regexp ('/^function$/') - */ - public function __construct(Repository $repository, Revision $revision, $file, $lineRange = null) - { - $this->repository = $repository; - $this->revision = $revision; - $this->lineRange = $lineRange; - $this->file = $file; + [$this->firstLine, $this->lastLine] = null !== $firstLine && null !== $lastLine && $lastLine < $firstLine ? [$lastLine, $firstLine] : [$firstLine, $lastLine]; } /** - * @return Line + * @param int $number The number of the line in the file + * + * @throws InvalidArgumentException The blame has no line $number */ - public function getLine($number) + public function getLine(int $number): Line { - if ($number < 1) { - throw new InvalidArgumentException('Line number should be at least 1'); - } - - $lines = $this->getLines(); - - if (!isset($lines[$number])) { - throw new InvalidArgumentException('Line does not exist'); - } - - return $lines[$number]; + return $this->getLines()[$number] ?? throw new InvalidArgumentException(\sprintf('The blame of "%s" has no line %d.', $this->file, $number)); } /** - * Returns lines grouped by commit. - * - * @return array a list of two-elements array (commit, lines) + * @return list The consecutive lines last changed by the same commit */ - public function getGroupedLines() + public function getHunks(): array { - $result = []; + $hunks = []; $commit = null; - $current = []; - - foreach ($this->getLines() as $lineNumber => $line) { - if ($commit !== $line->getCommit()) { - if (count($current)) { - $result[] = [$commit, $current]; - } - $commit = $line->getCommit(); - $current = []; + $lines = []; + foreach ($this->getLines() as $number => $line) { + if ($commit !== $line->getCommit() && [] !== $lines) { + $hunks[] = new Hunk($commit, $lines); + $lines = []; } - - $current[$lineNumber] = $line; + $commit = $line->getCommit(); + $lines[$number] = $line; } - if (count($current)) { - $result[] = [$commit, $current]; + if (null !== $commit) { + $hunks[] = new Hunk($commit, $lines); } - return $result; + return $hunks; } /** - * Returns all lines of the blame. - * - * @return array + * @return array The lines of the blame, by number */ - public function getLines() + public function getLines(): array { if (null !== $this->lines) { return $this->lines; } - $args = ['-p']; - - if (null !== $this->lineRange) { - $args[] = '-L'; - $args[] = $this->lineRange; + $hash = $this->revision->getCommit()->getHash(); + $this->lines = []; + foreach ($this->repository->getRepositoryDriver()->getBlameLines($hash, $this->file, $this->firstLine, $this->lastLine) as $data) { + $this->lines[$data->number] = new Line($this->repository->getCommit($data->commitHash), $data->number, $data->originalNumber, $data->content); } - $args[] = $this->revision->getRevision(); - $args[] = '--'; - $args[] = $this->file; - - $parser = new BlameParser($this->repository); - $parser->parse($this->repository->run('blame', $args)); - $this->lines = $parser->lines; - return $this->lines; } - /** - * @return int - */ - public function count() + public function count(): int { - return count($this->getLines()); + return \count($this->getLines()); } } diff --git a/src/Gitonomy/Git/Blame/Hunk.php b/src/Gitonomy/Git/Blame/Hunk.php new file mode 100644 index 00000000..457e8952 --- /dev/null +++ b/src/Gitonomy/Git/Blame/Hunk.php @@ -0,0 +1,45 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Blame; + +use Gitonomy\Git\Commit; + +/** + * Consecutive lines of a blame, last changed by the same commit. + * + * @author Grégoire Pineau + */ +final readonly class Hunk +{ + /** + * @param array $lines By number + */ + public function __construct( + private Commit $commit, + private array $lines, + ) { + } + + public function getCommit(): Commit + { + return $this->commit; + } + + /** + * @return array By number + */ + public function getLines(): array + { + return $this->lines; + } +} diff --git a/src/Gitonomy/Git/Blame/Line.php b/src/Gitonomy/Git/Blame/Line.php index 83a7a912..87156771 100644 --- a/src/Gitonomy/Git/Blame/Line.php +++ b/src/Gitonomy/Git/Blame/Line.php @@ -1,6 +1,6 @@ @@ -17,40 +17,45 @@ /** * @author Alexandre Salomé */ -class Line +final readonly class Line { /** - * @var Commit + * @param int $number The number of the line in the blamed file + * @param int $originalNumber The number of the line in the file of $commit */ - protected $commit; - protected $sourceLine; - protected $targetLine; - protected $blockLine; - protected $content; + public function __construct( + private Commit $commit, + private int $number, + private int $originalNumber, + private string $content, + ) { + } - /** - * Instanciates a new Line object. - */ - public function __construct(Commit $commit, $sourceLine, $targetLine, $blockLine, $content) + public function getContent(): string { - $this->commit = $commit; - $this->sourceLine = $sourceLine; - $this->targetLine = $targetLine; - $this->blockLine = $blockLine; - $this->content = $content; + return $this->content; } - public function getContent() + /** + * @return int The number of the line in the blamed file + */ + public function getNumber(): int { - return $this->content; + return $this->number; } - public function getLine() + /** + * @return int The number of the line in the file of the commit that last changed it + */ + public function getOriginalNumber(): int { - return $this->sourceLine; + return $this->originalNumber; } - public function getCommit() + /** + * @return Commit The commit that last changed the line + */ + public function getCommit(): Commit { return $this->commit; } diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index 10150935..4d4d4f8e 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -1,6 +1,6 @@ @@ -12,86 +12,77 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\RuntimeException; + /** - * Representation of a Blob commit. + * Representation of a blob, the content of a file. * * @author Alexandre Salomé */ -class Blob +final class Blob { /** - * @var Repository + * Size that git uses to look for NULL byte: https://git.kernel.org/pub/scm/git/git.git/tree/xdiff-interface.c?h=v2.44.0#n193. */ - protected $repository; + private const int FIRST_FEW_BYTES = 8000; - /** - * @var string - */ - protected $hash; + private ?string $content = null; - /** - * @var string - */ - protected $content; + private ?string $mimetype = null; - /** - * @var string - */ - protected $mimetype; + private ?bool $text = null; /** * @param Repository $repository Repository where the blob is located * @param string $hash Hash of the blob + * + * @internal Get it from Repository::getBlob(), which returns the same object while the application uses it */ - public function __construct(Repository $repository, $hash) - { - $this->repository = $repository; - $this->hash = $hash; + public function __construct( + private readonly Repository $repository, + private readonly string $hash, + ) { } - /** - * @return string - */ - public function getHash() + public function getHash(): string { return $this->hash; } /** - * Returns content of the blob. + * @return string content of the blob * - * @throws ProcessException Error occurred while getting content of blob + * @throws ReferenceNotFoundException The blob doesn't exist, or the object of its hash isn't a blob + * @throws DriverException git fails to read it */ - public function getContent() + public function getContent(): string { if (null === $this->content) { - $this->content = $this->repository->run('cat-file', ['-p', $this->hash]); + $this->content = $this->repository->getRepositoryDriver()->getBlobContent($this->hash); } return $this->content; } /** - * Determine the mimetype of the blob. + * @return string The mimetype of the content, like "text/plain" * - * @return string A mimetype + * @throws RuntimeException fileinfo can't read the content */ - public function getMimetype() + public function getMimetype(): string { - if (null === $this->mimetype) { - $finfo = new \finfo(FILEINFO_MIME); - $this->mimetype = $finfo->buffer($this->getContent()); - } - - return $this->mimetype; + return $this->mimetype ??= new \finfo(\FILEINFO_MIME_TYPE)->buffer($this->getContent()) ?: throw new RuntimeException(\sprintf('Unable to read the mimetype of the blob "%s".', $this->hash)); } /** * Determines if file is binary. * - * @return bool + * Uses the same check that git uses to determine if a file is binary or not + * https://git.kernel.org/pub/scm/git/git.git/tree/xdiff-interface.c?h=v2.44.0#n193 */ - public function isBinary() + public function isBinary(): bool { return !$this->isText(); } @@ -99,10 +90,15 @@ public function isBinary() /** * Determines if file is text. * - * @return bool + * Uses the same check that git uses to determine if a file is binary or not + * https://git.kernel.org/pub/scm/git/git.git/tree/xdiff-interface.c?h=v2.44.0#n193 */ - public function isText() + public function isText(): bool { - return (bool) preg_match('#^text/|^application/xml#', $this->getMimetype()); + if (null === $this->text) { + $this->text = !str_contains(substr($this->getContent(), 0, self::FIRST_FEW_BYTES), \chr(0)); + } + + return $this->text; } } diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 10141bec..54c05fb7 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -1,6 +1,6 @@ @@ -13,109 +13,96 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; -use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Driver\Data\CommitData; use Gitonomy\Git\Exception\ReferenceNotFoundException; -use Gitonomy\Git\Util\StringHelper; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Util\Message; /** * Representation of a Git commit. * * @author Alexandre Salomé */ -class Commit extends Revision +final class Commit extends Revision { /** - * Associative array of commit data. - * - * @var array + * Raw commit data, as returned by the Driver. Lazily loaded. */ - private $data = []; + private ?CommitData $data; + + // Values derived from the fields above, computed and cached independently. + private ?string $shortHash = null; + private ?Tree $tree = null; /** - * Constructor. + * @param Repository $repository Repository of the commit + * @param string $hash Hash of the commit * - * @param Gitonomy\Git\Repository $repository Repository of the commit - * @param string $hash Hash of the commit + * @internal Get it from Repository::getCommit(), which returns the same object while the application uses it */ - public function __construct(Repository $repository, $hash, array $data = []) + public function __construct(Repository $repository, string $hash, ?CommitData $data = null) { if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { - throw new ReferenceNotFoundException($hash); + throw new ReferenceNotFoundException(\sprintf('"%s" is not the hash of a commit: get a commit by its name with getRevision(...)->getCommit().', $hash)); } parent::__construct($repository, $hash); - $this->setData($data); + $this->data = $data; } - public function setData(array $data) + /** + * @internal + */ + public function setData(CommitData $data): void { - foreach ($data as $name => $value) { - $this->data[$name] = $value; - } + $this->data = $data; } /** - * @return Diff + * Returns the changes of the commit: against its first parent, like GitHub + * shows a merge commit, or against nothing for a root commit. */ - public function getDiff() + public function getDiff(): Diff { - $args = ['-r', '-p', '-m', '-M', '--no-commit-id', '--full-index', $this->revision]; - - $diff = Diff::parse($this->repository->run('diff-tree', $args)); - $diff->setRepository($this->repository); - - return $diff; + return Diff::fromData($this->repository->getRepositoryDriver()->getDiff($this->getParentHashes()[0] ?? null, $this->revision), $this->repository); } /** * Returns the commit hash. - * - * @return string A SHA1 hash */ - public function getHash() + public function getHash(): string { return $this->revision; } /** * Returns the short commit hash. - * - * @return string A SHA1 hash */ - public function getShortHash() + public function getShortHash(): string { - return $this->getData('shortHash'); - } - - /** - * Returns a fixed-with short hash. - */ - public function getFixedShortHash($length = 6) - { - return StringHelper::substr($this->revision, 0, $length); + return $this->shortHash ??= $this->repository->getRepositoryDriver()->getShortHash($this->revision); } /** * Returns parent hashes. * - * @return array An array of SHA1 hashes + * @return string[] An array of SHA1 hashes */ - public function getParentHashes() + public function getParentHashes(): array { - return $this->getData('parentHashes'); + return $this->data()->parentHashes; } /** * Returns the parent commits. * - * @return array An array of Commit objects + * @return Commit[] An array of Commit objects */ - public function getParents() + public function getParents(): array { $result = []; - foreach ($this->getData('parentHashes') as $parentHash) { + foreach ($this->data()->parentHashes as $parentHash) { $result[] = $this->repository->getCommit($parentHash); } @@ -124,119 +111,68 @@ public function getParents() /** * Returns the tree hash. - * - * @return string A SHA1 hash */ - public function getTreeHash() + public function getTreeHash(): string { - return $this->getData('treeHash'); + return $this->data()->treeHash; } - public function getTree() + public function getTree(): Tree { - return $this->getData('tree'); + return $this->tree ??= $this->repository->getTree($this->getTreeHash()); } /** - * @return Commit + * @throws ReferenceNotFoundException No commit up to this one changes $path */ - public function getLastModification($path = null) + public function getLastModification(?string $path = null): self { - if (0 === strpos($path, '/')) { - $path = StringHelper::substr($path, 1); - } - - if ($getWorkingDir = $this->repository->getWorkingDir()) { - $path = $getWorkingDir.'/'.$path; + if (null !== $path && str_starts_with($path, '/')) { + $path = substr($path, 1); } - $result = $this->repository->run('log', ['--format=%H', '-n', 1, $this->revision, '--', $path]); - - return $this->repository->getCommit(trim($result)); - } - - /** - * Returns the first line of the commit, and the first 50 characters. - * - * Ported from https://github.com/fabpot/Twig-extensions/blob/d67bc7e69788795d7905b52d31188bbc1d390e01/lib/Twig/Extensions/Extension/Text.php#L52-L109 - * - * @param int $length - * @param bool $preserve - * @param string $separator - * - * @return string - */ - public function getShortMessage($length = 50, $preserve = false, $separator = '...') - { - $message = $this->getData('subjectMessage'); - - if (StringHelper::strlen($message) > $length) { - if ($preserve && false !== ($breakpoint = StringHelper::strpos($message, ' ', $length))) { - $length = $breakpoint; - } - - return rtrim(StringHelper::substr($message, 0, $length)).$separator; + try { + return $this->getLog($path, null, 1)->getSingleCommit(); + } catch (ReferenceNotFoundException $e) { + throw new ReferenceNotFoundException(\sprintf('No commit up to "%s" changes "%s".', $this->revision, $path), 0, $e); } - - return $message; } /** * Resolves all references associated to this commit. * - * @return array An array of references (Branch, Tag, Squash) + * @return Reference[] An array of references (Branch, Tag, Squash) */ - public function resolveReferences() + public function resolveReferences(): array { return $this->repository->getReferences()->resolve($this); } /** - * Find branch containing the commit. - * - * @param bool $local set true to try to locate a commit on local repository - * @param bool $remote set true to try to locate a commit on remote repository + * Returns the local and remote branches whose history contains the commit. * - * @return array An array of Reference\Branch + * @return Branch[] */ - public function getIncludingBranches($local = true, $remote = true) + public function getIncludingBranches(): array { - $arguments = ['--contains', $this->revision]; - - if ($local && $remote) { - $arguments[] = '-a'; - } elseif (!$local && $remote) { - $arguments[] = '-r'; - } elseif (!$local && !$remote) { - throw new InvalidArgumentException('You should a least set one argument to true'); - } - - try { - $result = $this->repository->run('branch', $arguments); - } catch (ProcessException $e) { - return []; - } - - if (!$result) { - return []; - } - - $branchesName = explode("\n", trim(str_replace('*', '', $result))); - $branchesName = array_filter($branchesName, function ($v) { - return false === StringHelper::strpos($v, '->'); - }); - $branchesName = array_map('trim', $branchesName); - + $fullnames = $this->repository->getRepositoryDriver()->getIncludingBranches($this->revision); $references = $this->repository->getReferences(); + // A branch another process created since gitlib read the references. + if ([] !== array_filter($fullnames, static fn (string $fullname): bool => !$references->has($fullname))) { + $references->invalidate(); + } $branches = []; - foreach ($branchesName as $branchName) { - if (false === $local) { - $branches[] = $references->getRemoteBranch($branchName); - } elseif (0 === StringHelper::strrpos($branchName, 'remotes/')) { - $branches[] = $references->getRemoteBranch(str_replace('remotes/', '', $branchName)); - } else { - $branches[] = $references->getBranch($branchName); + foreach ($fullnames as $fullname) { + // Unknown still: the default branch of a remote, which the references leave out, even + // when a mirror leaves it as a regular reference, or a branch deleted meanwhile. + if (!$references->has($fullname)) { + continue; + } + + $branch = $references->get($fullname); + if ($branch instanceof Branch) { + $branches[] = $branch; } } @@ -245,164 +181,85 @@ public function getIncludingBranches($local = true, $remote = true) /** * Returns the author name. - * - * @return string A name */ - public function getAuthorName() + public function getAuthorName(): string { - return $this->getData('authorName'); + return $this->data()->authorName; } /** * Returns the author email. - * - * @return string An email */ - public function getAuthorEmail() + public function getAuthorEmail(): string { - return $this->getData('authorEmail'); + return $this->data()->authorEmail; } /** - * Returns the authoring date. - * - * @return \DateTime A time object + * Returns the authoring date, at the UTC offset of the author, like git + * log shows it. */ - public function getAuthorDate() + public function getAuthorDate(): \DateTimeImmutable { - return $this->getData('authorDate'); + return $this->data()->authorDate; } /** * Returns the committer name. - * - * @return string A name */ - public function getCommitterName() + public function getCommitterName(): string { - return $this->getData('committerName'); + return $this->data()->committerName; } /** - * Returns the comitter email. - * - * @return string An email + * Returns the committer email. */ - public function getCommitterEmail() + public function getCommitterEmail(): string { - return $this->getData('committerEmail'); + return $this->data()->committerEmail; } /** - * Returns the authoring date. - * - * @return \DateTime A time object + * Returns the committing date, at the UTC offset of the committer, like + * git log shows it. */ - public function getCommitterDate() + public function getCommitterDate(): \DateTimeImmutable { - return $this->getData('committerDate'); + return $this->data()->committerDate; } /** * Returns the message of the commit. - * - * @return string A commit message */ - public function getMessage() + public function getMessage(): string { - return $this->getData('message'); + return $this->data()->message; } /** - * Returns the subject message (the first line). - * - * @return string The subject message + * @return string The first paragraph of the message, on one line, like "git log --format=%s" */ - public function getSubjectMessage() + public function getSubjectMessage(): string { - return $this->getData('subjectMessage'); + return Message::subject($this->getMessage()); } /** - * Return the body message. - * - * @return string The body message + * @return string What follows the subject and its empty lines, like "git log --format=%b" */ - public function getBodyMessage() + public function getBodyMessage(): string { - return $this->getData('bodyMessage'); + return Message::body($this->getMessage()); } - /** - * {@inheritdoc} - */ - public function getCommit() + public function getCommit(): self { return $this; } - private function getData($name) + private function data(): CommitData { - if (isset($this->data[$name])) { - return $this->data[$name]; - } - - if ($name === 'shortHash') { - $this->data['shortHash'] = trim($this->repository->run('log', ['--abbrev-commit', '--format=%h', '-n', 1, $this->revision])); - - return $this->data['shortHash']; - } - - if ($name === 'tree') { - $this->data['tree'] = $this->repository->getTree($this->getData('treeHash')); - - return $this->data['tree']; - } - - if ($name === 'subjectMessage') { - $lines = explode("\n", $this->getData('message')); - $this->data['subjectMessage'] = reset($lines); - - return $this->data['subjectMessage']; - } - - if ($name === 'bodyMessage') { - $message = $this->getData('message'); - - $lines = explode("\n", $message); - - array_shift($lines); - array_shift($lines); - - $data['bodyMessage'] = implode("\n", $lines); - - return $data['bodyMessage']; - } - - $parser = new Parser\CommitParser(); - - try { - $result = $this->repository->run('cat-file', ['commit', $this->revision]); - } catch (ProcessException $e) { - throw new ReferenceNotFoundException(sprintf('Can not find reference "%s"', $this->revision)); - } - - $parser->parse($result); - - $this->data['treeHash'] = $parser->tree; - $this->data['parentHashes'] = $parser->parents; - $this->data['authorName'] = $parser->authorName; - $this->data['authorEmail'] = $parser->authorEmail; - $this->data['authorDate'] = $parser->authorDate; - $this->data['committerName'] = $parser->committerName; - $this->data['committerEmail'] = $parser->committerEmail; - $this->data['committerDate'] = $parser->committerDate; - $this->data['message'] = $parser->message; - - if (!isset($this->data[$name])) { - throw new \InvalidArgumentException(sprintf('No data named "%s" in Commit.', $name)); - } - - return $this->data[$name]; + return $this->data ??= $this->repository->getRepositoryDriver()->getCommitData($this->revision); } } diff --git a/src/Gitonomy/Git/Diff/Diff.php b/src/Gitonomy/Git/Diff/Diff.php index faafbcba..ee3ff2e9 100644 --- a/src/Gitonomy/Git/Diff/Diff.php +++ b/src/Gitonomy/Git/Diff/Diff.php @@ -1,6 +1,6 @@ @@ -12,6 +12,10 @@ namespace Gitonomy\Git\Diff; +use Gitonomy\Git\Driver\Data\DiffData; +use Gitonomy\Git\Driver\Data\DiffFileData; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Parser\DiffParser; use Gitonomy\Git\Repository; @@ -19,73 +23,58 @@ * Representation of a diff. * * @author Alexandre Salomé + * + * @phpstan-import-type FileArray from File */ -class Diff +final readonly class Diff { /** - * @var array - */ - protected $files; - - /** - * @var string - */ - protected $rawDiff; - - /** - * Constructs a new diff for a given revision. - * - * @param array $files The files - * @param string $rawDiff The raw diff + * @param File[] $files */ - public function __construct(array $files, $rawDiff) - { - $this->files = $files; - $this->rawDiff = $rawDiff; + public function __construct( + private array $files, + private string $rawDiff, + ) { } /** - * @return Diff + * Parses the output of ``git diff --raw --patch``, ``-z`` or not. + * + * @param Repository|null $repository The repository to read the blobs of the files from + * + * @throws InvalidArgumentException The diff has no raw information + * @throws RuntimeException The diff holds what the parser doesn't know, like the log of a submodule */ - public static function parse($rawDiff) + public static function parse(string $rawDiff, ?Repository $repository = null): self { $parser = new DiffParser(); $parser->parse($rawDiff); - return new self($parser->files, $rawDiff); - } - - public function setRepository(Repository $repository) - { - foreach ($this->files as $file) { - $file->setRepository($repository); - } + return new self(array_map(static fn (DiffFileData $file): File => File::fromData($file, $repository), $parser->files), $rawDiff); } /** - * @return array + * @internal */ - public function getRevisions() + public static function fromData(DiffData $data, Repository $repository): self { - return $this->revisions; + return new self(array_map(static fn (DiffFileData $file): File => File::fromData($file, $repository), $data->files), $data->rawDiff); } /** * Get list of files modified in the diff's revision. * - * @return array An array of Diff\File objects + * @return File[] An array of Diff\File objects */ - public function getFiles() + public function getFiles(): array { return $this->files; } /** * Returns the raw diff. - * - * @return string The raw diff */ - public function getRawDiff() + public function getRawDiff(): string { return $this->rawDiff; } @@ -93,38 +82,23 @@ public function getRawDiff() /** * Export a diff as array. * - * @return array The array + * @return array{rawDiff: string, files: list} */ - public function toArray() + public function toArray(): array { return [ 'rawDiff' => $this->rawDiff, - 'files' => array_map( - function (File $file) { - return $file->toArray(); - }, - $this->files - ), + 'files' => array_map(static fn (File $file): array => $file->toArray(), array_values($this->files)), ]; } /** * Create a new instance of Diff from an array. * - * @param array $array The array - * - * @return Diff The new instance + * @param array{rawDiff: string, files: list} $array */ - public static function fromArray(array $array) + public static function fromArray(array $array, ?Repository $repository = null): self { - return new static( - array_map( - function ($array) { - return File::fromArray($array); - }, - $array['files'] - ), - $array['rawDiff'] - ); + return new self(array_map(static fn (array $file): File => File::fromArray($file, $repository), $array['files']), $array['rawDiff']); } } diff --git a/src/Gitonomy/Git/Diff/DiffLine.php b/src/Gitonomy/Git/Diff/DiffLine.php new file mode 100644 index 00000000..ddb1a59a --- /dev/null +++ b/src/Gitonomy/Git/Diff/DiffLine.php @@ -0,0 +1,40 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Diff; + +/** + * A line of a hunk. + * + * @author Grégoire Pineau + */ +final readonly class DiffLine +{ + public function __construct( + private DiffLineType $type, + private string $content, + ) { + } + + public function getType(): DiffLineType + { + return $this->type; + } + + /** + * @return string The line, without its "+", "-" or " " prefix, nor its line break + */ + public function getContent(): string + { + return $this->content; + } +} diff --git a/src/Gitonomy/Git/Diff/DiffLineType.php b/src/Gitonomy/Git/Diff/DiffLineType.php new file mode 100644 index 00000000..0ee2e6bf --- /dev/null +++ b/src/Gitonomy/Git/Diff/DiffLineType.php @@ -0,0 +1,26 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Diff; + +/** + * The kind of a line of a hunk, its values the types of the lines of + * FileChange::toArray(). + * + * @author Grégoire Pineau + */ +enum DiffLineType: int +{ + case Addition = 1; + case Context = 0; + case Deletion = -1; +} diff --git a/src/Gitonomy/Git/Diff/File.php b/src/Gitonomy/Git/Diff/File.php index f7099769..ebe7e622 100644 --- a/src/Gitonomy/Git/Diff/File.php +++ b/src/Gitonomy/Git/Diff/File.php @@ -1,6 +1,6 @@ @@ -12,95 +12,61 @@ namespace Gitonomy\Git\Diff; +use Gitonomy\Git\Blob; +use Gitonomy\Git\Driver\Data\DiffFileData; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; /** * @author Alexandre Salomé + * + * @phpstan-import-type FileChangeArray from FileChange + * + * @phpstan-type FileArray array{old_name: string|null, new_name: string|null, old_mode: string|null, new_mode: string|null, old_index: string|null, new_index: string|null, is_binary: bool, is_copy: bool, changes: list} */ -class File +final readonly class File { - /** - * @var string - */ - protected $oldName; - - /** - * @var string - */ - protected $newName; - - /** - * @var string - */ - protected $oldMode; - - /** - * @var string - */ - protected $newMode; - - /** - * @var string - */ - protected $oldIndex; - - /** - * @var string - */ - protected $newIndex; + private string $name; /** - * @var bool - */ - protected $isBinary; - - /** - * @var array An array of FileChange objects - */ - protected $changes; - - /** - * @var Repository - */ - protected $repository; - - /** - * Instanciates a new File object. + * @param string|null $oldName Null for a creation + * @param string|null $newName Null for a deletion + * @param string|null $oldIndex The hash of the old blob, null when there is none (a creation...) + * @param string|null $newIndex The hash of the new blob, null when there is none (a deletion, a modified submodule...); the hash of a file of the working tree isn't in the repository + * @param FileChange[] $changes + * @param Repository|null $repository The repository to read the blobs from + * + * @throws InvalidArgumentException Both names are null */ - public function __construct($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary) - { - $this->oldName = $oldName; - $this->newName = $newName; - $this->oldMode = $oldMode; - $this->newMode = $newMode; - $this->oldIndex = $oldIndex; - $this->newIndex = $newIndex; - $this->isBinary = $isBinary; - - $this->changes = []; - } - - public function addChange(FileChange $change) - { - $this->changes[] = $change; + public function __construct( + private ?string $oldName, + private ?string $newName, + private ?string $oldMode, + private ?string $newMode, + private ?string $oldIndex, + private ?string $newIndex, + private bool $isBinary, + private bool $isCopy = false, + private array $changes = [], + private ?Repository $repository = null, + ) { + $this->name = $newName ?? $oldName ?? throw new InvalidArgumentException('A file of a diff has an old name, a new name, or both.'); } /** * Indicates if this diff file is a creation. - * - * @return bool */ - public function isCreation() + public function isCreation(): bool { return null === $this->oldName; } /** * Indicates if this diff file is a modification. - * - * @return bool */ - public function isModification() + public function isModification(): bool { return null !== $this->oldName && null !== $this->newName; } @@ -108,39 +74,35 @@ public function isModification() /** * Indicates if it's a rename. * - * A rename can only occurs if it's a modification (not a creation or a deletion). - * - * @return bool + * A rename can only occurs if it's a modification (not a creation or a deletion), and is not a copy. */ - public function isRename() + public function isRename(): bool { - return $this->isModification() && $this->oldName !== $this->newName; + return $this->isModification() && $this->oldName !== $this->newName && !$this->isCopy; } /** - * Indicates if the file mode has changed. + * Indicates if it's a copy. + * + * Unlike a rename, the source file of a copy still exists after the change. */ - public function isChangeMode() + public function isCopy(): bool { - return $this->isModification() && $this->oldMode !== $this->newMode; + return $this->isCopy; } /** - * Indicates if this diff file is a deletion. - * - * @return bool + * Indicates if the file mode has changed. */ - public function isDeletion() + public function isModeChange(): bool { - return null === $this->newName; + return $this->isModification() && $this->oldMode !== $this->newMode; } /** * Indicates if this diff file is a deletion. - * - * @return bool */ - public function isDelete() + public function isDeletion(): bool { return null === $this->newName; } @@ -148,143 +110,157 @@ public function isDelete() /** * @return int Number of added lines */ - public function getAdditions() + public function getAdditions(): int { - $result = 0; - foreach ($this->changes as $change) { - $result += $change->getCount(FileChange::LINE_ADD); - } - - return $result; + return array_sum(array_map(static fn (FileChange $change): int => $change->getAdditions(), $this->changes)); } /** * @return int Number of deleted lines */ - public function getDeletions() + public function getDeletions(): int { - $result = 0; - foreach ($this->changes as $change) { - $result += $change->getCount(FileChange::LINE_REMOVE); - } - - return $result; + return array_sum(array_map(static fn (FileChange $change): int => $change->getDeletions(), $this->changes)); } - public function getOldName() + public function getOldName(): ?string { return $this->oldName; } - public function getNewName() + public function getNewName(): ?string { return $this->newName; } - public function getName() + /** + * @return string The new name, or the old one for a deletion + */ + public function getName(): string { - if (null === $this->newName) { - return $this->oldName; - } - - return $this->newName; + return $this->name; } - public function getOldMode() + public function getOldMode(): ?string { return $this->oldMode; } - public function getNewMode() + public function getNewMode(): ?string { return $this->newMode; } - public function getOldIndex() + public function getOldIndex(): ?string { return $this->oldIndex; } - public function getNewIndex() + public function getNewIndex(): ?string { return $this->newIndex; } - public function isBinary() + public function isBinary(): bool { return $this->isBinary; } - public function getChanges() + /** + * @return FileChange[] + */ + public function getChanges(): array { return $this->changes; } - public function toArray() + /** + * @return FileArray + */ + public function toArray(): array { return [ - 'old_name' => $this->oldName, - 'new_name' => $this->newName, - 'old_mode' => $this->oldMode, - 'new_mode' => $this->newMode, + 'old_name' => $this->oldName, + 'new_name' => $this->newName, + 'old_mode' => $this->oldMode, + 'new_mode' => $this->newMode, 'old_index' => $this->oldIndex, 'new_index' => $this->newIndex, 'is_binary' => $this->isBinary, - 'changes' => array_map(function (FileChange $change) { - return $change->toArray(); - }, $this->changes), + 'is_copy' => $this->isCopy, + 'changes' => array_map(static fn (FileChange $change): array => $change->toArray(), array_values($this->changes)), ]; } - public static function fromArray(array $array) + /** + * @internal + */ + public static function fromData(DiffFileData $data, ?Repository $repository): self { - $file = new self($array['old_name'], $array['new_name'], $array['old_mode'], $array['new_mode'], $array['old_index'], $array['new_index'], $array['is_binary']); - - foreach ($array['changes'] as $change) { - $file->addChange(FileChange::fromArray($change)); - } - - return $file; + return new self($data->oldName, $data->newName, $data->oldMode, $data->newMode, $data->oldIndex, $data->newIndex, $data->isBinary, $data->isCopy, $data->changes, $repository); } - public function getAnchor() + /** + * @param array{old_name: string|null, new_name: string|null, old_mode: string|null, new_mode: string|null, old_index: string|null, new_index: string|null, is_binary: bool, is_copy?: bool, changes: list} $array Without "is_copy" from 1.x + */ + public static function fromArray(array $array, ?Repository $repository = null): self { - return substr($this->newIndex, 0, 12); + return new self( + $array['old_name'], + $array['new_name'], + $array['old_mode'], + $array['new_mode'], + // 1.x used an empty string for a missing hash. + '' === $array['old_index'] ? null : $array['old_index'], + '' === $array['new_index'] ? null : $array['new_index'], + $array['is_binary'], + $array['is_copy'] ?? false, + array_map(FileChange::fromArray(...), $array['changes']), + $repository, + ); } - public function getRepository() + public function getRepository(): ?Repository { return $this->repository; } - public function setRepository(Repository $repository) + /** + * @throws LogicException The file has no repository, or is a creation + * @throws RuntimeException The diff doesn't know the hash of the old blob + */ + public function getOldBlob(): Blob { - $this->repository = $repository; + if ($this->isCreation()) { + throw new LogicException(\sprintf('"%s" is a creation: it has no old blob.', $this->name)); + } + + return $this->blob($this->oldIndex, 'old'); } - public function getOldBlob() + /** + * @throws LogicException The file has no repository, or is a deletion + * @throws RuntimeException The diff doesn't know the hash of the new blob, like for a modified submodule + */ + public function getNewBlob(): Blob { - if (null === $this->repository) { - throw new \RuntimeException('Repository is missing to return Blob object.'); - } - - if ($this->isCreation()) { - throw new \LogicException('Can\'t return old Blob on a creation'); + if ($this->isDeletion()) { + throw new LogicException(\sprintf('"%s" is a deletion: it has no new blob.', $this->name)); } - return $this->repository->getBlob($this->oldIndex); + return $this->blob($this->newIndex, 'new'); } - public function getNewBlob() + private function blob(?string $hash, string $side): Blob { if (null === $this->repository) { - throw new \RuntimeException('Repository is missing to return Blob object.'); + throw new LogicException('A diff without repository can\'t read blobs: get it from a repository.'); } - if ($this->isDeletion()) { - throw new \LogicException('Can\'t return new Blob on a deletion'); + if (null === $hash) { + throw new RuntimeException(\sprintf('The diff doesn\'t know the hash of the %s blob of "%s".', $side, $this->name)); } - return $this->repository->getBlob($this->newIndex); + return $this->repository->getBlob($hash); } } diff --git a/src/Gitonomy/Git/Diff/FileChange.php b/src/Gitonomy/Git/Diff/FileChange.php index 898bbb48..cab89c02 100644 --- a/src/Gitonomy/Git/Diff/FileChange.php +++ b/src/Gitonomy/Git/Diff/FileChange.php @@ -1,6 +1,6 @@ @@ -12,77 +12,109 @@ namespace Gitonomy\Git\Diff; -class FileChange +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * A hunk of a file of a diff. + * + * @phpstan-type FileChangeArray array{range_old_start: int, range_old_count: int, range_new_start: int, range_new_count: int, lines: list} + */ +final readonly class FileChange { - const LINE_CONTEXT = 0; - const LINE_REMOVE = -1; - const LINE_ADD = 1; + /** + * @var list + */ + private array $lines; - protected $rangeOldStart; - protected $rangeOldCount; - protected $rangeNewStart; - protected $rangeNewCount; - protected $lines; + /** + * @param DiffLine[] $lines + */ + public function __construct( + private int $rangeOldStart, + private int $rangeOldCount, + private int $rangeNewStart, + private int $rangeNewCount, + array $lines, + ) { + $this->lines = array_values($lines); + } - public function __construct($rangeOldStart, $rangeOldCount, $rangeNewStart, $rangeNewCount, $lines) + public function getAdditions(): int { - $this->rangeOldStart = $rangeOldStart; - $this->rangeOldCount = $rangeOldCount; - $this->rangeNewStart = $rangeNewStart; - $this->rangeNewCount = $rangeNewCount; - $this->lines = $lines; + return $this->count(DiffLineType::Addition); } - public function getCount($type) + public function getDeletions(): int { - $result = 0; - foreach ($this->lines as $line) { - if ($line[0] === $type) { - $result++; - } - } - - return $result; + return $this->count(DiffLineType::Deletion); } - public function getRangeOldStart() + public function getRangeOldStart(): int { return $this->rangeOldStart; } - public function getRangeOldCount() + public function getRangeOldCount(): int { return $this->rangeOldCount; } - public function getRangeNewStart() + public function getRangeNewStart(): int { return $this->rangeNewStart; } - public function getRangeNewCount() + public function getRangeNewCount(): int { return $this->rangeNewCount; } - public function getLines() + /** + * @return list + */ + public function getDiffLines(): array { return $this->lines; } - public function toArray() + /** + * @return FileChangeArray + */ + public function toArray(): array { return [ 'range_old_start' => $this->rangeOldStart, 'range_old_count' => $this->rangeOldCount, 'range_new_start' => $this->rangeNewStart, 'range_new_count' => $this->rangeNewCount, - 'lines' => $this->lines, + 'lines' => array_map(static fn (DiffLine $line): array => [$line->getType()->value, $line->getContent()], $this->lines), ]; } - public static function fromArray(array $array) + /** + * @param FileChangeArray $array + */ + public static function fromArray(array $array): self + { + return new self( + $array['range_old_start'], + $array['range_old_count'], + $array['range_new_start'], + $array['range_new_count'], + array_map(static fn (array $line): DiffLine => new DiffLine(self::lineType($line[0]), $line[1]), $array['lines']), + ); + } + + /** + * @throws InvalidArgumentException $type is none of the values of DiffLineType + */ + private static function lineType(int $type): DiffLineType + { + return DiffLineType::tryFrom($type) ?? throw new InvalidArgumentException(\sprintf('Unknown type of line %d: the types are 1 (an addition), 0 (a context line) and -1 (a deletion).', $type)); + } + + private function count(DiffLineType $type): int { - return new self($array['range_old_start'], $array['range_old_count'], $array['range_new_start'], $array['range_new_count'], $array['lines']); + return \count(array_filter($this->lines, static fn (DiffLine $line): bool => $line->getType() === $type)); } } diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php new file mode 100644 index 00000000..3f1ec458 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -0,0 +1,168 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli; + +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\RepositoryPaths; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Util\RemoteUrl; +use Psr\Log\LoggerInterface; + +/** + * Runs the git binary, through Symfony Process. + * + * @author Grégoire Pineau + */ +final readonly class CliDriver implements DriverInterface +{ + /** + * git inherits the environment variables of PHP, like any process run by Symfony Process. + * + * @param string $command The git binary + * @param array $environmentVariables Set on every git process, false removing an inherited one + * @param float|null $processTimeout In seconds, for the whole process and without output; null for none + * @param LoggerInterface|null $logger Receives every git command run, with its output + */ + public function __construct( + private string $command = 'git', + private array $environmentVariables = [], + private ?float $processTimeout = 3600, + private ?LoggerInterface $logger = null, + ) { + } + + public function open(string $dir, ?string $workingDir): CliRepositoryDriver + { + $paths = RepositoryPaths::resolve($dir, $workingDir); + + return new CliRepositoryDriver($this->runner(), $paths->gitDir, $paths->workingDir); + } + + public function init(string $path): void + { + // --: a path may start with a dash. + $this->mustRun('init', ['-q', '--', $path]); + } + + public function initBare(string $path): void + { + $this->mustRun('init', ['-q', '--bare', '--', $path]); + } + + public function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void + { + $this->cloneRepository($path, $url, null === $branch ? [] : ['--branch', $branch]); + } + + public function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void + { + $this->cloneRepository($path, $url, null === $branch ? ['--bare'] : ['--branch', $branch, '--bare']); + } + + public function mirrorFrom(#[\SensitiveParameter] string $url, string $path): void + { + $this->cloneRepository($path, $url, ['--mirror']); + } + + 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(); + } + + public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch): bool + { + 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()); + if (!$process->isSuccessful()) { + return false; + } + + foreach (explode("\n", $process->getOutput()) as $line) { + if (str_ends_with($line, "\trefs/heads/".$branch)) { + return true; + } + } + + return false; + } + + /** + * @param string[] $args Arguments of ``git clone`` + * + * @throws ProcessException + */ + private function cloneRepository(string $path, #[\SensitiveParameter] string $url, array $args): void + { + // Like isValidRepository(), whose ls-remote can't take "--". + RemoteUrl::assertSafe($url); + + // --: a path may start with a dash. + $this->mustRun('clone', ['-q', ...$args, '--', $url, $path]); + } + + private function runner(): GitRunner + { + return new GitRunner($this->command, $this->environmentVariables, $this->processTimeout, $this->logger); + } + + /** + * @param string[] $args + */ + private function mustRun(string $command, #[\SensitiveParameter] array $args): void + { + $process = $this->runner()->run($command, $args); + + if (!$process->isSuccessful()) { + throw new ProcessException($process, $command); + } + } + + /** + * 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. + */ + private static function absoluteUrl(#[\SensitiveParameter] string $url): string + { + if (str_starts_with($url, '/') || 1 === preg_match('#^[a-zA-Z]:[\\\/]#', $url)) { + return $url; + } + + // Like git: a URL, or an address like "host:path", has a colon before any slash. + $colon = strpos($url, ':'); + $slash = strpos($url, '/'); + if (false !== $colon && (false === $slash || $colon < $slash)) { + return $url; + } + + $cwd = getcwd(); + + return false === $cwd ? $url : $cwd.'/'.$url; + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php new file mode 100644 index 00000000..7d060dbc --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -0,0 +1,539 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli; + +use Gitonomy\Git\Driver\Cli\Parser\BlameParser; +use Gitonomy\Git\Driver\Cli\Parser\LogParser; +use Gitonomy\Git\Driver\Cli\Parser\ReferenceParser; +use Gitonomy\Git\Driver\Cli\Parser\TagParser; +use Gitonomy\Git\Driver\Cli\Parser\TreeParser; +use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Driver\Data\DiffData; +use Gitonomy\Git\Driver\Data\ObjectType; +use Gitonomy\Git\Driver\Data\TagData; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\FastForward; +use Gitonomy\Git\Parser\DiffParser; +use Gitonomy\Git\Util\LeadingDash; +use Gitonomy\Git\Util\ReferenceName; +use Symfony\Component\Process\Process; + +/** + * Runs a git process for every operation on a repository. + * + * @author Grégoire Pineau + * + * @internal + */ +final readonly class CliRepositoryDriver implements RepositoryDriverInterface +{ + /** + * Sets back to git's defaults the configuration of the user that changes + * what git prints, reads or writes, where git 1.8.4 has no option for it. + * A git version that doesn't know a key ignores it. + * + * branch.autoSetupMerge and branch.autoSetupRebase: a branch created from + * a remote branch tracks it, without rebasing, like with LibGit2Driver. + * core.fsmonitor: git runs the command it names, which the configuration + * of a repository may set, to read the working tree, and even to diff a + * commit. + * diff.indentHeuristic is the default since git 2.14, and LibGit2Driver's. + */ + private const array CONFIG = [ + '-c', 'branch.autoSetupMerge=true', + '-c', 'branch.autoSetupRebase=never', + '-c', 'branch.sort=refname', + '-c', 'core.fsmonitor=false', + '-c', 'diff.indentHeuristic=true', + '-c', 'diff.suppressBlankEmpty=false', + '-c', 'log.follow=false', + '-c', 'log.showSignature=false', + ]; + + /** + * -z: the raw information gives the names as they are, without quoting. + * --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". + * The others set back to git's defaults the configuration "git diff" reads, + * unlike diff-tree: diff.algorithm, diff.context, diff.external, diff.noprefix, + * diff.submodule... + */ + private const array DIFF_ARGS = [ + '-r', '-p', '--raw', '-z', '-M', '--full-index', '--no-abbrev', + '--diff-algorithm=myers', '--dst-prefix=b/', '--inter-hunk-context=0', '--no-color', '--no-ext-diff', '--no-textconv', '-O/dev/null', '--src-prefix=a/', '--submodule=short', '-U3', + ]; + + /** + * The tree without any file: git knows it, even when no commit has it. + */ + private const string EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + + public function __construct( + private GitRunner $runner, + private string $gitDir, + private ?string $workingDir, + ) { + } + + public function getGitDir(): string + { + return $this->gitDir; + } + + public function getWorkingDir(): ?string + { + return $this->workingDir; + } + + public function getHeadReference(): ?string + { + $process = $this->execute('symbolic-ref', ['-q', 'HEAD']); + + // -q: git exits with 1, without output, when HEAD is detached. + if (1 === $process->getExitCode() && '' === $process->getOutput()) { + return null; + } + + if (!$process->isSuccessful()) { + throw new ProcessException($process, 'symbolic-ref'); + } + + return trim($process->getOutput()); + } + + public function resolveRevision(string $revision): string + { + LeadingDash::assertNone($revision); + + $process = $this->execute('rev-parse', ['--verify', '--quiet', $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. + if (1 === $process->getExitCode()) { + throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist.', $revision)); + } + + if (!$process->isSuccessful()) { + throw new ProcessException($process, 'rev-parse'); + } + + return trim($process->getOutput()); + } + + public function getShortHash(string $hash): string + { + LeadingDash::assertNone($hash); + + // --: a file of the working tree may have the name of the hash. + return trim($this->runOnObject($hash, null, 'log', ['--abbrev-commit', '--format=%h', '-n', '1', $hash, '--'])); + } + + public function getCommitData(string $hash): CommitData + { + LeadingDash::assertNone($hash); + + $parser = new LogParser(); + $parser->parse($this->runOnObject($hash, ObjectType::Commit, 'log', ['--encoding=UTF-8', '--format=raw', '--no-color', '-n', '1', $hash, '--'])); + + $commit = $parser->commits[0] ?? null; + // git log peels a tag to its commit. + if (null === $commit || !str_starts_with($commit->hash, $hash)) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a commit.', $hash)); + } + + return $commit; + } + + public function getCommitLog(?array $includes, array $excludes, array $paths, int $offset, ?int $limit): array + { + LeadingDash::assertNone(...$includes ?? [], ...$excludes); + + if ([] === $includes) { + return []; + } + + // Re-encodes the commits whose "encoding" header isn't UTF-8. + $args = ['--encoding=UTF-8', '--format=raw', '--no-color', '--skip='.$offset]; + + if (null !== $limit) { + $args[] = '-n'; + $args[] = (string) $limit; + } + + $parser = new LogParser(); + $parser->parse($this->runOnRevisions('log', $args, $includes, $excludes, $paths)); + + return $parser->commits; + } + + public function countCommits(?array $includes, array $excludes, array $paths): int + { + LeadingDash::assertNone(...$includes ?? [], ...$excludes); + + if ([] === $includes) { + return 0; + } + + return (int) $this->runOnRevisions('rev-list', ['--count'], $includes, $excludes, $paths); + } + + public function getMergeBases(string $hash1, string $hash2): array + { + LeadingDash::assertNone($hash1, $hash2); + + $process = $this->execute('merge-base', ['--all', $hash1, $hash2]); + + // git exits with 1 and no output when there is no common ancestor; any + // other failure (unknown revision, corrupt repository...) is an error. + if (1 === $process->getExitCode() && '' === $process->getOutput()) { + return []; + } + + if (!$process->isSuccessful()) { + throw new ProcessException($process, 'merge-base'); + } + + return explode("\n", trim($process->getOutput())); + } + + public function getTreeEntries(string $hash): array + { + $parser = new TreeParser(); + $parser->parse($this->readObject($hash, ObjectType::Tree)); + + return $parser->entries; + } + + public function getBlobContent(string $hash): string + { + return $this->readObject($hash, ObjectType::Blob); + } + + public function getTagData(string $hash): TagData + { + $parser = new TagParser(); + $parser->parse($this->readObject($hash, ObjectType::Tag)); + + return $parser->data; + } + + public function getReferences(): array + { + // Unlike for-each-ref in git 1.x, show-ref skips a dangling symbolic reference. + $process = $this->execute('show-ref', ['--dereference']); + + // git exits with 1 and no output when there is no reference. + if (1 === $process->getExitCode() && '' === $process->getOutput()) { + return []; + } + + if (!$process->isSuccessful()) { + throw new ProcessException($process, 'show-ref'); + } + + $parser = new ReferenceParser(); + $parser->parse($process->getOutput()); + + return $parser->references; + } + + public function createReference(string $fullname, string $hash): void + { + LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); + + // Zeros as the old value: git refuses to update a reference that exists. + $this->run('update-ref', ['--no-deref', $fullname, $hash, str_repeat('0', 40)]); + } + + public function updateReference(string $fullname, string $hash): void + { + LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); + + // --no-deref: a symbolic reference, like refs/remotes/origin/HEAD, is + // written itself, instead of the reference it points to. + $this->run('update-ref', ['--no-deref', $fullname, $hash]); + } + + public function deleteReference(string $fullname): void + { + LeadingDash::assertNone($fullname); + ReferenceName::assertUnderRefs($fullname); + + $this->run('update-ref', ['--no-deref', '-d', $fullname]); + } + + public function getIncludingBranches(string $hash): array + { + LeadingDash::assertNone($hash); + + // for-each-ref only accepts --contains since git 2.7. + return [ + ...$this->listBranches(['--contains', $hash], 'refs/heads/'), + ...$this->listBranches(['--remotes', '--contains', $hash], 'refs/remotes/'), + ]; + } + + public function getDiff(?string $from, string $to): DiffData + { + LeadingDash::assertNone($from ?? '', $to); + + return $this->diff('diff-tree', [$from ?? self::EMPTY_TREE, $to]); + } + + public function getWorkingTreeDiff(string $hash): DiffData + { + LeadingDash::assertNone($hash); + + return $this->diff('diff', [$hash]); + } + + public function getDiffPending(): DiffData + { + return $this->diff('diff', []); + } + + public function getDiffStaged(): DiffData + { + return $this->diff('diff', ['--staged']); + } + + public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array + { + LeadingDash::assertNone($hash); + + // --no-textconv: blame reads the files through their textconv filter by default. + $args = ['-p', '--no-textconv']; + if (null !== $firstLine || null !== $lastLine) { + $args[] = '-L'; + $args[] = ($firstLine ?? 1).','.$lastLine; + } + + $parser = new BlameParser(); + $parser->parse($this->run('blame', [...$args, $hash, '--', $path])); + + return $parser->lines; + } + + public function getUntrackedFiles(): array + { + $files = []; + foreach (explode("\0", $this->run('status', ['--porcelain', '--untracked-files=all', '-z'])) as $entry) { + if (str_starts_with($entry, '?? ')) { + $files[] = substr($entry, 3); + } + } + + return $files; + } + + public function checkout(string $revision): void + { + LeadingDash::assertNone($revision); + + // --: without it, git restores the file $revision names, when no + // revision has this name, discarding its changes. + $this->run('checkout', [$revision, '--']); + } + + public function checkoutNewBranch(string $branch, string $startPoint): void + { + LeadingDash::assertNone($branch, $startPoint); + + // The branch, then its files: "checkout -b" checks the files of an + // ambiguous $startPoint out before refusing it, and leaves them. + // CONFIG sets the upstream branch, like git's default: --track or + // --no-track would need to know whether $startPoint is a remote branch. + $this->run('branch', [$branch, $startPoint]); + + try { + $this->checkout($branch); + } catch (DriverException $e) { + // Like "checkout -b", which creates nothing when the checkout + // fails. The configuration of the branch goes with it. + $this->run('branch', ['-D', $branch]); + + throw $e; + } + } + + public function merge(string $revision, FastForward $fastForward): void + { + LeadingDash::assertNone($revision); + + $option = match ($fastForward) { + FastForward::Allow => '--ff', + FastForward::Never => '--no-ff', + FastForward::Only => '--ff-only', + }; + + $this->run('merge', [$option, $revision]); + } + + /** + * Runs any git command, for what no driver method covers. + * + * @param array $args + * @param string|null $input The standard input of git + * + * @throws ProcessException The command failed + */ + public function runProcess(string $command, #[\SensitiveParameter] array $args = [], ?string $input = null): Process + { + $process = $this->execute($command, array_map(strval(...), $args), $input); + + if (!$process->isSuccessful()) { + throw new ProcessException($process, $command); + } + + return $process; + } + + /** + * @param string[] $args + * + * @throws ProcessException + */ + private function run(string $command, array $args): string + { + return $this->runProcess($command, $args)->getOutput(); + } + + /** + * @param string[] $args + */ + private function diff(string $command, array $args): DiffData + { + // --: a file of the working tree may have the name of a revision. + $output = $this->run($command, [...self::DIFF_ARGS, ...$args, '--']); + + $parser = new DiffParser(); + $parser->parse($output); + + return new DiffData($parser->files, $output); + } + + /** + * Reads the content of the object $hash, in the format git stores it. + * + * Unlike ``git cat-file `` or ``git ls-tree``, refuses an object + * of another type, instead of reading the tree of a commit for instance. + * + * @throws ReferenceNotFoundException $hash doesn't exist, or isn't of the type $type + */ + private function readObject(string $hash, ObjectType $type): string + { + $output = $this->runProcess('cat-file', ['--batch'], $hash."\n")->getOutput(); + + // " ", or " missing". + $header = explode(' ', (string) strstr($output, "\n", true)); + if (3 !== \count($header)) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" does not exist.', $hash)); + } + if ($type->value !== $header[1]) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value)); + } + + return substr($output, \strlen(implode(' ', $header)) + 1, (int) $header[2]); + } + + /** + * Runs a command reading the object $hash. + * + * @param ObjectType|null $type The type $hash must have, null for any + * @param string[] $args + * + * @throws ReferenceNotFoundException $hash doesn't exist, or isn't of the type $type + */ + private function runOnObject(string $hash, ?ObjectType $type, string $command, array $args): string + { + try { + return $this->run($command, $args); + } catch (ProcessException $e) { + // Only checked when the command fails: one more process. + $process = $this->execute('cat-file', ['-t', $hash]); + if (!$process->isSuccessful()) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" does not exist.', $hash), 0, $e); + } + if (null !== $type && $type->value !== trim($process->getOutput())) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value), 0, $e); + } + + throw $e; + } + } + + /** + * Runs $command on the commits reachable from $includes, but not from + * $excludes. + * + * The hashes go through the standard input: thousands of them would + * exceed the limit of the command line. + * + * @param string[] $args Arguments of $command + * @param list|null $includes Null for every reference and HEAD + * @param list $excludes + * @param string[] $paths + */ + private function runOnRevisions(string $command, array $args, ?array $includes, array $excludes, array $paths): string + { + // A hash per line, "^" excluding it. The standard input takes no option, like "--all". + $input = ''; + foreach ([...$includes ?? [], ...array_map(static fn (string $hash): string => '^'.$hash, $excludes)] as $revision) { + $input .= $revision."\n"; + } + + return $this->runProcess($command, [...$args, ...null === $includes ? ['--all'] : [], '--stdin', '--', ...$paths], $input)->getOutput(); + } + + /** + * @param string[] $args Arguments of ``git branch`` + * + * @return string[] The full names of the branches git lists + */ + private function listBranches(array $args, string $prefix): array + { + $branches = []; + foreach (explode("\n", rtrim($this->run('branch', ['--no-color', '--no-column', ...$args]), "\n")) as $line) { + // "* " flags the current branch, "(HEAD detached at ...)" a detached + // HEAD, and "origin/HEAD -> origin/main" a symbolic reference. + $name = substr($line, 2); + if ('' === $name || str_starts_with($name, '(') || str_contains($name, ' -> ')) { + continue; + } + + $branches[] = $prefix.$name; + } + + return $branches; + } + + /** + * @param string[] $args + */ + private function execute(string $command, #[\SensitiveParameter] array $args, ?string $input = null): Process + { + // --no-replace-objects: like libgit2, gitlib reads the objects as they + // are, whatever "git replace" made of them in refs/replace/. + $globalOptions = [...self::CONFIG, '--git-dir', $this->gitDir, '--no-replace-objects']; + if (null !== $this->workingDir) { + $globalOptions = [...$globalOptions, '--work-tree', $this->workingDir]; + } + + // Relative paths, in arguments as in pathspecs, resolve against the repository. + return $this->runner->run($command, $args, $globalOptions, $this->workingDir ?? $this->gitDir, $input); + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/GitRunner.php b/src/Gitonomy/Git/Driver/Cli/GitRunner.php new file mode 100644 index 00000000..7cf1bb59 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/GitRunner.php @@ -0,0 +1,136 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli; + +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessTimedOutException; +use Gitonomy\Git\Util\Credentials; +use Gitonomy\Git\Util\NulByte; +use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Exception\ProcessStartFailedException; +use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException; +use Symfony\Component\Process\Exception\RuntimeException as SymfonyRuntimeException; +use Symfony\Component\Process\Process; + +/** + * Runs the git processes, as configured on CliDriver, and logs them. + * + * @author Grégoire Pineau + * + * @internal + */ +final readonly class GitRunner +{ + /** + * @param array $environmentVariables + */ + public function __construct( + private string $command, + private array $environmentVariables, + private ?float $timeout, + private ?LoggerInterface $logger, + ) { + } + + /** + * Runs a git command, successful or not. + * + * @param string[] $args Arguments of $command + * @param string[] $globalOptions Options of git itself, before $command (--git-dir...) + * @param string|null $input The standard input of git + * + * @throws InvalidArgumentException An argument contains a NUL byte + * @throws 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); + + $process = new Process([$this->command, ...$globalOptions, $command, ...$args], $workingDirectory); + $process->setEnv($this->environmentVariables); + $process->setInput($input); + + $process->setTimeout($this->timeout); + $process->setIdleTimeout($this->timeout); + + // The logs hide the credentials of the URLs, like the exceptions. + $this->logger?->info(Credentials::redact(\sprintf('run command: %s "%s" ', $command, implode(' ', $args)))); + $before = microtime(true); + + // The exceptions of Symfony aren't chained: their messages may hold the + // command line, with the credentials of its URLs. + try { + $process->start(); + } catch (SymfonyProcessTimedOutException) { + // A timeout shorter than the start of git. + throw $this->timedOut($command); + } catch (SymfonyRuntimeException $e) { + // Too many arguments (E2BIG), a working directory removed since the + // repository was opened... + $error = $e->getMessage(); + if ($e instanceof ProcessStartFailedException) { + // Its message ends with the error, after the command line. + $lines = explode("\n", $error); + $error = substr(end($lines), \strlen('Error: ')); + } + + throw self::notStarted($command, $error); + } + + try { + $process->wait(); + } catch (SymfonyProcessTimedOutException) { + throw $this->timedOut($command); + } catch (SymfonyRuntimeException $e) { + // Killed by a signal... + throw new DriverException(Credentials::redact(\sprintf('Unable to run the git command "%s": %s', $command, $e->getMessage()))); + } + + // Symfony Process runs git through a shell, which exits with them when it can't find or run git. + if (\in_array($process->getExitCode(), [126, 127], true)) { + throw 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())); + $this->logger->debug(Credentials::redact(\sprintf('last command (%s) output: %s', $command, $process->getOutput()))); + if (!$process->isSuccessful()) { + $this->logger->debug(Credentials::redact(\sprintf('last command (%s) error output: %s', $command, $process->getErrorOutput()))); + } + } + + return $process; + } + + /** + * Like the one of ProcessException, the message is a sentence, with the + * first line of $error. + */ + private static function notStarted(string $command, string $error): DriverException + { + $message = \sprintf('The git command "%s" could not start', $command); + $error = trim(explode("\n", trim($error), 2)[0]); + if ('' !== $error) { + $message .= ': '.$error; + } + + return new DriverException(Credentials::redact(1 === preg_match('/[.!?]$/', $message) ? $message : $message.'.')); + } + + private function timedOut(string $command): ProcessTimedOutException + { + return new ProcessTimedOutException(\sprintf('The git command "%s" ran longer than %s %s.', $command, $this->timeout, 1.0 === $this->timeout ? 'second' : 'seconds')); + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php new file mode 100644 index 00000000..10d6c44b --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php @@ -0,0 +1,89 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli\Parser; + +use Gitonomy\Git\Driver\Data\BlameLineData; +use Gitonomy\Git\Parser\ParserBase; + +/** + * Parses the output of ``git blame -p``. + * + * @internal Used by CliDriver only + */ +final class BlameParser extends ParserBase +{ + /** + * @var BlameLineData[] + */ + public array $lines = []; + + /** + * @var array + */ + private array $seenHashes = []; + + protected function doParse(): void + { + $this->lines = []; + $this->seenHashes = []; + + while (!$this->isFinished()) { + $hash = $this->consumeHash(); + $this->consume(' '); + // The number of lines of the group follows on its first line. + $vars = $this->consumeRegexp('/(\d+) (\d+)/A'); + $this->consumeTo("\n"); + $this->consumeNewLine(); + + if (!isset($this->seenHashes[$hash])) { + foreach (['author', 'author-mail', 'author-time', 'author-tz', + 'committer', 'committer-mail', 'committer-time', 'committer-tz', + 'summary', ] as $key) { + $this->consume($key); + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + + if ($this->expects('previous ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + + if ($this->expects('boundary')) { + $this->consumeNewLine(); + } + $this->consume('filename'); + $this->consumeTo("\n"); // filename + $this->consumeNewLine(); + $this->seenHashes[$hash] = true; + } else { + // git repeats them on each group of a commit it blames under + // several paths, after a merge whose sides renamed the file. + if ($this->expects('previous ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + + if ($this->expects('filename ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + } + $this->consume("\t"); + $content = $this->consumeTo("\n"); + $this->consumeNewLine(); + + $this->lines[] = new BlameLineData($hash, (int) $vars[2], (int) $vars[1], $content); + } + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php new file mode 100644 index 00000000..50116806 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php @@ -0,0 +1,83 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli\Parser; + +use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Parser\ParserBase; + +/** + * Parses the output of ``git log --format=raw``. + * + * @internal Used by CliDriver only + */ +final class LogParser extends ParserBase +{ + /** + * @var list + */ + public array $commits = []; + + protected function doParse(): void + { + $this->commits = []; + + while (!$this->isFinished()) { + $this->consume('commit '); + $hash = $this->consumeHash(); + $this->consumeNewLine(); + + $treeHash = null; + $parentHashes = []; + $author = null; + $committer = null; + // The headers end with an empty line, unless the commit is the + // last one and has no message. The lines following a header, of + // a signature (gpgsig) or a merged tag (mergetag), start with a + // space. + while (!$this->isFinished() && !$this->expects("\n")) { + [$name, $value] = explode(' ', $this->consumeTo("\n"), 2) + [1 => '']; + $this->consumeNewLine(); + while ($this->expects(' ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + + 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, + }; + } + + if (null === $treeHash || null === $author || null === $committer) { + throw new RuntimeException(\sprintf('The commit "%s" lacks a tree, an author or a committer.', $hash)); + } + + // git indents every line of the message. + $message = ''; + while ($this->expects(' ')) { + $message .= $this->consumeTo("\n")."\n"; + $this->consumeNewLine(); + } + + // An empty line separates the commits. + $this->expects("\n"); + + $this->commits[] = new CommitData($hash, $treeHash, $parentHashes, ...$author, ...$committer, message: $message); + } + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php new file mode 100644 index 00000000..7a5fe542 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.php @@ -0,0 +1,52 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli\Parser; + +use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Parser\ParserBase; + +/** + * Parses `` `` lines. + * + * @internal Used by CliDriver only + */ +final class ReferenceParser extends ParserBase +{ + /** + * @var ReferenceData[] + */ + public array $references = []; + + protected function doParse(): void + { + $this->references = []; + + while (!$this->isFinished()) { + $hash = $this->consumeHash(); + $this->consume(' '); + $fullname = $this->consumeTo("\n"); + $this->consumeNewLine(); + + // With --dereference, an annotated tag is followed by what it points to. + if (str_ends_with($fullname, '^{}')) { + $tag = array_pop($this->references) ?? throw new RuntimeException(\sprintf('No tag precedes "%s".', $fullname)); + $this->references[] = new ReferenceData($tag->fullname, $hash, $tag->commitHash); + + continue; + } + + $this->references[] = new ReferenceData($fullname, $hash); + } + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php new file mode 100644 index 00000000..ebffed40 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php @@ -0,0 +1,47 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli\Parser; + +use Gitonomy\Git\Driver\Data\TagData; +use Gitonomy\Git\Parser\ParserBase; + +/** + * Parses a tag object, as ``git cat-file --batch`` gives it. + * + * @internal Used by CliDriver only + */ +final class TagParser extends ParserBase +{ + public TagData $data; + + protected function doParse(): void + { + // The object the tag points to, which the reference gives already, and the name of the tag. + foreach (['object ', 'type ', 'tag '] as $header) { + $this->consume($header); + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + + // The oldest tags, like the first ones of git itself, have no tagger. + [$taggerName, $taggerEmail, $taggerDate] = [null, null, null]; + if ($this->expects('tagger ')) { + [$taggerName, $taggerEmail, $taggerDate] = $this->consumeNameEmailDate(); + $this->consumeNewLine(); + } + + $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 new file mode 100644 index 00000000..afc700fd --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php @@ -0,0 +1,61 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli\Parser; + +use Gitonomy\Git\Driver\Data\ObjectType; +use Gitonomy\Git\Driver\Data\TreeEntryData; +use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Parser\ParserBase; + +/** + * Parses a tree object, as ``git cat-file --batch`` gives it: for each + * entry, its mode, a space, its name, a NUL byte, and its hash in binary. + * + * @internal Used by CliDriver only + */ +final class TreeParser extends ParserBase +{ + /** + * @var TreeEntryData[] + */ + public array $entries = []; + + protected function doParse(): void + { + $this->entries = []; + + while (!$this->isFinished()) { + // git stores the mode of a tree as "40000". + $mode = str_pad($this->consumeRegexp('/[0-7]{5,6}/')[0], 6, '0', \STR_PAD_LEFT); + $this->consume(' '); + + $name = $this->consumeTo("\0"); + $this->consume("\0"); + + $hash = substr($this->content, $this->cursor, 20); + if (20 !== \strlen($hash)) { + throw new RuntimeException(\sprintf('The tree entry "%s" has no hash.', $name)); + } + $this->cursor += 20; + + $type = match ($mode) { + '040000' => ObjectType::Tree, + // A submodule: the hash is a commit of another repository. + '160000' => ObjectType::Commit, + default => ObjectType::Blob, + }; + + $this->entries[] = new TreeEntryData($mode, $type, bin2hex($hash), $name); + } + } +} diff --git a/src/Gitonomy/Git/Driver/Data/BlameLineData.php b/src/Gitonomy/Git/Driver/Data/BlameLineData.php new file mode 100644 index 00000000..fc651c91 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/BlameLineData.php @@ -0,0 +1,35 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * One line of a blame result, as read by a Driver. Keeps only the commit + * hash: resolving it to a Commit object is Blame's job, since Driver has no + * Repository reference (and thus no commit cache). + * + * @author Grégoire Pineau + */ +final readonly class BlameLineData +{ + /** + * @param int $number The number of the line in the blamed file + * @param int $originalNumber The number of the line in the file of $commitHash + */ + public function __construct( + public string $commitHash, + public int $number, + public int $originalNumber, + public string $content, + ) { + } +} diff --git a/src/Gitonomy/Git/Driver/Data/CommitData.php b/src/Gitonomy/Git/Driver/Data/CommitData.php new file mode 100644 index 00000000..e01dca73 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/CommitData.php @@ -0,0 +1,41 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * Raw commit data, as read by a Driver. Repository-agnostic: does not resolve + * parents/tree into objects, that's Commit's job. + * + * @author Grégoire Pineau + */ +final readonly class CommitData +{ + /** + * @param string[] $parentHashes + * @param \DateTimeImmutable $authorDate At the UTC offset of the author, like git log shows it + * @param \DateTimeImmutable $committerDate At the UTC offset of the committer, like git log shows it + */ + public function __construct( + public string $hash, + public string $treeHash, + public array $parentHashes, + public string $authorName, + public string $authorEmail, + public \DateTimeImmutable $authorDate, + public string $committerName, + public string $committerEmail, + public \DateTimeImmutable $committerDate, + public string $message, + ) { + } +} diff --git a/src/Gitonomy/Git/Driver/Data/DiffData.php b/src/Gitonomy/Git/Driver/Data/DiffData.php new file mode 100644 index 00000000..bcdccf36 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/DiffData.php @@ -0,0 +1,31 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * A diff, as computed by a Driver. + * + * @author Grégoire Pineau + */ +final readonly class DiffData +{ + /** + * @param list $files + * @param string $rawDiff The output of git the diff was parsed from, empty when no git command computed it + */ + public function __construct( + public array $files, + public string $rawDiff, + ) { + } +} diff --git a/src/Gitonomy/Git/Driver/Data/DiffFileData.php b/src/Gitonomy/Git/Driver/Data/DiffFileData.php new file mode 100644 index 00000000..aecb8682 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/DiffFileData.php @@ -0,0 +1,55 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * A file of a diff, as computed by a Driver. + * + * Its changes are FileChange objects already: unlike a file, they need no + * repository. + * + * @author Grégoire Pineau + */ +final readonly class DiffFileData +{ + /** + * The new name, or the old one for a deletion. + */ + public string $name; + + /** + * @param string|null $oldName Null for a creation + * @param string|null $newName Null for a deletion + * @param string|null $oldIndex The hash of the old blob, null when there is none + * @param string|null $newIndex The hash of the new blob, null when there is none; for a file of the working tree, the hash of its content, which the repository may not store + * @param list $changes + * + * @throws InvalidArgumentException Both names are null + */ + public function __construct( + public ?string $oldName, + public ?string $newName, + public ?string $oldMode, + public ?string $newMode, + public ?string $oldIndex, + public ?string $newIndex, + public bool $isBinary, + public bool $isCopy, + public array $changes, + ) { + $this->name = $newName ?? $oldName ?? throw new InvalidArgumentException('A file of a diff has an old name, a new name, or both.'); + } +} diff --git a/src/Gitonomy/Git/Driver/Data/ObjectType.php b/src/Gitonomy/Git/Driver/Data/ObjectType.php new file mode 100644 index 00000000..17f93c86 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/ObjectType.php @@ -0,0 +1,26 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * The type of a git object. + * + * @author Grégoire Pineau + */ +enum ObjectType: string +{ + case Blob = 'blob'; + case Commit = 'commit'; + case Tag = 'tag'; + case Tree = 'tree'; +} diff --git a/src/Gitonomy/Git/Driver/Data/ReferenceData.php b/src/Gitonomy/Git/Driver/Data/ReferenceData.php new file mode 100644 index 00000000..94603c63 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/ReferenceData.php @@ -0,0 +1,32 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * One reference entry (branch, tag, stash...), as read by a Driver. + * + * @author Grégoire Pineau + */ +final readonly class ReferenceData +{ + /** + * @param string $commitHash What the reference points to, through an annotated tag: a commit, unless a tag points to a tree or a blob + * @param string|null $tagHash The annotated tag object the reference points to, null for a branch or a lightweight tag + */ + public function __construct( + public string $fullname, + public string $commitHash, + public ?string $tagHash = null, + ) { + } +} diff --git a/src/Gitonomy/Git/Driver/Data/TagData.php b/src/Gitonomy/Git/Driver/Data/TagData.php new file mode 100644 index 00000000..9635a41d --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/TagData.php @@ -0,0 +1,51 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * Raw data of an annotated tag object, as read by a Driver. + * + * @author Grégoire Pineau + */ +final readonly class TagData +{ + /** + * The armors git signs tags with: GPG, then X.509 (gpgsm), then SSH. + */ + private const string SIGNATURE = '-----BEGIN (PGP SIGNATURE|PGP MESSAGE|SIGNED MESSAGE|SSH SIGNATURE)-----'; + + /** + * @param string|null $taggerName Null for a tag without a tagger, like the oldest tags of git itself + * @param \DateTimeImmutable|null $taggerDate At the UTC offset of the tagger, like git log shows it + */ + public function __construct( + public ?string $taggerName, + public ?string $taggerEmail, + public ?\DateTimeImmutable $taggerDate, + public string $message, + public ?string $gpgSignature, + ) { + } + + /** + * @param string $signedMessage The message of the tag object, followed by the signature git appends to it, if any + */ + public static function fromSignedMessage(?string $taggerName, ?string $taggerEmail, ?\DateTimeImmutable $taggerDate, string $signedMessage): self + { + if (!preg_match('/^(.*?)'.self::SIGNATURE.'(.*?)-----END \2-----/s', $signedMessage, $vars)) { + return new self($taggerName, $taggerEmail, $taggerDate, $signedMessage, null); + } + + return new self($taggerName, $taggerEmail, $taggerDate, $vars[1], $vars[3]); + } +} diff --git a/src/Gitonomy/Git/Driver/Data/TreeEntryData.php b/src/Gitonomy/Git/Driver/Data/TreeEntryData.php new file mode 100644 index 00000000..d2506dba --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/TreeEntryData.php @@ -0,0 +1,29 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +/** + * One entry of a tree, as read by a Driver. + * + * @author Grégoire Pineau + */ +final readonly class TreeEntryData +{ + public function __construct( + public string $mode, + public ObjectType $type, + public string $hash, + public string $name, + ) { + } +} diff --git a/src/Gitonomy/Git/Driver/DriverInterface.php b/src/Gitonomy/Git/Driver/DriverInterface.php new file mode 100644 index 00000000..dea15a12 --- /dev/null +++ b/src/Gitonomy/Git/Driver/DriverInterface.php @@ -0,0 +1,119 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver; + +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\UnsupportedOperationException; + +/** + * A configured git backend: opens repositories, and runs the operations that + * need no repository yet. + * + * Pass one to Repository, which uses {@see CliDriver} by default, to + * configure the backend, or to replace it in tests. + * + * Every method throws a {@see DriverException} when git reports a failure, + * an {@see UnsupportedOperationException} when the driver can't perform the + * operation at all, like LibGit2Driver cloning a bundle, and an + * {@see InvalidArgumentException} when an argument contains a NUL byte, which + * git doesn't accept. + * + * An application may get the URL of a repository from its users: every method + * taking one refuses, with an {@see InvalidArgumentException}, a URL git would + * misread, before running git. A URL starting with a dash is an option for + * git, an "ext::" URL runs its command, 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. + * + * gitlib may add methods to this interface in a minor version: the promise of + * backward compatibility covers the code calling it, not the drivers + * implementing it outside gitlib, like a stub in tests. + * + * @author Grégoire Pineau + */ +interface DriverInterface +{ + /** + * Opens the repository in $dir: a bare repository, or a working tree with + * its ".git" directory or file (a linked worktree, a submodule...). + * + * @param string|null $workingDir The working tree, when $dir is a git directory kept apart from it + * + * @throws InvalidArgumentException $dir doesn't exist, or isn't a repository + * @throws DriverException The driver can't open the repository + * @throws UnsupportedOperationException The repository uses SHA-256 hashes, or a format the driver can't read, like reftable for LibGit2Driver + */ + public function open(string $dir, ?string $workingDir): RepositoryDriverInterface; + + /** + * @throws DriverException + */ + public function init(string $path): void; + + /** + * @throws DriverException + */ + public function initBare(string $path): void; + + /** + * Clones $url with a working directory in $path, checking out $branch, + * or the default branch of $url when null; like git, a tag named $branch + * is checked out with HEAD detached, when $url has no such branch. + * + * @throws InvalidArgumentException $url is a URL git would misread + * @throws DriverException git fails to clone $url + * @throws UnsupportedOperationException The driver can't clone $url, like LibGit2Driver a bundle + */ + public function cloneFrom(string $url, string $path, ?string $branch): void; + + /** + * Clones the branches and the tags of $url into a bare repository in + * $path, with HEAD pointing to $branch, or to the default branch of $url + * when null; like git, HEAD is detached at a tag named $branch, when $url + * has no such branch. + * + * @throws InvalidArgumentException $url is a URL git would misread + * @throws DriverException git fails to clone $url + * @throws UnsupportedOperationException The driver can't clone $url, like LibGit2Driver a bundle + */ + public function cloneBareFrom(string $url, string $path, ?string $branch): void; + + /** + * Clones every reference of $url into a bare repository in $path, set up + * to mirror $url when fetching. + * + * @throws InvalidArgumentException $url is a URL git would misread + * @throws DriverException git fails to clone $url + * @throws UnsupportedOperationException The driver can't clone $url, like LibGit2Driver a bundle + */ + public function mirrorFrom(string $url, string $path): void; + + /** + * @param string $url A URL, or the path of a repository: never the name of a remote + * + * @return bool Whether $url can be reached + * + * @throws InvalidArgumentException $url is a URL git would misread + */ + public function isValidRepository(string $url): bool; + + /** + * @param string $url Same as {@see isValidRepository()} + * @param string $branch The name of the branch, exactly: "release" doesn't name "feature/release" + * + * @throws InvalidArgumentException $url is a URL git would misread + */ + public function isValidRepositoryAndBranch(string $url, string $branch): bool; +} diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php new file mode 100644 index 00000000..05feed0a --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php @@ -0,0 +1,260 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Ffi; + +use FFI\CData; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\Credentials; + +/** + * Loads libgit2, and helps calling it. + * + * @author Grégoire Pineau + * + * @internal + */ +final class LibGit2 +{ + public const int GIT_ENOTFOUND = -3; + + private const int GIT_CONFIG_LEVEL_SYSTEM = 2; + private const int GIT_FEATURE_SHA256 = 1 << 11; + private const int GIT_OPT_SET_SEARCH_PATH = 5; + + /** + * The only libgit2 version libgit2.h is written for: libgit2 changes its + * struct layouts on every minor release, so loading any other version + * would silently corrupt memory. + */ + private const array SUPPORTED_VERSION = [1, 9]; + + /** + * The names to look libgit2 up with, when no path is given. + */ + private const array LIBRARY_NAMES = [ + 'libgit2.so.1.9', + 'libgit2.1.9.dylib', + 'libgit2.so', + 'libgit2.dylib', + 'git2.dll', + ]; + + /** + * Loads libgit2 once per request (the whole process with the CLI) and per + * library path, or takes the one preload() loaded. + * + * @throws UnsupportedOperationException The FFI extension or libgit2 1.9 is missing, or "ffi.enable" restricts FFI + */ + public static function load(?string $libraryPath): \FFI + { + static $ffiByLibraryPath = []; + + return $ffiByLibraryPath[$libraryPath ?? ''] ??= self::doLoad($libraryPath); + } + + /** + * Loads and initializes libgit2 for the whole process, from the + * opcache.preload script: PHP unloads a library at the end of every + * request otherwise, and libgit2 initializes itself again (about 10 ms). + * + * @throws UnsupportedOperationException The FFI extension or libgit2 1.9 is missing + */ + public static function preload(?string $libraryPath): void + { + self::assertFfiIsAvailable(); + $path = $libraryPath ?? self::locate(); + self::assertSupportedVersion($path); + + // FFI::load() reads the library to load from the header. + $header = tempnam(sys_get_temp_dir(), 'gitlib-libgit2-'); + + try { + file_put_contents($header, \sprintf("#define FFI_SCOPE \"%s\"\n#define FFI_LIB \"%s\"\n", self::scope($libraryPath), $path).self::header()); + $ffi = \FFI::load($header); + } catch (\FFI\Exception $e) { + throw new UnsupportedOperationException(\sprintf('Unable to preload libgit2 from "%s".', $path), 0, $e); + } finally { + unlink($header); + } + + // FFI::load() only warns while preloading. + if (null === $ffi) { + throw new UnsupportedOperationException(\sprintf('Unable to preload libgit2 from "%s".', $path)); + } + + // Once for the forked processes (php-fpm workers...) too. + self::initialize($ffi); + } + + /** + * @throws DriverException When $rc is a libgit2 error code + */ + public static function check(\FFI $ffi, int $rc, #[\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)))); + } + } + + /** + * A PHP string can't be stored in a `char *` field: copy it into a C + * buffer, to keep alive as long as the field is in use. + */ + public static function cString(\FFI $ffi, string $value): CData + { + $buffer = $ffi->new('char['.(\strlen($value) + 1).']'); + \FFI::memcpy($buffer, $value, \strlen($value)); + + return $buffer; + } + + public static function oidToHex(\FFI $ffi, CData $oid): string + { + $buffer = $ffi->new('char[41]'); + $ffi->git_oid_tostr($buffer, 41, $oid); + + return \FFI::string($buffer); + } + + private static function doLoad(?string $libraryPath): \FFI + { + self::assertFfiIsAvailable(); + + $ffi = self::preloaded($libraryPath) ?? self::define($libraryPath ?? self::locate()); + // Only counts one more user once libgit2 is initialized. + self::initialize($ffi); + + return $ffi; + } + + /** + * @return \FFI|null What preload() loaded for $libraryPath, if it was called + */ + private static function preloaded(?string $libraryPath): ?\FFI + { + try { + return \FFI::scope(self::scope($libraryPath)); + } catch (\FFI\Exception) { + return null; + } + } + + private static function define(string $libraryPath): \FFI + { + self::assertSupportedVersion($libraryPath); + + try { + return \FFI::cdef(self::header(), $libraryPath); + } catch (\FFI\Exception $e) { + throw new UnsupportedOperationException(\sprintf('Unable to load libgit2 from "%s"; use CliDriver.', $libraryPath), 0, $e); + } + } + + /** + * @return string The declarations of libgit2 the drivers call + */ + private static function header(): string + { + $header = file_get_contents(__DIR__.'/libgit2.h'); + if (false === $header) { + throw new UnsupportedOperationException(\sprintf('Unable to read "%s".', __DIR__.'/libgit2.h')); + } + + return $header; + } + + private static function scope(?string $libraryPath): string + { + return 'gitonomy/gitlib:'.($libraryPath ?? ''); + } + + private static function assertFfiIsAvailable(): void + { + if (!\extension_loaded('ffi')) { + throw new UnsupportedOperationException('LibGit2Driver requires the FFI extension; use CliDriver.'); + } + + try { + \FFI::cdef(); + } catch (\FFI\Exception $e) { + // ffi.enable defaults to "preload": outside the CLI, only preloaded code can use FFI. + throw new UnsupportedOperationException('The "ffi.enable" setting restricts the FFI API: call LibGit2Driver::preload() from the opcache.preload script, set "ffi.enable" to "true", or use CliDriver.', 0, $e); + } + } + + private static function assertSupportedVersion(string $libraryPath): void + { + try { + $ffi = \FFI::cdef('int git_libgit2_version(int *major, int *minor, int *rev); int git_libgit2_features(void);', $libraryPath); + } catch (\FFI\Exception $e) { + throw new UnsupportedOperationException(\sprintf('Unable to load libgit2 from "%s"; use CliDriver.', $libraryPath), 0, $e); + } + + $major = $ffi->new('int'); + $minor = $ffi->new('int'); + $rev = $ffi->new('int'); + $ffi->git_libgit2_version(\FFI::addr($major), \FFI::addr($minor), \FFI::addr($rev)); + if (self::SUPPORTED_VERSION !== [$major->cdata, $minor->cdata]) { + throw new UnsupportedOperationException(\sprintf('LibGit2Driver requires libgit2 %s, but "%s" is libgit2 %d.%d.%d; use CliDriver.', implode('.', self::SUPPORTED_VERSION), $libraryPath, $major->cdata, $minor->cdata, $rev->cdata)); + } + + // Its experimental SHA-256 build changes git_oid, and every structure holding one. + if (0 !== ($ffi->git_libgit2_features() & self::GIT_FEATURE_SHA256)) { + throw new UnsupportedOperationException(\sprintf('LibGit2Driver requires libgit2 built for SHA-1 only, but "%s" supports SHA-256; use CliDriver.', $libraryPath)); + } + } + + private static function initialize(\FFI $ffi): void + { + if ($ffi->git_libgit2_init() < 0) { + throw new UnsupportedOperationException(\sprintf('Unable to initialize libgit2: %s', self::lastErrorMessage($ffi))); + } + + // Like git, no system configuration (/etc/gitconfig) when + // GIT_CONFIG_NOSYSTEM is true: libgit2 only reads this variable when + // it reads GIT_DIR, GIT_INDEX_FILE... too. An empty search path also + // leaves the system gitattributes out. FFI passes the level, a + // variadic argument, as a 64-bit integer, which reads as the int + // libgit2 expects. + if (filter_var(getenv('GIT_CONFIG_NOSYSTEM'), \FILTER_VALIDATE_BOOL) && $ffi->git_libgit2_opts(self::GIT_OPT_SET_SEARCH_PATH, self::GIT_CONFIG_LEVEL_SYSTEM, '') < 0) { + throw new UnsupportedOperationException(\sprintf('Unable to ignore the system configuration: %s', self::lastErrorMessage($ffi))); + } + } + + private static function locate(): string + { + foreach (self::LIBRARY_NAMES as $name) { + try { + \FFI::cdef('', $name); + + return $name; + } catch (\FFI\Exception) { + continue; + } + } + + throw new UnsupportedOperationException('LibGit2Driver requires libgit2 1.9, but none could be found: pass its path to LibGit2Driver, or use CliDriver.'); + } + + private static function lastErrorMessage(\FFI $ffi): string + { + $error = $ffi->git_error_last(); + if (\FFI::isNull($error)) { + return 'unknown error.'; + } + + return rtrim(\FFI::string($error->message), '.').'.'; + } +} diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php new file mode 100644 index 00000000..3d227ccf --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -0,0 +1,480 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Ffi; + +use FFI\CData; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\RepositoryPaths; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\Credentials; +use Gitonomy\Git\Util\NulByte; +use Gitonomy\Git\Util\RemoteUrl; + +/** + * Runs libgit2 1.9 in-process, through PHP's FFI extension. + * + * @author Grégoire Pineau + * + * @experimental + */ +final readonly class LibGit2Driver implements DriverInterface +{ + private const int GIT_CHECKOUT_FORCE = 1 << 1; + private const int GIT_CHECKOUT_NONE = 1 << 30; + private const int GIT_DIRECTION_FETCH = 0; + private const int GIT_PROXY_AUTO = 1; + private const int GIT_REMOTE_DOWNLOAD_TAGS_ALL = 3; + + private \FFI $ffi; + + /** + * @param string|null $libraryPath The libgit2 shared library, looked up under its usual names when null + * + * @throws UnsupportedOperationException The FFI extension or libgit2 1.9 is missing, or "ffi.enable" restricts FFI + */ + public function __construct(?string $libraryPath = null) + { + $this->ffi = LibGit2::load($libraryPath); + } + + /** + * To call from the opcache.preload script, with php-fpm for instance: + * libgit2 then stays loaded and initialized for the whole process, + * instead of every request loading it again (about 10 ms), and the driver + * may call FFI whatever "ffi.enable" is. + * + * @param string|null $libraryPath The libgit2 shared library, as given to the constructor + * + * @throws UnsupportedOperationException The FFI extension or libgit2 1.9 is missing + */ + public static function preload(?string $libraryPath = null): void + { + LibGit2::preload($libraryPath); + + // Classes declared while preloading may call FFI, even when + // "ffi.enable" is "preload", its default. + class_exists(LibGit2RepositoryDriver::class); + class_exists(PathFilter::class); + } + + public static function isSupported(): bool + { + try { + LibGit2::load(null); + + return true; + } catch (UnsupportedOperationException) { + return false; + } + } + + public function open(string $dir, ?string $workingDir): LibGit2RepositoryDriver + { + $paths = RepositoryPaths::resolve($dir, $workingDir); + + return new LibGit2RepositoryDriver($this->ffi, $paths->gitDir, $paths->workingDir); + } + + public function init(string $path): void + { + NulByte::assertNone($path); + + $this->ffi->git_repository_free($this->initRepository($path, false)); + } + + public function initBare(string $path): void + { + NulByte::assertNone($path); + + $this->ffi->git_repository_free($this->initRepository($path, true)); + } + + public function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void + { + NulByte::assertNone($url, $path, $branch ?? ''); + // libgit2 runs no command for a URL, unlike git: refused like by CliDriver. + RemoteUrl::assertSafe($url); + self::assertNotBundle($url); + + // git_clone() only checks a branch out, where git also checks a tag out, HEAD detached. + $tag = null !== $branch && $this->isTagOnly($url, $branch) ? $branch : null; + + $options = $this->ffi->new('git_clone_options'); + LibGit2::check($this->ffi, $this->ffi->git_clone_options_init(\FFI::addr($options), 1), 'Unable to initialize clone options'); + // Like git, through the proxy that http.proxy or the environment configures. + $options->fetch_opts->proxy_opts->type = self::GIT_PROXY_AUTO; + + // The buffer must stay alive until git_clone() returns. + $branchBuffer = null; + if (null !== $tag) { + $options->checkout_opts->checkout_strategy = self::GIT_CHECKOUT_NONE; + } elseif (null !== $branch) { + $branchBuffer = LibGit2::cString($this->ffi, $branch); + $options->checkout_branch = $this->ffi->cast('const char *', $branchBuffer); + } + + $existing = is_dir($path); + $repository = $this->ffi->new('git_repository*'); + LibGit2::check($this->ffi, $this->ffi->git_clone(\FFI::addr($repository), $url, $path, \FFI::addr($options)), \sprintf('Unable to clone "%s"', $url)); + + try { + try { + if (null !== $tag) { + $this->checkOutTag($repository, $tag); + } + } finally { + $this->ffi->git_repository_free($repository); + } + } catch (DriverException $e) { + // Like git, which removes what it created when a clone fails. + self::remove($path, $existing); + + throw $e; + } + + self::removeFetchHead($path.'/.git'); + } + + public function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void + { + NulByte::assertNone($url, $path, $branch ?? ''); + RemoteUrl::assertSafe($url); + self::assertNotBundle($url); + + // Like git, only the branches (and the tags). + $this->fetchInto($path, $url, '+refs/heads/*:refs/heads/*', $branch, ['remote.origin.url' => $url]); + } + + public function mirrorFrom(#[\SensitiveParameter] string $url, string $path): void + { + NulByte::assertNone($url, $path); + RemoteUrl::assertSafe($url); + self::assertNotBundle($url); + + $this->fetchInto($path, $url, '+refs/*:refs/*', null, [ + 'remote.origin.url' => $url, + 'remote.origin.fetch' => '+refs/*:refs/*', + 'remote.origin.mirror' => 'true', + 'remote.origin.tagopt' => '--no-tags', + ]); + } + + public function isValidRepository(#[\SensitiveParameter] string $url): bool + { + NulByte::assertNone($url); + RemoteUrl::assertSafe($url); + + return null !== $this->listRemoteReferences($url); + } + + public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch): bool + { + NulByte::assertNone($url, $branch); + RemoteUrl::assertSafe($url); + + return \in_array('refs/heads/'.$branch, $this->listRemoteReferences($url) ?? [], true); + } + + /** + * git clones a bundle file like a repository, libgit2 doesn't. + * + * @throws UnsupportedOperationException $url is a bundle + */ + private static function assertNotBundle(#[\SensitiveParameter] string $url): void + { + // A local path only, a Windows one included: is_file() would hand any + // other URL to the stream wrapper of its scheme, like the one of + // "ftp://", which connects with the credentials of the URL. A + // "host:path" address isn't local either. + $path = str_starts_with($url, 'file://') ? substr($url, \strlen('file://')) : $url; + if (1 === preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $path) && 1 !== preg_match('#^[a-zA-Z]:[\\\/]#', $path)) { + return; + } + + // The signatures of the formats of bundles. + if (is_file($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)); + } + } + + private function initRepository(string $path, bool $bare): CData + { + $repository = $this->ffi->new('git_repository*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_init(\FFI::addr($repository), $path, (int) $bare), \sprintf('Unable to initialize a repository in "%s"', $path)); + + return $repository; + } + + /** + * Initializes a bare repository in $path, and fetches $refspec from $url + * into it. + * + * @param array $config + */ + private function fetchInto(string $path, #[\SensitiveParameter] string $url, string $refspec, ?string $branch, #[\SensitiveParameter] array $config): void + { + // Like git, which refuses to clone into a directory with files. + $existing = is_dir($path); + if ($existing && [] !== array_diff(scandir($path) ?: [], ['.', '..'])) { + throw new DriverException(\sprintf('Unable to clone "%s": "%s" exists and is not an empty directory.', Credentials::redact($url), $path)); + } + + $repository = $this->initRepository($path, true); + + try { + try { + $this->fetch($repository, $url, $refspec, $branch); + $this->setConfig($repository, $config); + } finally { + $this->ffi->git_repository_free($repository); + } + } catch (DriverException $e) { + // Like git, which removes what it created when a clone fails. + self::remove($path, $existing); + + throw $e; + } + + self::removeFetchHead($path); + } + + /** + * libgit2 writes an empty FETCH_HEAD, even when told not to, where a clone with git writes none. + */ + private static function removeFetchHead(string $gitDir): void + { + if (is_file($gitDir.'/FETCH_HEAD')) { + unlink($gitDir.'/FETCH_HEAD'); + } + } + + /** + * @return bool Whether $url has a tag named $name, and no branch of this name + */ + private function isTagOnly(#[\SensitiveParameter] string $url, string $name): bool + { + $references = $this->listRemoteReferences($url) ?? []; + + return !\in_array('refs/heads/'.$name, $references, true) && \in_array('refs/tags/'.$name, $references, true); + } + + /** + * Checks the commit of the tag $tag out, HEAD detached, and removes the + * local branch git_clone() created: git creates none. + */ + private function checkOutTag(CData $repository, string $tag): void + { + $head = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_lookup(\FFI::addr($head), $repository, 'HEAD'), 'Unable to read HEAD'); + + try { + $branch = $this->ffi->git_reference_type($head) === $this->ffi->GIT_REFERENCE_SYMBOLIC ? $this->ffi->git_reference_symbolic_target($head) : null; + } finally { + $this->ffi->git_reference_free($head); + } + + $commit = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_revparse_single(\FFI::addr($commit), $repository, 'refs/tags/'.$tag.'^{commit}'), \sprintf('Unable to resolve the tag "%s"', $tag)); + + try { + $options = $this->ffi->new('git_checkout_options'); + LibGit2::check($this->ffi, $this->ffi->git_checkout_options_init(\FFI::addr($options), 1), 'Unable to initialize checkout options'); + // The clone wrote no file. + $options->checkout_strategy = self::GIT_CHECKOUT_FORCE; + LibGit2::check($this->ffi, $this->ffi->git_checkout_tree($repository, $commit, \FFI::addr($options)), \sprintf('Unable to checkout the tag "%s"', $tag)); + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($repository, $this->ffi->git_object_id($commit)), 'Unable to detach HEAD'); + } finally { + $this->ffi->git_object_free($commit); + } + + if (null === $branch) { + return; + } + + $reference = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($reference), $repository, $branch); + // An empty remote has no default branch. + if (LibGit2::GIT_ENOTFOUND === $rc) { + return; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up "%s"', $branch)); + + try { + LibGit2::check($this->ffi, $this->ffi->git_branch_delete($reference), \sprintf('Unable to delete "%s"', $branch)); + } finally { + $this->ffi->git_reference_free($reference); + } + } + + /** + * Removes $path, or only its content when $keep. + */ + private static function remove(string $path, bool $keep): void + { + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + /** @var \SplFileInfo $file */ + foreach ($files as $file) { + if (is_dir($file) && !is_link($file)) { + rmdir($file); + } else { + unlink($file); + } + } + + if (!$keep) { + rmdir($path); + } + } + + /** + * Fetches $refspec from $url, and points HEAD to $branch, or to the + * default branch of $url. + */ + private function fetch(CData $repository, #[\SensitiveParameter] string $url, string $refspec, ?string $branch): void + { + $remote = $this->ffi->new('git_remote*'); + LibGit2::check($this->ffi, $this->ffi->git_remote_create_anonymous(\FFI::addr($remote), $repository, $url), \sprintf('Unable to create a remote for "%s"', $url)); + + try { + $options = $this->ffi->new('git_fetch_options'); + LibGit2::check($this->ffi, $this->ffi->git_fetch_options_init(\FFI::addr($options), 1), 'Unable to initialize fetch options'); + $options->download_tags = self::GIT_REMOTE_DOWNLOAD_TAGS_ALL; + $options->proxy_opts->type = self::GIT_PROXY_AUTO; + // A clone writes no FETCH_HEAD. + $options->update_fetchhead = 0; + + $refspecBuffer = LibGit2::cString($this->ffi, $refspec); + $refspecs = $this->ffi->new('char*[1]'); + $refspecs[0] = $this->ffi->cast('char *', $refspecBuffer); + $refspecArray = $this->ffi->new('git_strarray'); + $refspecArray->strings = $this->ffi->cast('char **', $refspecs); + $refspecArray->count = 1; + + LibGit2::check($this->ffi, $this->ffi->git_remote_fetch($remote, \FFI::addr($refspecArray), \FFI::addr($options), null), \sprintf('Unable to fetch "%s"', $url)); + + $head = null !== $branch ? 'refs/heads/'.$branch : $this->defaultBranch($remote); + } finally { + $this->ffi->git_remote_free($remote); + } + + if (null === $head) { + return; + } + + $reference = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($reference), $repository, $head); + if (LibGit2::GIT_ENOTFOUND === $rc && null !== $branch) { + // Like git, HEAD detached at the commit of a tag of this name. + $this->detachHeadAtTag($repository, $branch, $url); + + return; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up "%s"', $head)); + $this->ffi->git_reference_free($reference); + + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($repository, $head), 'Unable to update HEAD'); + } + + /** + * @throws DriverException $repository has no tag named $tag + */ + private function detachHeadAtTag(CData $repository, string $tag, #[\SensitiveParameter] string $url): void + { + $commit = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_revparse_single(\FFI::addr($commit), $repository, 'refs/tags/'.$tag.'^{commit}'); + if (LibGit2::GIT_ENOTFOUND === $rc) { + throw new DriverException(\sprintf('Remote branch "%s" not found in "%s".', $tag, Credentials::redact($url))); + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve the tag "%s"', $tag)); + + try { + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($repository, $this->ffi->git_object_id($commit)), 'Unable to detach HEAD'); + } finally { + $this->ffi->git_object_free($commit); + } + } + + /** + * @return string|null The full name of the branch HEAD points to on the remote, or null for an empty repository + */ + private function defaultBranch(CData $remote): ?string + { + $buffer = $this->ffi->new('git_buf'); + $rc = $this->ffi->git_remote_default_branch(\FFI::addr($buffer), $remote); + if (LibGit2::GIT_ENOTFOUND === $rc) { + return null; + } + LibGit2::check($this->ffi, $rc, 'Unable to find the default branch of the remote'); + + try { + return \FFI::string($buffer->ptr, $buffer->size); + } finally { + $this->ffi->git_buf_dispose(\FFI::addr($buffer)); + } + } + + /** + * @param array $config + */ + private function setConfig(CData $repository, #[\SensitiveParameter] array $config): void + { + $handle = $this->ffi->new('git_config*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_config(\FFI::addr($handle), $repository), 'Unable to open the repository configuration'); + + try { + foreach ($config as $name => $value) { + LibGit2::check($this->ffi, $this->ffi->git_config_set_string($handle, $name, $value), \sprintf('Unable to set "%s"', $name)); + } + } finally { + $this->ffi->git_config_free($handle); + } + } + + /** + * @return string[]|null The full names of the references of $url, or null when it can't be reached + */ + private function listRemoteReferences(#[\SensitiveParameter] string $url): ?array + { + $remote = $this->ffi->new('git_remote*'); + if ($this->ffi->git_remote_create_detached(\FFI::addr($remote), $url) < 0) { + return null; + } + + try { + $proxy = $this->ffi->new('git_proxy_options'); + $proxy->version = 1; + $proxy->type = self::GIT_PROXY_AUTO; + if ($this->ffi->git_remote_connect($remote, self::GIT_DIRECTION_FETCH, null, \FFI::addr($proxy), null) < 0) { + return null; + } + + try { + $heads = $this->ffi->new('git_remote_head**'); + $count = $this->ffi->new('size_t'); + LibGit2::check($this->ffi, $this->ffi->git_remote_ls(\FFI::addr($heads), \FFI::addr($count), $remote), \sprintf('Unable to list the references of "%s"', $url)); + + $references = []; + for ($i = 0; $i < $count->cdata; ++$i) { + $references[] = \FFI::string($heads[$i]->name); + } + + return $references; + } finally { + $this->ffi->git_remote_disconnect($remote); + } + } finally { + $this->ffi->git_remote_free($remote); + } + } +} diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php new file mode 100644 index 00000000..fb569e03 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -0,0 +1,2066 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Ffi; + +use FFI\CData; +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\DiffLineType; +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Driver\Data\BlameLineData; +use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Driver\Data\DiffData; +use Gitonomy\Git\Driver\Data\DiffFileData; +use Gitonomy\Git\Driver\Data\ObjectType; +use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Driver\Data\TagData; +use Gitonomy\Git\Driver\Data\TreeEntryData; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\FastForward; +use Gitonomy\Git\Util\LeadingDash; +use Gitonomy\Git\Util\NulByte; +use Gitonomy\Git\Util\ReferenceName; +use Symfony\Component\String\ByteString; +use Symfony\Component\String\Exception\InvalidArgumentException; + +/** + * Runs libgit2 in-process on a repository, through PHP's FFI extension. + * + * Every libgit2 object it allocates is freed in a `finally` block, so that a + * failure halfway through an operation doesn't leak native memory. + * + * @author Grégoire Pineau + * + * @internal + */ +final readonly class LibGit2RepositoryDriver implements RepositoryDriverInterface +{ + private const int GIT_EAMBIGUOUS = -5; + private const int GIT_EUNBORNBRANCH = -9; + private const int GIT_EINVALIDSPEC = -12; + private const int GIT_EPEEL = -19; + private const int GIT_ITEROVER = -31; + + private const int GIT_SORT_NONE = 0; + private const int GIT_SORT_TOPOLOGICAL = 1 << 0; + private const int GIT_DELTA_ADDED = 1; + private const int GIT_DELTA_CONFLICTED = 10; + private const int GIT_DELTA_COPIED = 5; + private const int GIT_DELTA_DELETED = 2; + private const int GIT_DIFF_DISABLE_PATHSPEC_MATCH = 1 << 12; + private const int GIT_DIFF_FLAG_BINARY = 1 << 0; + private const int GIT_DIFF_INDENT_HEURISTIC = 1 << 18; + private const int GIT_DIFF_FIND_RENAMES = 1 << 0; + private const int GIT_DIRECTION_FETCH = 0; + private const int GIT_INDEX_ENTRY_INTENT_TO_ADD = 1 << 13; + private const int GIT_STATUS_OPT_INCLUDE_UNTRACKED = 1 << 0; + private const int GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS = 1 << 4; + private const int GIT_STATUS_WT_NEW = 1 << 7; + + /** + * The hash of the empty file, which `git add -N` stages. + */ + private const string EMPTY_BLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'; + + private CData $repo; + + /** + * @param string $gitDir The git directory + * @param string|null $workingDir The working tree, null for a bare repository + * + * @throws UnsupportedOperationException The repository stores its references in the reftable format + */ + public function __construct( + private \FFI $ffi, + private string $gitDir, + private ?string $workingDir, + ) { + // libgit2 1.9 refuses to open it, as any repository with an extension it doesn't know. + if (is_dir($gitDir.'/reftable')) { + throw new UnsupportedOperationException(\sprintf('The repository "%s" stores its references in the reftable format, which LibGit2Driver doesn\'t support; use CliDriver.', $gitDir)); + } + + $out = $this->ffi->new('git_repository*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_open(\FFI::addr($out), $gitDir), \sprintf('Unable to open the repository "%s"', $gitDir)); + + if (null !== $workingDir) { + try { + LibGit2::check($this->ffi, $this->ffi->git_repository_set_workdir($out, $workingDir, 0), 'Unable to set working directory'); + } catch (DriverException $e) { + // __destruct() isn't called when the constructor throws. + $this->ffi->git_repository_free($out); + + throw $e; + } + } + + $this->repo = $out; + } + + public function __destruct() + { + $this->ffi->git_repository_free($this->repo); + } + + /** + * Two drivers sharing one git_repository would free it twice. + */ + private function __clone() + { + } + + public function getGitDir(): string + { + return $this->gitDir; + } + + public function getWorkingDir(): ?string + { + return $this->workingDir; + } + + public function getHeadReference(): ?string + { + $head = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_lookup(\FFI::addr($head), $this->repo, 'HEAD'), 'Unable to read HEAD'); + + try { + if ($this->ffi->git_reference_type($head) !== $this->ffi->GIT_REFERENCE_SYMBOLIC) { + return null; + } + + return $this->ffi->git_reference_symbolic_target($head); + } finally { + $this->ffi->git_reference_free($head); + } + } + + public function resolveRevision(string $revision): string + { + NulByte::assertNone($revision); + LeadingDash::assertNone($revision); + + $object = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_revparse_single(\FFI::addr($object), $this->repo, self::expandAt($revision)); + if (\in_array($rc, [LibGit2::GIT_ENOTFOUND, self::GIT_EAMBIGUOUS, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { + throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist.', $revision)); + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve "%s"', $revision)); + + try { + return $this->oidToHex($this->ffi->git_object_id($object)); + } finally { + $this->ffi->git_object_free($object); + } + } + + public function getCommitData(string $hash): CommitData + { + NulByte::assertNone($hash); + LeadingDash::assertNone($hash); + + $commit = $this->lookupCommit($hash); + + try { + return $this->commitToData($commit); + } finally { + $this->ffi->git_commit_free($commit); + } + } + + public function getCommitLog(?array $includes, array $excludes, array $paths, int $offset, ?int $limit): array + { + NulByte::assertNone(...$includes ?? [], ...$excludes, ...$paths); + LeadingDash::assertNone(...$includes ?? [], ...$excludes); + + $result = []; + if (0 === $limit) { + return $result; + } + + $skipped = 0; + foreach ($this->walk($includes, $excludes, $paths) as $hash) { + if ($skipped < $offset) { + ++$skipped; + + continue; + } + + $result[] = $this->getCommitData($hash); + + // Stops before walking to the next commit: it can be far away in the history. + if (null !== $limit && \count($result) >= $limit) { + break; + } + } + + return $result; + } + + public function countCommits(?array $includes, array $excludes, array $paths): int + { + NulByte::assertNone(...$includes ?? [], ...$excludes, ...$paths); + LeadingDash::assertNone(...$includes ?? [], ...$excludes); + + return iterator_count($this->walk($includes, $excludes, $paths)); + } + + public function getMergeBases(string $hash1, string $hash2): array + { + NulByte::assertNone($hash1, $hash2); + LeadingDash::assertNone($hash1, $hash2); + + $oneOid = $this->hexToOid($hash1); + $twoOid = $this->hexToOid($hash2); + $bases = $this->ffi->new('git_oidarray'); + $rc = $this->ffi->git_merge_bases(\FFI::addr($bases), $this->repo, \FFI::addr($oneOid), \FFI::addr($twoOid)); + if (LibGit2::GIT_ENOTFOUND === $rc) { + return []; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to compute the merge bases of "%s" and "%s"', $hash1, $hash2)); + + try { + $hashes = []; + for ($i = 0; $i < $bases->count; ++$i) { + $hashes[] = $this->oidToHex(\FFI::addr($bases->ids[$i])); + } + + return $hashes; + } finally { + $this->ffi->git_oidarray_dispose(\FFI::addr($bases)); + } + } + + public function getShortHash(string $hash): string + { + NulByte::assertNone($hash); + LeadingDash::assertNone($hash); + + $object = $this->lookupObject($hash); + + try { + $buf = $this->ffi->new('git_buf'); + LibGit2::check($this->ffi, $this->ffi->git_object_short_id(\FFI::addr($buf), $object), 'Unable to compute short hash'); + + try { + return \FFI::string($buf->ptr, $buf->size); + } finally { + $this->ffi->git_buf_dispose(\FFI::addr($buf)); + } + } finally { + $this->ffi->git_object_free($object); + } + } + + public function getTreeEntries(string $hash): array + { + NulByte::assertNone($hash); + + $tree = $this->lookupTree($hash); + + try { + $count = $this->ffi->git_tree_entrycount($tree); + $result = []; + for ($i = 0; $i < $count; ++$i) { + $entry = $this->ffi->git_tree_entry_byindex($tree, $i); + $result[] = new TreeEntryData( + \sprintf('%06o', $this->ffi->git_tree_entry_filemode($entry)), + $this->objectType($this->ffi->git_tree_entry_type($entry)), + $this->oidToHex($this->ffi->git_tree_entry_id($entry)), + $this->ffi->git_tree_entry_name($entry), + ); + } + + return $result; + } finally { + $this->ffi->git_tree_free($tree); + } + } + + public function getBlobContent(string $hash): string + { + NulByte::assertNone($hash); + + $oid = $this->hexToOid($hash); + $blob = $this->ffi->new('git_blob*'); + $this->checkFound($this->ffi->git_blob_lookup_prefix(\FFI::addr($blob), $this->repo, \FFI::addr($oid), \strlen($hash)), $hash, ObjectType::Blob, 'Unable to read the blob'); + + try { + return \FFI::string($this->ffi->git_blob_rawcontent($blob), $this->ffi->git_blob_rawsize($blob)); + } finally { + $this->ffi->git_blob_free($blob); + } + } + + public function getTagData(string $hash): TagData + { + NulByte::assertNone($hash); + + $object = $this->lookupObject($hash); + + try { + if ($this->ffi->git_object_type($object) !== $this->ffi->GIT_OBJECT_TAG) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a tag.', $hash)); + } + + $tag = $this->ffi->new('git_tag*'); + LibGit2::check($this->ffi, $this->ffi->git_tag_lookup(\FFI::addr($tag), $this->repo, $this->ffi->git_object_id($object)), 'Unable to look up tag'); + } finally { + $this->ffi->git_object_free($object); + } + + try { + // The oldest tags, like the first ones of git itself, have no tagger. + $tagger = $this->ffi->git_tag_tagger($tag); + + return TagData::fromSignedMessage( + null === $tagger ? null : \FFI::string($tagger->name), + null === $tagger ? null : \FFI::string($tagger->email), + null === $tagger ? null : $this->signatureDate($tagger), + $this->ffi->git_tag_message($tag) ?? '', + ); + } finally { + $this->ffi->git_tag_free($tag); + } + } + + public function getReferences(): array + { + $result = []; + foreach ($this->iterateReferences() as $ref) { + $hash = $this->referenceTargetHex($ref); + if (null === $hash) { + continue; + } + + $fullname = $this->ffi->git_reference_name($ref); + // Like git, through an annotated tag, whatever the name of the reference: a fetch can put one under refs/remotes/. + $commitHash = $this->peeledHex($ref); + $result[$fullname] = new ReferenceData($fullname, $commitHash, $commitHash === $hash ? null : $hash); + } + + // Like git, sorted by name: libgit2 lists the loose references first. + ksort($result, \SORT_STRING); + + return array_values($result); + } + + public function createReference(string $fullname, string $hash): void + { + NulByte::assertNone($fullname, $hash); + LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); + + $oid = $this->hexToOid($hash); + $ref = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_create(\FFI::addr($ref), $this->repo, $fullname, \FFI::addr($oid), 0, null), \sprintf('Unable to create reference "%s"', $fullname)); + $this->ffi->git_reference_free($ref); + } + + public function updateReference(string $fullname, string $hash): void + { + NulByte::assertNone($fullname, $hash); + LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); + + $oid = $this->hexToOid($hash); + $ref = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_create(\FFI::addr($ref), $this->repo, $fullname, \FFI::addr($oid), 1, null), \sprintf('Unable to update reference "%s"', $fullname)); + $this->ffi->git_reference_free($ref); + } + + public function deleteReference(string $fullname): void + { + NulByte::assertNone($fullname); + LeadingDash::assertNone($fullname); + ReferenceName::assertUnderRefs($fullname); + + $ref = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname); + // Like git update-ref -d, deleting a missing reference does nothing. + if (LibGit2::GIT_ENOTFOUND === $rc) { + return; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to find reference "%s"', $fullname)); + + try { + LibGit2::check($this->ffi, $this->ffi->git_reference_delete($ref), \sprintf('Unable to delete reference "%s"', $fullname)); + } finally { + $this->ffi->git_reference_free($ref); + } + } + + public function getIncludingBranches(string $hash): array + { + NulByte::assertNone($hash); + LeadingDash::assertNone($hash); + + $tips = []; + foreach ($this->iterateBranches() as [$fullname, $ref]) { + // Like git, through an annotated tag, and without the branches pointing to no commit. + $commit = $this->peeledCommitHex($ref); + if (null !== $commit) { + $tips[$fullname] = $commit; + } + } + // Like git: the local branches, then the remote ones, sorted by name. + ksort($tips, \SORT_STRING); + + // One walk from every branch down to $hash, instead of a merge base + // per branch: parents first, a commit reaches $hash when one of its + // parents does. + $reaching = [$hash => true]; + $commits = iterator_to_array($this->revwalk(array_values($tips), $this->parentHashes($hash), self::GIT_SORT_TOPOLOGICAL), false); + foreach (array_reverse($commits) as $commit) { + foreach ($this->parentHashes($commit) as $parent) { + if (isset($reaching[$parent])) { + $reaching[$commit] = true; + + break; + } + } + } + + return array_keys(array_filter($tips, static fn (string $tip): bool => isset($reaching[$tip]))); + } + + public function getDiff(?string $from, string $to): DiffData + { + NulByte::assertNone($from ?? '', $to); + LeadingDash::assertNone($from ?? '', $to); + + $oldTree = null === $from ? null : $this->treeForRevision($from); + + try { + $newTree = $this->treeForRevision($to); + + try { + return $this->diffTrees($oldTree, $newTree); + } finally { + $this->ffi->git_tree_free($newTree); + } + } finally { + if (null !== $oldTree) { + $this->ffi->git_tree_free($oldTree); + } + } + } + + public function getWorkingTreeDiff(string $hash): DiffData + { + NulByte::assertNone($hash); + LeadingDash::assertNone($hash); + + $tree = $this->treeForRevision($hash); + + try { + $diff = $this->ffi->new('git_diff*'); + $opts = $this->diffOptions(); + LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_workdir_with_index(\FFI::addr($diff), $this->repo, $tree, \FFI::addr($opts)), 'Unable to compute working copy diff'); + $unmerged = $this->unmergedPaths($diff); + // libgit2 keeps a file the working tree changes back to the commit, after the index changed it, or `git add -N` added it. + $files = array_values(array_filter($this->buildDiff($diff)->files, static fn (DiffFileData $file): bool => [] !== $file->changes || $file->oldName !== $file->newName || $file->oldMode !== $file->newMode || $file->oldIndex !== $file->newIndex)); + + if ([] !== $unmerged) { + // Like git diff , which compares the unmerged files, conflict markers included, to the commit. + $unmergedDiff = $this->ffi->new('git_diff*'); + $opts = $this->diffOptions(); + // Keeps the C strings of the pathspec alive until the diff is computed. + $pathspec = $this->pathspec($opts, $unmerged); + $opts->flags |= self::GIT_DIFF_DISABLE_PATHSPEC_MATCH; + LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_workdir(\FFI::addr($unmergedDiff), $this->repo, $tree, \FFI::addr($opts)), 'Unable to compute the diff of the unmerged files'); + + $files = [...$files, ...$this->buildDiff($unmergedDiff)->files]; + // Sorted by name, like git. + usort($files, static fn (DiffFileData $a, DiffFileData $b): int => strcmp($a->name, $b->name)); + } + } finally { + $this->ffi->git_tree_free($tree); + } + + return new DiffData($files, ''); + } + + public function getDiffPending(): DiffData + { + $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) { + if (isset($intentToAdd[$file->name]) && null !== $file->newName) { + $files[] = new DiffFileData(null, $file->newName, null, $file->newMode, null, $file->newIndex, $file->isBinary, $file->isCopy, $file->changes); + } else { + $files[] = $file; + } + unset($intentToAdd[$file->name]); + } + + if ([] !== $intentToAdd) { + // Still empty, as `git add -N` staged them: libgit2 sees no change. + foreach ($intentToAdd as $path => $mode) { + $files[] = new DiffFileData(null, $path, null, $mode, null, self::EMPTY_BLOB, false, false, []); + } + // Sorted by name, like git. + usort($files, static fn (DiffFileData $a, DiffFileData $b): int => strcmp($a->name, $b->name)); + } + + return new DiffData($files, ''); + } + + public function getDiffStaged(): DiffData + { + $tree = $this->headTree(); + + try { + $diff = $this->ffi->new('git_diff*'); + $opts = $this->diffOptions(); + LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_index(\FFI::addr($diff), $this->repo, $tree, null, \FFI::addr($opts)), 'Unable to compute staged diff'); + } finally { + if (null !== $tree) { + $this->ffi->git_tree_free($tree); + } + } + + // Like git since 2.19, which diffs the files `git add -N` added as absent from the index: not staged, or deleted when HEAD has them. + $intentToAdd = $this->intentToAddFiles(); + $files = []; + foreach ($this->buildDiff($diff)->files as $file) { + if (!isset($intentToAdd[$file->name])) { + $files[] = $file; + } elseif (null !== $file->oldName) { + $files[] = new DiffFileData($file->oldName, null, $file->oldMode, null, $file->oldIndex, null, $file->isBinary, $file->isCopy, $file->changes); + } + } + + return new DiffData($files, ''); + } + + public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array + { + NulByte::assertNone($hash, $path); + LeadingDash::assertNone($hash); + + $range = null !== $firstLine || null !== $lastLine; + $firstLine ??= 1; + + $options = $this->ffi->new('git_blame_options'); + LibGit2::check($this->ffi, $this->ffi->git_blame_options_init(\FFI::addr($options), 1), 'Unable to initialize blame options'); + $options->newest_commit = $this->hexToOid($this->resolveCommit($hash)); + + $blame = $this->ffi->new('git_blame*'); + LibGit2::check($this->ffi, $this->ffi->git_blame_file(\FFI::addr($blame), $this->repo, $this->toRepoRelativePath($path), \FFI::addr($options)), \sprintf('Unable to blame "%s"', $path)); + + try { + $count = $this->ffi->git_blame_linecount($blame); + // git_blame_linecount() counts one more line for a file not ending with a newline. + if ($count > 0 && null === $this->ffi->git_blame_line_byindex($blame, $count)) { + --$count; + } + // Like git, which stops a range at the end of the file, but refuses one starting after it. + if ($range && $firstLine > $count) { + throw new DriverException(\sprintf('The file "%s" has only %d lines.', $path, $count)); + } + + $result = []; + for ($i = $firstLine; $i <= min($lastLine ?? $count, $count); ++$i) { + $hunk = $this->ffi->git_blame_hunk_byline($blame, $i); + $line = $this->ffi->git_blame_line_byindex($blame, $i); + $content = rtrim(\FFI::string($line->ptr, $line->len), "\n"); + $originalNumber = $hunk->orig_start_line_number + ($i - $hunk->final_start_line_number); + + $result[] = new BlameLineData($this->oidToHex(\FFI::addr($hunk->final_commit_id)), $i, $originalNumber, $content); + } + + return $result; + } finally { + $this->ffi->git_blame_free($blame); + } + } + + public function getUntrackedFiles(): array + { + $opts = $this->ffi->new('git_status_options'); + LibGit2::check($this->ffi, $this->ffi->git_status_options_init(\FFI::addr($opts), 1), 'Unable to initialize status options'); + $opts->flags = self::GIT_STATUS_OPT_INCLUDE_UNTRACKED | self::GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS; + + $list = $this->ffi->new('git_status_list*'); + LibGit2::check($this->ffi, $this->ffi->git_status_list_new(\FFI::addr($list), $this->repo, \FFI::addr($opts)), 'Unable to compute status'); + + try { + $count = $this->ffi->git_status_list_entrycount($list); + $result = []; + for ($i = 0; $i < $count; ++$i) { + $entry = $this->ffi->git_status_byindex($list, $i); + if (0 !== ($entry->status & self::GIT_STATUS_WT_NEW)) { + $result[] = $entry->index_to_workdir->new_file->path; + } + } + + return $result; + } finally { + $this->ffi->git_status_list_free($list); + } + } + + public function checkout(string $revision): void + { + NulByte::assertNone($revision); + LeadingDash::assertNone($revision); + + // `git checkout ` attaches HEAD when names a + // local branch, before a tag of the same name, or is @{-}, the nth + // branch checked out before. It leaves HEAD as it is for HEAD, or @, + // and detaches it for anything else (a tag, a hash, a remote branch, a + // full reference name...). + $name = $this->expandPriorCheckout($revision); + $branch = $this->referenceExists('refs/heads/'.$name) ? 'refs/heads/'.$name : null; + // Resolved before changing anything. + $hash = $this->resolveCommit($branch ?? $revision); + + $this->checkoutRevision($hash); + + if (null !== $branch) { + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, $branch), 'Unable to update HEAD'); + } elseif ('HEAD' !== self::expandAt($revision)) { + $oid = $this->hexToOid($hash); + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($this->repo, \FFI::addr($oid)), 'Unable to detach HEAD'); + } + } + + public function checkoutNewBranch(string $branch, string $startPoint): void + { + NulByte::assertNone($branch, $startPoint); + LeadingDash::assertNone($branch, $startPoint); + + if ($this->referenceExists('refs/heads/'.$branch)) { + throw new DriverException(\sprintf('A branch named "%s" already exists.', $branch)); + } + + // Validated before changing anything. + $valid = $this->ffi->new('int'); + LibGit2::check($this->ffi, $this->ffi->git_branch_name_is_valid(\FFI::addr($valid), $branch), 'Unable to validate the branch name'); + if (0 === $valid->cdata) { + throw new DriverException(\sprintf('"%s" is not a valid branch name.', $branch)); + } + $startHash = $this->resolveCommit($startPoint); + $upstream = $this->upstreamOf($startPoint); + $commit = $this->lookupCommit($startHash); + + $reference = $this->ffi->new('git_reference*'); + + try { + LibGit2::check($this->ffi, $this->ffi->git_branch_create(\FFI::addr($reference), $this->repo, $branch, $commit, 0), \sprintf('Unable to create branch "%s"', $branch)); + } finally { + $this->ffi->git_commit_free($commit); + } + + // The branch and its upstream before the files, which may conflict + // with local changes: then, like git, nothing is left behind. + try { + if (null !== $upstream) { + $this->setUpstream($branch, ...$upstream); + } + $this->checkoutRevision($startHash); + } catch (DriverException $e) { + // Its configuration with it. + LibGit2::check($this->ffi, $this->ffi->git_branch_delete($reference), \sprintf('Unable to delete the branch "%s" after a failed checkout', $branch)); + + throw $e; + } finally { + $this->ffi->git_reference_free($reference); + } + + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, 'refs/heads/'.$branch), 'Unable to update HEAD'); + } + + public function merge(string $revision, FastForward $fastForward): void + { + NulByte::assertNone($revision); + LeadingDash::assertNone($revision); + + $branch = $this->getHeadReference(); + $theirHash = $this->resolveCommit($revision); + + // Like git, which fast-forwards a branch yet to be born. + if (null !== $branch && !$this->referenceExists($branch)) { + if (FastForward::Never === $fastForward) { + throw new DriverException(\sprintf('Unable to merge "%s" without a fast-forward: the branch "%s" has no commit yet.', $revision, $branch)); + } + + $this->fastForward($branch, $theirHash); + + return; + } + + $headHash = $this->resolveCommit('HEAD'); + $head = $this->hexToOid($headHash); + $their = $this->hexToOid($theirHash); + + // Already up to date, even with --no-ff. + if ($headHash === $theirHash || $this->isDescendantOf($head, $their)) { + return; + } + + $canFastForward = $this->isDescendantOf($their, $head); + if (FastForward::Only === $fastForward && !$canFastForward) { + throw new DriverException(\sprintf('Unable to fast-forward to "%s".', $revision)); + } + + if (FastForward::Never !== $fastForward && $canFastForward) { + $this->fastForward($branch, $theirHash); + + return; + } + + // Like git. + if ([] === $this->getMergeBases($headHash, $theirHash)) { + throw new DriverException(\sprintf('Refusing to merge "%s", whose history is unrelated.', $revision)); + } + if ([] !== $this->getDiffStaged()->files) { + throw new DriverException(\sprintf('Unable to merge "%s": commit or stash the staged changes first.', $revision)); + } + + $tree = $this->mergeTrees($headHash, $theirHash, $revision); + + try { + $mergeHash = $this->createMergeCommit($tree, $headHash, $theirHash, $this->mergeMessage($revision, $branch)); + $this->checkoutObject($this->ffi->cast('git_object *', $tree), 'Unable to checkout merge result'); + } finally { + $this->ffi->git_tree_free($tree); + } + $this->moveHead($branch, $mergeHash); + } + + // -- history ----------------------------------------------------------- + + /** + * Walks the history in `git log`'s default order. With $paths, only + * yields the commits that change them, and simplifies the history like + * git does. + * + * @param list|null $includes null for every reference and HEAD + * @param list $excludes + * @param string[] $paths + * + * @return \Generator Commit hashes + */ + private function walk(?array $includes, array $excludes, array $paths): \Generator + { + $tips = $includes ?? $this->referencedCommits(); + $bottoms = $excludes; + if ([] === $tips) { + return; + } + + $pathFilter = $this->pathFilter($paths); + + if (null === $pathFilter) { + yield from $this->revwalk($tips, $bottoms); + + return; + } + + // With hidden commits, libgit2 walks the whole range before yielding the first one anyway. + $interesting = [] === $bottoms ? null : array_fill_keys(iterator_to_array($this->revwalk($tips, $bottoms), false), true); + + yield from $this->simplifiedHistory($tips, $bottoms, $interesting, $pathFilter); + } + + /** + * Walks the history with libgit2, whose default sorting is git's. + * + * @param string[] $tips The commits to walk from + * @param string[] $bottoms The commits to hide, along with their ancestors + * + * @return \Generator + */ + private function revwalk(array $tips, array $bottoms, int $sorting = self::GIT_SORT_NONE): \Generator + { + $walker = $this->ffi->new('git_revwalk*'); + LibGit2::check($this->ffi, $this->ffi->git_revwalk_new(\FFI::addr($walker), $this->repo), 'Unable to create revwalk'); + + try { + LibGit2::check($this->ffi, $this->ffi->git_revwalk_sorting($walker, $sorting), 'Unable to sort the walk'); + + // git starts from the newest tip (the first given one, at equal + // dates), libgit2 from the last pushed one. + $times = array_map($this->commitTime(...), $tips); + uksort($tips, static fn (int $a, int $b): int => $times[$a] <=> $times[$b] ?: $b <=> $a); + foreach ($tips as $hash) { + $oid = $this->hexToOid($hash); + LibGit2::check($this->ffi, $this->ffi->git_revwalk_push($walker, \FFI::addr($oid)), \sprintf('Unable to push "%s"', $hash)); + } + foreach ($bottoms as $hash) { + $oid = $this->hexToOid($hash); + LibGit2::check($this->ffi, $this->ffi->git_revwalk_hide($walker, \FFI::addr($oid)), \sprintf('Unable to hide "%s"', $hash)); + } + + $oid = $this->ffi->new('git_oid'); + while (0 === ($rc = $this->ffi->git_revwalk_next(\FFI::addr($oid), $walker))) { + yield $this->oidToHex(\FFI::addr($oid)); + } + $this->checkIterationEnd($rc, 'Unable to walk the history'); + } finally { + $this->ffi->git_revwalk_free($walker); + } + } + + /** + * @return list The commits HEAD and the references point to; like `git log --all`, ignores the references to other objects and the dangling ones + */ + private function referencedCommits(): array + { + $hashes = []; + + // A detached HEAD may name a commit no reference has. + $head = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_repository_head(\FFI::addr($head), $this->repo); + if (self::GIT_EUNBORNBRANCH !== $rc && LibGit2::GIT_ENOTFOUND !== $rc) { + LibGit2::check($this->ffi, $rc, 'Unable to resolve HEAD'); + + try { + $commit = $this->peeledCommitHex($head); + if (null !== $commit) { + $hashes[] = $commit; + } + } finally { + $this->ffi->git_reference_free($head); + } + } + + foreach ($this->iterateReferences() as $ref) { + $commit = $this->peeledCommitHex($ref); + if (null !== $commit) { + $hashes[] = $commit; + } + } + + return $hashes; + } + + /** + * @param string[] $paths + * + * @throws UnsupportedOperationException A path uses the magic of git's pathspecs + */ + private function pathFilter(array $paths): ?PathFilter + { + if ([] === $paths) { + return null; + } + + foreach ($paths as $path) { + // git reads magic in a path starting with ":", like ":(exclude)src" or ":/src": libgit2 would match it as a name. + if (str_starts_with($path, ':')) { + throw new UnsupportedOperationException(\sprintf('The path "%s" uses the magic of git\'s pathspecs, which LibGit2Driver doesn\'t support; use CliDriver.', $path)); + } + } + + return new PathFilter($this->ffi, $this->repo, array_values(array_map($this->toRepoRelativePath(...), $paths)), $this->diffOptions()); + } + + /** + * Walks the history like git does with paths: from a merge that has the + * same paths as one of its parents, it only follows that parent, and it + * only yields the commits that change the paths. + * + * @param string[] $tips The commits to walk from + * @param string[] $bottoms The commits hidden along with their ancestors + * @param array|null $interesting The commits the bottoms don't hide, null without bottoms + * + * @return \Generator + */ + private function simplifiedHistory(array $tips, array $bottoms, ?array $interesting, PathFilter $pathFilter): \Generator + { + // git doesn't simplify a merge to a hidden parent, unless it is a bottom. + $relevant = null === $interesting ? null : $interesting + array_fill_keys($bottoms, true); + + // Newest commit first, then first queued first, like git. + $queue = new \SplPriorityQueue(); + $queued = []; + $enqueue = function (string $hash) use ($queue, &$queued, $interesting): void { + if (isset($queued[$hash]) || (null !== $interesting && !isset($interesting[$hash]))) { + return; + } + + $queued[$hash] = true; + $queue->insert($hash, [$this->commitTime($hash), -\count($queued)]); + }; + + foreach ($tips as $hash) { + $enqueue($hash); + } + + while (!$queue->isEmpty()) { + /** @var string $hash SplPriorityQueue extracts the data alone by default. */ + $hash = $queue->extract(); + + [$parents, $changed] = $this->simplifyCommit($hash, $pathFilter, $relevant); + foreach ($parents as $parent) { + $enqueue($parent); + } + + if ($changed) { + yield $hash; + } + } + } + + /** + * Compares a commit with its parents, on the paths of $pathFilter, like + * git's history simplification. + * + * @param array|null $relevant The parents a merge can be simplified to, null for any + * + * @return array{list, bool} The parents to follow, and whether the commit changes the paths + */ + private function simplifyCommit(string $hash, PathFilter $pathFilter, ?array $relevant): array + { + $commit = $this->lookupCommit($hash); + + try { + $tree = $this->commitTree($commit); + + try { + $count = $this->ffi->git_commit_parentcount($commit); + if (0 === $count) { + return [[], $pathFilter->differ(null, $tree)]; + } + + $parents = []; + $changed = false; + for ($i = 0; $i < $count; ++$i) { + $parents[] = $parent = $this->oidToHex($this->ffi->git_commit_parent_id($commit, $i)); + $parentTree = $this->parentTree($commit, $i); + + try { + $same = !$pathFilter->differ($parentTree, $tree); + } finally { + $this->ffi->git_tree_free($parentTree); + } + + if ($same && (null === $relevant || isset($relevant[$parent]))) { + return [[$parent], false]; + } + + $changed = $changed || !$same; + } + + return [$parents, $changed]; + } finally { + $this->ffi->git_tree_free($tree); + } + } finally { + $this->ffi->git_commit_free($commit); + } + } + + /** + * git normalizes paths, and also accepts the absolute paths of files in + * the working tree, where libgit2 only deals with normalized paths + * relative to the repository. + * + * @return string $path relative to the root of the repository, which "" names, without "." nor ".." components, nor extra slashes + * + * @throws DriverException $path is outside the repository + */ + private function toRepoRelativePath(string $path): string + { + $components = self::pathComponents($path); + + if (str_starts_with($path, '/')) { + $root = null === $this->workingDir ? null : self::pathComponents($this->workingDir); + if (null === $root || \array_slice($components, 0, \count($root)) !== $root) { + // Like git. + throw new DriverException(\sprintf('The path "%s" is outside the repository.', $path)); + } + + $components = \array_slice($components, \count($root)); + } + + return implode('/', $components); + } + + /** + * @return list The components of $path, once "." and ".." are resolved, like git does + * + * @throws DriverException ".." goes above the first component of $path + */ + private static function pathComponents(string $path): array + { + $components = []; + foreach (explode('/', $path) as $component) { + if ('..' === $component && [] === $components) { + // Like git. + throw new DriverException(\sprintf('The path "%s" is outside the repository.', $path)); + } + + if ('..' === $component) { + array_pop($components); + } elseif ('' !== $component && '.' !== $component) { + $components[] = $component; + } + } + + return $components; + } + + // -- diff ---------------------------------------------------------------- + + private function diffTrees(?CData $oldTree, CData $newTree): DiffData + { + $diff = $this->ffi->new('git_diff*'); + $opts = $this->diffOptions(); + LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_tree(\FFI::addr($diff), $this->repo, $oldTree, $newTree, \FFI::addr($opts)), 'Unable to compute diff'); + + return $this->buildDiff($diff); + } + + private function diffOptions(): CData + { + $opts = $this->ffi->new('git_diff_options'); + LibGit2::check($this->ffi, $this->ffi->git_diff_options_init(\FFI::addr($opts), 1), 'Unable to initialize diff options'); + // git's default since 2.14. + $opts->flags |= self::GIT_DIFF_INDENT_HEURISTIC; + + return $opts; + } + + /** + * @return list The paths a merge conflict left unmerged, which buildDiff() leaves out + */ + private function unmergedPaths(CData $diff): array + { + $paths = []; + for ($i = 0, $count = $this->ffi->git_diff_num_deltas($diff); $i < $count; ++$i) { + $delta = $this->ffi->git_diff_get_delta($diff, $i); + if (self::GIT_DELTA_CONFLICTED === $delta->status) { + $paths[] = $delta->new_file->path; + } + } + + return $paths; + } + + /** + * @return array The mode of the files `git add -N` added to the index, by path + */ + private function intentToAddFiles(): array + { + $index = $this->ffi->new('git_index*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_index(\FFI::addr($index), $this->repo), 'Unable to open the index'); + + try { + LibGit2::check($this->ffi, $this->ffi->git_index_read($index, 0), 'Unable to read the index'); + // git writes an index of version 2, without the extended flags, unless an entry has one. + if ($this->ffi->git_index_version($index) < 3) { + return []; + } + + $files = []; + for ($i = 0, $count = $this->ffi->git_index_entrycount($index); $i < $count; ++$i) { + $entry = $this->ffi->git_index_get_byindex($index, $i); + if (0 !== ($entry->flags_extended & self::GIT_INDEX_ENTRY_INTENT_TO_ADD)) { + $files[$entry->path] = \sprintf('%06o', $entry->mode); + } + } + + return $files; + } finally { + $this->ffi->git_index_free($index); + } + } + + /** + * Restricts a diff to $paths, matched as they are. + * + * @param list $paths + * + * @return list The C strings of the pathspec, to keep alive as long as $opts + */ + private function pathspec(CData $opts, array $paths): array + { + $buffers = []; + $strings = $this->ffi->new('char*['.\count($paths).']'); + foreach ($paths as $i => $path) { + $buffers[] = $buffer = LibGit2::cString($this->ffi, $path); + $strings[$i] = $this->ffi->cast('char *', $buffer); + } + $buffers[] = $strings; + + $opts->pathspec->strings = $this->ffi->cast('char **', $strings); + $opts->pathspec->count = \count($paths); + + return $buffers; + } + + /** + * Converts a git_diff, and frees it. + */ + private function buildDiff(CData $diff): DiffData + { + try { + // Like CliDriver's `-M`. + $findOptions = $this->ffi->new('git_diff_find_options'); + LibGit2::check($this->ffi, $this->ffi->git_diff_find_options_init(\FFI::addr($findOptions), 1), 'Unable to initialize rename detection options'); + $findOptions->flags = self::GIT_DIFF_FIND_RENAMES; + LibGit2::check($this->ffi, $this->ffi->git_diff_find_similar($diff, \FFI::addr($findOptions)), 'Unable to detect renames'); + + $files = []; + $count = $this->ffi->git_diff_num_deltas($diff); + for ($i = 0; $i < $count; ++$i) { + // Like git, leave the unmerged paths of a conflict out. + if (self::GIT_DELTA_CONFLICTED === $this->ffi->git_diff_get_delta($diff, $i)->status) { + continue; + } + + $file = $this->patchToFile($diff, $i); + + // libgit2 splits a type change into a creation, then a deletion: git into a deletion, then a creation. + $previous = [] === $files ? null : $files[\count($files) - 1]; + if (null !== $previous && null === $file->newName && null === $previous->oldName && $previous->newName === $file->oldName) { + array_pop($files); + $files[] = $file; + $files[] = $previous; + + continue; + } + + $files[] = $file; + } + + return new DiffData($files, ''); + } finally { + $this->ffi->git_diff_free($diff); + } + } + + private function patchToFile(CData $diff, int $index): DiffFileData + { + $patch = $this->ffi->new('git_patch*'); + LibGit2::check($this->ffi, $this->ffi->git_patch_from_diff(\FFI::addr($patch), $diff, $index), 'Unable to compute patch'); + + try { + $changes = []; + $hunkCount = $this->ffi->git_patch_num_hunks($patch); + for ($hunkIndex = 0; $hunkIndex < $hunkCount; ++$hunkIndex) { + $hunk = $this->ffi->new('git_diff_hunk*'); + $lineCount = $this->ffi->new('size_t'); + LibGit2::check($this->ffi, $this->ffi->git_patch_get_hunk(\FFI::addr($hunk), \FFI::addr($lineCount), $patch, $hunkIndex), 'Unable to read diff hunk'); + + $lines = []; + for ($lineIndex = 0; $lineIndex < $lineCount->cdata; ++$lineIndex) { + $line = $this->ffi->new('git_diff_line*'); + LibGit2::check($this->ffi, $this->ffi->git_patch_get_line_in_hunk(\FFI::addr($line), $patch, $hunkIndex, $lineIndex), 'Unable to read diff line'); + + // ->origin is a scalar `char`, which PHP FFI represents + // as a single-character PHP string. + $type = match ($line->origin) { + '+' => DiffLineType::Addition, + '-' => DiffLineType::Deletion, + ' ' => DiffLineType::Context, + default => null, + }; + + if (null !== $type) { + $lines[] = new DiffLine($type, rtrim(\FFI::string($line->content, $line->content_len), "\n")); + } + } + + $changes[] = new FileChange($hunk->old_start, $hunk->old_lines, $hunk->new_start, $hunk->new_lines, $lines); + } + + return $this->deltaToFile($this->ffi->git_patch_get_delta($patch), $changes); + } finally { + $this->ffi->git_patch_free($patch); + } + } + + /** + * @param list $changes + */ + private function deltaToFile(CData $delta, array $changes): DiffFileData + { + $status = $delta->status; + $isCopy = self::GIT_DELTA_COPIED === $status; + $isAdded = self::GIT_DELTA_ADDED === $status; + $isDeleted = self::GIT_DELTA_DELETED === $status; + $isBinary = 0 !== ($delta->flags & self::GIT_DIFF_FLAG_BINARY); + + $oldName = $isAdded ? null : $delta->old_file->path; + $newName = $isDeleted ? null : $delta->new_file->path; + + $oldMode = $isAdded ? null : \sprintf('%06o', $delta->old_file->mode); + $newMode = $isDeleted ? null : \sprintf('%06o', $delta->new_file->mode); + + $oldIndex = $this->ffi->git_oid_is_zero(\FFI::addr($delta->old_file->id)) ? null : $this->oidToHex(\FFI::addr($delta->old_file->id)); + $newIndex = $this->ffi->git_oid_is_zero(\FFI::addr($delta->new_file->id)) ? null : $this->oidToHex(\FFI::addr($delta->new_file->id)); + + return new DiffFileData($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary, $isCopy, $changes); + } + + // -- working copy -------------------------------------------------------- + + private function checkoutRevision(string $revision): void + { + $target = $this->revparse($revision); + + try { + $this->checkoutObject($target, \sprintf('Unable to checkout "%s"', $revision)); + } finally { + $this->ffi->git_object_free($target); + } + } + + /** + * Safe checkout (libgit2's default strategy): like `git checkout`, it + * refuses to overwrite local changes, and keeps the ones it doesn't + * conflict with. + */ + private function checkoutObject(CData $treeish, string $context): void + { + $opts = $this->ffi->new('git_checkout_options'); + LibGit2::check($this->ffi, $this->ffi->git_checkout_options_init(\FFI::addr($opts), 1), 'Unable to initialize checkout options'); + LibGit2::check($this->ffi, $this->ffi->git_checkout_tree($this->repo, $treeish, \FFI::addr($opts)), $context); + } + + /** + * Checks $hash out, and points the checked out branch, or a detached HEAD, to it. + */ + private function fastForward(?string $branch, string $hash): void + { + $target = $this->revparse($hash); + + try { + $this->checkoutObject($target, 'Unable to fast-forward'); + } finally { + $this->ffi->git_object_free($target); + } + $this->moveHead($branch, $hash); + } + + /** + * Points the checked out branch, or a detached HEAD, to $hash. + */ + private function moveHead(?string $branch, string $hash): void + { + if (null !== $branch) { + $this->updateReference($branch, $hash); + + return; + } + + $oid = $this->hexToOid($hash); + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($this->repo, \FFI::addr($oid)), 'Unable to update HEAD'); + } + + /** + * Merges two commits in memory and writes the resulting tree. + */ + private function mergeTrees(string $oursHash, string $theirsHash, string $revision): CData + { + $ours = $this->lookupCommit($oursHash); + + try { + $theirs = $this->lookupCommit($theirsHash); + + try { + $mergeOpts = $this->ffi->new('git_merge_options'); + LibGit2::check($this->ffi, $this->ffi->git_merge_options_init(\FFI::addr($mergeOpts), 1), 'Unable to initialize merge options'); + + $index = $this->ffi->new('git_index*'); + LibGit2::check($this->ffi, $this->ffi->git_merge_commits(\FFI::addr($index), $this->repo, $ours, $theirs, \FFI::addr($mergeOpts)), \sprintf('Unable to merge "%s"', $revision)); + + try { + if (0 !== $this->ffi->git_index_has_conflicts($index)) { + throw new DriverException(\sprintf('Merging "%s" produces conflicts, which LibGit2Driver does not resolve.', $revision)); + } + + $treeOid = $this->ffi->new('git_oid'); + LibGit2::check($this->ffi, $this->ffi->git_index_write_tree_to(\FFI::addr($treeOid), $index, $this->repo), 'Unable to write merge tree'); + + return $this->lookupTree($this->oidToHex(\FFI::addr($treeOid))); + } finally { + $this->ffi->git_index_free($index); + } + } finally { + $this->ffi->git_commit_free($theirs); + } + } finally { + $this->ffi->git_commit_free($ours); + } + } + + private function createMergeCommit(CData $tree, string $oursHash, string $theirsHash, string $message): string + { + // Like git, GIT_AUTHOR_* and GIT_COMMITTER_* before the configuration. + $author = $this->ffi->new('git_signature*'); + $committer = $this->ffi->new('git_signature*'); + LibGit2::check($this->ffi, $this->ffi->git_signature_default_from_env(\FFI::addr($author), \FFI::addr($committer), $this->repo), 'Unable to determine the merge commit author (configure user.name and user.email)'); + + try { + $ours = $this->lookupCommit($oursHash); + + try { + $theirs = $this->lookupCommit($theirsHash); + + try { + $parents = $this->ffi->new('git_commit*[2]'); + $parents[0] = $ours; + $parents[1] = $theirs; + + $oid = $this->ffi->new('git_oid'); + LibGit2::check($this->ffi, $this->ffi->git_commit_create(\FFI::addr($oid), $this->repo, null, $author, $committer, null, $message, $tree, 2, $parents), 'Unable to create merge commit'); + + return $this->oidToHex(\FFI::addr($oid)); + } finally { + $this->ffi->git_commit_free($theirs); + } + } finally { + $this->ffi->git_commit_free($ours); + } + } finally { + $this->ffi->git_signature_free($author); + $this->ffi->git_signature_free($committer); + } + } + + /** + * The message `git merge` writes (see git's merge_name() and fmt-merge-msg). + */ + private function mergeMessage(string $revision, ?string $branch): string + { + $object = $this->revparse($revision); + + try { + $tag = $this->ffi->git_object_type($object) === $this->ffi->GIT_OBJECT_TAG ? $this->oidToHex($this->ffi->git_object_id($object)) : null; + } finally { + $this->ffi->git_object_free($object); + } + + $message = 'Merge '.$this->mergedName($this->expandPriorCheckout($revision), $tag); + + // git leaves out the destination when merging into main or master. + if (null === $branch) { + $message .= ' into HEAD'; + } elseif (!\in_array($branch, ['refs/heads/main', 'refs/heads/master'], true)) { + $message .= ' into '.substr($branch, \strlen('refs/heads/')); + } + + // Then the message of the annotated tag, without its signature. + if (null !== $tag) { + $message .= "\n\n".$this->getTagData($tag)->message; + } + + return $this->prettifyMessage($message); + } + + /** + * @param string $name The revision merged, @{-} expanded + * @param string|null $tag The annotated tag $name names, if any + * + * @return string How git's merge_name() names $name: as given, after what it names + */ + private function mergedName(string $name, ?string $tag): string + { + $reference = $this->dwimReference($name) ?? ''; + foreach (['refs/heads/' => 'branch', 'refs/tags/' => 'tag', 'refs/remotes/' => 'remote-tracking branch'] as $prefix => $kind) { + if (str_starts_with($reference, $prefix)) { + return \sprintf("%s '%s'", $kind, $name); + } + } + + // ~ or ^: an early part of the branch, unless is 0. + if (1 === preg_match('/^(.+?)(\^+|~\d*)$/', $name, $match) && $this->referenceExists('refs/heads/'.$match[1])) { + return \sprintf("branch '%s'%s", $match[1], 1 === preg_match('/^~0+$/', $match[2]) ? '' : ' (early part)'); + } + + return null === $tag ? \sprintf("commit '%s'", $name) : \sprintf("tag '%s'", $name); + } + + /** + * Cleans a message up like git does without an editor: without + * whitespace at the end of its lines, nor blank lines at its start, at + * its end or in a row. + */ + private function prettifyMessage(string $message): string + { + $buffer = $this->ffi->new('git_buf'); + LibGit2::check($this->ffi, $this->ffi->git_message_prettify(\FFI::addr($buffer), $message, 0, '#'), 'Unable to clean the message up'); + + try { + return \FFI::string($buffer->ptr, $buffer->size); + } finally { + $this->ffi->git_buf_dispose(\FFI::addr($buffer)); + } + } + + // -- references ---------------------------------------------------------- + + /** + * @return string|null The full name of the reference $name names, like git's dwim_ref(), null when it names none + */ + private function dwimReference(string $name): ?string + { + $ref = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_dwim(\FFI::addr($ref), $this->repo, $name); + if (LibGit2::GIT_ENOTFOUND === $rc || self::GIT_EINVALIDSPEC === $rc) { + return null; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up the reference "%s"', $name)); + + try { + return $this->ffi->git_reference_name($ref); + } finally { + $this->ffi->git_reference_free($ref); + } + } + + /** + * Like git's dwim_ref(), which counts the references a name may be the + * short name of, unlike git_reference_dwim(). + * + * @return list The full names of the references $name names, after the symbolic ones: several when $name is ambiguous + */ + private function dwimReferences(string $name): array + { + $references = []; + // The rules of git's ref_rev_parse_rules. + foreach (['%s', 'refs/%s', 'refs/tags/%s', 'refs/heads/%s', 'refs/remotes/%s', 'refs/remotes/%s/HEAD'] as $rule) { + $fullname = \sprintf($rule, $name); + $ref = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname); + if (LibGit2::GIT_ENOTFOUND === $rc || self::GIT_EINVALIDSPEC === $rc) { + continue; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up the reference "%s"', $fullname)); + + try { + $resolved = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_resolve(\FFI::addr($resolved), $ref); + // Like git, which ignores a symbolic reference to nothing. + if (LibGit2::GIT_ENOTFOUND === $rc) { + continue; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve the reference "%s"', $fullname)); + + try { + $references[] = $this->ffi->git_reference_name($resolved); + } finally { + $this->ffi->git_reference_free($resolved); + } + } finally { + $this->ffi->git_reference_free($ref); + } + } + + return $references; + } + + /** + * Like git, reads "@" as HEAD, alone or at the start of $revision before + * "~", "^" or ":": libgit2 only reads it alone. "@{...}" is left as it is. + */ + private static function expandAt(string $revision): string + { + if ('@' === $revision || (str_starts_with($revision, '@') && \in_array($revision[1], ['~', '^', ':'], true))) { + return 'HEAD'.substr($revision, 1); + } + + return $revision; + } + + /** + * Like git, replaces @{-} at the start of $revision with the nth + * branch checked out before, or commit when HEAD was detached. + */ + private function expandPriorCheckout(string $revision): string + { + if (1 !== preg_match('/^@\{-([1-9]\d*)\}/', $revision, $match)) { + return $revision; + } + + $prior = $this->priorCheckout((int) $match[1]); + + return null === $prior ? $revision : $prior.substr($revision, \strlen($match[0])); + } + + /** + * @return string|null The nth branch checked out before, or commit when HEAD was detached, read from the reflog of HEAD like git does; null after fewer checkouts + */ + private function priorCheckout(int $n): ?string + { + $reflog = $this->ffi->new('git_reflog*'); + LibGit2::check($this->ffi, $this->ffi->git_reflog_read(\FFI::addr($reflog), $this->repo, 'HEAD'), 'Unable to read the reflog of HEAD'); + + try { + // The newest entry first. + for ($i = 0, $count = $this->ffi->git_reflog_entrycount($reflog); $i < $count; ++$i) { + $message = $this->ffi->git_reflog_entry_message($this->ffi->git_reflog_entry_byindex($reflog, $i)); + if (null !== $message && 1 === preg_match('/^checkout: moving from (.+?) to /', $message, $match) && 0 === --$n) { + return $match[1]; + } + } + + return null; + } finally { + $this->ffi->git_reflog_free($reflog); + } + } + + /** + * Like git with branch.autoSetupMerge=true, its default, a branch created + * from a remote branch tracks it. + * + * @return array{string, string}|null The remote, and its branch, that $startPoint names, like ["origin", "refs/heads/feature"] for "origin/feature"; null when $startPoint names another revision, or a branch no remote fetches + * + * @throws DriverException $startPoint names several references, or a branch several remotes fetch + */ + private function upstreamOf(string $startPoint): ?array + { + $references = $this->dwimReferences($startPoint); + // Like git, which refuses to create a branch from an ambiguous name. + if (\count($references) > 1) { + throw new DriverException(\sprintf('The name "%s" is ambiguous: "%s".', $startPoint, implode('", "', $references))); + } + $reference = $references[0] ?? null; + if (null === $reference || !str_starts_with($reference, 'refs/remotes/')) { + return null; + } + + // Like git, which only tracks the branch of a remote it knows. + $remotes = $this->ffi->new('git_strarray'); + LibGit2::check($this->ffi, $this->ffi->git_remote_list(\FFI::addr($remotes), $this->repo), 'Unable to list the remotes'); + + try { + $upstream = null; + for ($i = 0; $i < $remotes->count; ++$i) { + $remote = \FFI::string($remotes->strings[$i]); + $branch = $this->fetchedBranch($remote, $reference); + if (null === $branch) { + continue; + } + if (null !== $upstream) { + throw new DriverException(\sprintf('Unable to tell the upstream of "%s": the remotes "%s" and "%s" fetch it.', $startPoint, $upstream[0], $remote)); + } + $upstream = [$remote, $branch]; + } + + return $upstream; + } finally { + $this->ffi->git_strarray_dispose(\FFI::addr($remotes)); + } + } + + /** + * @return string|null The branch of $remote a fetch writes to $reference, like "refs/heads/feature" for "refs/remotes/origin/feature", null when no fetch of $remote writes it + */ + private function fetchedBranch(string $remote, string $reference): ?string + { + $handle = $this->ffi->new('git_remote*'); + LibGit2::check($this->ffi, $this->ffi->git_remote_lookup(\FFI::addr($handle), $this->repo, $remote), \sprintf('Unable to look up the remote "%s"', $remote)); + + try { + for ($i = 0, $count = $this->ffi->git_remote_refspec_count($handle); $i < $count; ++$i) { + $refspec = $this->ffi->git_remote_get_refspec($handle, $i); + if (self::GIT_DIRECTION_FETCH !== $this->ffi->git_refspec_direction($refspec) || 0 === $this->ffi->git_refspec_dst_matches($refspec, $reference)) { + continue; + } + + $branch = $this->ffi->new('git_buf'); + LibGit2::check($this->ffi, $this->ffi->git_refspec_rtransform(\FFI::addr($branch), $refspec, $reference), \sprintf('Unable to find the branch of the remote "%s" fetched to "%s"', $remote, $reference)); + + try { + return \FFI::string($branch->ptr, $branch->size); + } finally { + $this->ffi->git_buf_dispose(\FFI::addr($branch)); + } + } + + return null; + } finally { + $this->ffi->git_remote_free($handle); + } + } + + /** + * Writes the configuration git_branch_set_upstream() would, which looks + * for a local branch of the name of the remote one first. + * + * @param string $merge The branch on the remote, like "refs/heads/feature" + */ + private function setUpstream(string $branch, string $remote, string $merge): void + { + $config = $this->ffi->new('git_config*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_config(\FFI::addr($config), $this->repo), 'Unable to open the repository configuration'); + + try { + foreach (['remote' => $remote, 'merge' => $merge] as $key => $value) { + LibGit2::check($this->ffi, $this->ffi->git_config_set_string($config, 'branch.'.$branch.'.'.$key, $value), \sprintf('Unable to set the upstream of "%s"', $branch)); + } + } finally { + $this->ffi->git_config_free($config); + } + } + + private function referenceExists(string $fullname): bool + { + $ref = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname); + if (LibGit2::GIT_ENOTFOUND === $rc || self::GIT_EINVALIDSPEC === $rc) { + return false; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up reference "%s"', $fullname)); + $this->ffi->git_reference_free($ref); + + return true; + } + + /** + * @return \Generator The git_reference* of every reference under refs/, freed once the consumer moves on + */ + private function iterateReferences(): \Generator + { + $this->assertGitDirExists(); + + $iter = $this->ffi->new('git_reference_iterator*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_iterator_glob_new(\FFI::addr($iter), $this->repo, 'refs/*'), 'Unable to create reference iterator'); + + try { + $ref = $this->ffi->new('git_reference*'); + while (0 === ($rc = $this->ffi->git_reference_next(\FFI::addr($ref), $iter))) { + try { + yield $ref; + } finally { + $this->ffi->git_reference_free($ref); + } + } + $this->checkIterationEnd($rc, 'Unable to list references'); + } finally { + $this->ffi->git_reference_iterator_free($iter); + } + } + + /** + * @return \Generator The full name and the git_reference* of every local and remote branch, symbolic ones excluded; the reference is freed once the consumer moves on + */ + private function iterateBranches(): \Generator + { + $this->assertGitDirExists(); + + $iter = $this->ffi->new('git_branch_iterator*'); + LibGit2::check($this->ffi, $this->ffi->git_branch_iterator_new(\FFI::addr($iter), $this->repo, $this->ffi->GIT_BRANCH_ALL), 'Unable to create branch iterator'); + + try { + $ref = $this->ffi->new('git_reference*'); + $type = $this->ffi->new('git_branch_t'); + while (0 === ($rc = $this->ffi->git_branch_next(\FFI::addr($ref), \FFI::addr($type), $iter))) { + try { + if ($this->ffi->git_reference_type($ref) === $this->ffi->GIT_REFERENCE_DIRECT) { + yield [$this->ffi->git_reference_name($ref), $ref]; + } + } finally { + $this->ffi->git_reference_free($ref); + } + } + $this->checkIterationEnd($rc, 'Unable to list branches'); + } finally { + $this->ffi->git_branch_iterator_free($iter); + } + } + + /** + * libgit2 lists no reference, instead of failing, once the repository is removed. + * + * @throws DriverException + */ + private function assertGitDirExists(): void + { + if (!is_dir($this->gitDir)) { + throw new DriverException(\sprintf('The repository "%s" does not exist anymore.', $this->gitDir)); + } + } + + /** + * @return string|null The object $ref points to, null for a dangling symbolic reference + */ + private function referenceTargetHex(CData $ref): ?string + { + if ($this->ffi->git_reference_type($ref) !== $this->ffi->GIT_REFERENCE_SYMBOLIC) { + return $this->oidToHex($this->ffi->git_reference_target($ref)); + } + + $resolved = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_resolve(\FFI::addr($resolved), $ref); + // Like git show-ref, which skips it. + if (LibGit2::GIT_ENOTFOUND === $rc) { + return null; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve "%s"', $this->ffi->git_reference_name($ref))); + + try { + return $this->oidToHex($this->ffi->git_reference_target($resolved)); + } finally { + $this->ffi->git_reference_free($resolved); + } + } + + /** + * @return string The object $ref points to, through annotated tags + */ + private function peeledHex(CData $ref): string + { + $object = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_peel(\FFI::addr($object), $ref, $this->ffi->GIT_OBJECT_ANY), \sprintf('Unable to peel "%s"', $this->ffi->git_reference_name($ref))); + + try { + return $this->oidToHex($this->ffi->git_object_id($object)); + } finally { + $this->ffi->git_object_free($object); + } + } + + /** + * @return string|null The commit $ref points to, through annotated tags, or null when it points to another object or to nothing + */ + private function peeledCommitHex(CData $ref): ?string + { + $commit = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_reference_peel(\FFI::addr($commit), $ref, $this->ffi->GIT_OBJECT_COMMIT); + if (\in_array($rc, [LibGit2::GIT_ENOTFOUND, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { + return null; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to peel "%s"', $this->ffi->git_reference_name($ref))); + + try { + return $this->oidToHex($this->ffi->git_object_id($commit)); + } finally { + $this->ffi->git_object_free($commit); + } + } + + private function isDescendantOf(CData $commit, CData $ancestor): bool + { + $rc = $this->ffi->git_graph_descendant_of($this->repo, \FFI::addr($commit), \FFI::addr($ancestor)); + LibGit2::check($this->ffi, $rc, 'Unable to compare commits'); + + return 1 === $rc; + } + + // -- objects ------------------------------------------------------------- + + private function revparse(string $spec): CData + { + $out = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_revparse_single(\FFI::addr($out), $this->repo, self::expandAt($spec)), \sprintf('Unable to resolve "%s"', $spec)); + + return $out; + } + + /** + * Resolves a revision to a commit hash, peeling annotated tags: the + * object it names, as "^{commit}" would be part of the text of ":/text". + */ + private function resolveCommit(string $revision): string + { + $object = $this->revparse($revision); + + try { + $commit = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_object_peel(\FFI::addr($commit), $object, $this->ffi->GIT_OBJECT_COMMIT), \sprintf('Unable to resolve "%s" to a commit', $revision)); + } finally { + $this->ffi->git_object_free($object); + } + + try { + return $this->oidToHex($this->ffi->git_object_id($commit)); + } finally { + $this->ffi->git_object_free($commit); + } + } + + private function lookupCommit(string $hash): CData + { + $oid = $this->hexToOid($hash); + $out = $this->ffi->new('git_commit*'); + $this->checkFound($this->ffi->git_commit_lookup_prefix(\FFI::addr($out), $this->repo, \FFI::addr($oid), \strlen($hash)), $hash, ObjectType::Commit, 'Unable to read the commit'); + + return $out; + } + + /** + * @return list + */ + private function parentHashes(string $hash): array + { + $commit = $this->lookupCommit($hash); + + try { + $parents = []; + for ($i = 0, $count = $this->ffi->git_commit_parentcount($commit); $i < $count; ++$i) { + $parents[] = $this->oidToHex($this->ffi->git_commit_parent_id($commit, $i)); + } + + return $parents; + } finally { + $this->ffi->git_commit_free($commit); + } + } + + private function commitTime(string $hash): int + { + $commit = $this->lookupCommit($hash); + + try { + return $this->ffi->git_commit_committer($commit)->when->time; + } finally { + $this->ffi->git_commit_free($commit); + } + } + + private function lookupTree(string $hash): CData + { + $oid = $this->hexToOid($hash); + $out = $this->ffi->new('git_tree*'); + $this->checkFound($this->ffi->git_tree_lookup_prefix(\FFI::addr($out), $this->repo, \FFI::addr($oid), \strlen($hash)), $hash, ObjectType::Tree, 'Unable to read the tree'); + + return $out; + } + + private function commitTree(CData $commit): CData + { + $out = $this->ffi->new('git_tree*'); + LibGit2::check($this->ffi, $this->ffi->git_commit_tree(\FFI::addr($out), $commit), 'Unable to get commit tree'); + + return $out; + } + + private function parentTree(CData $commit, int $n): CData + { + $parent = $this->ffi->new('git_commit*'); + LibGit2::check($this->ffi, $this->ffi->git_commit_parent(\FFI::addr($parent), $commit, $n), 'Unable to get parent commit'); + + try { + return $this->commitTree($parent); + } finally { + $this->ffi->git_commit_free($parent); + } + } + + private function treeForRevision(string $spec): CData + { + $object = $this->revparse($spec); + + try { + $peeled = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_object_peel(\FFI::addr($peeled), $object, $this->ffi->GIT_OBJECT_TREE), \sprintf('Unable to resolve "%s" to a tree', $spec)); + + try { + // git_object_peel() hands back a generic git_object*; look it + // up again as the git_tree* the diff functions expect. + return $this->lookupTree($this->oidToHex($this->ffi->git_object_id($peeled))); + } finally { + $this->ffi->git_object_free($peeled); + } + } finally { + $this->ffi->git_object_free($object); + } + } + + /** + * @return CData|null The tree of HEAD, or null on an unborn branch + */ + private function headTree(): ?CData + { + $head = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_repository_head(\FFI::addr($head), $this->repo); + if (self::GIT_EUNBORNBRANCH === $rc || LibGit2::GIT_ENOTFOUND === $rc) { + return null; + } + LibGit2::check($this->ffi, $rc, 'Unable to resolve HEAD'); + + try { + $peeled = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_peel(\FFI::addr($peeled), $head, $this->ffi->GIT_OBJECT_TREE), 'Unable to peel HEAD to a tree'); + + try { + return $this->lookupTree($this->oidToHex($this->ffi->git_object_id($peeled))); + } finally { + $this->ffi->git_object_free($peeled); + } + } finally { + $this->ffi->git_reference_free($head); + } + } + + private function commitToData(CData $commit): CommitData + { + $parentCount = $this->ffi->git_commit_parentcount($commit); + $parents = []; + for ($i = 0; $i < $parentCount; ++$i) { + $parents[] = $this->oidToHex($this->ffi->git_commit_parent_id($commit, $i)); + } + + $author = $this->ffi->git_commit_author($commit); + $committer = $this->ffi->git_commit_committer($commit); + $encoding = $this->ffi->git_commit_message_encoding($commit); + + return new CommitData( + $this->oidToHex($this->ffi->git_commit_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)), + ); + } + + /** + * Like `git log`, which CliDriver reads commits with: without + * blank lines around it, nor whitespace at the end of its lines. + */ + private static function cleanMessage(string $message): string + { + // @phpstan-ignore argument.type (Without the "u" modifier, preg_replace() fails on no subject.) + $message = trim(preg_replace('/[ \t\r]+$/m', '', $message), "\n"); + + return '' === $message ? '' : $message."\n"; + } + + /** + * Re-encodes a text of a commit from its "encoding" header, like `git log --encoding=UTF-8`. + */ + private function toUtf8(string $text, ?string $encoding): string + { + if (null === $encoding) { + return $text; + } + + try { + return new ByteString($text)->toCodePointString($encoding)->toString(); + } catch (\ValueError|InvalidArgumentException) { + // Like git, keep the text as is when the encoding is unknown or wrong. + return $text; + } + } + + /** + * @return \DateTimeImmutable The date at the UTC offset of the signature, like CliDriver's parsers + */ + private function signatureDate(CData $signature): \DateTimeImmutable + { + // In minutes. libgit2 gives 0 for an offset of more than 14 hours or + // 59 minutes, which no timezone has, in a corrupt object. + $offset = $signature->when->offset; + // @phpstan-ignore argument.type (libgit2.h declares the offset an int, which PHPStan can't see.) + $timezone = new \DateTimeZone(\sprintf('%s%02d%02d', $offset < 0 ? '-' : '+', intdiv(abs($offset), 60), abs($offset) % 60)); + + return new \DateTimeImmutable('@'.$signature->when->time)->setTimezone($timezone); + } + + private function objectType(int $type): ObjectType + { + return match ($type) { + $this->ffi->GIT_OBJECT_BLOB => ObjectType::Blob, + $this->ffi->GIT_OBJECT_COMMIT => ObjectType::Commit, + $this->ffi->GIT_OBJECT_TAG => ObjectType::Tag, + $this->ffi->GIT_OBJECT_TREE => ObjectType::Tree, + default => throw new DriverException(\sprintf('Unknown git object type %d.', $type)), + }; + } + + /** + * @param CData $oidPtr a `git_oid*` — callers holding a `git_oid` value must pass `FFI::addr($value)` + */ + private function oidToHex(CData $oidPtr): string + { + return LibGit2::oidToHex($this->ffi, $oidPtr); + } + + /** + * @param string $hash A hash, abbreviated or not + * + * @throws ReferenceNotFoundException The object $hash doesn't exist + */ + private function lookupObject(string $hash): CData + { + $oid = $this->hexToOid($hash); + $object = $this->ffi->new('git_object*'); + $this->checkFound($this->ffi->git_object_lookup_prefix(\FFI::addr($object), $this->repo, \FFI::addr($oid), \strlen($hash), $this->ffi->GIT_OBJECT_ANY), $hash, null, 'Unable to read the object'); + + return $object; + } + + /** + * @param ObjectType|null $type The type the lookup asked for, null for any + * + * @throws ReferenceNotFoundException The object $hash doesn't exist, or isn't of the type $type + */ + private function checkFound(int $rc, string $hash, ?ObjectType $type, string $context): void + { + if (LibGit2::GIT_ENOTFOUND === $rc) { + // libgit2 reports an object of another type as not found. + if (null !== $type && $this->objectExists($hash)) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value)); + } + + throw new ReferenceNotFoundException(\sprintf('The object "%s" does not exist.', $hash)); + } + LibGit2::check($this->ffi, $rc, \sprintf('%s "%s"', $context, $hash)); + } + + private function objectExists(string $hash): bool + { + $oid = $this->hexToOid($hash); + $object = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_object_lookup_prefix(\FFI::addr($object), $this->repo, \FFI::addr($oid), \strlen($hash), $this->ffi->GIT_OBJECT_ANY); + if (LibGit2::GIT_ENOTFOUND === $rc) { + return false; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to read the object "%s"', $hash)); + $this->ffi->git_object_free($object); + + return true; + } + + private function hexToOid(string $hash): CData + { + $oid = $this->ffi->new('git_oid'); + LibGit2::check($this->ffi, $this->ffi->git_oid_fromstrn(\FFI::addr($oid), $hash, \strlen($hash)), \sprintf('Invalid hash "%s"', $hash)); + + return $oid; + } + + /** + * libgit2 iterators return GIT_ITEROVER once exhausted, and another + * negative code when they stop on an error. + */ + private function checkIterationEnd(int $rc, string $context): void + { + if (self::GIT_ITEROVER !== $rc) { + LibGit2::check($this->ffi, $rc, $context); + } + } +} diff --git a/src/Gitonomy/Git/Driver/Ffi/PathFilter.php b/src/Gitonomy/Git/Driver/Ffi/PathFilter.php new file mode 100644 index 00000000..3ce1f8ab --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/PathFilter.php @@ -0,0 +1,135 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Ffi; + +use FFI\CData; + +/** + * Tells whether two trees differ on some paths, matched like git's pathspecs. + * + * @author Grégoire Pineau + * + * @internal + */ +final readonly class PathFilter +{ + /** + * The part of each path before its first wildcard. + * + * @var list + */ + private array $prefixes; + + private CData $diffOptions; + + /** + * The C strings the diff options point to, to keep alive as long as them. + * + * @var list + */ + private array $buffers; // @phpstan-ignore property.onlyWritten + + /** + * @param list $paths Relative to the repository, without leading or trailing slash + */ + public function __construct( + private \FFI $ffi, + private CData $repo, + private array $paths, + CData $diffOptions, + ) { + $prefixes = []; + foreach ($paths as $path) { + $wildcard = strcspn($path, '*?[\\'); + // A wildcard can match more than a name: keep the directories before it. + $prefixes[] = \strlen($path) === $wildcard ? $path : substr($path, 0, (int) strrpos(substr($path, 0, $wildcard), '/')); + } + $this->prefixes = $prefixes; + + $buffers = []; + $strings = $ffi->new('char*['.\count($paths).']'); + foreach ($paths as $i => $path) { + $buffers[] = $buffer = LibGit2::cString($ffi, $path); + $strings[$i] = $ffi->cast('char *', $buffer); + } + $buffers[] = $strings; + $this->buffers = $buffers; + + $diffOptions->pathspec->strings = $ffi->cast('char **', $strings); + $diffOptions->pathspec->count = \count($paths); + $this->diffOptions = $diffOptions; + } + + /** + * @param CData|null $oldTree null for the empty tree + */ + public function differ(?CData $oldTree, CData $newTree): bool + { + // Comparing the entries at the paths is much cheaper than a diff, and + // enough for a path without wildcards. + $needsDiff = false; + foreach ($this->paths as $i => $path) { + $prefix = $this->prefixes[$i]; + if ($this->entryId($oldTree, $prefix) === $this->entryId($newTree, $prefix)) { + continue; + } + + if ($prefix === $path) { + return true; + } + + $needsDiff = true; + } + + return $needsDiff && $this->diffHasDeltas($oldTree, $newTree); + } + + /** + * @return string|null The hash and the mode of the entry at $path ('' for the tree itself), or null when there is none + */ + private function entryId(?CData $tree, string $path): ?string + { + if (null === $tree) { + return null; + } + + if ('' === $path) { + return LibGit2::oidToHex($this->ffi, $this->ffi->git_tree_id($tree)); + } + + $entry = $this->ffi->new('git_tree_entry*'); + $rc = $this->ffi->git_tree_entry_bypath(\FFI::addr($entry), $tree, $path); + if (LibGit2::GIT_ENOTFOUND === $rc) { + return null; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up "%s"', $path)); + + try { + return LibGit2::oidToHex($this->ffi, $this->ffi->git_tree_entry_id($entry)).' '.$this->ffi->git_tree_entry_filemode($entry); + } finally { + $this->ffi->git_tree_entry_free($entry); + } + } + + private function diffHasDeltas(?CData $oldTree, CData $newTree): bool + { + $diff = $this->ffi->new('git_diff*'); + LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_tree(\FFI::addr($diff), $this->repo, $oldTree, $newTree, \FFI::addr($this->diffOptions)), 'Unable to compute diff'); + + try { + return $this->ffi->git_diff_num_deltas($diff) > 0; + } finally { + $this->ffi->git_diff_free($diff); + } + } +} diff --git a/src/Gitonomy/Git/Driver/Ffi/libgit2.h b/src/Gitonomy/Git/Driver/Ffi/libgit2.h new file mode 100644 index 00000000..e8804746 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/libgit2.h @@ -0,0 +1,536 @@ +/* + * This file is part of Gitonomy. + * + * (c) Alexandre Salomé + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + * + * Trimmed C declarations for libgit2 1.9, hand-maintained for use with + * PHP's FFI::cdef(). Struct layouts are transcribed from the real libgit2 + * 1.9 headers (git2/*.h) with GIT_EXPERIMENTAL_SHA256 unset (the default, + * plain SHA1 git_oid). libgit2 breaks its ABI on every minor release (struct + * layouts change while the *_OPTIONS_VERSION constants stay at 1), so + * LibGit2Driver refuses to load any other minor version. Function-pointer fields that this driver never + * populates (left zeroed) are declared as plain `void *` to avoid pulling + * in every callback typedef: this is safe for layout purposes since a + * function pointer and a data pointer have the same size/representation + * on every platform libgit2 supports. + */ + +typedef struct git_oid { unsigned char id[20]; } git_oid; + +typedef struct { char *ptr; size_t reserved; size_t size; } git_buf; + +typedef struct git_strarray { char **strings; size_t count; } git_strarray; + +typedef struct git_oidarray { git_oid *ids; size_t count; } git_oidarray; + +typedef struct git_time { int64_t time; int offset; char sign; } git_time; + +typedef struct git_signature { + char *name; + char *email; + git_time when; +} git_signature; + +typedef struct git_error { char *message; int klass; } git_error; + +typedef enum { + GIT_OBJECT_ANY = -2, GIT_OBJECT_INVALID = -1, + GIT_OBJECT_COMMIT = 1, GIT_OBJECT_TREE = 2, GIT_OBJECT_BLOB = 3, GIT_OBJECT_TAG = 4, + GIT_OBJECT_OFS_DELTA = 6, GIT_OBJECT_REF_DELTA = 7 +} git_object_t; + +typedef enum { + GIT_REFERENCE_INVALID = 0, GIT_REFERENCE_DIRECT = 1, GIT_REFERENCE_SYMBOLIC = 2, GIT_REFERENCE_ALL = 3 +} git_reference_t; + +typedef enum { + GIT_BRANCH_LOCAL = 1, GIT_BRANCH_REMOTE = 2, GIT_BRANCH_ALL = 3 +} git_branch_t; + +typedef enum { + GIT_FILEMODE_UNREADABLE = 0000000, GIT_FILEMODE_TREE = 0040000, + GIT_FILEMODE_BLOB = 0100644, GIT_FILEMODE_BLOB_EXECUTABLE = 0100755, + GIT_FILEMODE_LINK = 0120000, GIT_FILEMODE_COMMIT = 0160000 +} git_filemode_t; + +typedef struct git_repository git_repository; +typedef struct git_object git_object; +typedef struct git_commit git_commit; +typedef struct git_tree git_tree; +typedef struct git_tree_entry git_tree_entry; +typedef struct git_blob git_blob; +typedef struct git_tag git_tag; +typedef struct git_reference git_reference; +typedef struct git_reference_iterator git_reference_iterator; +typedef struct git_reflog git_reflog; +typedef struct git_reflog_entry git_reflog_entry; +typedef struct git_branch_iterator git_branch_iterator; +typedef struct git_revwalk git_revwalk; +typedef struct git_index git_index; +typedef struct git_diff git_diff; +typedef struct git_blame git_blame; +typedef struct git_status_list git_status_list; +typedef struct git_patch git_patch; +typedef struct git_remote git_remote; +typedef struct git_refspec git_refspec; +typedef struct git_config git_config; + +/* -- global -- */ + +int git_libgit2_init(void); +int git_libgit2_version(int *major, int *minor, int *rev); +int git_libgit2_features(void); +int git_libgit2_opts(int option, ...); + +/* -- buffers / signatures / arrays -- */ + +void git_buf_dispose(git_buf *buffer); +int git_signature_default_from_env(git_signature **author_out, git_signature **committer_out, git_repository *repo); +void git_signature_free(git_signature *sig); +void git_oidarray_dispose(git_oidarray *array); +void git_strarray_dispose(git_strarray *array); +int git_message_prettify(git_buf *out, const char *message, int strip_comments, char comment_char); + +/* -- errors -- */ + +const git_error *git_error_last(void); + +/* -- oid -- */ + +int git_oid_fromstrn(git_oid *out, const char *str, size_t length); +char *git_oid_tostr(char *out, size_t n, const git_oid *id); +int git_oid_is_zero(const git_oid *id); + +/* -- repository -- */ + +int git_repository_open(git_repository **out, const char *path); +int git_repository_set_workdir(git_repository *repo, const char *workdir, int update_gitlink); +void git_repository_free(git_repository *repo); +int git_repository_init(git_repository **out, const char *path, unsigned is_bare); +int git_repository_head(git_reference **out, git_repository *repo); +int git_repository_set_head(git_repository *repo, const char *refname); +int git_repository_set_head_detached(git_repository *repo, const git_oid *committish); +int git_repository_config(git_config **out, git_repository *repo); +int git_repository_index(git_index **out, git_repository *repo); + +/* -- config -- */ + +int git_config_set_string(git_config *cfg, const char *name, const char *value); +void git_config_free(git_config *cfg); + +/* -- object / commit / tree / blob / tag -- */ + +int git_object_lookup_prefix(git_object **object_out, git_repository *repo, const git_oid *id, size_t len, git_object_t type); +const git_oid *git_object_id(const git_object *obj); +git_object_t git_object_type(const git_object *obj); +int git_object_short_id(git_buf *out, const git_object *obj); +int git_object_peel(git_object **peeled, const git_object *object, git_object_t target_type); +void git_object_free(git_object *object); + +int git_commit_lookup_prefix(git_commit **commit, git_repository *repo, const git_oid *id, size_t len); +void git_commit_free(git_commit *commit); +const git_oid *git_commit_id(const git_commit *commit); +const char *git_commit_message(const git_commit *commit); +const char *git_commit_message_encoding(const git_commit *commit); +const git_signature *git_commit_committer(const git_commit *commit); +const git_signature *git_commit_author(const git_commit *commit); +int git_commit_tree(git_tree **tree_out, const git_commit *commit); +const git_oid *git_commit_tree_id(const git_commit *commit); +unsigned int git_commit_parentcount(const git_commit *commit); +int git_commit_parent(git_commit **out, const git_commit *commit, unsigned int n); +const git_oid *git_commit_parent_id(const git_commit *commit, unsigned int n); +int git_commit_create( + git_oid *id, git_repository *repo, const char *update_ref, + const git_signature *author, const git_signature *committer, + const char *message_encoding, const char *message, + const git_tree *tree, size_t parent_count, const git_commit *parents[]); + +int git_tree_lookup_prefix(git_tree **out, git_repository *repo, const git_oid *id, size_t len); +void git_tree_free(git_tree *tree); +const git_oid *git_tree_id(const git_tree *tree); +int git_tree_entry_bypath(git_tree_entry **out, const git_tree *root, const char *path); +void git_tree_entry_free(git_tree_entry *entry); +size_t git_tree_entrycount(const git_tree *tree); +const git_tree_entry *git_tree_entry_byindex(const git_tree *tree, size_t idx); +const char *git_tree_entry_name(const git_tree_entry *entry); +const git_oid *git_tree_entry_id(const git_tree_entry *entry); +git_object_t git_tree_entry_type(const git_tree_entry *entry); +git_filemode_t git_tree_entry_filemode(const git_tree_entry *entry); + +int git_blob_lookup_prefix(git_blob **blob, git_repository *repo, const git_oid *id, size_t len); +void git_blob_free(git_blob *blob); +const void *git_blob_rawcontent(const git_blob *blob); +uint64_t git_blob_rawsize(const git_blob *blob); + +int git_tag_lookup(git_tag **out, git_repository *repo, const git_oid *id); +void git_tag_free(git_tag *tag); +const git_signature *git_tag_tagger(const git_tag *tag); +const char *git_tag_message(const git_tag *tag); + +/* -- revparse / revwalk -- */ + +int git_revparse_single(git_object **out, git_repository *repo, const char *spec); + +int git_revwalk_new(git_revwalk **out, git_repository *repo); +int git_revwalk_push(git_revwalk *walk, const git_oid *id); +int git_revwalk_hide(git_revwalk *walk, const git_oid *commit_id); +int git_revwalk_next(git_oid *out, git_revwalk *walk); +int git_revwalk_sorting(git_revwalk *walk, unsigned int sort_mode); +void git_revwalk_free(git_revwalk *walk); + +/* -- refs / branch -- */ + +int git_reference_lookup(git_reference **out, git_repository *repo, const char *name); +int git_reference_dwim(git_reference **out, git_repository *repo, const char *shorthand); +int git_reference_resolve(git_reference **out, const git_reference *ref); +const git_oid *git_reference_target(const git_reference *ref); +const char *git_reference_symbolic_target(const git_reference *ref); +git_reference_t git_reference_type(const git_reference *ref); +const char *git_reference_name(const git_reference *ref); +int git_reference_create(git_reference **out, git_repository *repo, const char *name, const git_oid *id, int force, const char *log_message); +int git_reference_delete(git_reference *ref); +void git_reference_free(git_reference *ref); +int git_reference_iterator_glob_new(git_reference_iterator **out, git_repository *repo, const char *glob); +int git_reference_next(git_reference **out, git_reference_iterator *iter); +void git_reference_iterator_free(git_reference_iterator *iter); +int git_reference_peel(git_object **out, const git_reference *ref, git_object_t type); + +int git_reflog_read(git_reflog **out, git_repository *repo, const char *name); +size_t git_reflog_entrycount(git_reflog *reflog); +const git_reflog_entry *git_reflog_entry_byindex(const git_reflog *reflog, size_t idx); +const char *git_reflog_entry_message(const git_reflog_entry *entry); +void git_reflog_free(git_reflog *reflog); + +int git_branch_create(git_reference **out, git_repository *repo, const char *branch_name, const git_commit *target, int force); +int git_branch_delete(git_reference *branch); +int git_branch_name_is_valid(int *valid, const char *name); +int git_branch_iterator_new(git_branch_iterator **out, git_repository *repo, git_branch_t list_flags); +int git_branch_next(git_reference **out, git_branch_t *out_type, git_branch_iterator *iter); +void git_branch_iterator_free(git_branch_iterator *iter); + +/* -- graph -- */ + +int git_graph_descendant_of(git_repository *repo, const git_oid *commit, const git_oid *ancestor); + +/* -- merge_base -- */ + +int git_merge_bases(git_oidarray *out, git_repository *repo, const git_oid *one, const git_oid *two); + +/* -- diff -- */ + +typedef struct { + git_oid id; + const char *path; + uint64_t size; + uint32_t flags; + uint16_t mode; + uint16_t id_abbrev; +} git_diff_file; + +typedef struct { + int status; + uint32_t flags; + uint16_t similarity; + uint16_t nfiles; + git_diff_file old_file; + git_diff_file new_file; +} git_diff_delta; + +typedef struct { + unsigned int version; + uint32_t flags; + int ignore_submodules; + git_strarray pathspec; + void *notify_cb; + void *progress_cb; + void *payload; + uint32_t context_lines; + uint32_t interhunk_lines; + int oid_type; + uint16_t id_abbrev; + int64_t max_size; + const char *old_prefix; + const char *new_prefix; +} git_diff_options; + +typedef struct { + int old_start; + int old_lines; + int new_start; + int new_lines; + size_t header_len; + char header[128]; +} git_diff_hunk; + +typedef struct { + char origin; + int old_lineno; + int new_lineno; + int num_lines; + size_t content_len; + int64_t content_offset; + /* Declared non-const (unlike the real libgit2 header) so PHP FFI does not + * auto-convert it to a NUL-terminated PHP string: this span is documented + * as *not* NUL-terminated, so it must be read with an explicit length via + * FFI::string($line->content, $line->content_len). */ + char *content; +} git_diff_line; + +typedef struct { + unsigned int version; + uint32_t flags; + uint16_t rename_threshold; + uint16_t rename_from_rewrite_threshold; + uint16_t copy_threshold; + uint16_t break_rewrite_threshold; + size_t rename_limit; + void *metric; +} git_diff_find_options; + +int git_diff_options_init(git_diff_options *opts, unsigned int version); +void git_diff_free(git_diff *diff); +int git_diff_tree_to_tree(git_diff **diff, git_repository *repo, git_tree *old_tree, git_tree *new_tree, const git_diff_options *opts); +int git_diff_tree_to_index(git_diff **diff, git_repository *repo, git_tree *old_tree, git_index *index, const git_diff_options *opts); +int git_diff_tree_to_workdir(git_diff **diff, git_repository *repo, git_tree *old_tree, const git_diff_options *opts); +int git_diff_tree_to_workdir_with_index(git_diff **diff, git_repository *repo, git_tree *old_tree, const git_diff_options *opts); +int git_diff_index_to_workdir(git_diff **diff, git_repository *repo, git_index *index, const git_diff_options *opts); +int git_diff_find_options_init(git_diff_find_options *opts, unsigned int version); +int git_diff_find_similar(git_diff *diff, const git_diff_find_options *options); +size_t git_diff_num_deltas(const git_diff *diff); +const git_diff_delta *git_diff_get_delta(const git_diff *diff, size_t idx); + +/* -- patch -- */ + +int git_patch_from_diff(git_patch **out, git_diff *diff, size_t idx); +const git_diff_delta *git_patch_get_delta(const git_patch *patch); +size_t git_patch_num_hunks(const git_patch *patch); +int git_patch_get_hunk(const git_diff_hunk **out, size_t *lines_in_hunk, git_patch *patch, size_t hunk_idx); +int git_patch_get_line_in_hunk(const git_diff_line **out, git_patch *patch, size_t hunk_idx, size_t line_of_hunk); +void git_patch_free(git_patch *patch); + +/* -- blame -- */ + +typedef struct git_blame_options { + unsigned int version; + unsigned int flags; + uint16_t min_match_characters; + git_oid newest_commit; + git_oid oldest_commit; + size_t min_line; + size_t max_line; +} git_blame_options; + +typedef struct git_blame_hunk { + size_t lines_in_hunk; + git_oid final_commit_id; + size_t final_start_line_number; + git_signature *final_signature; + git_signature *final_committer; + git_oid orig_commit_id; + const char *orig_path; + size_t orig_start_line_number; + git_signature *orig_signature; + git_signature *orig_committer; + const char *summary; + char boundary; +} git_blame_hunk; + +/* ->ptr declared non-const so PHP FFI does not auto-convert it to a + * NUL-terminated string; read with FFI::string($line->ptr, $line->len). */ +typedef struct git_blame_line { char *ptr; size_t len; } git_blame_line; + +int git_blame_options_init(git_blame_options *opts, unsigned int version); +int git_blame_file(git_blame **out, git_repository *repo, const char *path, git_blame_options *options); +size_t git_blame_linecount(git_blame *blame); +const git_blame_hunk *git_blame_hunk_byline(git_blame *blame, size_t lineno); +const git_blame_line *git_blame_line_byindex(git_blame *blame, size_t idx); +void git_blame_free(git_blame *blame); + +/* -- status -- */ + +typedef struct { + unsigned int version; + int show; + unsigned int flags; + git_strarray pathspec; + git_tree *baseline; + uint16_t rename_threshold; +} git_status_options; + +typedef struct { + int status; + git_diff_delta *head_to_index; + git_diff_delta *index_to_workdir; +} git_status_entry; + +int git_status_options_init(git_status_options *opts, unsigned int version); +int git_status_list_new(git_status_list **out, git_repository *repo, const git_status_options *opts); +size_t git_status_list_entrycount(git_status_list *statuslist); +const git_status_entry *git_status_byindex(git_status_list *statuslist, size_t idx); +void git_status_list_free(git_status_list *statuslist); + +/* -- index -- */ + +typedef struct { + int32_t seconds; + uint32_t nanoseconds; +} git_index_time; + +typedef struct git_index_entry { + git_index_time ctime; + git_index_time mtime; + uint32_t dev; + uint32_t ino; + uint32_t mode; + uint32_t uid; + uint32_t gid; + uint32_t file_size; + git_oid id; + uint16_t flags; + uint16_t flags_extended; + const char *path; +} git_index_entry; + +int git_index_read(git_index *index, int force); +unsigned int git_index_version(git_index *index); +size_t git_index_entrycount(const git_index *index); +const git_index_entry *git_index_get_byindex(git_index *index, size_t n); +void git_index_free(git_index *index); +int git_index_write_tree_to(git_oid *out, git_index *index, git_repository *repo); +int git_index_has_conflicts(const git_index *index); + +/* -- checkout -- */ + +typedef struct git_checkout_options { + unsigned int version; + unsigned int checkout_strategy; + int disable_filters; + unsigned int dir_mode; + unsigned int file_mode; + int file_open_flags; + unsigned int notify_flags; + void *notify_cb; + void *notify_payload; + void *progress_cb; + void *progress_payload; + git_strarray paths; + git_tree *baseline; + git_index *baseline_index; + const char *target_directory; + const char *ancestor_label; + const char *our_label; + const char *their_label; + void *perfdata_cb; + void *perfdata_payload; +} git_checkout_options; + +int git_checkout_options_init(git_checkout_options *opts, unsigned int version); +int git_checkout_tree(git_repository *repo, const git_object *treeish, const git_checkout_options *opts); + +/* -- merge -- */ + +typedef struct { + unsigned int version; + uint32_t flags; + unsigned int rename_threshold; + unsigned int target_limit; + void *metric; + unsigned int recursion_limit; + const char *default_driver; + int file_favor; + uint32_t file_flags; +} git_merge_options; + +int git_merge_options_init(git_merge_options *opts, unsigned int version); +int git_merge_commits(git_index **out, git_repository *repo, const git_commit *our_commit, const git_commit *their_commit, const git_merge_options *opts); + +/* -- clone / remote (network operations) -- */ + +typedef struct git_proxy_options { + unsigned int version; + int type; + const char *url; + void *credentials; + void *certificate_check; + void *payload; +} git_proxy_options; + +typedef struct { + unsigned int version; + void *sideband_progress; + void *completion; + void *credentials; + void *certificate_check; + void *transfer_progress; + void *update_tips; + void *pack_progress; + void *push_transfer_progress; + void *push_update_reference; + void *push_negotiation; + void *transport; + void *remote_ready; + void *payload; + void *resolve_url; + void *update_refs; +} git_remote_callbacks; + +typedef struct { + int version; + git_remote_callbacks callbacks; + int prune; + unsigned int update_fetchhead; + int download_tags; + git_proxy_options proxy_opts; + int depth; + int follow_redirects; + git_strarray custom_headers; +} git_fetch_options; + +typedef struct { + unsigned int version; + git_checkout_options checkout_opts; + git_fetch_options fetch_opts; + int bare; + int local; + const char *checkout_branch; + void *repository_cb; + void *repository_cb_payload; + void *remote_cb; + void *remote_cb_payload; +} git_clone_options; + +int git_clone_options_init(git_clone_options *options, unsigned int version); +int git_clone(git_repository **out, const char *url, const char *local_path, const git_clone_options *options); + +typedef struct git_remote_head { + int local; + git_oid oid; + git_oid loid; + char *name; + char *symref_target; +} git_remote_head; + +int git_fetch_options_init(git_fetch_options *opts, unsigned int version); + +int git_remote_create_anonymous(git_remote **out, git_repository *repo, const char *url); +int git_remote_create_detached(git_remote **out, const char *url); +int git_remote_default_branch(git_buf *out, git_remote *remote); +int git_remote_fetch(git_remote *remote, const git_strarray *refspecs, const git_fetch_options *opts, const char *reflog_message); +int git_remote_connect(git_remote *remote, int direction, const git_remote_callbacks *callbacks, const git_proxy_options *proxy_opts, const git_strarray *custom_headers); +int git_remote_ls(const git_remote_head ***out, size_t *size, git_remote *remote); +int git_remote_disconnect(git_remote *remote); +void git_remote_free(git_remote *remote); +int git_remote_list(git_strarray *out, git_repository *repo); +int git_remote_lookup(git_remote **out, git_repository *repo, const char *name); +size_t git_remote_refspec_count(const git_remote *remote); +const git_refspec *git_remote_get_refspec(const git_remote *remote, size_t n); + +/* -- refspec -- */ + +int git_refspec_direction(const git_refspec *spec); +int git_refspec_dst_matches(const git_refspec *refspec, const char *refname); +int git_refspec_rtransform(git_buf *out, const git_refspec *spec, const char *name); diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php new file mode 100644 index 00000000..89e59ac1 --- /dev/null +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -0,0 +1,238 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver; + +use Gitonomy\Git\Driver\Data\BlameLineData; +use Gitonomy\Git\Driver\Data\CommitData; +use Gitonomy\Git\Driver\Data\DiffData; +use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Driver\Data\TagData; +use Gitonomy\Git\Driver\Data\TreeEntryData; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\FastForward; + +/** + * Operations on one repository, opened by {@see DriverInterface::open()}. + * + * Implementations know nothing of Repository: they read and write git data, + * and the domain objects (Commit, Tree, Reference...) build on it. + * + * resolveRevision(), and the methods reading an object by its hash, like + * getCommitData(), throw a {@see ReferenceNotFoundException} when it doesn't + * exist. Every method throws a {@see DriverException} when git reports + * another failure (a conflict, a lock...), an + * {@see UnsupportedOperationException} when the driver can't perform the + * operation at all, and an {@see InvalidArgumentException} when an argument + * contains a NUL byte, which git doesn't accept. + * + * git reads an argument starting with a dash as an option: the revision + * "--output=/path" of a log writes a file. Every method refuses, with an + * {@see InvalidArgumentException}, a revision, a reference or a hash starting + * with a dash, before running git. A path may start with one. + * + * gitlib may add methods to this interface in a minor version: the promise of + * backward compatibility covers the code calling it, not the drivers + * implementing it outside gitlib, like a stub in tests. + * + * @author Grégoire Pineau + */ +interface RepositoryDriverInterface +{ + /** + * @return string The absolute path of the git directory + */ + public function getGitDir(): string; + + /** + * @return string|null The working tree, null for a bare repository + */ + public function getWorkingDir(): ?string; + + /** + * @return string|null The full name of the reference HEAD points to (a branch, which may have no commit yet), or null when HEAD is detached + */ + public function getHeadReference(): ?string; + + /** + * Resolves a revision to the hash of the object it names (``rev-parse --verify``). + * + * @throws ReferenceNotFoundException Nothing has this name + */ + public function resolveRevision(string $revision): string; + + /** + * @throws ReferenceNotFoundException $hash doesn't exist + */ + public function getShortHash(string $hash): string; + + /** + * @throws ReferenceNotFoundException $hash doesn't exist, or isn't a commit + */ + public function getCommitData(string $hash): CommitData; + + /** + * @param list|null $includes The commits whose history to list, null for every reference and HEAD + * @param list $excludes The commits whose history to leave out + * @param string[] $paths Paths, relative to the root of the repository or absolute in the working tree, with git's wildcards + * + * @return list Newest first + * + * @throws DriverException An absolute path is outside the working tree + */ + public function getCommitLog(?array $includes, array $excludes, array $paths, int $offset, ?int $limit): array; + + /** + * @param list|null $includes Same as {@see getCommitLog()} + * @param list $excludes + * @param string[] $paths + */ + public function countCommits(?array $includes, array $excludes, array $paths): int; + + /** + * @return list The best common ancestors of both commits, none when they have no common history + */ + public function getMergeBases(string $hash1, string $hash2): array; + + /** + * @return TreeEntryData[] + * + * @throws ReferenceNotFoundException $hash doesn't exist, or isn't a tree + */ + public function getTreeEntries(string $hash): array; + + /** + * @throws ReferenceNotFoundException $hash doesn't exist, or isn't a blob + */ + public function getBlobContent(string $hash): string; + + /** + * @param string $hash An annotated tag object + * + * @throws ReferenceNotFoundException $hash doesn't exist, or isn't an annotated tag + */ + public function getTagData(string $hash): TagData; + + /** + * @return ReferenceData[] Every reference under ``refs/``, none for an empty repository + */ + public function getReferences(): array; + + /** + * @param string $fullname The full name of a reference under ``refs/``, like those getReferences() lists + * + * @throws DriverException $fullname exists already + * @throws InvalidArgumentException $fullname isn't under ``refs/``, like "HEAD" or "master" + */ + public function createReference(string $fullname, string $hash): void; + + /** + * A symbolic reference is written itself, never the reference it points to. + * + * @param string $fullname The full name of a reference under ``refs/`` + * + * @throws InvalidArgumentException $fullname isn't under ``refs/``, like "HEAD" or "master" + */ + public function updateReference(string $fullname, string $hash): void; + + /** + * Like ``git update-ref -d``, deleting a missing reference does nothing. + * A symbolic reference is deleted itself, never the reference it points to. + * + * @param string $fullname The full name of a reference under ``refs/`` + * + * @throws InvalidArgumentException $fullname isn't under ``refs/``, like "HEAD" or "master" + */ + public function deleteReference(string $fullname): void; + + /** + * @return string[] Full names of the local and remote branches whose history contains $hash, symbolic ones excluded + */ + public function getIncludingBranches(string $hash): array; + + /** + * Changes from the tree of $from to the one of $to. + * + * @param string|null $from A commit, null for the empty tree + * @param string $to A commit + */ + public function getDiff(?string $from, string $to): DiffData; + + /** + * Changes of the working tree, staged or not, since a commit. + * + * During a merge conflict, like ``git diff ``, the unmerged + * files compare their content, conflict markers included, to the commit. + */ + public function getWorkingTreeDiff(string $hash): DiffData; + + /** + * Changes of the working tree not staged yet, the unmerged files of a conflict left out. + */ + public function getDiffPending(): DiffData; + + /** + * Changes staged in the index, the unmerged files of a conflict left out. + */ + public function getDiffStaged(): DiffData; + + /** + * @param int|null $firstLine The first line to blame, from 1, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null + * + * @return BlameLineData[] + */ + public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array; + + /** + * @return string[] Paths of the untracked files, relative to the root of the working tree + */ + public function getUntrackedFiles(): array; + + /** + * Checks a revision out, like ``git checkout``: HEAD points to the branch + * when $revision is the name of a local branch, or @{-}, the nth + * branch checked out before, stays as it is for "HEAD", and points to + * the commit otherwise (a tag, a hash, a remote branch...). + * + * $revision names a commit, which WorkingCopy::checkout() checks before: + * it throws a {@see ReferenceNotFoundException} for a name that no + * revision has, where ``git checkout`` would create the local branch of a + * remote one, like "feature" for "origin/feature". $revision is never a + * path either, whose changes ``git checkout`` would discard. + */ + public function checkout(string $revision): void; + + /** + * Creates the branch $branch at $startPoint and checks it out, like + * ``git checkout -b``: the branch tracks $startPoint when it is a remote + * branch, like "origin/feature", as with branch.autoSetupMerge=true, + * git's default, whatever the configuration. A failure leaves the + * branches and the files as they were: like ``git checkout -b``, no + * branch is created when the checkout fails. + * + * @param string $startPoint A revision naming a commit, which WorkingCopy::checkoutNewBranch() checks before + * + * @throws DriverException $branch exists already or isn't a valid name, $startPoint is the short name of several references, which git refuses, or the checkout would overwrite local changes + */ + public function checkoutNewBranch(string $branch, string $startPoint): void; + + /** + * Merges $revision, a name the message of the merge commit names, into the current branch. + * + * @param string $revision A revision naming a commit, which WorkingCopy::merge() checks before + */ + public function merge(string $revision, FastForward $fastForward): void; +} diff --git a/src/Gitonomy/Git/Driver/RepositoryPaths.php b/src/Gitonomy/Git/Driver/RepositoryPaths.php new file mode 100644 index 00000000..71141e74 --- /dev/null +++ b/src/Gitonomy/Git/Driver/RepositoryPaths.php @@ -0,0 +1,97 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver; + +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\NulByte; + +/** + * The git directory and the working tree of a repository, found the same way by every driver. + * + * @internal + */ +final readonly class RepositoryPaths +{ + private function __construct( + public string $gitDir, + public ?string $workingDir, + ) { + } + + /** + * @param string $dir A bare repository, a working tree, or a git directory when $workingDir is given + * @param string|null $workingDir The working tree of the git directory $dir + * + * @throws InvalidArgumentException $dir doesn't exist, or isn't a repository, or a path contains a NUL byte + * @throws DriverException The ".git" file of $dir can't be read, or doesn't point to a git directory + * @throws UnsupportedOperationException The repository uses SHA-256 hashes + */ + public static function resolve(string $dir, ?string $workingDir): self + { + NulByte::assertNone($dir, $workingDir ?? ''); + + $realDir = realpath($dir); + if (false === $realDir || !is_dir($realDir)) { + throw new InvalidArgumentException(\sprintf('Directory "%s" does not exist or is not a directory.', $dir)); + } + + $paths = match (true) { + null !== $workingDir => new self($realDir, $workingDir), + is_dir($realDir.'/.git') => new self($realDir.'/.git', $realDir), + // In a linked worktree, a submodule or a clone with --separate-git-dir, ".git" is a file pointing to the git directory. + is_file($realDir.'/.git') => new self(self::readGitFile($realDir), $realDir), + default => new self($realDir, null), + }; + + // Like git, which requires it in any git directory. + if (!is_file($paths->gitDir.'/HEAD')) { + throw new InvalidArgumentException(\sprintf('Directory "%s" is not a git repository.', $dir)); + } + + $config = is_file($paths->gitDir.'/config') ? file_get_contents($paths->gitDir.'/config') : false; + if (false !== $config && preg_match('/^\s*objectformat\s*=\s*sha256\s*$/mi', $config)) { + throw new UnsupportedOperationException(\sprintf('The repository "%s" uses SHA-256 hashes, which gitlib doesn\'t support yet.', $dir)); + } + + return $paths; + } + + /** + * Reads the file like git: "gitdir: " then the path, absolute or relative + * to $dir, without the line ending, "\r\n" included. The path may end with + * a space. + * + * @return string The git directory the ".git" file of $dir points to + * + * @throws DriverException The file can't be read, or doesn't point to a git directory + */ + private static function readGitFile(string $dir): string + { + $content = @file_get_contents($dir.'/.git'); + if (false === $content) { + throw new DriverException(\sprintf('Unable to read the "%s/.git" file.', $dir)); + } + + $path = rtrim(substr($content, \strlen('gitdir: ')), "\r\n"); + // realpath() refuses a NUL byte, where git would read the path up to it. + if (!str_starts_with($content, 'gitdir: ') || '' === $path || str_contains($path, "\0")) { + throw new DriverException(\sprintf('The "%s/.git" file doesn\'t point to a git directory.', $dir)); + } + + $gitDir = str_starts_with($path, '/') || preg_match('/^[a-zA-Z]:[\\\\\/]/', $path) ? $path : $dir.'/'.$path; + + return realpath($gitDir) ?: throw new DriverException(\sprintf('The git directory "%s" of "%s" does not exist.', $path, $dir)); + } +} diff --git a/src/Gitonomy/Git/CommitReference.php b/src/Gitonomy/Git/Exception/DriverException.php similarity index 56% rename from src/Gitonomy/Git/CommitReference.php rename to src/Gitonomy/Git/Exception/DriverException.php index bb20bc3c..4b8a3081 100644 --- a/src/Gitonomy/Git/CommitReference.php +++ b/src/Gitonomy/Git/Exception/DriverException.php @@ -1,6 +1,6 @@ @@ -10,14 +10,13 @@ * with this source code in the file LICENSE. */ -namespace Gitonomy\Git; +namespace Gitonomy\Git\Exception; -class CommitReference +/** + * Thrown when git reports a failure, whatever the driver running it. + * + * @author Grégoire Pineau + */ +class DriverException extends RuntimeException { - private $hash; - - public function __construct($hash) - { - $this->hash = $hash; - } } diff --git a/src/Gitonomy/Git/Exception/GitExceptionInterface.php b/src/Gitonomy/Git/Exception/GitExceptionInterface.php index 6e7e3898..febbd51f 100644 --- a/src/Gitonomy/Git/Exception/GitExceptionInterface.php +++ b/src/Gitonomy/Git/Exception/GitExceptionInterface.php @@ -1,6 +1,6 @@ diff --git a/src/Gitonomy/Git/Exception/InvalidArgumentException.php b/src/Gitonomy/Git/Exception/InvalidArgumentException.php index 32b01b6f..ed1dcc39 100644 --- a/src/Gitonomy/Git/Exception/InvalidArgumentException.php +++ b/src/Gitonomy/Git/Exception/InvalidArgumentException.php @@ -1,5 +1,15 @@ + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + namespace Gitonomy\Git\Exception; class InvalidArgumentException extends \InvalidArgumentException implements GitExceptionInterface diff --git a/src/Gitonomy/Git/Exception/LogicException.php b/src/Gitonomy/Git/Exception/LogicException.php index 8d299890..13b2e29c 100644 --- a/src/Gitonomy/Git/Exception/LogicException.php +++ b/src/Gitonomy/Git/Exception/LogicException.php @@ -1,7 +1,17 @@ + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + namespace Gitonomy\Git\Exception; -class LogicException extends \LogicException implements GitExceptionInterface +final class LogicException extends \LogicException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index 6d6b6912..44992fd7 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -1,33 +1,60 @@ + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + namespace Gitonomy\Git\Exception; +use Gitonomy\Git\Util\Credentials; use Symfony\Component\Process\Process; -class ProcessException extends RuntimeException implements GitExceptionInterface +final class ProcessException extends DriverException implements GitExceptionInterface { - protected $process; - - public function __construct(Process $process) - { - parent::__construct( - "Error while running git command:\n". - $process->getCommandLine()."\n". - "\n". - $process->getErrorOutput()."\n". - "\n". - $process->getOutput() - ); + /** + * The message is a sentence, with the error of git, from its error + * output: "The git command "clone" failed (exit 128): fatal: ...". It + * hides the credentials of the URLs, unlike getErrorOutput() and getOutput(). + * + * @param string|null $command The git command, like "clone", which the message names + */ + public function __construct( + private readonly Process $process, + ?string $command = null, + ) { + $message = (null === $command ? 'The git command' : \sprintf('The git command "%s"', $command)).\sprintf(' failed (exit %d)', $process->getExitCode()); + // One line of the error output only: getOutput() may be long, and + // hold the data of the repository. The first error, as warnings may + // come before it, like the one of a HOME git can't read; the first + // line otherwise. A colon announces the next lines: "error: Your + // local changes ... would be overwritten by checkout:". + $errorOutput = $process->getErrorOutput(); + $error = 1 === preg_match('/^(?:fatal|error):.*$/m', $errorOutput, $matches) ? $matches[0] : explode("\n", trim($errorOutput), 2)[0]; + // A merge writes its conflicts on the standard output, like + // "CONFLICT (content): Merge conflict in README.md". + if ('' === trim($errorOutput) && 1 === preg_match('/^CONFLICT .*$/m', $process->getOutput(), $matches)) { + $error = $matches[0]; + } + $error = rtrim(trim($error), ':'); + if ('' !== $error) { + $message .= ': '.$error; + } - $this->process = $process; + parent::__construct(Credentials::redact(1 === preg_match('/[.!?]$/', $message) ? $message : $message.'.')); } - public function getErrorOutput() + public function getErrorOutput(): string { return $this->process->getErrorOutput(); } - public function getOutput() + public function getOutput(): string { return $this->process->getOutput(); } diff --git a/src/Gitonomy/Git/Exception/ProcessTimedOutException.php b/src/Gitonomy/Git/Exception/ProcessTimedOutException.php new file mode 100644 index 00000000..1af2780f --- /dev/null +++ b/src/Gitonomy/Git/Exception/ProcessTimedOutException.php @@ -0,0 +1,22 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Exception; + +/** + * A git process ran longer than the timeout of CliDriver. + * + * @author Grégoire Pineau + */ +final class ProcessTimedOutException extends DriverException +{ +} diff --git a/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php b/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php index dd9ae98d..fcfe134e 100644 --- a/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php +++ b/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php @@ -1,6 +1,6 @@ @@ -12,10 +12,9 @@ namespace Gitonomy\Git\Exception; -class ReferenceNotFoundException extends \InvalidArgumentException implements GitExceptionInterface +/** + * A revision, a reference or an object doesn't exist. + */ +final class ReferenceNotFoundException extends InvalidArgumentException { - public function __construct($reference) - { - parent::__construct(sprintf('Reference not found: "%s"', $reference)); - } } diff --git a/src/Gitonomy/Git/Exception/RuntimeException.php b/src/Gitonomy/Git/Exception/RuntimeException.php index 06a76d8d..2886429b 100644 --- a/src/Gitonomy/Git/Exception/RuntimeException.php +++ b/src/Gitonomy/Git/Exception/RuntimeException.php @@ -1,5 +1,15 @@ + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + namespace Gitonomy\Git\Exception; class RuntimeException extends \RuntimeException implements GitExceptionInterface diff --git a/src/Gitonomy/Git/Exception/UnexpectedValueException.php b/src/Gitonomy/Git/Exception/UnexpectedValueException.php index 83b3bebb..a9abcaa1 100644 --- a/src/Gitonomy/Git/Exception/UnexpectedValueException.php +++ b/src/Gitonomy/Git/Exception/UnexpectedValueException.php @@ -1,7 +1,21 @@ + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + namespace Gitonomy\Git\Exception; -class UnexpectedValueException extends \UnexpectedValueException implements GitExceptionInterface +/** + * gitlib no longer throws it: Tree::resolvePath(), which threw it in 1.x, + * throws an InvalidArgumentException instead. + */ +final class UnexpectedValueException extends \UnexpectedValueException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Exception/UnsupportedOperationException.php b/src/Gitonomy/Git/Exception/UnsupportedOperationException.php new file mode 100644 index 00000000..036653ae --- /dev/null +++ b/src/Gitonomy/Git/Exception/UnsupportedOperationException.php @@ -0,0 +1,23 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Exception; + +/** + * Thrown when the driver can't perform an operation at all: unlike a + * DriverException, using another driver may succeed. + * + * @author Grégoire Pineau + */ +final class UnsupportedOperationException extends RuntimeException +{ +} diff --git a/src/Gitonomy/Git/FastForward.php b/src/Gitonomy/Git/FastForward.php new file mode 100644 index 00000000..4b27e32f --- /dev/null +++ b/src/Gitonomy/Git/FastForward.php @@ -0,0 +1,36 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git; + +/** + * Whether a merge fast-forwards the branch, when its history allows it. + * + * @author Grégoire Pineau + */ +enum FastForward +{ + /** + * Fast-forwards when possible, merges otherwise, like "git merge --ff". + */ + case Allow; + + /** + * Always creates a merge commit, like "git merge --no-ff". + */ + case Never; + + /** + * Refuses anything but a fast-forward, like "git merge --ff-only". + */ + case Only; +} diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index 3c19d2c4..29d145ac 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -1,6 +1,6 @@ @@ -13,26 +13,18 @@ namespace Gitonomy\Git; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\RuntimeException; /** * Hooks collection, aggregated by repository. * * @author Alexandre Salomé */ -class Hooks +final readonly class Hooks { - /** - * @var Gitonomy\Git\Repository - */ - protected $repository; - - /** - * @var Repository - */ - public function __construct(Repository $repository) - { - $this->repository = $repository; + public function __construct( + private Repository $repository, + ) { } /** @@ -40,11 +32,15 @@ public function __construct(Repository $repository) * * @param string $name Name of the hook * - * @return bool + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory */ - public function has($name) + public function has(string $name): bool { - return file_exists($this->getPath($name)); + $path = $this->getPath($name); + + // A symbolic link to a file not deployed yet is a hook too: set() + // would write through it, out of the hooks directory. + return is_link($path) || file_exists($path); } /** @@ -52,17 +48,23 @@ public function has($name) * * @param string $name Name of the hook * - * @throws InvalidArgumentException Hook does not exist - * * @return string Content of the hook + * + * @throws InvalidArgumentException The hook does not exist, or the name isn't valid + * @throws RuntimeException The hook can't be read */ - public function get($name) + public function get(string $name): string { if (!$this->has($name)) { - throw new InvalidArgumentException(sprintf('Hook named "%s" is not present', $name)); + throw new InvalidArgumentException(\sprintf('The hook "%s" does not exist.', $name)); } - return file_get_contents($this->getPath($name)); + $content = @file_get_contents($this->getPath($name)); + if (false === $content) { + throw new RuntimeException(\sprintf('Unable to read the hook "%s".', $name)); + } + + return $content; } /** @@ -71,18 +73,15 @@ public function get($name) * @param string $name Name of the hook to insert * @param string $file Target of symlink * - * @throws LogicException Hook is already present - * @throws RuntimeException Error on symlink creation + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory + * @throws RuntimeException The hook exists already, or can't be created */ - public function setSymlink($name, $file) + public function setSymlink(string $name, string $file): void { - if ($this->has($name)) { - throw new LogicException(sprintf('A hook "%s" is already defined', $name)); - } + $path = $this->createPath($name); - $path = $this->getPath($name); - if (false === symlink($file, $path)) { - throw new RuntimeException(sprintf('Unable to create hook "%s"', $name, $path)); + if (false === @symlink($file, $path)) { + throw new RuntimeException(\sprintf('Unable to create the hook "%s" (%s).', $name, $path)); } } @@ -92,17 +91,16 @@ public function setSymlink($name, $file) * @param string $name Name of the hook * @param string $content Content of the hook * - * @throws LogicException The hook is already defined + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory + * @throws RuntimeException The hook exists already, or can't be created */ - public function set($name, $content) + public function set(string $name, string $content): void { - if ($this->has($name)) { - throw new LogicException(sprintf('A hook "%s" is already defined', $name)); - } + $path = $this->createPath($name); - $path = $this->getPath($name); - file_put_contents($path, $content); - chmod($path, 0777); + if (false === @file_put_contents($path, $content) || false === @chmod($path, 0o700)) { + throw new RuntimeException(\sprintf('Unable to create the hook "%s" (%s).', $name, $path)); + } } /** @@ -110,19 +108,71 @@ public function set($name, $content) * * @param string $name Name of the hook * - * @throws LogicException The hook is not present + * @throws InvalidArgumentException The hook doesn't exist, or the name isn't the one of a file of the hooks directory + * @throws RuntimeException The hook can't be removed */ - public function remove($name) + public function remove(string $name): void { + // Like get(). if (!$this->has($name)) { - throw new LogicException(sprintf('The hook "%s" was not found', $name)); + throw new InvalidArgumentException(\sprintf('The hook "%s" does not exist.', $name)); } - unlink($this->getPath($name)); + if (false === @unlink($this->getPath($name))) { + throw new RuntimeException(\sprintf('Unable to remove the hook "%s".', $name)); + } } - protected function getPath($name) + /** + * @throws RuntimeException The hook exists already, or the repository has no hooks directory + */ + private function createPath(string $name): string { - return $this->repository->getGitDir().'/hooks/'.$name; + if ($this->has($name)) { + throw new RuntimeException(\sprintf('The hook "%s" exists already.', $name)); + } + + // A repository created with an empty template has none. + $directory = $this->getDirectory(); + if (!is_dir($directory) && !@mkdir($directory) && !is_dir($directory)) { + throw new RuntimeException(\sprintf('Unable to create the hooks directory "%s".', $directory)); + } + + return $this->getPath($name); + } + + /** + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory + */ + private function getPath(string $name): string + { + // "../config" would be the configuration of the repository. A name may + // hold a dot, but not start with one: git for Windows runs "pre-commit.exe". + if (1 !== preg_match('/^[a-zA-Z0-9_-][a-zA-Z0-9._-]*$/D', $name)) { + throw new InvalidArgumentException(\sprintf('"%s" is not a valid hook name: it must be the name of a file of the hooks directory, like "pre-commit".', addcslashes($name, "\0"))); + } + + return $this->getDirectory().'/'.$name; + } + + /** + * @throws RuntimeException The "commondir" file of a linked worktree can't be read + */ + private function getDirectory(): string + { + $gitDir = $this->repository->getGitDir(); + // Like git, a linked worktree runs the hooks of the repository it belongs to. + if (!is_file($gitDir.'/commondir')) { + return $gitDir.'/hooks'; + } + + $commonDir = file_get_contents($gitDir.'/commondir'); + if (false === $commonDir) { + throw new RuntimeException(\sprintf('Unable to read "%s/commondir".', $gitDir)); + } + + $commonDir = rtrim($commonDir, "\r\n"); + + return (preg_match('#^(/|[a-zA-Z]:[\\\/])#', $commonDir) ? $commonDir : $gitDir.'/'.$commonDir).'/hooks'; } } diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index 13f1fb0f..a39780fd 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -1,6 +1,6 @@ @@ -12,185 +12,132 @@ namespace Gitonomy\Git; -use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; -use Gitonomy\Git\Util\StringHelper; /** * @author Alexandre Salomé + * + * @implements \IteratorAggregate */ -class Log implements \Countable, \IteratorAggregate +final class Log implements \Countable, \IteratorAggregate { - /** - * @var Repository - */ - protected $repository; + private ?RevisionList $revisions; /** - * @var null|RevisionList + * @var string[] */ - protected $revisions; + private array $paths; - /** - * @var array - */ - protected $paths; + private ?int $offset; - /** - * @var int - */ - protected $offset; + private ?int $limit; /** - * @var int - */ - protected $limit; - - /** - * Instanciates a git log object. + * Instantiates a git log object. + * + * The revisions are names, ranges ("a..b", "a...b"), names to exclude + * ("^a"), or "--all". + * + * @param Repository $repository the repository where log occurs + * @param RevisionList|Revision|string|array|null $revisions a list of revisions or null if you want all history + * @param string[]|string|null $paths paths to filter on, with git's wildcards + * @param int|null $offset start list from a given position + * @param int|null $limit limit number of fetched elements * - * @param Repository $repository the repository where log occurs - * @param RevisionList|Revision|array|null $revisions a list of revisions or null if you want all history - * @param array $paths paths to filter on - * @param int|null $offset start list from a given position - * @param int|null $limit limit number of fetched elements + * @throws InvalidArgumentException $offset or $limit is negative */ - public function __construct(Repository $repository, $revisions = null, $paths = null, $offset = null, $limit = null) - { + public function __construct( + private readonly Repository $repository, + RevisionList|Revision|string|array|null $revisions = null, + array|string|null $paths = null, + ?int $offset = null, + ?int $limit = null, + ) { if (null !== $revisions && !$revisions instanceof RevisionList) { $revisions = new RevisionList($repository, $revisions); } if (null === $paths) { $paths = []; - } elseif (is_string($paths)) { + } elseif (\is_string($paths)) { $paths = [$paths]; - } elseif (!is_array($paths)) { - throw new \InvalidArgumentException(sprintf('Expected a string or an array, got a "%s".', is_object($paths) ? get_class($paths) : gettype($paths))); } - $this->repository = $repository; $this->revisions = $revisions; $this->paths = $paths; - $this->offset = $offset; - $this->limit = $limit; - } - - /** - * @return Diff - */ - public function getDiff() - { - return $this->repository->getDiff($this->revisions); + $this->offset = self::offset($offset); + $this->limit = self::limit($limit); } - /** - * @return RevisionList - */ - public function getRevisions() + public function getRevisions(): ?RevisionList { return $this->revisions; } /** - * @return array + * @return string[] */ - public function getPaths() + public function getPaths(): array { return $this->paths; } - /** - * @return int - */ - public function getOffset() + public function getOffset(): ?int { return $this->offset; } /** - * @param int $offset + * @return self The log, starting from the commit $offset + * + * @throws InvalidArgumentException $offset is negative */ - public function setOffset($offset) + public function withOffset(?int $offset): self { - $this->offset = $offset; + $log = clone $this; + $log->offset = self::offset($offset); - return $this; + return $log; } - /** - * @return int - */ - public function getLimit() + public function getLimit(): ?int { return $this->limit; } /** - * @param int $limit + * @return self The log, of $limit commits at most + * + * @throws InvalidArgumentException $limit is negative */ - public function setLimit($limit) + public function withLimit(?int $limit): self { - $this->limit = $limit; + $log = clone $this; + $log->limit = self::limit($limit); - return $this; + return $log; } - public function getSingleCommit() + /** + * @throws ReferenceNotFoundException The log is empty + */ + public function getSingleCommit(): Commit { - $limit = $this->limit; - $this->limit = 1; - $commits = $this->getCommits(); - $this->setLimit($limit); - - if (count($commits) === 0) { - throw new ReferenceNotFoundException('The log is empty'); - } - - return array_pop($commits); + return $this->withLimit(1)->getCommits()[0] ?? throw new ReferenceNotFoundException('The log has no commit.'); } /** - * @return array + * @return Commit[] */ - public function getCommits() + public function getCommits(): array { - $args = ['--encoding='.StringHelper::getEncoding(), '--format=raw']; - - if (null !== $this->offset) { - $args[] = '--skip='.((int) $this->offset); - } - - if (null !== $this->limit) { - $args[] = '-n'; - $args[] = (int) $this->limit; - } - - if (null !== $this->revisions) { - $args = array_merge($args, $this->revisions->getAsTextArray()); - } else { - $args[] = '--all'; - } - - $args[] = '--'; - - $args = array_merge($args, $this->paths); - - try { - $output = $this->repository->run('log', $args); - } catch (ProcessException $e) { - throw new ReferenceNotFoundException(sprintf('Can not find revision "%s"', implode(' ', $this->revisions->getAsTextArray())), null, $e); - } - - $parser = new Parser\LogParser(); - $parser->parse($output); + [$includes, $excludes] = $this->range(); + $commitLog = $this->repository->getRepositoryDriver()->getCommitLog($includes, $excludes, $this->paths, $this->offset ?? 0, $this->limit); $result = []; - foreach ($parser->log as $commitData) { - $hash = $commitData['id']; - unset($commitData['id']); - - $commit = $this->repository->getCommit($hash); + foreach ($commitLog as $commitData) { + $commit = $this->repository->getCommit($commitData->hash); $commit->setData($commitData); $result[] = $commit; @@ -201,33 +148,113 @@ public function getCommits() /** * @see Countable + * + * @return int The number of commits of the log, after its offset and up to its limit: as many as getCommits() returns */ - public function count() + public function count(): int { - return $this->countCommits(); + $count = max(0, $this->countCommits() - ($this->offset ?? 0)); + + return null === $this->limit ? $count : min($count, $this->limit); } /** * @see IteratorAggregate + * + * @return \ArrayIterator */ - public function getIterator() + public function getIterator(): \ArrayIterator { return new \ArrayIterator($this->getCommits()); } /** * Count commits, without offset or limit. + */ + public function countCommits(): int + { + [$includes, $excludes] = $this->range(); + + return $this->repository->getRepositoryDriver()->countCommits($includes, $excludes, $this->paths); + } + + /** + * Resolves the revisions to the commits whose history to list, and the + * ones whose history to leave out. * - * @return int + * @return array{list|null, list} Null for every reference and HEAD + * + * @throws ReferenceNotFoundException A revision names no commit + */ + private function range(): array + { + if (null === $this->revisions) { + return [null, []]; + } + + $all = false; + $includes = []; + $excludes = []; + foreach ($this->revisions->getAll() as $revision) { + if ($revision instanceof Commit || $revision instanceof Reference) { + $includes[] = $revision->getCommit()->getHash(); + + continue; + } + + $name = $revision->getRevision(); + if ('--all' === $name) { + $all = true; + } elseif (str_starts_with($name, '^')) { + $excludes[] = $this->commitHash(substr($name, 1)); + } elseif (str_contains($name, '...')) { + // Reachable from either end, but not from both. + [$one, $two] = array_map($this->commitHash(...), explode('...', $name, 2)); + array_push($includes, $one, $two); + array_push($excludes, ...$this->repository->getRepositoryDriver()->getMergeBases($one, $two)); + } elseif (str_contains($name, '..')) { + [$from, $to] = array_map($this->commitHash(...), explode('..', $name, 2)); + $includes[] = $to; + $excludes[] = $from; + } elseif (str_starts_with($name, '-')) { + throw new InvalidArgumentException(\sprintf('The log of "%s" is not supported: pass revisions, ranges, names to exclude or "--all".', $name)); + } else { + $includes[] = $this->commitHash($name); + } + } + + return [$all ? null : $includes, $excludes]; + } + + /** + * @param string $revision An end of a range, HEAD when empty + */ + private function commitHash(string $revision): string + { + return $this->repository->getRevision('' === $revision ? 'HEAD' : $revision)->getCommit()->getHash(); + } + + /** + * @throws InvalidArgumentException $offset is negative + */ + private static function offset(?int $offset): ?int + { + if (null !== $offset && $offset < 0) { + throw new InvalidArgumentException(\sprintf('The offset of a log can\'t be negative, %d given.', $offset)); + } + + return $offset; + } + + /** + * @throws InvalidArgumentException $limit is negative */ - public function countCommits() + private static function limit(?int $limit): ?int { - if (null !== $this->revisions && count($this->revisions)) { - $output = $this->repository->run('rev-list', array_merge(['--count'], $this->revisions->getAsTextArray(), ['--'], $this->paths)); - } else { - $output = $this->repository->run('rev-list', array_merge(['--count', '--all', '--'], $this->paths)); + if (null !== $limit && $limit < 0) { + throw new InvalidArgumentException(\sprintf('The limit of a log can\'t be negative, %d given.', $limit)); } - return (int) $output; + return $limit; } } diff --git a/src/Gitonomy/Git/Parser/BlameParser.php b/src/Gitonomy/Git/Parser/BlameParser.php deleted file mode 100644 index 8dd6c570..00000000 --- a/src/Gitonomy/Git/Parser/BlameParser.php +++ /dev/null @@ -1,75 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Parser; - -use Gitonomy\Git\Blame\Line; -use Gitonomy\Git\Repository; - -class BlameParser extends ParserBase -{ - public $lines; - - protected $repository; - - public function __construct(Repository $repository) - { - $this->repository = $repository; - } - - protected function doParse() - { - $this->lines = []; - - $memory = []; - - $line = 1; - while (!$this->isFinished()) { - $hash = $this->consumeHash(); - $this->consume(' '); - $vars = $this->consumeRegexp('/(\d+) (\d+)( (\d+))?/A'); - $sourceLine = $vars[1]; - $targetLine = $vars[2]; - $blockLine = isset($vars[4]) ? $vars[4] : null; - $this->consumeTo("\n"); - $this->consumeNewLine(); - - if (!isset($memory[$hash])) { - foreach (['author', 'author-mail', 'author-time', 'author-tz', - 'committer', 'committer-mail', 'committer-time', 'committer-tz', - 'summary', ] as $key) { - $this->consume($key); - $this->consumeTo("\n"); - $this->consumeNewLine(); - } - - if ($this->expects('previous ')) { - $this->consumeTo("\n"); - $this->consumeNewLine(); - } - - if ($this->expects('boundary')) { - $this->consumeNewLine(); - } - $this->consume('filename'); - $this->consumeTo("\n"); // filename - $this->consumeNewLine(); - $memory[$hash] = $this->repository->getCommit($hash); - } - $content = $this->consumeTo("\n"); // content of line - $this->consumeNewLine(); - - $this->lines[$line] = new Line($memory[$hash], $sourceLine, $targetLine, $blockLine, $content); - $line++; - } - } -} diff --git a/src/Gitonomy/Git/Parser/CommitParser.php b/src/Gitonomy/Git/Parser/CommitParser.php deleted file mode 100644 index 98234f6d..00000000 --- a/src/Gitonomy/Git/Parser/CommitParser.php +++ /dev/null @@ -1,80 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Parser; - -use Gitonomy\Git\Exception\RuntimeException; - -class CommitParser extends ParserBase -{ - public $tree; - public $parents; - public $authorName; - public $authorEmail; - public $authorDate; - public $committerName; - public $committerEmail; - public $committerDate; - public $message; - - protected function doParse() - { - $this->consume('tree '); - $this->tree = $this->consumeHash(); - $this->consumeNewLine(); - - $this->parents = []; - while ($this->expects('parent ')) { - $this->parents[] = $this->consumeHash(); - $this->consumeNewLine(); - } - - $this->consume('author '); - list($this->authorName, $this->authorEmail, $this->authorDate) = $this->consumeNameEmailDate(); - $this->authorDate = $this->parseDate($this->authorDate); - $this->consumeNewLine(); - - $this->consume('committer '); - list($this->committerName, $this->committerEmail, $this->committerDate) = $this->consumeNameEmailDate(); - $this->committerDate = $this->parseDate($this->committerDate); - - // will consume an GPG signed commit if there is one - $this->consumeGPGSignature(); - - $this->consumeNewLine(); - - $this->consumeNewLine(); - $this->message = $this->consumeAll(); - } - - protected function consumeNameEmailDate() - { - if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+ [+-]\d{4}))/A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('Unable to parse name, email and date'); - } - - $this->cursor += strlen($vars[1]); - - return [$vars[2], $vars[3], $vars[4]]; - } - - protected function parseDate($text) - { - $date = \DateTime::createFromFormat('U e O', $text.' UTC'); - - if (!$date instanceof \DateTime) { - throw new RuntimeException(sprintf('Unable to convert "%s" to datetime', $text)); - } - - return $date; - } -} diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index 2b84df8b..7c5a343d 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -1,6 +1,6 @@ @@ -12,123 +12,260 @@ namespace Gitonomy\Git\Parser; -use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\DiffLineType; use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Driver\Data\DiffFileData; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\RuntimeException; -class DiffParser extends ParserBase +/** + * Parses the output of ``git diff --raw --patch``, with ``-z`` or not: the + * names, modes, hashes and status of the files come from the raw + * information, and the hunks from the patches. + * + * Unmerged paths, during a conflict, are left out. + */ +final class DiffParser extends ParserBase { - public $files; + private const string HASH = '([0-9a-f]{7,64})(?:\.\.\.)?'; + + /** + * The extended header lines of a patch, but the "index" one. + */ + private const array HEADERS = ['copy from ', 'copy to ', 'deleted file mode ', 'dissimilarity index ', 'new file mode ', 'new mode ', 'old mode ', 'rename from ', 'rename to ', 'similarity index ']; - protected function doParse() + /** + * @var list + */ + public array $files = []; + + protected function doParse(): void { $this->files = []; - while (!$this->isFinished()) { - // 1. title - $vars = $this->consumeRegexp('/diff --git (a\/.*) (b\/.*)\n/'); - $oldName = $vars[1]; - $newName = $vars[2]; - $oldIndex = null; - $newIndex = null; - $oldMode = null; - $newMode = null; - - // 2. mode - if ($this->expects('new file mode ')) { - $newMode = $this->consumeTo("\n"); - $this->consumeNewLine(); - $oldMode = null; - } - if ($this->expects('old mode ')) { - $oldMode = $this->consumeTo("\n"); - $this->consumeNewLine(); - $this->consume('new mode '); - $newMode = $this->consumeTo("\n"); - $this->consumeNewLine(); + if ($this->isFinished()) { + return; + } + + if (!str_starts_with($this->content, ':')) { + throw new InvalidArgumentException('The diff has no raw information: Diff::parse() parses the output of "git diff --raw --patch".'); + } + + foreach ($this->consumeRawEntries() as $entry) { + // A type change (a file replaced by a symbolic link...) has the patch of a deletion, then of a creation. + if ('T' === $entry['status']) { + $this->files[] = $this->consumePatch($entry['oldName'], null, $entry['oldMode'], null, $entry['oldHash'], null, false); + $this->files[] = $this->consumePatch(null, $entry['newName'], null, $entry['newMode'], null, $entry['newHash'], false); + + continue; } - if ($this->expects('deleted file mode ')) { - $oldMode = $this->consumeTo("\n"); - $newMode = null; - $this->consumeNewLine(); + + $this->files[] = $this->consumePatch( + 'A' === $entry['status'] ? null : $entry['oldName'], + 'D' === $entry['status'] ? null : $entry['newName'], + $entry['oldMode'], + $entry['newMode'], + $entry['oldHash'], + $entry['newHash'], + 'C' === $entry['status'], + ); + } + + $this->consumeUnmergedPaths(); + + // What the parser doesn't know would be lost: the patches of the next + // files, after the log of a submodule of "git diff --submodule=log"... + // The message has the start of its line only: the lines of a diff come + // from the files of the repository. + if (!$this->isFinished()) { + throw new RuntimeException(\sprintf('Unable to parse the diff from "%s".', explode("\n", substr($this->content, $this->cursor, 100), 2)[0])); + } + } + + /** + * @return list The entries of merged paths + */ + private function consumeRawEntries(): array + { + $entries = []; + $nul = null; + while ($this->expects(':')) { + // A combined entry, "::" and more, is an unmerged path. + $parents = 1; + while ($this->expects(':')) { + ++$parents; } - if ($this->expects('similarity index ')) { - $this->consumeRegexp('/\d{1,3}%\n/'); - $this->consume('rename from '); - $this->consumeTo("\n"); - $this->consumeNewLine(); - $this->consume('rename to '); - $this->consumeTo("\n"); - $this->consumeNewLine(); + $modes = []; + for ($i = 0; $i <= $parents; ++$i) { + $modes[] = $this->consumeRegexp('/(\d{6}) /')[1]; + } + $hashes = []; + for ($i = 0; $i <= $parents; ++$i) { + $hashes[] = $this->consumeRegexp('/'.self::HASH.' /')[1]; } + $status = $this->consumeRegexp('/([A-Z]+)\d*/')[1]; - // 4. File informations - $isBinary = false; - if ($this->expects('index ')) { - $oldIndex = $this->consumeShortHash(); - $this->consume('..'); - $newIndex = $this->consumeShortHash(); - if ($this->expects(' ')) { - $vars = $this->consumeRegexp('/\d{6}/'); - $newMode = $oldMode = $vars[0]; + // -z ends the fields with NUL characters, and doesn't quote names. + $nul ??= "\0" === ($this->content[$this->cursor] ?? ''); + $this->consume($nul ? "\0" : "\t"); + $oldName = $this->consumeName($nul); + $newName = $oldName; + if (\in_array($status, ['C', 'R'], true)) { + if (!$nul) { + $this->consume("\t"); } + $newName = $this->consumeName($nul); + } + if (!$nul) { $this->consumeNewLine(); + } - if ($this->expects('--- ')) { - $oldName = $this->consumeTo("\n"); - $this->consumeNewLine(); - $this->consume('+++ '); - $newName = $this->consumeTo("\n"); - $this->consumeNewLine(); - } elseif ($this->expects('Binary files ')) { - $vars = $this->consumeRegexp('/(.*) and (.*) differ\n/'); - $isBinary = true; - $oldName = $vars[1]; - $newName = $vars[2]; - } + if (1 === $parents && 'U' !== $status) { + $entries[] = [ + 'status' => $status, + 'oldName' => $oldName, + 'newName' => $newName, + 'oldMode' => self::nullIfZeros($modes[0]), + 'newMode' => self::nullIfZeros($modes[1]), + 'oldHash' => self::nullIfZeros($hashes[0]), + 'newHash' => self::nullIfZeros($hashes[1]), + ]; } + } - $oldName = $oldName === '/dev/null' ? null : substr($oldName, 2); - $newName = $newName === '/dev/null' ? null : substr($newName, 2); - $oldIndex = preg_match('/^0+$/', $oldIndex) ? null : $oldIndex; - $newIndex = preg_match('/^0+$/', $newIndex) ? null : $newIndex; - $file = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary); - - // 5. Diff - while ($this->expects('@@ ')) { - $vars = $this->consumeRegexp('/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?/'); - $rangeOldStart = $vars[1]; - $rangeOldCount = $vars[2]; - $rangeNewStart = $vars[3]; - $rangeNewCount = isset($vars[4]) ? $vars[4] : $vars[2]; // @todo Ici, t'as pris un gros raccourci mon loulou - $this->consume(' @@'); - $this->consumeTo("\n"); - $this->consumeNewLine(); + // The raw information ends with an empty field, or line, when the patches follow. + if (!$this->isFinished()) { + $this->consume($nul ? "\0" : "\n"); + } - // 6. Lines - $lines = []; - while (true) { - if ($this->expects(' ')) { - $lines[] = [FileChange::LINE_CONTEXT, $this->consumeTo("\n")]; - } elseif ($this->expects('+')) { - $lines[] = [FileChange::LINE_ADD, $this->consumeTo("\n")]; - } elseif ($this->expects('-')) { - $lines[] = [FileChange::LINE_REMOVE, $this->consumeTo("\n")]; - } elseif ($this->expects("\ No newline at end of file")) { - // Ignore this case... - } else { - break; - } - - $this->consumeNewLine(); - } + return $entries; + } + + private function consumeName(bool $nul): string + { + if ($nul) { + $name = $this->consumeTo("\0"); + $this->consume("\0"); + + return $name; + } + + return self::unquote($this->consumeRegexp('/("(?:[^"\\\]|\\\.)*"|[^\t\n]*)/')[1]); + } + + private function consumePatch(?string $oldName, ?string $newName, ?string $oldMode, ?string $newMode, ?string $oldHash, ?string $newHash, bool $isCopy): DiffFileData + { + $this->consumeUnmergedPaths(); - $change = new FileChange($rangeOldStart, $rangeOldCount, $rangeNewStart, $rangeNewCount, $lines); + // Without --patch. + if (!$this->expects('diff --git ')) { + return new DiffFileData($oldName, $newName, $oldMode, $newMode, $oldHash, $newHash, false, $isCopy, []); + } + // The names of the header are ambiguous with spaces: the raw information gives them. + $this->consumeTo("\n"); + $this->consumeNewLine(); - $file->addChange($change); + while (true) { + // The hashes of the files of the working tree, which the raw information leaves out. + if ($this->expects('index ')) { + $vars = $this->consumeRegexp('/'.self::HASH.'\.\.'.self::HASH.'/'); + $oldHash = self::nullIfZeros($vars[1]); + $newHash = self::nullIfZeros($vars[2]); + } elseif (!$this->expectsOneOf(self::HEADERS)) { + break; } - $this->files[] = $file; + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + + $isBinary = false; + if ($this->expects('--- ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + $this->consume('+++ '); + $this->consumeTo("\n"); + $this->consumeNewLine(); + } elseif ($this->expects('Binary files ')) { + $isBinary = true; + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + + $changes = []; + while ($this->expects('@@ ')) { + $changes[] = $this->consumeHunk(); + } + + return new DiffFileData($oldName, $newName, $oldMode, $newMode, $oldHash, $newHash, $isBinary, $isCopy, $changes); + } + + /** + * During a conflict, an unmerged path has this line instead of its patch. + */ + private function consumeUnmergedPaths(): void + { + while ($this->expects('* Unmerged path ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + } + + private function consumeHunk(): FileChange + { + if (!preg_match('/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[^\n]*\n/A', $this->content, $vars, \PREG_UNMATCHED_AS_NULL, $this->cursor)) { + throw new RuntimeException(\sprintf('Unable to parse the hunk header "%s".', substr($this->content, $this->cursor - 3, 50))); } + $this->cursor += \strlen($vars[0]); + + $lines = []; + while (true) { + if ($this->expects(' ')) { + $lines[] = new DiffLine(DiffLineType::Context, $this->consumeTo("\n")); + } elseif ($this->expects('+')) { + $lines[] = new DiffLine(DiffLineType::Addition, $this->consumeTo("\n")); + } elseif ($this->expects('-')) { + $lines[] = new DiffLine(DiffLineType::Deletion, $this->consumeTo("\n")); + } elseif ($this->expects('\ No newline at end of file')) { + // The previous line has no line break. + } else { + break; + } + + $this->consumeNewLine(); + } + + // A count is left out when it is 1. + return new FileChange((int) $vars[1], (int) ($vars[2] ?? 1), (int) $vars[3], (int) ($vars[4] ?? 1), $lines); + } + + /** + * @param string[] $prefixes + */ + private function expectsOneOf(array $prefixes): bool + { + foreach ($prefixes as $prefix) { + if ($this->expects($prefix)) { + return true; + } + } + + return false; + } + + private static function nullIfZeros(string $value): ?string + { + return '' === trim($value, '0') ? null : $value; + } + + private static function unquote(string $name): string + { + if (!str_starts_with($name, '"')) { + return $name; + } + + return stripcslashes(substr($name, 1, -1)); } } diff --git a/src/Gitonomy/Git/Parser/LogParser.php b/src/Gitonomy/Git/Parser/LogParser.php deleted file mode 100644 index 2672186e..00000000 --- a/src/Gitonomy/Git/Parser/LogParser.php +++ /dev/null @@ -1,75 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Parser; - -class LogParser extends CommitParser -{ - public $log = []; - - protected function doParse() - { - $this->log = []; - - while (!$this->isFinished()) { - $commit = []; - $this->consume('commit '); - $commit['id'] = $this->consumeHash(); - $this->consumeNewLine(); - - $this->consume('tree '); - $commit['treeHash'] = $this->consumeHash(); - $this->consumeNewLine(); - - $commit['parentHashes'] = []; - while ($this->expects('parent ')) { - $commit['parentHashes'][] = $this->consumeHash(); - $this->consumeNewLine(); - } - - $this->consume('author '); - list($commit['authorName'], $commit['authorEmail'], $commit['authorDate']) = $this->consumeNameEmailDate(); - $commit['authorDate'] = $this->parseDate($commit['authorDate']); - $this->consumeNewLine(); - - $this->consume('committer '); - list($commit['committerName'], $commit['committerEmail'], $commit['committerDate']) = $this->consumeNameEmailDate(); - $commit['committerDate'] = $this->parseDate($commit['committerDate']); - - // will consume an GPG signed commit if there is one - $this->consumeGPGSignature(); - - $this->consumeNewLine(); - $this->consumeNewLine(); - - $message = ''; - if ($this->expects(' ')) { - $this->cursor -= strlen(' '); - - while ($this->expects(' ')) { - $message .= $this->consumeTo("\n")."\n"; - $this->consumeNewLine(); - } - } else { - $this->cursor--; - } - - if (!$this->isFinished()) { - $this->consumeNewLine(); - } - - $commit['message'] = $message; - - $this->log[] = $commit; - } - } -} diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index 94155ce1..56609014 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -1,6 +1,6 @@ @@ -16,37 +16,42 @@ abstract class ParserBase { - protected $cursor; - protected $content; - protected $length; + protected int $cursor; + protected string $content; + protected int $length; - abstract protected function doParse(); - - public function parse($content) + public function parse(string $content): void { $this->cursor = 0; $this->content = $content; - $this->length = strlen($this->content); + $this->length = \strlen($this->content); $this->doParse(); } - protected function isFinished() + abstract protected function doParse(): void; + + protected function isFinished(): bool { return $this->cursor === $this->length; } - protected function consumeAll() + protected function consumeAll(): string { $rest = substr($this->content, $this->cursor); - $this->cursor += strlen($rest); + $this->cursor += \strlen($rest); return $rest; } - protected function expects($expected) + /** + * Consumes $expected if the content continues with it. + * + * @phpstan-impure + */ + protected function expects(string $expected): bool { - $length = strlen($expected); + $length = \strlen($expected); $actual = substr($this->content, $this->cursor, $length); if ($actual !== $expected) { return false; @@ -57,21 +62,21 @@ protected function expects($expected) return true; } - protected function consumeShortHash() + protected function consumeShortHash(): string { - if (!preg_match('/([A-Za-z0-9]{7,40})/A', $this->content, $vars, null, $this->cursor)) { - throw new RuntimeException('No short hash found: '.substr($this->content, $this->cursor, 7)); + if (!preg_match('/([A-Za-z0-9]{7,40})/A', $this->content, $vars, 0, $this->cursor)) { + throw new RuntimeException(\sprintf('Expected a hash, got "%s".', substr($this->content, $this->cursor, 7))); } - $this->cursor += strlen($vars[1]); + $this->cursor += \strlen($vars[1]); return $vars[1]; } - protected function consumeHash() + protected function consumeHash(): string { - if (!preg_match('/([A-Za-z0-9]{40})/A', $this->content, $vars, null, $this->cursor)) { - throw new RuntimeException('No hash found: '.substr($this->content, $this->cursor, 40)); + if (!preg_match('/([A-Za-z0-9]{40})/A', $this->content, $vars, 0, $this->cursor)) { + throw new RuntimeException(\sprintf('Expected a hash, got "%s".', substr($this->content, $this->cursor, 40))); } $this->cursor += 40; @@ -79,23 +84,26 @@ protected function consumeHash() return $vars[1]; } - protected function consumeRegexp($regexp) + /** + * @return array The matches, like the ones of preg_match() + */ + protected function consumeRegexp(string $regexp): array { - if (!preg_match($regexp.'A', $this->content, $vars, null, $this->cursor)) { - throw new RuntimeException('No match for regexp '.$regexp.' Upcoming: '.substr($this->content, $this->cursor, 30)); + if (!preg_match($regexp.'A', $this->content, $vars, 0, $this->cursor)) { + throw new RuntimeException(\sprintf('Expected %s, got "%s".', $regexp, substr($this->content, $this->cursor, 500))); } - $this->cursor += strlen($vars[0]); + $this->cursor += \strlen($vars[0]); return $vars; } - protected function consumeTo($text) + protected function consumeTo(string $text): string { $pos = strpos($this->content, $text, $this->cursor); if (false === $pos) { - throw new RuntimeException(sprintf('Unable to find "%s"', $text)); + throw new RuntimeException(\sprintf('Expected "%s" after "%s".', $text, substr($this->content, $this->cursor, 100))); } $result = substr($this->content, $this->cursor, $pos - $this->cursor); @@ -104,36 +112,81 @@ protected function consumeTo($text) return $result; } - protected function consume($expected) + protected function consume(string $expected): string { - $length = strlen($expected); + $length = \strlen($expected); $actual = substr($this->content, $this->cursor, $length); if ($actual !== $expected) { - throw new RuntimeException(sprintf('Expected "%s", but got "%s" (%s)', $expected, $actual, substr($this->content, $this->cursor, 10))); + throw new RuntimeException(\sprintf('Expected "%s", got "%s".', $expected, substr($this->content, $this->cursor, 10))); } $this->cursor += $length; return $expected; } - protected function consumeNewLine() + protected function consumeNewLine(): string { return $this->consume("\n"); } /** - * @return string + * @return array{string, string, \DateTimeImmutable} The name, email and date of an author, committer or tagger + * + * @throws RuntimeException The line has no email between "<" and ">" */ - protected function consumeGPGSignature() + protected function consumeNameEmailDate(): array { - $expected = "\ngpgsig "; - $length = strlen($expected); - $actual = substr($this->content, $this->cursor, $length); - if ($actual != $expected) { - return ''; + return self::parseSignature($this->consumeTo("\n")); + } + + /** + * Reads "Name 1700000000 +0200" like libgit2, which LibGit2Driver + * reads it with, and git: old commits, imported from other tools, have + * signatures git wouldn't write anymore, like "Name", or no date. + * + * @return array{string, string, \DateTimeImmutable} The name, email and date, the epoch in UTC without one + * + * @throws RuntimeException $signature has no email between "<" and ">" + */ + protected static function parseSignature(string $signature): array + { + $emailStart = strrpos($signature, '<'); + $emailEnd = strrpos($signature, '>'); + if (false === $emailStart || false === $emailEnd || $emailEnd < $emailStart) { + throw new RuntimeException(\sprintf('Unable to parse the name, email and date of "%s".', $signature)); } - $this->cursor += $length; - return $this->consumeTo("\n\n"); + // The date, then the offset, are optional. + preg_match('/^\s*(\d+)(?:\s+([+-]\d+))?/', substr($signature, $emailEnd + 1), $date); + + return [ + trim(substr($signature, 0, $emailStart)), + trim(substr($signature, $emailStart + 1, $emailEnd - $emailStart - 1)), + self::signatureDate($date[1] ?? '0', $date[2] ?? '+0000'), + ]; + } + + /** + * @param string $timestamp Seconds since the epoch, as git writes them + * @param string $offset The UTC offset of the author, committer or tagger, as git writes it: "+0200", "-0330"..., a sign then hours and minutes + * + * @return \DateTimeImmutable The date at $offset, like git log shows it + * + * @throws RuntimeException The date is out of range, in a corrupt object + */ + protected static function signatureDate(string $timestamp, string $offset): \DateTimeImmutable + { + // Like libgit2, which LibGit2Driver reads dates with: an offset of + // more than 14 hours or 59 minutes, which no timezone has, in a + // corrupt object, gives UTC. + $hoursAndMinutes = (int) substr($offset, 1); + [$hours, $minutes] = [intdiv($hoursAndMinutes, 100), $hoursAndMinutes % 100]; + $timezone = $hours > 14 || $minutes > 59 ? '+0000' : \sprintf('%s%02d%02d', $offset[0], $hours, $minutes); + + try { + return new \DateTimeImmutable('@'.$timestamp)->setTimezone(new \DateTimeZone($timezone)); + } catch (\DateMalformedStringException $e) { + throw new RuntimeException(\sprintf('The date "%s" is out of range.', $timestamp), 0, $e); + } } } diff --git a/src/Gitonomy/Git/Parser/ReferenceParser.php b/src/Gitonomy/Git/Parser/ReferenceParser.php deleted file mode 100644 index 82e3a8b5..00000000 --- a/src/Gitonomy/Git/Parser/ReferenceParser.php +++ /dev/null @@ -1,31 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Parser; - -class ReferenceParser extends ParserBase -{ - public $references; - - protected function doParse() - { - $this->references = []; - - while (!$this->isFinished()) { - $hash = $this->consumeHash(); - $this->consume(' '); - $name = $this->consumeTo("\n"); - $this->consumeNewLine(); - $this->references[] = [$hash, $name]; - } - } -} diff --git a/src/Gitonomy/Git/Parser/TreeParser.php b/src/Gitonomy/Git/Parser/TreeParser.php deleted file mode 100644 index d01f4b28..00000000 --- a/src/Gitonomy/Git/Parser/TreeParser.php +++ /dev/null @@ -1,39 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Parser; - -class TreeParser extends ParserBase -{ - public $entries = []; - - protected function doParse() - { - while (!$this->isFinished()) { - $vars = $this->consumeRegexp('/\d{6}/A'); - $mode = $vars[0]; - $this->consume(' '); - - $vars = $this->consumeRegexp('/(blob|tree|commit)/A'); - $type = $vars[0]; - $this->consume(' '); - - $hash = $this->consumeHash(); - $this->consume("\t"); - - $name = $this->consumeTo("\n"); - $this->consumeNewLine(); - - $this->entries[] = [$mode, $type, $hash, $name]; - } - } -} diff --git a/src/Gitonomy/Git/PushReference.php b/src/Gitonomy/Git/PushReference.php index 3911bd2e..4ed8727b 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -1,6 +1,6 @@ @@ -20,91 +20,68 @@ * * @author Julien DIDIER */ -class PushReference +final class PushReference { - const ZERO = '0000000000000000000000000000000000000000'; - - /** - * @var string - */ - protected $reference; - - /** - * @var string - */ - protected $before; + public const string ZERO = '0000000000000000000000000000000000000000'; - /** - * @var string - */ - protected $after; + private ?bool $isForce = null; /** - * @var bool + * @param string $fullname The full name of the pushed reference + * @param string $before Its commit before the push, ZERO for a creation + * @param string $after Its commit after the push, ZERO for a deletion */ - protected $isForce; - - public function __construct(Repository $repository, $reference, $before, $after) - { - $this->repository = $repository; - $this->reference = $reference; - $this->before = $before; - $this->after = $after; - $this->isForce = $this->getForce(); + public function __construct( + private readonly Repository $repository, + private readonly string $fullname, + private readonly string $before, + private readonly string $after, + ) { } - /** - * @return Repository - */ - public function getRepository() + public function getRepository(): Repository { return $this->repository; } /** - * @return string + * @return string The full name of the pushed reference */ - public function getReference() + public function getFullname(): string { - return $this->reference; + return $this->fullname; } - /** - * @return string - */ - public function getBefore() + public function getBefore(): string { return $this->before; } - /** - * @return string - */ - public function getAfter() + public function getAfter(): string { return $this->after; } /** - * @return array + * @param string[] $excludes */ - public function getLog($excludes = []) + public function getLog(array $excludes = []): Log { - return $this->repository->getLog(array_merge( - [$this->getRevision()], - array_map(function ($e) { - return '^'.$e; - }, $excludes) - )); + return $this->repository->getLog([$this->getRange(), ...array_map(static fn (string $exclude): string => '^'.$exclude, $excludes)]); } - public function getRevision() + /** + * @return string The pushed commits: "before..after", or "after" for a creation + * + * @throws LogicException The push deletes the reference + */ + public function getRange(): string { - if ($this->isDelete()) { - throw new LogicException('No revision for deletion'); + if ($this->isDeletion()) { + throw new LogicException('A deletion pushes no commit.'); } - if ($this->isCreate()) { + if ($this->isCreation()) { return $this->getAfter(); } @@ -112,59 +89,43 @@ public function getRevision() } /** - * @return bool + * @return bool Whether the push creates the reference, like File::isCreation() for a file */ - public function isCreate() + public function isCreation(): bool { return $this->isZero($this->before); } /** - * @return bool + * @return bool Whether the push deletes the reference, like File::isDeletion() for a file */ - public function isDelete() + public function isDeletion(): bool { return $this->isZero($this->after); } - /** - * @return bool - */ - public function isForce() + public function isForce(): bool { - return $this->isForce; + return $this->isForce ??= $this->getForce(); } - /** - * @return bool - */ - public function isFastForward() + public function isFastForward(): bool { - return !$this->isDelete() && !$this->isCreate() && !$this->isForce(); + return !$this->isDeletion() && !$this->isCreation() && !$this->isForce(); } - /** - * @return bool - */ - protected function isZero($reference) + private function isZero(string $hash): bool { - return self::ZERO === $reference; + return self::ZERO === $hash; } - /** - * @return bool - */ - protected function getForce() + private function getForce(): bool { - if ($this->isDelete() || $this->isCreate()) { + if ($this->isDeletion() || $this->isCreation()) { return false; } - $result = $this->repository->run('merge-base', [ - $this->before, - $this->after, - ]); - - return $this->before !== trim($result); + // Without a common history either, it is a force update. + return !\in_array($this->before, $this->repository->getRepositoryDriver()->getMergeBases($this->before, $this->after), true); } } diff --git a/src/Gitonomy/Git/Reference.php b/src/Gitonomy/Git/Reference.php index 23f1cb75..6753aac8 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -1,6 +1,6 @@ @@ -15,56 +15,59 @@ /** * Reference in a Git repository. * + * Branch, Tag and Stash extend it: extending it outside gitlib isn't + * supported, and won't be possible in 3.0. + * * @author Alexandre Salomé * @author Julien DIDIER */ abstract class Reference extends Revision { - protected $commitHash; - - public function __construct(Repository $repository, $revision, $commitHash = null) - { - $this->repository = $repository; - $this->revision = $revision; - $this->commitHash = $commitHash; + /** + * @param string $commitHash What the reference points to, through an annotated tag + * + * @internal Get it from Repository::getReferences(), which gitlib reads again after its writes + */ + public function __construct( + Repository $repository, + string $fullname, + protected readonly string $commitHash, + ) { + parent::__construct($repository, $fullname); } - public function getFullname() + /** + * @return string The name, without its prefix: "master", "origin/master" for a remote branch, "0.1" for a tag + */ + abstract public function getName(): string; + + public function getFullname(): string { return $this->revision; } - public function delete() + public function delete(): void { $this->repository->getReferences()->delete($this->getFullname()); } - public function getCommitHash() + /** + * @return string The object the reference points to, when it was read, through an annotated tag: a commit, unless a tag points to a tree or a blob + */ + public function getCommitHash(): string { - if (null !== $this->commitHash) { - return $this->commitHash; - } - - try { - $result = $this->repository->run('rev-parse', ['--verify', $this->revision]); - } catch (ProcessException $e) { - throw new ReferenceNotFoundException(sprintf('Can not find revision "%s"', $this->revision)); - } - - return $this->commitHash = trim($result); + return $this->commitHash; } /** * Returns the commit associated to the reference. - * - * @return Commit */ - public function getCommit() + public function getCommit(): Commit { - return $this->repository->getCommit($this->getCommitHash()); + return $this->repository->getCommit($this->commitHash); } - public function getLastModification($path = null) + public function getLastModification(?string $path = null): Commit { return $this->getCommit()->getLastModification($path); } diff --git a/src/Gitonomy/Git/Reference/Branch.php b/src/Gitonomy/Git/Reference/Branch.php index 0f263a76..039b97f1 100644 --- a/src/Gitonomy/Git/Reference/Branch.php +++ b/src/Gitonomy/Git/Reference/Branch.php @@ -1,6 +1,6 @@ @@ -14,17 +14,16 @@ use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference; +use Gitonomy\Git\Revision; /** * Representation of a branch reference. * * @author Alexandre Salomé */ -class Branch extends Reference +final class Branch extends Reference { - private $local = null; - - public function getName() + public function getName(): string { $fullname = $this->getFullname(); @@ -36,27 +35,42 @@ public function getName() return $vars['remote'].'/'.$vars['name']; } - throw new RuntimeException(sprintf('Cannot extract branch name from "%s"', $fullname)); + throw new RuntimeException(\sprintf('Cannot extract branch name from "%s".', $fullname)); } - public function isRemote() + public function isRemote(): bool { - $this->detectBranchType(); + return str_starts_with($this->getFullname(), 'refs/remotes/'); + } - return !$this->local; + public function isLocal(): bool + { + return !$this->isRemote(); } - public function isLocal() + /** + * Checks if the history of $revision contains this branch. + */ + public function isMergedInto(Revision|string $revision): bool { - $this->detectBranchType(); + $destination = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); - return $this->local; + return \in_array($this->commitHash, $this->repository->getRepositoryDriver()->getMergeBases($this->commitHash, $destination), true); } - private function detectBranchType() + /** + * Points the branch to $revision. + * + * @return self The branch, pointing to $revision + */ + public function moveTo(Revision|string $revision): self { - if (null === $this->local) { - $this->local = !preg_match('#^refs/remotes/(?[^/]*)/(?.*)$#', $this->getFullname()); - } + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); + $this->repository->getRepositoryDriver()->updateReference($this->getFullname(), $hash); + + $references = $this->repository->getReferences(); + $references->invalidate(); + + return $this->isLocal() ? $references->getBranch($this->getName()) : $references->getRemoteBranch($this->getName()); } } diff --git a/src/Gitonomy/Git/Reference/Stash.php b/src/Gitonomy/Git/Reference/Stash.php index 9f26802f..ff6854bc 100644 --- a/src/Gitonomy/Git/Reference/Stash.php +++ b/src/Gitonomy/Git/Reference/Stash.php @@ -1,6 +1,6 @@ @@ -17,9 +17,9 @@ /** * @author Alexandre Salomé */ -class Stash extends Reference +final class Stash extends Reference { - public function getName() + public function getName(): string { return 'stash'; } diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index 8e42376d..d316e92b 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -1,6 +1,6 @@ @@ -12,22 +12,128 @@ namespace Gitonomy\Git\Reference; +use Gitonomy\Git\Driver\Data\TagData; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference; +use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\Message; /** * Representation of a tag reference. * * @author Alexandre Salomé + * @author Bruce Wells */ -class Tag extends Reference +final class Tag extends Reference { - public function getName() + /** + * The annotated tag object, read through the driver when needed. + */ + private ?TagData $data = null; + + /** + * @param string $commitHash What the tag points to, through its annotated tag object + * @param string|null $tagHash The annotated tag object, null for a lightweight tag + * + * @internal Get it from Repository::getReferences(), which gitlib reads again after its writes + */ + public function __construct( + Repository $repository, + string $fullname, + string $commitHash, + private readonly ?string $tagHash = null, + ) { + parent::__construct($repository, $fullname, $commitHash); + } + + public function getName(): string { if (!preg_match('#^refs/tags/(.*)$#', $this->revision, $vars)) { - throw new RuntimeException(sprintf('Cannot extract tag name from "%s"', $this->revision)); + throw new RuntimeException(\sprintf('Cannot extract tag name from "%s".', $this->revision)); } return $vars[1]; } + + /** + * Check if tag is annotated. + */ + public function isAnnotated(): bool + { + return null !== $this->tagHash; + } + + /** + * @return string|null The name of the tagger, null for a lightweight tag or a tag without tagger + */ + public function getTaggerName(): ?string + { + return $this->data()?->taggerName; + } + + /** + * @return string|null The email of the tagger, null for a lightweight tag or a tag without tagger + */ + public function getTaggerEmail(): ?string + { + return $this->data()?->taggerEmail; + } + + /** + * @return \DateTimeImmutable|null The tagging date, at the UTC offset of the tagger, like git log shows it; null for a lightweight tag or a tag without tagger + */ + public function getTaggerDate(): ?\DateTimeImmutable + { + return $this->data()?->taggerDate; + } + + /** + * @return string|null The message of the annotated tag, without its signature; null for a lightweight tag + */ + public function getMessage(): ?string + { + return $this->data()?->message; + } + + /** + * @return string|null The first paragraph of the message, on one line, null for a lightweight tag + */ + public function getSubjectMessage(): ?string + { + $message = $this->getMessage(); + + return null === $message ? null : Message::subject($message); + } + + /** + * @return string|null What follows the subject and its empty lines, null for a lightweight tag + */ + public function getBodyMessage(): ?string + { + $message = $this->getMessage(); + + return null === $message ? null : Message::body($message); + } + + /** + * @return string|null The signature (GPG, X.509 or SSH) between its armor lines, null for an unsigned or lightweight tag + */ + public function getGpgSignature(): ?string + { + return $this->data()?->gpgSignature; + } + + public function isSigned(): bool + { + return null !== $this->getGpgSignature(); + } + + private function data(): ?TagData + { + if (null === $this->tagHash) { + return null; + } + + return $this->data ??= $this->repository->getRepositoryDriver()->getTagData($this->tagHash); + } } diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 178fd9f2..78970c06 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -1,6 +1,6 @@ @@ -12,8 +12,8 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; -use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Stash; use Gitonomy\Git\Reference\Tag; @@ -23,378 +23,273 @@ * * @author Alexandre Salomé * @author Julien DIDIER + * + * @implements \IteratorAggregate */ -class ReferenceBag implements \Countable, \IteratorAggregate +final class ReferenceBag implements \Countable, \IteratorAggregate { /** - * Repository object. - * - * @var Gitonomy\Git\Repository - */ - protected $repository; - - /** - * Associative array of fullname references. + * The references, by full name: null until read, and after every write. * - * @var array + * @var array|null */ - protected $references; + private ?array $references = null; /** - * List with all tags. - * - * @var array - */ - protected $tags; - - /** - * List with all branches. - * - * @var array - */ - protected $branches; - - /** - * A boolean indicating if the bag is already initialized. - * - * @var bool + * @internal Get it from Repository::getReferences(), which gitlib reads again after its writes */ - protected $initialized = false; - - /** - * Constructor. - * - * @param Gitonomy\Git\Repository $repository The repository - */ - public function __construct($repository) - { - $this->repository = $repository; - $this->references = []; - $this->tags = []; - $this->branches = []; + public function __construct( + private readonly Repository $repository, + ) { } /** * Returns a reference, by name. * - * @param string $fullname Fullname of the reference (refs/heads/master, for example). - * - * @return Reference A reference object. + * @param string $fullname fullname of the reference (refs/heads/master, for example) */ - public function get($fullname) + public function get(string $fullname): Reference { - $this->initialize(); - - if (!isset($this->references[$fullname])) { - throw new ReferenceNotFoundException($fullname); - } - - return $this->references[$fullname]; + return $this->references()[$fullname] ?? throw new ReferenceNotFoundException(\sprintf('The reference "%s" does not exist.', $fullname)); } - public function has($fullname) + public function has(string $fullname): bool { - $this->initialize(); - - return isset($this->references[$fullname]); + return isset($this->references()[$fullname]); } - public function update(Reference $reference) - { - $fullname = $reference->getFullname(); - - $this->initialize(); - $this->repository->run('update-ref', [$fullname, $reference->getCommitHash()]); - - $this->references[$fullname] = $reference; - - return $reference; - } - - public function createBranch($name, $commitHash) + /** + * @throws InvalidArgumentException The branch exists already + */ + public function createBranch(string $name, Revision|string $revision): Branch { - $branch = new Branch($this->repository, 'refs/heads/'.$name, $commitHash); + $this->create('refs/heads/'.$name, $revision); - return $this->update($branch); + return $this->getBranch($name); } - public function createTag($name, $commitHash) + /** + * Creates a lightweight tag. + * + * @throws InvalidArgumentException The tag exists already + */ + public function createTag(string $name, Revision|string $revision): Tag { - $tag = new Tag($this->repository, 'refs/tags/'.$name, $commitHash); + $this->create('refs/tags/'.$name, $revision); - return $this->update($tag); + return $this->getTag($name); } - public function delete($fullname) + /** + * Like `git update-ref -d`, deleting a missing reference does nothing. + * + * @param string $fullname fullname of the reference (refs/heads/master, for example) + * + * @throws InvalidArgumentException $fullname isn't the full name of a reference under "refs/", like "HEAD" or "master" + */ + public function delete(string $fullname): void { - $this->repository->run('update-ref', ['-d', $fullname]); - - unset($this->references[$fullname]); + $this->repository->getRepositoryDriver()->deleteReference($fullname); + $this->invalidate(); } - public function hasBranches() + public function hasBranches(): bool { - $this->initialize(); - - return count($this->branches) > 0; + return [] !== $this->getBranches(); } - public function hasBranch($name) + public function hasBranch(string $name): bool { return $this->has('refs/heads/'.$name); } - public function hasRemoteBranch($name) + public function hasRemoteBranch(string $name): bool { return $this->has('refs/remotes/'.$name); } - public function hasTag($name) + public function hasTag(string $name): bool { return $this->has('refs/tags/'.$name); } - public function getFirstBranch() + public function getFirstBranch(): ?Branch { - $this->initialize(); - reset($this->branches); - - return current($this->references); + return $this->getBranches()[0] ?? null; } /** - * @return array An array of Tag objects + * @return Tag[] The tags pointing to $commit, annotated or not */ - public function resolveTags($hash) + public function resolveTags(Commit|string $commit): array { - $this->initialize(); - - if ($hash instanceof Commit) { - $hash = $hash->getHash(); - } - - $tags = []; - foreach ($this->references as $reference) { - if ($reference instanceof Reference\Tag && $reference->getCommitHash() === $hash) { - $tags[] = $reference; - } - } - - return $tags; + return array_values(array_filter($this->resolve($commit), static fn (Reference $reference): bool => $reference instanceof Tag)); } /** - * @return array An array of Branch objects + * @return Branch[] The branches pointing to $commit */ - public function resolveBranches($hash) + public function resolveBranches(Commit|string $commit): array { - $this->initialize(); - - if ($hash instanceof Commit) { - $hash = $hash->getHash(); - } - - $branches = []; - foreach ($this->references as $reference) { - if ($reference instanceof Reference\Branch && $reference->getCommitHash() === $hash) { - $branches[] = $reference; - } - } - - return $branches; + return array_values(array_filter($this->resolve($commit), static fn (Reference $reference): bool => $reference instanceof Branch)); } /** - * @return array An array of references + * @return Reference[] The references pointing to $commit, through annotated tags */ - public function resolve($hash) + public function resolve(Commit|string $commit): array { - $this->initialize(); + $hash = match (true) { + $commit instanceof Commit => $commit->getHash(), + 1 === preg_match('/^[0-9a-f]{40}$/', $commit) => $commit, + default => $this->repository->getRevision($commit)->getCommit()->getHash(), + }; - if ($hash instanceof Commit) { - $hash = $hash->getHash(); - } - - $result = []; - foreach ($this->references as $k => $reference) { - if ($reference->getCommitHash() === $hash) { - $result[] = $reference; - } - } - - return $result; + return array_values(array_filter($this->references(), static fn (Reference $reference): bool => $reference->getCommitHash() === $hash)); } /** - * Returns all tags. - * - * @return array + * @return Tag[] all tags */ - public function getTags() + public function getTags(): array { - $this->initialize(); - - return $this->tags; + return array_values(array_filter($this->references(), static fn (Reference $reference): bool => $reference instanceof Tag)); } /** - * Returns all branches. - * - * @return array + * @return Branch[] all branches */ - public function getBranches() + public function getBranches(): array { - $this->initialize(); - - $result = []; - foreach ($this->references as $reference) { - if ($reference instanceof Reference\Branch) { - $result[] = $reference; - } - } - - return $result; + return array_values(array_filter($this->references(), static fn (Reference $reference): bool => $reference instanceof Branch)); } /** - * Returns all locales branches. - * - * @return array + * @return Branch[] all local branches */ - public function getLocalBranches() + public function getLocalBranches(): array { - $result = []; - foreach ($this->getBranches() as $branch) { - if ($branch->isLocal()) { - $result[] = $branch; - } - } - - return $result; + return array_values(array_filter($this->getBranches(), static fn (Branch $branch): bool => $branch->isLocal())); } /** - * Returns all remote branches. - * - * @return array + * @return Branch[] all remote branches */ - public function getRemoteBranches() + public function getRemoteBranches(): array { - $result = []; - foreach ($this->getBranches() as $branch) { - if ($branch->isRemote()) { - $result[] = $branch; - } - } - - return $result; + return array_values(array_filter($this->getBranches(), static fn (Branch $branch): bool => $branch->isRemote())); } /** - * @return array An associative array with fullname as key (refs/heads/master, refs/tags/0.1) + * @return array An associative array with fullname as key (refs/heads/master, refs/tags/0.1) */ - public function getAll() + public function getAll(): array { - $this->initialize(); + return $this->references(); + } - return $this->references; + public function getTag(string $name): Tag + { + return $this->getAs('refs/tags/'.$name, Tag::class); } - /** - * @return Tag - */ - public function getTag($name) + public function getBranch(string $name): Branch { - $this->initialize(); + return $this->getAs('refs/heads/'.$name, Branch::class); + } - return $this->get('refs/tags/'.$name); + public function getRemoteBranch(string $name): Branch + { + return $this->getAs('refs/remotes/'.$name, Branch::class); } /** - * @return Branch + * @see Countable */ - public function getBranch($name) + public function count(): int { - $this->initialize(); - - return $this->get('refs/heads/'.$name); + return \count($this->references()); } /** - * @return Branch + * @see IteratorAggregate + * + * @return \ArrayIterator */ - public function getRemoteBranch($name) + public function getIterator(): \ArrayIterator { - $this->initialize(); + return new \ArrayIterator($this->references()); + } - return $this->get('refs/remotes/'.$name); + /** + * Reads the references again when next needed: gitlib calls it after + * every write, through this bag or not (checkout, merge, run()...). + * + * @internal + */ + public function invalidate(): void + { + $this->references = null; } - protected function initialize() + /** + * @throws InvalidArgumentException $fullname exists already + */ + private function create(string $fullname, Revision|string $revision): void { - if (true === $this->initialized) { - return; - } - $this->initialized = true; - - try { - $parser = new Parser\ReferenceParser(); - $output = $this->repository->run('show-ref'); - } catch (RuntimeException $e) { - $output = $e->getOutput(); - $error = $e->getErrorOutput(); - if ($error) { - throw new RuntimeException('Error while getting list of references: '.$error); - } + if ($this->has($fullname)) { + throw new InvalidArgumentException(\sprintf('The reference "%s" exists already.', $fullname)); } - $parser->parse($output); - - foreach ($parser->references as $row) { - list($commitHash, $fullname) = $row; - if (preg_match('#^refs/(heads|remotes)/(.*)$#', $fullname)) { - if (preg_match('#.*HEAD$#', $fullname)) { - continue; - } - $reference = new Branch($this->repository, $fullname, $commitHash); - $this->references[$fullname] = $reference; - $this->branches[] = $reference; - } elseif (preg_match('#^refs/tags/(.*)$#', $fullname)) { - $reference = new Tag($this->repository, $fullname, $commitHash); - $this->references[$fullname] = $reference; - $this->tags[] = $reference; - } elseif ($fullname === 'refs/stash') { - $reference = new Stash($this->repository, $fullname, $commitHash); - $this->references[$fullname] = $reference; - } elseif (preg_match('#^refs/pull/(.*)$#', $fullname)) { - // Do nothing here - } elseif ($fullname === 'refs/notes/gtm-data') { - // Do nothing here - } else { - throw new RuntimeException(sprintf('Unable to parse "%s"', $fullname)); - } - } + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); + $this->repository->getRepositoryDriver()->createReference($fullname, $hash); + $this->invalidate(); } /** - * @return int + * @template T of Reference * - * @see Countable + * @param class-string $class + * + * @return T */ - public function count() + private function getAs(string $fullname, string $class): Reference { - $this->initialize(); + $reference = $this->get($fullname); - return count($this->references); + if (!$reference instanceof $class) { + throw new ReferenceNotFoundException(\sprintf('The reference "%s" does not exist.', $fullname)); + } + + return $reference; } /** - * @see IteratorAggregate + * @return array */ - public function getIterator() + private function references(): array { - $this->initialize(); + if (null !== $this->references) { + return $this->references; + } + + $references = []; + foreach ($this->repository->getRepositoryDriver()->getReferences() as $data) { + $fullname = $data->fullname; + + if (str_starts_with($fullname, 'refs/heads/') || str_starts_with($fullname, 'refs/remotes/')) { + // The default branch of a remote, a symbolic reference to one of its branches; a remote may be named "foo/bar". + if (str_ends_with($fullname, '/HEAD') && str_starts_with($fullname, 'refs/remotes/')) { + continue; + } + $references[$fullname] = new Branch($this->repository, $fullname, $data->commitHash); + } elseif (str_starts_with($fullname, 'refs/tags/')) { + $references[$fullname] = new Tag($this->repository, $fullname, $data->commitHash, $data->tagHash); + } elseif ('refs/stash' === $fullname) { + $references[$fullname] = new Stash($this->repository, $fullname, $data->commitHash); + } + } - return new \ArrayIterator($this->references); + return $this->references = $references; } } diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 36e35850..b2505e41 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -1,6 +1,6 @@ @@ -13,10 +13,20 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\Cli\CliRepositoryDriver; +use Gitonomy\Git\Driver\Data\ObjectType; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ProcessTimedOutException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; -use Psr\Log\LoggerInterface; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Util\WeakCache; use Symfony\Component\Process\Process; /** @@ -26,150 +36,172 @@ * * @author Alexandre Salomé */ -class Repository +final class Repository { - const DEFAULT_DESCRIPTION = "Unnamed repository; edit this file 'description' to name the repository.\n"; + public const string DEFAULT_DESCRIPTION = "Unnamed repository; edit this file 'description' to name the repository.\n"; /** * Directory containing git files. - * - * @var string */ - protected $gitDir; + private readonly string $gitDir; /** * Working directory. - * - * @var string */ - protected $workingDir; + private readonly ?string $workingDir; /** - * Cache containing all objects of the repository. + * The commits, by hash, as long as the application uses them. * - * Associative array, indexed by object hash - * - * @var array + * @var WeakCache */ - protected $objects; + private readonly WeakCache $commits; /** - * Reference bag associated to this repository. + * The trees, by hash, as long as the application uses them. * - * @var ReferenceBag + * @var WeakCache */ - protected $referenceBag; + private readonly WeakCache $trees; /** - * Logger (can be null). + * The blobs, by hash, as long as the application uses them. * - * @var LoggerInterface + * @var WeakCache */ - protected $logger; + private readonly WeakCache $blobs; /** - * Path to git command. + * Reference bag associated to this repository. */ - protected $command; + private ?ReferenceBag $referenceBag = null; /** - * Debug flag, indicating if errors should be thrown. - * - * @var bool + * Operations on this repository, opened by the driver. */ - protected $debug; + private readonly RepositoryDriverInterface $repositoryDriver; /** - * Environment variables that should be set for every running process. + * Opens the repository in $dir: a bare repository, or a working tree with + * its ".git" directory or file (a linked worktree, a submodule...). + * + * @param string|null $workingDir The working tree, when $dir is a git directory kept apart from it + * @param DriverInterface $driver Backend running the operations * - * @var array + * @throws InvalidArgumentException $dir doesn't exist or isn't a repository + * @throws DriverException The driver can't open the repository + * @throws UnsupportedOperationException The repository uses SHA-256 hashes, or with LibGit2Driver the reftable format */ - protected $environmentVariables; + public function __construct(string $dir, ?string $workingDir = null, DriverInterface $driver = new CliDriver()) + { + $this->commits = new WeakCache(); + $this->trees = new WeakCache(); + $this->blobs = new WeakCache(); + $this->repositoryDriver = $driver->open($dir, $workingDir); + $this->gitDir = $this->repositoryDriver->getGitDir(); + $this->workingDir = $this->repositoryDriver->getWorkingDir(); + } /** - * Timeout that should be set for every running process. + * Initializes a repository with a working directory in $path. * - * @var int + * @throws DriverException The directory can't be written, or is already a file */ - protected $processTimeout; + public static function init(string $path, DriverInterface $driver = new CliDriver()): self + { + $driver->init($path); + + return new self($path, null, $driver); + } /** - * Constructs a new repository. - * - * Available options are: - * - * * working_dir : specify where working copy is located (option --work-tree) + * Initializes a bare repository in $path. * - * * debug : (default: true) enable/disable minimize errors and reduce log level + * @throws DriverException The directory can't be written, or is already a file + */ + public static function initBare(string $path, DriverInterface $driver = new CliDriver()): self + { + $driver->initBare($path); + + return new self($path, null, $driver); + } + + /** + * Clones $url with a working directory in $path. * - * * logger : a logger to use for logging actions (Psr\Log\LoggerInterface) + * @param string|null $branch The branch to check out, the default branch of $url when null * - * * environment_variables : define environment variables for every ran process + * @throws DriverException + * @throws UnsupportedOperationException $url is a bundle, with LibGit2Driver + */ + public static function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self + { + $driver->cloneFrom($url, $path, $branch); + + return new self($path, null, $driver); + } + + /** + * Clones the branches and the tags of $url into a bare repository in $path. * - * @param string $dir path to git repository - * @param array $options array of options values + * @param string|null $branch The branch HEAD points to, the default branch of $url when null * - * @throws InvalidArgumentException The folder does not exists + * @throws DriverException + * @throws UnsupportedOperationException $url is a bundle, with LibGit2Driver */ - public function __construct($dir, $options = []) + public static function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self { - $is_windows = defined('PHP_WINDOWS_VERSION_BUILD'); - $options = array_merge([ - 'working_dir' => null, - 'debug' => true, - 'logger' => null, - 'environment_variables' => $is_windows ? ['PATH' => getenv('path')] : [], - 'command' => 'git', - 'process_timeout' => 3600, - ], $options); - - if (null !== $options['logger'] && !$options['logger'] instanceof LoggerInterface) { - throw new InvalidArgumentException(sprintf('Argument "logger" passed to Repository should be a Psr\Log\LoggerInterface. A %s was provided', is_object($options['logger']) ? get_class($options['logger']) : gettype($options['logger']))); - } + $driver->cloneBareFrom($url, $path, $branch); - $this->logger = $options['logger']; - $this->initDir($dir, $options['working_dir']); + return new self($path, null, $driver); + } - $this->objects = []; - $this->debug = (bool) $options['debug']; - $this->environmentVariables = $options['environment_variables']; - $this->processTimeout = $options['process_timeout']; - $this->command = $options['command']; + /** + * Clones every reference of $url into a bare repository in $path, set up + * to mirror $url when fetching. + * + * @throws DriverException + * @throws UnsupportedOperationException $url is a bundle, with LibGit2Driver + */ + public static function mirrorFrom(#[\SensitiveParameter] string $url, string $path, DriverInterface $driver = new CliDriver()): self + { + $driver->mirrorFrom($url, $path); - if (true === $this->debug && null !== $this->logger) { - $this->logger->debug(sprintf('Repository created (git dir: "%s", working dir: "%s")', $this->gitDir, $this->workingDir ?: 'none')); - } + return new self($path, null, $driver); } /** - * Initializes directory attributes on repository:. - * - * @param string $gitDir directory of a working copy with files checked out - * @param string $workingDir directory containing git files (objects, config...) + * Checks, without cloning it, that $url is a repository the driver can + * read: false when it doesn't exist, can't be reached, or refuses access. */ - private function initDir($gitDir, $workingDir = null) + public static function isValidRepository(#[\SensitiveParameter] string $url, DriverInterface $driver = new CliDriver()): bool { - $realGitDir = realpath($gitDir); + return $driver->isValidRepository($url); + } - if (false === $realGitDir) { - throw new InvalidArgumentException(sprintf('Directory "%s" does not exist or is not a directory', $gitDir)); - } elseif (!is_dir($realGitDir)) { - throw new InvalidArgumentException(sprintf('Directory "%s" does not exist or is not a directory', $realGitDir)); - } elseif (null === $workingDir && is_dir($realGitDir.'/.git')) { - $workingDir = $realGitDir; - $realGitDir = $realGitDir.'/.git'; - } + /** + * Checks, without cloning it, that $url is a repository the driver can + * read, and that it has the $branch branch. + */ + public static function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch, DriverInterface $driver = new CliDriver()): bool + { + return $driver->isValidRepositoryAndBranch($url, $branch); + } - $this->gitDir = $realGitDir; - $this->workingDir = $workingDir; + /** + * Returns the operations on this repository, opened by the driver. + * + * @internal + */ + public function getRepositoryDriver(): RepositoryDriverInterface + { + return $this->repositoryDriver; } /** * Tests if repository is a bare repository. - * - * @return bool */ - public function isBare() + public function isBare(): bool { return null === $this->workingDir; } @@ -177,95 +209,63 @@ public function isBare() /** * Returns the HEAD resolved as a commit. * - * @return Commit|null returns a Commit or ``null`` if repository is empty + * @throws ReferenceNotFoundException HEAD points to a branch without commits yet */ - public function getHeadCommit() + public function getHeadCommit(): Commit { - $head = $this->getHead(); - - if ($head instanceof Reference) { - return $head->getCommit(); - } - - return $head; + return $this->getRevision('HEAD')->getCommit(); } /** - * @throws RuntimeException Unable to find file HEAD (debug-mode only) + * @return Branch|Commit The branch HEAD points to, or the commit when HEAD is detached * - * @return Reference|Commit|null current HEAD object or null if error occurs + * @throws ReferenceNotFoundException HEAD points to a branch without commits yet, or to a reference that isn't a branch */ - public function getHead() + public function getHead(): Branch|Commit { - $file = $this->gitDir.'/HEAD'; - - if (!file_exists($file)) { - $message = sprintf('Unable to find HEAD file ("%s")', $file); - - if (null !== $this->logger) { - $this->logger->error($message); - } - - if (true === $this->debug) { - throw new RuntimeException($message); - } - } + $reference = $this->repositoryDriver->getHeadReference(); - $content = trim(file_get_contents($file)); - - if (null !== $this->logger) { - $this->logger->debug('HEAD file read: '.$content); + if (null === $reference) { + return $this->getCommit($this->repositoryDriver->resolveRevision('HEAD')); } - if (preg_match('/^ref: (.+)$/', $content, $vars)) { - return $this->getReferences()->get($vars[1]); - } elseif (preg_match('/^[0-9a-f]{40}$/', $content)) { - return $this->getCommit($content); + $references = $this->getReferences(); + // A branch another process checked out since gitlib read the references. + if (!$references->has($reference)) { + $references->invalidate(); } - $message = sprintf('Unexpected HEAD file content (file: %s). Content of file: %s', $file, $content); - - if (null !== $this->logger) { - $this->logger->error($message); + $head = $references->get($reference); + // git lets HEAD point to any reference under "refs/", a tag included. + if (!$head instanceof Branch) { + throw new ReferenceNotFoundException(\sprintf('HEAD points to "%s", which is not a branch.', $reference)); } - if (true === $this->debug) { - throw new RuntimeException($message); - } + return $head; } - /** - * @return bool - */ - public function isHeadDetached() + public function isHeadDetached(): bool { return !$this->isHeadAttached(); } - /** - * @return bool - */ - public function isHeadAttached() + public function isHeadAttached(): bool { - return $this->getHead() instanceof Reference; + return null !== $this->repositoryDriver->getHeadReference(); } /** * Returns the path to the git repository. - * - * @return string A directory path */ - public function getPath() + public function getPath(): string { - return $this->workingDir === null ? $this->gitDir : $this->workingDir; + return null === $this->workingDir ? $this->gitDir : $this->workingDir; } /** * Returns the directory containing git files (git-dir). - * - * @return string */ - public function getGitDir() + public function getGitDir(): string { return $this->gitDir; } @@ -273,193 +273,159 @@ public function getGitDir() /** * Returns the work-tree directory. This may be null if repository is * bare. - * - * @return string path to repository or null if repository is bare */ - public function getWorkingDir() + public function getWorkingDir(): ?string { return $this->workingDir; } /** - * Instanciates a revision. + * Instantiates a revision. * * @param string $name Name of the revision - * - * @return Revision */ - public function getRevision($name) + public function getRevision(string $name): Revision { return new Revision($this, $name); } - /** - * @return WorkingCopy - */ - public function getWorkingCopy() + public function getWorkingCopy(): WorkingCopy { return new WorkingCopy($this); } /** - * Returns the reference list associated to the repository. - * - * @return ReferenceBag + * Returns the references of the repository, read again after every write + * gitlib makes. Open a new Repository to see the writes of other + * processes. */ - public function getReferences() + public function getReferences(): ReferenceBag { - if (null === $this->referenceBag) { - $this->referenceBag = new ReferenceBag($this); - } - - return $this->referenceBag; + return $this->referenceBag ??= new ReferenceBag($this); } /** - * Instanciates a commit object or fetches one from the cache. + * Returns a commit, the same object while the application uses it. * - * @param string $hash A commit hash, with a length of 40 + * @param string $hash The full hash of the commit: getRevision()->getCommit() resolves a name * - * @return Commit + * @throws ReferenceNotFoundException $hash isn't a full hash */ - public function getCommit($hash) + public function getCommit(string $hash): Commit { - if (!isset($this->objects[$hash])) { - $this->objects[$hash] = new Commit($this, $hash); + $commit = $this->commits->get($hash); + if (null === $commit) { + $commit = new Commit($this, $hash); + $this->commits->set($hash, $commit); } - return $this->objects[$hash]; + return $commit; } /** - * Instanciates a tree object or fetches one from the cache. + * Returns a tree, the same object while the application uses it. * - * @param string $hash A tree hash, with a length of 40 + * @param string $hash The hash of the tree, abbreviated or not * - * @return Tree + * @throws ReferenceNotFoundException $hash isn't a hash, or names nothing */ - public function getTree($hash) + public function getTree(string $hash): Tree { - if (!isset($this->objects[$hash])) { - $this->objects[$hash] = new Tree($this, $hash); + $hash = $this->fullHash($hash, ObjectType::Tree); + $tree = $this->trees->get($hash); + if (null === $tree) { + $tree = new Tree($this, $hash); + $this->trees->set($hash, $tree); } - return $this->objects[$hash]; + return $tree; } /** - * Instanciates a blob object or fetches one from the cache. + * Returns a blob, the same object while the application uses it. * - * @param string $hash A blob hash, with a length of 40 + * @param string $hash The hash of the blob, abbreviated or not * - * @return Blob + * @throws ReferenceNotFoundException $hash isn't a hash, or names nothing */ - public function getBlob($hash) + public function getBlob(string $hash): Blob { - if (!isset($this->objects[$hash])) { - $this->objects[$hash] = new Blob($this, $hash); + $hash = $this->fullHash($hash, ObjectType::Blob); + $blob = $this->blobs->get($hash); + if (null === $blob) { + $blob = new Blob($this, $hash); + $this->blobs->set($hash, $blob); } - return $this->objects[$hash]; + return $blob; } - public function getBlame($revision, $file, $lineRange = null) + /** + * Returns the best common ancestor of two commits, the one "git diff a...b" compares to. + * + * @return Commit|null Null when they have no common history + */ + public function getMergeBase(Revision|string $one, Revision|string $two): ?Commit { - if (is_string($revision)) { - $revision = $this->getRevision($revision); - } + $hash = $this->repositoryDriver->getMergeBases($this->commitHash($one), $this->commitHash($two))[0] ?? null; - return new Blame($this, $revision, $file, $lineRange); + return null === $hash ? null : $this->getCommit($hash); } /** - * Returns log for a given set of revisions and paths. - * - * All those values can be null, meaning everything. + * Returns the commits that last changed the lines of $file. * - * @param array $revisions An array of revisions to show logs from. Can be - * any text value type - * @param array $paths Restrict log to modifications occurring on given - * paths. - * @param int $offset Start from a given offset in results. - * @param int $limit Limit number of total results. + * @param int|null $firstLine The first line to blame, from 1, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null * - * @return Log + * @throws InvalidArgumentException An invalid range of lines */ - public function getLog($revisions = null, $paths = null, $offset = null, $limit = null) + public function getBlame(Revision|string $revision, string $file, ?int $firstLine = null, ?int $lastLine = null): Blame { - return new Log($this, $revisions, $paths, $offset, $limit); + return new Blame($this, \is_string($revision) ? $this->getRevision($revision) : $revision, $file, $firstLine, $lastLine); } /** - * @return Diff + * Returns log for a given set of revisions and paths. + * + * All those values can be null, meaning everything. + * + * @param RevisionList|Revision|string|array|null $revisions An array of revisions to show logs from. Can be + * any text value type + * @param string[]|string|null $paths restrict log to modifications occurring on given + * paths + * @param int|null $offset start from a given offset in results + * @param int|null $limit limit number of total results + * + * @throws InvalidArgumentException $offset or $limit is negative */ - public function getDiff($revisions) + public function getLog(RevisionList|Revision|string|array|null $revisions = null, array|string|null $paths = null, ?int $offset = null, ?int $limit = null): Log { - if (null !== $revisions && !$revisions instanceof RevisionList) { - $revisions = new RevisionList($this, $revisions); - } - - $args = array_merge(['-r', '-p', '-m', '-M', '--no-commit-id', '--full-index'], $revisions->getAsTextArray()); - - $diff = Diff::parse($this->run('diff', $args)); - $diff->setRepository($this); - - return $diff; + return new Log($this, $revisions, $paths, $offset, $limit); } /** - * Returns the size of repository, in kilobytes. - * - * @throws RuntimeException An error occurred while computing size - * - * @return int A sum, in kilobytes + * Returns the changes from the commit $from to the commit $to. */ - public function getSize() + public function getDiff(Revision|string $from, Revision|string $to): Diff { - $process = new Process(['du', '-skc', $this->gitDir]); - $process->run(); - - if (!preg_match('/(\d+)\s+total$/', trim($process->getOutput()), $vars)) { - $message = sprintf("Unable to parse process output\ncommand: %s\noutput: %s", $process->getCommandLine(), $process->getOutput()); - - if (null !== $this->logger) { - $this->logger->error($message); - } - - if (true === $this->debug) { - throw new RuntimeException('unable to parse repository size output'); - } - - return; - } - - return $vars[1]; + return Diff::fromData($this->repositoryDriver->getDiff($this->commitHash($from), $this->commitHash($to)), $this); } /** - * Executes a shell command on the repository, using PHP pipes. + * @return int The size of the files of the git directory, working tree excluded, in bytes * - * @param string $command The command to execute + * @throws RuntimeException A file or a directory of the git directory can't be read */ - public function shell($command, array $env = []) + public function getSizeInBytes(): int { - $argument = sprintf('%s \'%s\'', $command, $this->gitDir); - - $prefix = ''; - foreach ($env as $name => $value) { - $prefix .= sprintf('export %s=%s;', escapeshellarg($name), escapeshellarg($value)); - } - - proc_open($prefix.'git shell -c '.escapeshellarg($argument), [STDIN, STDOUT, STDERR], $pipes); + return self::directorySize($this->gitDir); } /** * Returns the hooks object. - * - * @return Hooks */ - public function getHooks() + public function getHooks(): Hooks { return new Hooks($this); } @@ -469,49 +435,40 @@ public function getHooks() * * @return string The description */ - public function getDescription() + public function getDescription(): string { $file = $this->gitDir.'/description'; - $exists = is_file($file); - if (null !== $this->logger && true === $this->debug) { - if (false === $exists) { - $this->logger->debug(sprintf('no description file in repository ("%s")', $file)); - } else { - $this->logger->debug(sprintf('reading description file in repository ("%s")', $file)); - } + if (!is_file($file)) { + return self::DEFAULT_DESCRIPTION; } - if (false === $exists) { - return static::DEFAULT_DESCRIPTION; + $description = file_get_contents($file); + if (false === $description) { + throw new RuntimeException(\sprintf('Unable to read "%s".', $file)); } - return file_get_contents($this->gitDir.'/description'); + return $description; } /** * Tests if repository has a custom set description. - * - * @return bool */ - public function hasDescription() + public function hasDescription(): bool { - return static::DEFAULT_DESCRIPTION !== $this->getDescription(); + return self::DEFAULT_DESCRIPTION !== $this->getDescription(); } /** * Changes the repository description (file description in git-directory). * - * @return Repository the current repository + * @return $this */ - public function setDescription($description) + public function setDescription(string $description): static { - $file = $this->gitDir.'/description'; - - if (null !== $this->logger && true === $this->debug) { - $this->logger->debug(sprintf('change description file content to "%s" (file: %s)', $description, $file)); + if (false === file_put_contents($this->gitDir.'/description', $description)) { + throw new RuntimeException(\sprintf('Unable to write the description of "%s".', $this->gitDir)); } - file_put_contents($file, $description); return $this; } @@ -520,110 +477,140 @@ public function setDescription($description) * This command is a facility command. You can run any command * directly on git repository. * - * @param string $command Git command to run (checkout, branch, tag) - * @param array $args Arguments of git command + * Note that this only returns the standard output of the process. Some + * git commands (`push`, for instance) write their meaningful output to + * stderr even when they succeed: use {@see runProcess()} instead if you + * need access to it. * - * @throws RuntimeException Error while executing git command (debug-mode only) + * @param string $command Git command to run (checkout, branch, tag) + * @param array $args Arguments of git command * - * @return string Output of a successful process or null if execution failed and debug-mode is disabled. + * @return string output of the process + * + * @throws ProcessException Error while executing git command + * @throws ProcessTimedOutException git runs longer than the timeout of the CliDriver + * @throws DriverException git can't start, or is killed + * @throws UnsupportedOperationException The driver isn't a CliDriver */ - public function run($command, $args = []) + public function run(string $command, #[\SensitiveParameter] array $args = []): string { - $process = $this->getProcess($command, $args); - - if ($this->logger) { - $this->logger->info(sprintf('run command: %s "%s" ', $command, implode(' ', $args))); - $before = microtime(true); - } - - $process->run(); - - $output = $process->getOutput(); - - if ($this->logger && $this->debug) { - $duration = microtime(true) - $before; - $this->logger->debug(sprintf('last command (%s) duration: %sms', $command, sprintf('%.2f', $duration * 1000))); - $this->logger->debug(sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); - $this->logger->debug(sprintf('last command (%s) output: %s', $command, $output)); - } - - if (!$process->isSuccessful()) { - $error = sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput()); - - if ($this->logger) { - $this->logger->error($error); - } - - if ($this->debug) { - throw new ProcessException($process); - } - - return; - } - - return $output; + return $this->runProcess($command, $args)->getOutput(); } /** - * Set repository logger. + * Same as {@see run()}, but returns the full, already-run process + * instead of only its standard output on success. + * + * This is useful for commands like `push`, which write their + * meaningful output to stderr even when they succeed, so it can't be + * read through run(). * - * @param LoggerInterface $logger A logger + * @param string $command Git command to run (checkout, branch, tag) + * @param array $args Arguments of git command * - * @return Repository The current repository + * @throws ProcessException Error while executing git command + * @throws ProcessTimedOutException git runs longer than the timeout of the CliDriver + * @throws DriverException git can't start, or is killed + * @throws UnsupportedOperationException The driver isn't a CliDriver */ - public function setLogger(LoggerInterface $logger) + public function runProcess(string $command, #[\SensitiveParameter] array $args = []): Process { - $this->logger = $logger; + if (!$this->repositoryDriver instanceof CliRepositoryDriver) { + throw new UnsupportedOperationException('Only CliDriver can run git commands.'); + } - return $this; + try { + return $this->repositoryDriver->runProcess($command, $args); + } finally { + // Any command may write references, even a failing one. + $this->referenceBag?->invalidate(); + } } - /** - * Returns repository logger. - * - * @return LoggerInterface the logger or null - */ - public function getLogger() + private function commitHash(Revision|string $revision): string { - return $this->logger; + return ($revision instanceof Revision ? $revision : $this->getRevision($revision))->getCommit()->getHash(); } /** - * Clones the current repository to a new directory and return instance of new repository. - * - * @param string $path path to the new repository in which current repository will be cloned - * @param bool $bare flag indicating if repository is bare or has a working-copy + * @param ObjectType $type The type of the object, which an abbreviated hash must name * - * @return Repository the newly created repository + * @throws ReferenceNotFoundException $hash isn't a hash, or names no object of this type */ - public function cloneTo($path, $bare = true, array $options = []) + private function fullHash(string $hash, ObjectType $type): string { - return Admin::cloneTo($path, $this->gitDir, $bare, $options); + if (!preg_match('/^[0-9a-f]{4,40}$/', $hash)) { + throw new ReferenceNotFoundException(\sprintf('"%s" is not a hash.', $hash)); + } + + // The type of an object of a full hash is checked when it is read. + if (40 === \strlen($hash)) { + return $hash; + } + + try { + $fullHash = $this->repositoryDriver->resolveRevision($hash.'^{'.$type->value.'}'); + } catch (ReferenceNotFoundException) { + // One more call, only on failure: an object of another type, or none. + try { + $this->repositoryDriver->resolveRevision($hash); + } catch (ReferenceNotFoundException) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" does not exist.', $hash)); + } + + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value)); + } + + // "^{tree}" also peels a commit to its tree. + if (!str_starts_with($fullHash, $hash)) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value)); + } + + return $fullHash; } /** - * This internal method is used to create a process object. - * - * Made private to be sure that process creation is handled through the run method. - * run method ensures logging and debug. + * git may delete a file or a directory while it is counted, during a gc + * or a push: a lock, a temporary pack, the quarantine of a push... It no + * longer counts. * - * @see self::run + * @throws RuntimeException A file or a directory of $dir can't be read */ - private function getProcess($command, $args = []) + private static function directorySize(string $dir): int { - $base = [$this->command, '--git-dir', $this->gitDir]; + try { + $files = new \FilesystemIterator($dir, \FilesystemIterator::SKIP_DOTS); + } catch (\UnexpectedValueException $e) { + if (!file_exists($dir)) { + return 0; + } - if ($this->workingDir) { - $base = array_merge($base, ['--work-tree', $this->workingDir]); + throw new RuntimeException(\sprintf('Unable to read the directory "%s".', $dir), 0, $e); } - $base[] = $command; + $size = 0; + /** @var \SplFileInfo $file */ + foreach ($files as $file) { + // A link isn't followed: it may point to a file not deployed yet, like a hook. + if ($file->isLink()) { + continue; + } + + if ($file->isDir()) { + $size += self::directorySize($file->getPathname()); - $process = new Process(array_merge($base, $args)); - $process->setEnv($this->environmentVariables); - $process->setTimeout($this->processTimeout); - $process->setIdleTimeout($this->processTimeout); + continue; + } + + try { + $size += $file->getSize(); + } catch (\RuntimeException $e) { + if (file_exists($file->getPathname())) { + throw new RuntimeException(\sprintf('Unable to read the size of "%s".', $file->getPathname()), 0, $e); + } + } + } - return $process; + return $size; } } diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index 9e1921fb..1fe5b36b 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -1,6 +1,6 @@ @@ -12,57 +12,68 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Util\LeadingDash; + /** + * A name git resolves to a commit: a branch, a tag, a hash, "HEAD~2"... + * + * Commit and Reference extend it: extending it outside gitlib isn't + * supported, and won't be possible in 3.0. + * * @author Alexandre Salomé */ class Revision { /** - * @var Repository + * @internal Get it from Repository::getRevision() */ - protected $repository; - - /** - * @var string - */ - protected $revision; - - public function __construct(Repository $repository, $revision) - { - $this->repository = $repository; - $this->revision = $revision; + public function __construct( + protected readonly Repository $repository, + protected readonly string $revision, + ) { } /** - * @return Log + * @param string[]|string|null $paths Paths to filter on, with git's wildcards + * + * @throws InvalidArgumentException $offset or $limit is negative */ - public function getLog($paths = null, $offset = null, $limit = null) + public function getLog(array|string|null $paths = null, ?int $offset = null, ?int $limit = null): Log { return $this->repository->getLog($this, $paths, $offset, $limit); } /** - * Returns the last modification date of the reference. + * Returns the commit the revision names. * - * @return Commit + * @throws ReferenceNotFoundException The revision names no commit */ - public function getCommit() + public function getCommit(): Commit { - return $this->getLog()->getSingleCommit(); + $driver = $this->repository->getRepositoryDriver(); + // Before the driver gets "^{commit}", which its message would quote. + LeadingDash::assertNone($this->revision); + + try { + // "^{commit}" would be part of the text of ":/text", the commit + // whose message matches it: its hash first. + $revision = str_starts_with($this->revision, ':/') ? $driver->resolveRevision($this->revision) : $this->revision; + $hash = $driver->resolveRevision($revision.'^{commit}'); + } catch (ReferenceNotFoundException $e) { + throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist, or names no commit.', $this->revision), 0, $e); + } + + return $this->repository->getCommit($hash); } - /** - * @return string - */ - public function getRevision() + public function getRevision(): string { return $this->revision; } - /** - * @return Repository - */ - public function getRepository() + public function getRepository(): Repository { return $this->repository; } diff --git a/src/Gitonomy/Git/RevisionList.php b/src/Gitonomy/Git/RevisionList.php index 760abb67..da325c2d 100644 --- a/src/Gitonomy/Git/RevisionList.php +++ b/src/Gitonomy/Git/RevisionList.php @@ -1,6 +1,6 @@ @@ -12,61 +12,78 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\InvalidArgumentException; + /** * @author Alexandre Salomé + * + * @implements \IteratorAggregate */ -class RevisionList implements \IteratorAggregate, \Countable +final readonly class RevisionList implements \IteratorAggregate, \Countable { - protected $revisions; + /** + * @var list + */ + private array $revisions; /** * Constructs a revision list from a variety of types. * - * @param mixed $revisions can be a string, an array of strings or an array of Revision, Branch, Tag, Commit + * @param string|Revision|array $revisions can be a string, a Revision, an array of strings or an array of Revision, Branch, Tag, Commit */ - public function __construct(Repository $repository, $revisions) + public function __construct(Repository $repository, string|Revision|array $revisions) { - if (is_string($revisions)) { + if (\is_string($revisions)) { $revisions = [$repository->getRevision($revisions)]; } elseif ($revisions instanceof Revision) { $revisions = [$revisions]; - } elseif (!is_array($revisions)) { - throw new \InvalidArgumentException(sprintf('Expected a string, a Revision or an array, got a "%s".', is_object($revisions) ? get_class($revisions) : gettype($revisions))); } - if (count($revisions) == 0) { - throw new \InvalidArgumentException(sprintf('Empty revision list not allowed')); + if ([] === $revisions) { + throw new InvalidArgumentException('A revision list needs one revision at least.'); } - foreach ($revisions as $i => $revision) { - if (is_string($revision)) { - $revisions[$i] = new Revision($repository, $revision); - } elseif (!$revision instanceof Revision) { - throw new \InvalidArgumentException(sprintf('Expected a "Revision", got a "%s".', is_object($revision) ? get_class($revision) : gettype($revision))); + $normalizedRevisions = []; + foreach ($revisions as $revision) { + if (\is_string($revision)) { + $revision = new Revision($repository, $revision); + } elseif (!$revision instanceof Revision) { // @phpstan-ignore instanceof.alwaysTrue (PHP doesn't check the types of the elements, unlike PHPStan.) + throw new InvalidArgumentException(\sprintf('Expected a "Revision", got a "%s".', get_debug_type($revision))); } + + $normalizedRevisions[] = $revision; } - $this->revisions = $revisions; + $this->revisions = $normalizedRevisions; } - public function getAll() + /** + * @return list + */ + public function getAll(): array { return $this->revisions; } - public function getIterator() + /** + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator { return new \ArrayIterator($this->revisions); } - public function count() + public function count(): int { - return count($this->revisions); + return \count($this->revisions); } - public function getAsTextArray() + /** + * @return string[] + */ + public function getAsTextArray(): array { - return array_map(function ($revision) { + return array_map(static function (Revision $revision) { return $revision->getRevision(); }, $this->revisions); } diff --git a/src/Gitonomy/Git/Submodule.php b/src/Gitonomy/Git/Submodule.php new file mode 100644 index 00000000..754a4556 --- /dev/null +++ b/src/Gitonomy/Git/Submodule.php @@ -0,0 +1,33 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git; + +/** + * A submodule in a tree: the commit of another repository it points to. + * + * @author Grégoire Pineau + */ +final readonly class Submodule +{ + public function __construct(private string $hash) + { + } + + /** + * @return string The commit of the submodule, in its own repository + */ + public function getHash(): string + { + return $this->hash; + } +} diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index d3179837..b2a86943 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -1,6 +1,6 @@ @@ -12,97 +12,118 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\UnexpectedValueException; /** + * Iterating a tree gives its entries, by name. + * * @author Alexandre Salomé + * + * @implements \IteratorAggregate */ -class Tree +final class Tree implements \IteratorAggregate { - protected $repository; - protected $hash; - protected $isInitialized = false; - protected $entries; + /** + * @var array|null By name, null until read + */ + private ?array $entries = null; - public function __construct(Repository $repository, $hash) - { - $this->repository = $repository; - $this->hash = $hash; + /** + * @internal Get it from Repository::getTree(), which returns the same object while the application uses it + */ + public function __construct( + private readonly Repository $repository, + private readonly string $hash, + ) { } - public function getHash() + public function getHash(): string { return $this->hash; } - protected function initialize() + /** + * @return \ArrayIterator The entries, by name + */ + public function getIterator(): \ArrayIterator { - if (true === $this->isInitialized) { - return; - } - - $output = $this->repository->run('cat-file', ['-p', $this->hash]); - $parser = new Parser\TreeParser(); - $parser->parse($output); - - $this->entries = []; + return new \ArrayIterator($this->entries()); + } - foreach ($parser->entries as $entry) { - list($mode, $type, $hash, $name) = $entry; - if ($type == 'blob') { - $this->entries[$name] = [$mode, $this->repository->getBlob($hash)]; - } elseif ($type == 'tree') { - $this->entries[$name] = [$mode, $this->repository->getTree($hash)]; - } else { - $this->entries[$name] = [$mode, new CommitReference($hash)]; - } - } + /** + * @return array The blobs, by name + */ + public function getBlobs(): array + { + return array_filter($this->entries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof Blob); + } - $this->isInitialized = true; + /** + * @return array The submodules, by name + */ + public function getSubmodules(): array + { + return array_filter($this->entries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof Submodule); } /** - * @return array An associative array name => $object + * @return array The trees, by name */ - public function getEntries() + public function getTrees(): array { - $this->initialize(); + return array_filter($this->entries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof self); + } - return $this->entries; + /** + * @throws InvalidArgumentException The tree has no entry $name + */ + public function getEntry(string $name): Submodule|self|Blob + { + return ($this->entries()[$name] ?? throw new InvalidArgumentException(\sprintf('The tree "%s" has no entry "%s".', $this->hash, $name)))->getObject(); } - public function getEntry($name) + /** + * @param string $path Relative to the tree, "" or "/" for the tree itself + * + * @throws InvalidArgumentException $path doesn't exist in the tree + */ + public function resolvePath(string $path): Submodule|self|Blob { - $this->initialize(); + $element = $this; + $resolved = []; + foreach (array_filter(explode('/', $path), static fn (string $segment): bool => '' !== $segment) as $segment) { + if (!$element instanceof self) { + throw new InvalidArgumentException(\sprintf('Unable to resolve "%s": "%s" is a %s, not a tree.', $path, implode('/', $resolved), $element instanceof Blob ? 'file' : 'submodule')); + } - if (!isset($this->entries[$name])) { - throw new InvalidArgumentException('No entry '.$name); + $resolved[] = $segment; + $element = ($element->entries()[$segment] ?? throw new InvalidArgumentException(\sprintf('Unable to resolve "%s": "%s" doesn\'t exist.', $path, implode('/', $resolved))))->getObject(); } - return $this->entries[$name][1]; + return $element; } - public function resolvePath($path) + /** + * @return array By name + */ + private function entries(): array { - if ($path == '') { - return $this; + if (null !== $this->entries) { + return $this->entries; } - $path = preg_replace('#^/#', '', $path); - - $segments = explode('/', $path); - $element = $this; - foreach ($segments as $segment) { - if ($element instanceof self) { - $element = $element->getEntry($segment); - } elseif ($entry instanceof Blob) { - throw new InvalidArgumentException('Unresolvable path'); - } else { - throw new UnexpectedValueException('Unknow type of element'); - } + $this->entries = []; + foreach ($this->repository->getRepositoryDriver()->getTreeEntries($this->hash) as $data) { + $object = match ($data->type) { + ObjectType::Blob => $this->repository->getBlob($data->hash), + ObjectType::Tree => $this->repository->getTree($data->hash), + // A commit in a tree is a submodule. + default => new Submodule($data->hash), + }; + $this->entries[$data->name] = new TreeEntry($data->name, $data->mode, $object); } - return $element; + return $this->entries; } } diff --git a/src/Gitonomy/Git/TreeEntry.php b/src/Gitonomy/Git/TreeEntry.php new file mode 100644 index 00000000..a7eab659 --- /dev/null +++ b/src/Gitonomy/Git/TreeEntry.php @@ -0,0 +1,46 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git; + +/** + * An entry of a tree: a blob, a tree or a submodule, with its name and mode. + * + * @author Grégoire Pineau + */ +final readonly class TreeEntry +{ + public function __construct( + private string $name, + private string $mode, + private Blob|Tree|Submodule $object, + ) { + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return string The mode git stores, like "100644" for a file or "040000" for a tree + */ + public function getMode(): string + { + return $this->mode; + } + + public function getObject(): Blob|Tree|Submodule + { + return $this->object; + } +} diff --git a/src/Gitonomy/Git/Util/Credentials.php b/src/Gitonomy/Git/Util/Credentials.php new file mode 100644 index 00000000..fbc87192 --- /dev/null +++ b/src/Gitonomy/Git/Util/Credentials.php @@ -0,0 +1,36 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * A URL like "https://user:token@host/repository.git" carries credentials: + * the messages of exceptions and of the logger hide them. + * + * @author Grégoire Pineau + * + * @internal + */ +final class Credentials +{ + /** + * Replaces the user information of the URLs of $text, a token or a user + * and a password, by "***". + */ + public static function redact(#[\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. + // @phpstan-ignore return.type (possessive quantifiers never reach the backtracking limit, and without "u" there is no UTF-8 to fail on) + return preg_replace('#\b([a-z][a-z0-9+.-]*+://)(?:[^/\s@]*+@)++#i', '$1***@', $text); + } +} diff --git a/src/Gitonomy/Git/Util/LeadingDash.php b/src/Gitonomy/Git/Util/LeadingDash.php new file mode 100644 index 00000000..84ccab62 --- /dev/null +++ b/src/Gitonomy/Git/Util/LeadingDash.php @@ -0,0 +1,42 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * git reads an argument starting with a dash as an option: the revision + * "--output=/path" of a log writes a file, the URL "--upload-pack=..." of + * ls-remote runs a command. git 1.8.4 has no "--end-of-options", and what + * follows "--" is a path for most commands: such a revision, reference, hash + * or URL is refused, by both drivers. + * + * @author Grégoire Pineau + * + * @internal + */ +final class LeadingDash +{ + /** + * @throws InvalidArgumentException One of the values starts with a dash + */ + public static function assertNone(#[\SensitiveParameter] string ...$values): void + { + foreach ($values as $value) { + if (str_starts_with($value, '-')) { + // The value may be a URL. + throw new InvalidArgumentException(Credentials::redact(\sprintf('"%s" starts with a dash, which git would read as an option.', $value))); + } + } + } +} diff --git a/src/Gitonomy/Git/Util/Message.php b/src/Gitonomy/Git/Util/Message.php new file mode 100644 index 00000000..7e671b1d --- /dev/null +++ b/src/Gitonomy/Git/Util/Message.php @@ -0,0 +1,39 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * Splits the message of a commit or a tag like git does. + * + * @author Grégoire Pineau + * + * @internal + */ +final class Message +{ + /** + * @return string The first paragraph of $message, on one line, like "git log --format=%s" + */ + public static function subject(string $message): string + { + return implode(' ', array_map(trim(...), explode("\n", trim(explode("\n\n", $message, 2)[0])))); + } + + /** + * @return string What follows the first paragraph of $message and its empty lines, like "git log --format=%b" + */ + public static function body(string $message): string + { + return ltrim(explode("\n\n", trim($message, "\n")."\n", 2)[1] ?? '', "\n"); + } +} diff --git a/src/Gitonomy/Git/Util/NulByte.php b/src/Gitonomy/Git/Util/NulByte.php new file mode 100644 index 00000000..5f3d74e0 --- /dev/null +++ b/src/Gitonomy/Git/Util/NulByte.php @@ -0,0 +1,39 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * git and libgit2 read their arguments as C strings, which end at the first + * NUL byte: libgit2 would read "a.txt\0.md" as "a.txt". + * + * @author Grégoire Pineau + * + * @internal + */ +final class NulByte +{ + /** + * @throws InvalidArgumentException One of the values contains a NUL byte + */ + public static function assertNone(#[\SensitiveParameter] string ...$values): void + { + foreach ($values as $value) { + if (str_contains($value, "\0")) { + // The value may be a URL. + throw new InvalidArgumentException(Credentials::redact(\sprintf('"%s" contains a NUL byte, which git doesn\'t accept.', addcslashes($value, "\0")))); + } + } + } +} diff --git a/src/Gitonomy/Git/Util/ReferenceName.php b/src/Gitonomy/Git/Util/ReferenceName.php new file mode 100644 index 00000000..525a297a --- /dev/null +++ b/src/Gitonomy/Git/Util/ReferenceName.php @@ -0,0 +1,38 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * gitlib writes the references under "refs/" only, the ones it lists. "git + * update-ref" writes any other name: it deletes HEAD, which leaves no + * repository, and writes the short name "master" next to HEAD. Both drivers + * refuse such a name. + * + * @author Grégoire Pineau + * + * @internal + */ +final class ReferenceName +{ + /** + * @throws InvalidArgumentException $fullname isn't under "refs/" + */ + public static function assertUnderRefs(string $fullname): void + { + if (!str_starts_with($fullname, 'refs/')) { + throw new InvalidArgumentException(\sprintf('"%s" is not the full name of a reference under "refs/", like "refs/heads/master".', $fullname)); + } + } +} diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php new file mode 100644 index 00000000..b29b3c82 --- /dev/null +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -0,0 +1,82 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * Refuses the URLs of a repository that make git run a command, or use a + * file descriptor of PHP, as an application may get them from its users. + * + * @author Grégoire Pineau + * + * @internal + */ +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 + */ + public static function assertSafe(#[\SensitiveParameter] string $url): void + { + // git would run in the working directory of PHP, which may be a repository. + if ('' === $url) { + throw new InvalidArgumentException('The URL of a repository can\'t be empty.'); + } + + LeadingDash::assertNone($url); + + // "ext::sh -c ..." runs the command, to talk to it: git refuses it by + // default since 2.12 only, through its protocol.allow setting. + if (str_starts_with($url, 'ext::')) { + throw new InvalidArgumentException(\sprintf('"%s" uses the "ext::" transport, which runs a command.', Credentials::redact($url))); + } + + // "fd::7" talks through the file descriptors git inherits from PHP: + // an open file, the socket of a database... git waits on it until it + // times out, an hour by default. + if (str_starts_with($url, 'fd::')) { + throw new InvalidArgumentException(\sprintf('"%s" uses the "fd::" transport, which talks through a file descriptor of PHP.', Credentials::redact($url))); + } + + // Before 2.14.1 (CVE-2017-1000117), git passes the "user@host" of an ssh + // URL to ssh, without its brackets: ssh reads "ssh://-oProxyCommand=..." + // as an option, and runs the command. The command of core.gitProxy gets + // the host of a git URL the same way. A valid host never starts with a dash. + $authority = self::authority($url); + if (null !== $authority && 1 === preg_match('/(?:^|@)\[?-/', $authority)) { + throw new InvalidArgumentException(\sprintf('"%s" has a host or a user starting with a dash, which ssh, or the command of core.gitProxy, would read as an option.', Credentials::redact($url))); + } + } + + /** + * @return string|null The "user@host:port" of an ssh or git URL, decoded, + * or of an address like "user@host:path", which git + * reaches through ssh; null for any other URL, or a + * local path + */ + private static function authority(#[\SensitiveParameter] string $url): ?string + { + // git decodes a URL before it reads its host: "%2D" is a dash, "%5B" a bracket. + if (1 === preg_match('#^(?:git|ssh|git\+ssh|ssh\+git)://([^/]*)#', $url, $matches)) { + return rawurldecode($matches[1]); + } + + // Like git: a colon before any slash, but not the one of "scheme://". + if (1 === preg_match('#^([^/:]*):(?!//)#', $url, $matches)) { + return $matches[1]; + } + + return null; + } +} diff --git a/src/Gitonomy/Git/Util/StringHelper.php b/src/Gitonomy/Git/Util/StringHelper.php deleted file mode 100644 index d784b66b..00000000 --- a/src/Gitonomy/Git/Util/StringHelper.php +++ /dev/null @@ -1,57 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Util; - -/** - * Helper class to support language particularity. - * - * @author Alexandre Salomé - */ -class StringHelper -{ - private static $encoding = 'utf-8'; - - public static function getEncoding() - { - return self::$encoding; - } - - public static function setEncoding($encoding) - { - self::$encoding = $encoding; - } - - public static function strlen($string) - { - return function_exists('mb_strlen') ? mb_strlen($string, self::$encoding) : strlen($string); - } - - public static function substr($string, $start, $length = false) - { - if (false === $length) { - $length = self::strlen($string); - } - - return function_exists('mb_substr') ? mb_substr($string, $start, $length, self::$encoding) : substr($string, $start, $length); - } - - public static function strpos($haystack, $needle, $offset = 0) - { - return function_exists('mb_strpos') ? mb_strpos($haystack, $needle, $offset, self::$encoding) : strpos($haystack, $needle, $offset); - } - - public static function strrpos($haystack, $needle, $offset = 0) - { - return function_exists('mb_strrpos') ? mb_strrpos($haystack, $needle, $offset, self::$encoding) : strrpos($haystack, $needle, $offset); - } -} diff --git a/src/Gitonomy/Git/Util/WeakCache.php b/src/Gitonomy/Git/Util/WeakCache.php new file mode 100644 index 00000000..edaf2c20 --- /dev/null +++ b/src/Gitonomy/Git/Util/WeakCache.php @@ -0,0 +1,62 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * Objects by key, as long as the application uses them. + * + * The keys of the objects freed since are removed once the cache has grown + * twice as big: a long-running process reading millions of commits keeps + * only the ones it uses. + * + * @author Grégoire Pineau + * + * @template T of object + * + * @internal + */ +final class WeakCache +{ + private const int MIN_SWEEP_SIZE = 64; + + /** + * @var array> + */ + private array $references = []; + + private int $sweepSize = self::MIN_SWEEP_SIZE; + + /** + * @return T|null + */ + public function get(string $key): ?object + { + return ($this->references[$key] ?? null)?->get(); + } + + /** + * @param T $object + */ + public function set(string $key, object $object): void + { + $this->references[$key] = \WeakReference::create($object); + + if (\count($this->references) < $this->sweepSize) { + return; + } + + $this->references = array_filter($this->references, static fn (\WeakReference $reference): bool => null !== $reference->get()); + // Each sweep at least halves the cache, or waits for it to double: constant time on average. + $this->sweepSize = max(self::MIN_SWEEP_SIZE, 2 * \count($this->references)); + } +} diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index 057253b5..27a59f03 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -1,6 +1,6 @@ @@ -13,89 +13,141 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; -use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Reference\Branch; /** * @author Alexandre Salomé */ -class WorkingCopy +final readonly class WorkingCopy { + public function __construct( + private Repository $repository, + ) { + if ($this->repository->isBare()) { + throw new LogicException('A bare repository has no working copy.'); + } + } + /** - * @var Repository + * @return string[] */ - protected $repository; + public function getUntrackedFiles(): array + { + return $this->repository->getRepositoryDriver()->getUntrackedFiles(); + } - public function __construct(Repository $repository) + /** + * Returns the changes of the working tree, staged or not, since $revision. + */ + public function getDiff(Revision|string $revision = 'HEAD'): Diff { - $this->repository = $repository; + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); - if ($this->repository->isBare()) { - throw new LogicException('Can\'t create a working copy on a bare repository'); - } + return Diff::fromData($this->repository->getRepositoryDriver()->getWorkingTreeDiff($hash), $this->repository); } - public function getStatus() + /** + * Returns the changes of the working tree not staged yet. + */ + public function getDiffPending(): Diff { - return WorkingStatus::parseOutput(); + return Diff::fromData($this->repository->getRepositoryDriver()->getDiffPending(), $this->repository); } - public function getUntrackedFiles() + /** + * Returns the changes staged in the index. + */ + public function getDiffStaged(): Diff { - $lines = explode("\0", $this->run('status', ['--porcelain', '--untracked-files=all', '-z'])); - $lines = array_filter($lines, function ($l) { - return substr($l, 0, 3) === '?? '; - }); - $lines = array_map(function ($l) { - return substr($l, 3); - }, $lines); - - return $lines; + return Diff::fromData($this->repository->getRepositoryDriver()->getDiffStaged(), $this->repository); } - public function getDiffPending() + /** + * Checks $revision out, like `git checkout`: HEAD points to it when it is + * a local branch, or @{-}, the nth branch checked out before, stays + * as it is for "HEAD", and points to the commit otherwise: a tag, a hash, + * a remote branch... + * + * Unlike `git checkout`, a name that no revision has throws, the name of + * a remote branch alone included, like "feature" for "origin/feature": + * create its local branch with checkoutNewBranch(). + * + * @throws ReferenceNotFoundException $revision names no commit + * @throws DriverException The checkout would overwrite local changes + */ + public function checkout(Revision|string $revision): static { - $diff = Diff::parse($this->run('diff', ['-r', '-p', '-m', '-M', '--full-index'])); - $diff->setRepository($this->repository); + // A name that no revision has throws, where "git checkout" would create + // the branch of a remote one, or discard the changes of a file. + ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit(); + + try { + $this->repository->getRepositoryDriver()->checkout(self::name($revision)); + } finally { + $this->repository->getReferences()->invalidate(); + } - return $diff; + return $this; } - public function getDiffStaged() + /** + * Creates the branch $branch at $startPoint, and checks it out, like + * `git checkout -b`. The branch tracks $startPoint when it is a remote + * branch, like "origin/feature", as with branch.autoSetupMerge=true, + * git's default, whatever the configuration. A failure leaves the + * branches and the files as they were: like `git checkout -b`, no + * branch is created when the checkout fails. + * + * @throws ReferenceNotFoundException $startPoint names no commit + * @throws DriverException $branch exists already or isn't a valid name, $startPoint is the short name of several references, which git refuses, or the checkout would overwrite local changes + */ + public function checkoutNewBranch(string $branch, Revision|string $startPoint = 'HEAD'): static { - $diff = Diff::parse($this->run('diff', ['-r', '-p', '-m', '-M', '--full-index', '--staged'])); - $diff->setRepository($this->repository); + // Like checkout(). The driver gets the name, which tells a remote branch. + ($startPoint instanceof Revision ? $startPoint : $this->repository->getRevision($startPoint))->getCommit(); - return $diff; + try { + $this->repository->getRepositoryDriver()->checkoutNewBranch($branch, $startPoint instanceof Revision ? $startPoint->getRevision() : $startPoint); + } finally { + $this->repository->getReferences()->invalidate(); + } + + return $this; } /** - * @return WorkingCopy + * Merges a revision into the current branch. + * + * @throws ReferenceNotFoundException $revision names no commit + * @throws DriverException The merge fails: a conflict, staged changes, an unrelated history... */ - public function checkout($revision, $branch = null) + public function merge(Revision|string $revision, FastForward $fastForward = FastForward::Allow): static { - $args = []; - if ($revision instanceof Commit) { - $args[] = $revision->getHash(); - } elseif ($revision instanceof Reference) { - $args[] = $revision->getFullname(); - } elseif (is_string($revision)) { - $args[] = $revision; - } else { - throw new InvalidArgumentException(sprintf('Unknown type "%s"', gettype($revision))); - } + // Like checkout(). The driver gets the name, which the message of the merge commit has. + ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit(); - if (null !== $branch) { - $args = array_merge($args, ['-b', $branch]); + try { + $this->repository->getRepositoryDriver()->merge(self::name($revision), $fastForward); + } finally { + $this->repository->getReferences()->invalidate(); } - $this->run('checkout', $args); - return $this; } - protected function run($command, array $args = []) + /** + * @return string The name git checks out or merges: a local branch by its name, to attach HEAD to it + */ + private static function name(Revision|string $revision): string { - return $this->repository->run($command, $args); + return match (true) { + $revision instanceof Branch && $revision->isLocal() => $revision->getName(), + $revision instanceof Reference => $revision->getFullname(), + $revision instanceof Revision => $revision->getRevision(), + default => $revision, + }; } } diff --git a/test-git-versions.php b/test-git-versions.php new file mode 100755 index 00000000..787dd32e --- /dev/null +++ b/test-git-versions.php @@ -0,0 +1,203 @@ +#!/usr/bin/env php + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +require __DIR__.'/vendor/autoload.php'; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Process\Process; + +const GIT_URL = 'https://github.com/git/git.git'; +const BUILDS_PATH = __DIR__.'/git-builds'; + +// v1.8.4, the oldest version gitlib supports, and v2.y releases picked for how +// long and how widely each shipped as the default git on a major distro, rather +// than for a spread across git's history: v2.17.1 (Ubuntu 18.04), v2.25.1 (Ubuntu +// 20.04), v2.34.1 (Ubuntu 22.04), v2.43.0 (Ubuntu 24.04). +const DEFAULT_VERSIONS = ['v1.8.4', 'v2.17.1', 'v2.25.1', 'v2.34.1', 'v2.43.0']; + +function sh(array $args, ?string $cwd = null, ?string $logfile = null): Process +{ + $process = new Process($args, $cwd, timeout: null); + + if (null !== $logfile) { + file_put_contents($logfile, '$ '.implode(' ', $args)."\n", \FILE_APPEND); + $process->run(static function (string $type, string $data) use ($logfile): void { + file_put_contents($logfile, $data, \FILE_APPEND); + }); + if (!$process->isSuccessful()) { + throw new RuntimeException(sprintf('Command "%s" failed, see %s', implode(' ', $args), $logfile)); + } + } else { + $process->mustRun(); + } + + return $process; +} + +function cachePath(): string +{ + return BUILDS_PATH.'/cache'; +} + +function versionPath(string $version): string +{ + return BUILDS_PATH.'/'.$version; +} + +function gitBinary(string $version): string +{ + return versionPath($version).'/build/bin/git'; +} + +#[AsCommand(name: 'build', description: 'Build one or more git versions from source, ready to be used by the "test" command')] +final class BuildCommand extends Command +{ + protected function configure(): void + { + $this + ->addArgument('versions', InputArgument::IS_ARRAY, 'git tags to build (defaults to the curated list)', DEFAULT_VERSIONS) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $versions = $input->getArgument('versions'); + + if (!is_dir(BUILDS_PATH)) { + mkdir(BUILDS_PATH); + } + + if (is_dir(cachePath())) { + $io->section('Updating cache from GitHub'); + sh(['git', 'fetch', '--quiet'], cachePath()); + } else { + $io->section('Cloning git/git from GitHub'); + sh(['git', 'clone', '--quiet', GIT_URL, cachePath()]); + } + + foreach ($versions as $version) { + $io->section(sprintf('Building %s', $version)); + + $path = versionPath($version); + $lockfile = BUILDS_PATH.'/'.$version.'.lock'; + $logfile = BUILDS_PATH.'/'.$version.'.log'; + + if (is_file($lockfile)) { + $io->warning('A previous build was interrupted, rebuilding from scratch.'); + new Process(['rm', '-rf', $path])->mustRun(); + unlink($lockfile); + } + + if (is_dir($path)) { + $io->writeln('Already built, skipping.'); + + continue; + } + + touch($lockfile); + mkdir($path, recursive: true); + file_put_contents($logfile, ''); + + $io->writeln(sprintf('Log: %s', $logfile)); + + $source = $path.'/source'; + sh(['git', 'clone', '--shared', '--quiet', cachePath(), $source]); + sh(['git', 'checkout', '--quiet', $version], $source); + + $prefix = $path.'/build'; + mkdir($prefix); + + $io->writeln('Compiling (autoconf, configure, make, make install)...'); + sh(['autoconf'], $source, $logfile); + sh(['./configure', '--prefix='.$prefix], $source, $logfile); + // gitlib only ever shells out to plumbing/porcelain in the main "git" binary, + // so building the GUI/language extras is both unnecessary and, for a build + // this old, unreliable on a modern machine: + // - -std=gnu17: recent git releases declare an "unreachable" identifier that + // collides with the C23 unreachable() macro glibc/gcc now define by default. + // - NO_OPENSSL: older git-imap-send.c uses OpenSSL 1.0 APIs (HMAC_CTX as a + // stack struct) removed in OpenSSL 3; the test suite is fully offline and + // never needs imap-send. + // - NO_PYTHON, NO_TCLTK: skip the Python remote-helper bridge and git-gui/gitk, + // which need toolchains (Python 2, Tcl/Tk) this machine doesn't have. + // All flags are passed to both invocations: git's Makefile forces a full + // rebuild whenever they change between "make all" and "make install". + $makeFlags = ['CFLAGS=-std=gnu17', 'NO_OPENSSL=1', 'NO_PYTHON=1', 'NO_TCLTK=1']; + sh(['make', 'all', ...$makeFlags], $source, $logfile); + sh(['make', 'install', ...$makeFlags], $source, $logfile); + + unlink($lockfile); + $io->success(sprintf('%s built at %s', $version, gitBinary($version))); + } + + return Command::SUCCESS; + } +} + +#[AsCommand(name: 'test', description: 'Run the test suite against one or more already-built git versions')] +final class TestCommand extends Command +{ + protected function configure(): void + { + $this + ->addArgument('versions', InputArgument::IS_ARRAY, 'git tags to test against (defaults to the curated list)', DEFAULT_VERSIONS) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $versions = $input->getArgument('versions'); + + $results = []; + + foreach ($versions as $version) { + $binary = gitBinary($version); + + if (!is_file($binary)) { + $io->error(sprintf('%s is not built yet. Run the "build" command first.', $version)); + $results[] = [$version, 'not built']; + + continue; + } + + $io->section(sprintf('Testing against %s', $version)); + $io->writeln(sprintf('Command: %s', $binary)); + + // The bootstrap of the tests gives git a configuration of its own. + $process = new Process(['vendor/bin/phpunit'], __DIR__, ['GIT_COMMAND' => $binary], timeout: null); + $process->run(static function (string $type, string $data) use ($output): void { + $output->write($data); + }); + + $results[] = [$version, $process->isSuccessful() ? 'ok' : 'FAILED']; + } + + $io->table(['Version', 'Result'], $results); + + $failed = array_filter($results, static fn (array $result) => 'ok' !== $result[1]); + + return [] === $failed ? Command::SUCCESS : Command::FAILURE; + } +} + +$app = new Application('gitlib multi-version git test tool'); +$app->addCommands([new BuildCommand(), new TestCommand()]); +exit($app->run()); diff --git a/test-git-versions.sh b/test-git-versions.sh deleted file mode 100755 index 0990e697..00000000 --- a/test-git-versions.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash -set -e - -URL="git@github.com:git/git.git" -VERSIONS="v1.6.3 v1.6.6 v1.7.1 v1.7.5 v1.7.9 v1.8.1" -DIRNAME="git-builds" - -if [ ! -d "$DIRNAME" ]; then - mkdir - "$DIRNAME" -fi - -cd "$DIRNAME" - -# remote repository -if [ -d cache ]; then - echo "- update from github..." - cd cache - git fetch -q - cd .. -else - echo "- clone from github..." - git clone "$URL" cache -q -fi - -# build -for VERSION in $VERSIONS; do - - echo "- build $VERSION" - LOGFILE="build.$VERSION.log" - LOCKFILE="build.$VERSION.lock" - - if [ -f "$LOCKFILE" ]; then - echo " - lock present, destroy build..." - rm -rf "$VERSION" "build.$VERSION.log" - fi - - if [ -d "$VERSION" ]; then - echo " - already built, skipping..." - continue - fi - - touch "$LOCKFILE" - - echo " - log: $LOGFILE" - touch "$LOGFILE" - mkdir "$VERSION" - echo " - clone repository" - git clone -s cache "$VERSION/source" -q - echo " - checkout version" - cd "$VERSION/source" - git checkout "$VERSION" -q - - mkdir ../build - PREFIX="`readlink -f ../build`" - - echo " - build..." - autoconf >> "../../$LOGFILE" 2>&1 - ./configure --prefix="$PREFIX" >> "../../$LOGFILE" 2>&1 - make all >> "../../$LOGFILE" 2>&1 - make install >> "../../$LOGFILE" 2>&1 - echo " - build ok" - cd ../.. - rm "$LOCKFILE" -done -cd .. - -# test -echo "" -for VERSION in $VERSIONS; do - echo "- test $VERSION" - GIT_COMMAND="`readlink -f $DIRNAME/$VERSION/build/bin/git`" - echo " - command: $GIT_COMMAND" - GIT_COMMAND="$GIT_COMMAND" phpunit || true -done diff --git a/tests/Gitonomy/Git/Tests/AbstractTest.php b/tests/Gitonomy/Git/Tests/AbstractTest.php deleted file mode 100644 index a47f3792..00000000 --- a/tests/Gitonomy/Git/Tests/AbstractTest.php +++ /dev/null @@ -1,150 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Tests; - -use Gitonomy\Git\Admin; -use Gitonomy\Git\Repository; -use PHPUnit\Framework\TestCase; - -abstract class AbstractTest extends TestCase -{ - const REPOSITORY_URL = 'http://github.com/gitonomy/foobar.git'; - - const LONGFILE_COMMIT = '4f17752acc9b7c54ba679291bf24cb7d354f0f4f'; - const BEFORE_LONGFILE_COMMIT = 'e0ec50e2af75fa35485513f60b2e658e245227e9'; - const LONGMESSAGE_COMMIT = '3febd664b6886344a9b32d70657687ea4b1b4fab'; - const INITIAL_COMMIT = '74acd054c8ec873ae6be044041d3a85a4f890ba5'; - const MERGE_COMMIT = '2f5b9d0a4e6e7173d7816e417805709c708674f8'; - const ENCODING_COMMIT = '779420b9b936f18a0b6579e1499a85b14270802e'; - const SIGNED_COMMIT = 'e1a83f16ed61ae3807e5652c7ef894692c813513'; - - /** - * Local clone of remote URL. Avoids network call on each test. - */ - private static $localRepository; - - /** - * Creates an empty git repository and returns instance. - * - * @return Repository - */ - public static function createEmptyRepository($bare = true) - { - $dir = self::createTempDir(); - $repository = Admin::init($dir, $bare, self::getOptions()); - self::registerDeletion($repository); - - return $repository; - } - - /** - * Can be used as data provider to get bare/not-bare repositories. - */ - public static function provideFoobar() - { - return [ - [self::createFoobarRepository()], - [self::createFoobarRepository(false)], - ]; - } - - /** - * Can be used as data provider to get bare/not-bare repositories. - */ - public static function provideEmpty() - { - return [ - [self::createEmptyRepository()], - [self::createEmptyRepository(false)], - ]; - } - - /** - * Creates a fixture test repository. - * - * @return Repository - */ - public static function createFoobarRepository($bare = true) - { - if (null === self::$localRepository) { - self::$localRepository = Admin::cloneTo(self::createTempDir(), self::REPOSITORY_URL, $bare, self::getOptions()); - } - - $repository = self::$localRepository->cloneTo(self::createTempDir(), $bare, self::getOptions()); - self::registerDeletion($repository); - - return $repository; - } - - public static function registerDeletion(Repository $repository) - { - register_shutdown_function(function () use ($repository) { - if ($repository->getWorkingDir()) { - $dir = $repository->getWorkingDir(); - } else { - $dir = $repository->getGitDir(); - } - self::deleteDir($dir); - }); - } - - /** - * Created an empty directory and return path to it. - * - * @return string a fullpath - */ - public static function createTempDir() - { - $tmpDir = tempnam(sys_get_temp_dir(), 'gitlib_'); - unlink($tmpDir); - mkdir($tmpDir); - - return $tmpDir; - } - - /** - * Deletes a directory recursively. - * - * @param string $dir directory to delete - */ - public static function deleteDir($dir) - { - $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS); - $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::CHILD_FIRST); - foreach ($iterator as $file) { - if (!is_link($file)) { - chmod($file, 0777); - } - if (is_dir($file)) { - rmdir($file); - } else { - unlink($file); - } - } - - chmod($dir, 0777); - rmdir($dir); - } - - protected static function getOptions() - { - $command = isset($_SERVER['GIT_COMMAND']) ? $_SERVER['GIT_COMMAND'] : 'git'; - $envs = isset($_SERVER['GIT_ENVS']) ? (array) $_SERVER['GIT_ENVS'] : []; - - return [ - 'command' => $command, - 'environment_variables' => $envs, - 'process_timeout' => 60, - ]; - } -} diff --git a/tests/Gitonomy/Git/Tests/AbstractTestCase.php b/tests/Gitonomy/Git/Tests/AbstractTestCase.php new file mode 100644 index 00000000..a67912e5 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/AbstractTestCase.php @@ -0,0 +1,327 @@ + + * (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\Cli\CliDriver; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\AfterClass; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Process; + +abstract class AbstractTestCase extends TestCase +{ + /** + * Local git bundle used as a fixture repository: cloning from it behaves like + * cloning a remote repository, but requires no network access. + */ + public const REPOSITORY_URL = __DIR__.'/../../../fixtures/foobar.bundle'; + + public const NO_MESSAGE_COMMIT = '011cd0c1625190d2959ee9a8f9f822006d94b661'; + public const LONGFILE_COMMIT = '4f17752acc9b7c54ba679291bf24cb7d354f0f4f'; + public const BEFORE_LONGFILE_COMMIT = 'e0ec50e2af75fa35485513f60b2e658e245227e9'; + public const LONGMESSAGE_COMMIT = '3febd664b6886344a9b32d70657687ea4b1b4fab'; + public const INITIAL_COMMIT = '74acd054c8ec873ae6be044041d3a85a4f890ba5'; + public const MERGE_COMMIT = '2f5b9d0a4e6e7173d7816e417805709c708674f8'; + public const ENCODING_COMMIT = '779420b9b936f18a0b6579e1499a85b14270802e'; + public const SIGNED_COMMIT = 'e1a83f16ed61ae3807e5652c7ef894692c813513'; + + /** + * Bare clone of REPOSITORY_URL, the fixture repositories are cloned from: + * for the tests of a class, as each class deletes it. + */ + private static ?Repository $localRepository = null; + + /** + * Materialized (unbundled) copy of REPOSITORY_URL, used in place of the + * raw .bundle file for drivers whose clone implementation doesn't + * understand the git bundle format (unlike the `git` CLI, which + * special-cases it) — currently LibGit2Driver. + */ + private static ?string $materializedRepositoryUrl = null; + + /** + * Directories created by createTempDir() for the current test. + * + * @var string[] + */ + private static array $tempDirs = []; + + /** + * Directories kept for the tests of a class. + * + * @var string[] + */ + private static array $classTempDirs = []; + + /** + * Creates an empty git repository and returns instance. + */ + public static function createEmptyRepository(bool $bare = true): Repository + { + $dir = self::createTempDir(); + + return $bare ? Repository::initBare($dir, driver: self::getDriver()) : Repository::init($dir, driver: self::getDriver()); + } + + /** + * Data provider of the "bare" argument of createFoobarRepository() and + * createEmptyRepository(). + * + * @return array + */ + public static function provideBare(): array + { + return [ + 'bare' => [true], + 'working copy' => [false], + ]; + } + + /** + * Creates a fixture test repository. + */ + public static function createFoobarRepository(bool $bare = true): Repository + { + if (null === self::$localRepository) { + self::$localRepository = Repository::cloneBareFrom(self::repositoryUrl(), self::createClassTempDir(), driver: self::getDriver()); + } + + $dir = self::createTempDir(); + $url = self::$localRepository->getGitDir(); + + return $bare ? Repository::cloneBareFrom($url, $dir, driver: self::getDriver()) : Repository::cloneFrom($url, $dir, driver: self::getDriver()); + } + + /** + * Creates an empty directory, deleted after the current test. + */ + public static function createTempDir(): string + { + return self::$tempDirs[] = self::createDir(); + } + + #[After] + public function deleteTempDirs(): void + { + foreach (self::$tempDirs as $dir) { + self::deleteDir($dir); + } + self::$tempDirs = []; + } + + #[AfterClass] + public static function deleteClassTempDirs(): void + { + foreach (self::$classTempDirs as $dir) { + self::deleteDir($dir); + } + self::$classTempDirs = []; + self::$localRepository = null; + self::$materializedRepositoryUrl = null; + } + + /** + * Returns the source to clone the fixture from: the raw .bundle file for + * drivers that understand it (the `git` CLI does natively), or a + * one-time unbundled bare repository otherwise. + */ + protected static function repositoryUrl(): string + { + if (self::getDriver() instanceof CliDriver) { + return self::REPOSITORY_URL; + } + + if (null === self::$materializedRepositoryUrl) { + $dir = self::createClassTempDir(); + $process = new Process([self::gitCommand(), 'clone', '-q', '--bare', self::REPOSITORY_URL, $dir]); + $process->mustRun(); + self::$materializedRepositoryUrl = $dir; + } + + return self::$materializedRepositoryUrl; + } + + /** + * Deletes a directory recursively. + */ + protected static function deleteDir(string $dir): void + { + // Without FOLLOW_SYMLINKS: a link to a directory is deleted, never what it points to. + $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); + $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::CHILD_FIRST); + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (is_link($file)) { + unlink($file); + } elseif (is_dir($file)) { + chmod($file, 0o777); + rmdir($file); + } else { + chmod($file, 0o777); + unlink($file); + } + } + + chmod($dir, 0o777); + rmdir($dir); + } + + /** + * Skips the current test under LibGit2Driver: use at the top of tests + * whose subject is the CLI-only Repository::run()/runProcess() escape + * hatch, or the configuration of CliDriver. For mere setup, use git(). + */ + protected function skipUnlessCliDriver(): void + { + if (!self::getDriver() instanceof CliDriver) { + self::markTestSkipped('This test is specific to CliDriver.'); + } + } + + /** + * Returns the branch HEAD points to, failing the test when it is detached. + */ + protected static function headBranch(Repository $repository): Branch + { + $head = $repository->getHead(); + self::assertInstanceOf(Branch::class, $head); + + return $head; + } + + /** + * Runs a git command on $repository through CliDriver, whatever the + * driver under test: for setup (committing, staging, tagging...) that no + * driver method covers. + * + * @param array $args + */ + protected static function git(Repository $repository, string $command, array $args = []): string + { + $cli = new Repository($repository->getPath(), driver: self::getCliDriver()); + + try { + return $cli->run($command, $args); + } finally { + // Like a write through $repository itself. + $repository->getReferences()->invalidate(); + } + } + + /** + * Writes a loose object, without the checks recent git versions apply to + * it (e.g. to a tag without tagger), nor the --literally option old ones lack. + * + * @return string The hash of the object + */ + protected static function writeObject(Repository $repository, string $type, string $content): string + { + $object = $type.' '.\strlen($content)."\0".$content; + $hash = sha1($object); + + $dir = $repository->getGitDir().'/objects/'.substr($hash, 0, 2); + if (!is_dir($dir)) { + mkdir($dir); + } + file_put_contents($dir.'/'.substr($hash, 2), gzcompress($object)); + + return $hash; + } + + /** + * Returns the driver under test: LibGit2Driver when the GITLIB_DRIVER + * environment variable is "ffi", CliDriver otherwise. + */ + protected static function getDriver(): DriverInterface + { + return 'ffi' === ($_SERVER['GITLIB_DRIVER'] ?? 'cli') ? new LibGit2Driver() : self::getCliDriver(); + } + + /** + * @param array $environmentVariables Set on git processes, in addition to the GIT_ENVS environment variable + */ + protected static function getCliDriver(array $environmentVariables = [], ?float $processTimeout = 60, ?LoggerInterface $logger = null): CliDriver + { + return new CliDriver(self::gitCommand(), [...self::gitEnvs(), ...$environmentVariables], processTimeout: $processTimeout, logger: $logger); + } + + /** + * @return string The git binary of the tests: the GIT_COMMAND environment variable, or "git" + */ + protected static function gitCommand(): string + { + $command = $_SERVER['GIT_COMMAND'] ?? 'git'; + if (!\is_string($command)) { + throw new \UnexpectedValueException('The GIT_COMMAND environment variable must be a string.'); + } + + return $command; + } + + /** + * @return string The version of the git binary of the tests, like "2.25.1" + */ + protected static function gitVersion(): string + { + $process = new Process([self::gitCommand(), 'version']); + $process->mustRun(); + + if (1 !== preg_match('/^git version (\d+\.\d+\.\d+)/', $process->getOutput(), $matches)) { + throw new \UnexpectedValueException(\sprintf('Unable to read the version of git in "%s".', $process->getOutput())); + } + + return $matches[1]; + } + + /** + * @return array The environment variables of git: GIT_ENVS, which the bootstrap sets on Windows + */ + protected static function gitEnvs(): array + { + $envs = []; + $variables = $_SERVER['GIT_ENVS'] ?? []; + if (!\is_array($variables)) { + throw new \UnexpectedValueException('GIT_ENVS must be an array.'); + } + foreach ($variables as $name => $value) { + if (!\is_string($name) || !\is_string($value)) { + throw new \UnexpectedValueException('GIT_ENVS must map names to values.'); + } + $envs[$name] = $value; + } + + return $envs; + } + + /** + * Creates an empty directory, deleted after the last test of the class. + */ + private static function createClassTempDir(): string + { + return self::$classTempDirs[] = self::createDir(); + } + + private static function createDir(): string + { + $dir = tempnam(sys_get_temp_dir(), 'gitlib_') ?: throw new \RuntimeException('Unable to create a temporary file.'); + unlink($dir); + mkdir($dir); + + return $dir; + } +} diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php deleted file mode 100644 index d000dd44..00000000 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ /dev/null @@ -1,178 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Tests; - -use Gitonomy\Git\Admin; -use Gitonomy\Git\Reference\Branch; -use Gitonomy\Git\Repository; - -class AdminTest extends AbstractTest -{ - private $tmpDir; - - public function setUp() - { - $this->tmpDir = self::createTempDir(); - } - - public function tearDown() - { - $this->deleteDir(self::createTempDir()); - } - - public function testBare() - { - $repository = Admin::init($this->tmpDir, true, self::getOptions()); - - $objectDir = $this->tmpDir.'/objects'; - - $this->assertTrue($repository->isBare(), 'Repository is bare'); - $this->assertTrue(is_dir($objectDir), 'objects/ folder is present'); - $this->assertTrue($repository instanceof Repository, 'Admin::init returns a repository'); - $this->assertEquals($this->tmpDir, $repository->getGitDir(), 'The folder passed as argument is git dir'); - $this->assertNull($repository->getWorkingDir(), 'No working dir in bare repository'); - } - - public function testNotBare() - { - $repository = Admin::init($this->tmpDir, false, self::getOptions()); - - $objectDir = $this->tmpDir.'/.git/objects'; - - $this->assertFalse($repository->isBare(), 'Repository is not bare'); - $this->assertTrue(is_dir($objectDir), 'objects/ folder is present'); - $this->assertTrue($repository instanceof Repository, 'Admin::init returns a repository'); - $this->assertEquals($this->tmpDir.'/.git', $repository->getGitDir(), 'git dir as subfolder of argument'); - $this->assertEquals($this->tmpDir, $repository->getWorkingDir(), 'working dir present in bare repository'); - } - - /** - * @dataProvider provideFoobar - */ - public function testClone($repository) - { - $newDir = self::createTempDir(); - $new = $repository->cloneTo($newDir, $repository->isBare(), self::getOptions()); - self::registerDeletion($new); - - $newRefs = array_keys($new->getReferences()->getAll()); - - $this->assertTrue(in_array('refs/heads/master', $newRefs)); - $this->assertTrue(in_array('refs/tags/0.1', $newRefs)); - - if ($repository->isBare()) { - $this->assertEquals($newDir, $new->getGitDir()); - $this->assertTrue(in_array('refs/heads/new-feature', $newRefs)); - } else { - $this->assertEquals($newDir.'/.git', $new->getGitDir()); - $this->assertEquals($newDir, $new->getWorkingDir()); - } - } - - public function testCloneBranchBare() - { - //we can't use AbstractText::createFoobarRepository() - //because it does not clone other branches than "master" - //so we test it directly against the remote repository - - $newDir = self::createTempDir(); - $new = Admin::cloneBranchTo($newDir, self::REPOSITORY_URL, 'new-feature'); - self::registerDeletion($new); - - $head = $new->getHead(); - $this->assertTrue($head instanceof Branch, 'HEAD is a branch'); - $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); - } - - public function testCloneBranchNotBare() - { - //we can't use AbstractText::createFoobarRepository() - //because it does not clone other branches than "master" - //so we test it directly against remote repository - - $newDir = self::createTempDir(); - $new = Admin::cloneBranchTo($newDir, self::REPOSITORY_URL, 'new-feature', false); - self::registerDeletion($new); - - $head = $new->getHead(); - $this->assertTrue($head instanceof Branch, 'HEAD is a branch'); - $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); - } - - /** - * @dataProvider provideFoobar - */ - public function testMirror($repository) - { - $newDir = self::createTempDir(); - $new = Admin::mirrorTo($newDir, $repository->getGitDir(), self::getOptions()); - self::registerDeletion($new); - - $newRefs = array_keys($new->getReferences()->getAll()); - - $this->assertTrue(in_array('refs/heads/master', $newRefs)); - $this->assertTrue(in_array('refs/tags/0.1', $newRefs)); - $this->assertEquals($newDir, $new->getGitDir()); - - if ($repository->isBare()) { - $this->assertTrue(in_array('refs/heads/new-feature', $newRefs)); - } else { - $this->assertTrue(in_array('refs/remotes/origin/new-feature', $newRefs)); - } - } - - /** - * @dataProvider provideFoobar - */ - public function testCheckValidRepository($repository) - { - $url = $repository->getGitDir(); - $this->assertTrue(Admin::isValidRepository($url)); - } - - public function testCheckInvalidRepository() - { - $url = $this->tmpDir.'/invalid.git'; - mkdir($url); - - $this->assertFalse(Admin::isValidRepository($url)); - } - - /** - * @expectedException RuntimeException - */ - public function testExistingFile() - { - $file = $this->tmpDir.'/test'; - touch($file); - - Admin::init($file, true, self::getOptions()); - } - - public function testCloneRepository() - { - $newDir = self::createTempDir(); - $args = []; - - $new = Admin::cloneRepository($newDir, self::REPOSITORY_URL, $args, self::getOptions()); - self::registerDeletion($new); - - $newRefs = array_keys($new->getReferences()->getAll()); - - $this->assertTrue(in_array('refs/heads/master', $newRefs)); - $this->assertTrue(in_array('refs/tags/0.1', $newRefs)); - - $this->assertEquals($newDir.'/.git', $new->getGitDir()); - $this->assertEquals($newDir, $new->getWorkingDir()); - } -} diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index 6d92547d..b6ddf22b 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -1,6 +1,6 @@ @@ -12,33 +12,138 @@ namespace Gitonomy\Git\Tests; -class BlameTest extends AbstractTest +use Gitonomy\Git\Blame\Line; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; + +class BlameTest extends AbstractTestCase { - /** - * @dataProvider provideFoobar - */ - public function testBlame($repository) + #[DataProvider('provideBare')] + public function testBlame(bool $bare): void { + $repository = self::createFoobarRepository($bare); $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md'); $this->assertCount(7, $blame); - $this->assertEquals('alice', $blame->getLine(1)->getCommit()->getAuthorName()); - $this->assertEquals(self::INITIAL_COMMIT, $blame->getLine(1)->getCommit()->getHash()); + $this->assertSame('Foo Bar project', $blame->getLine(1)->getContent()); + + $this->assertSame('alice', $blame->getLine(1)->getCommit()->getAuthorName()); + $this->assertSame(self::INITIAL_COMMIT, $blame->getLine(1)->getCommit()->getHash()); + + $this->assertSame('alice', $blame->getLine(5)->getCommit()->getAuthorName()); + $this->assertNotSame(self::INITIAL_COMMIT, $blame->getLine(5)->getCommit()->getHash()); + } + + #[DataProvider('provideBare')] + public function testHunks(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $hunks = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getHunks(); + + $this->assertCount(3, $hunks); + $this->assertSame(self::INITIAL_COMMIT, $hunks[0]->getCommit()->getHash()); + $this->assertSame(range(1, \count($hunks[0]->getLines())), array_keys($hunks[0]->getLines())); + } + + #[DataProvider('provideBare')] + public function testBlameLineRange(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $full = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md'); + + $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 3, 5); + + $this->assertCount(3, $blame); + $this->assertSame([3, 4, 5], array_keys($blame->getLines())); + foreach ([3, 4, 5] as $number) { + $this->assertSame($number, $blame->getLine($number)->getNumber()); + $this->assertSame($full->getLine($number)->getOriginalNumber(), $blame->getLine($number)->getOriginalNumber()); + $this->assertSame($full->getLine($number)->getCommit()->getHash(), $blame->getLine($number)->getCommit()->getHash()); + $this->assertSame($full->getLine($number)->getContent(), $blame->getLine($number)->getContent()); + } + $this->assertCount(5, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 3)); + $this->assertCount(2, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', lastLine: 2)); + } + + public function testBlameRefusesAnInvalidRange(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid range of lines "0,3": the lines are numbered from 1.'); - $this->assertEquals('alice', $blame->getLine(5)->getCommit()->getAuthorName()); - $this->assertNotEquals(self::INITIAL_COMMIT, $blame->getLine(5)->getCommit()->getHash()); + $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 0, 3); + } + + public function testBlameOfAnInvertedRangeIsTheRangeInOrder(): void + { + $repository = self::createFoobarRepository(); + + // Like git blame -L 5,3. + $this->assertSame([3, 4, 5], array_keys($repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 5, 3)->getLines())); + } + + public function testBlameOfARangeAfterTheEndThrows(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(DriverException::class); + + $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 100)->getLines(); } /** - * @dataProvider provideFoobar + * After a merge whose sides kept a file under two names, git blames a + * commit under both: "git blame -p" repeats its "filename" line on each + * of its groups of lines. */ - public function testGroupedBlame($repository) + public function testBlameOfACommitUnderTwoPaths(): void { - $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getGroupedLines(); + $repository = Repository::init(self::createTempDir(), self::getDriver()); + $dir = $repository->getWorkingDir(); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + file_put_contents($dir.'/a', "L1\nL2\nL3\n"); + file_put_contents($dir.'/b', "L1\nL2\nL3\n"); + self::git($repository, 'add', ['a', 'b']); + self::git($repository, 'commit', ['-q', '-m', 'Both']); + $both = trim(self::git($repository, 'rev-parse', ['HEAD'])); + self::git($repository, 'checkout', ['-q', '-b', 'side']); + self::git($repository, 'rm', ['-q', 'a']); + self::git($repository, 'commit', ['-q', '-m', 'Without a']); + self::git($repository, 'checkout', ['-q', 'master']); + file_put_contents($dir.'/a', "L1\nL2\nNEW\n"); + self::git($repository, 'rm', ['-q', 'b']); + self::git($repository, 'commit', ['-q', '-a', '-m', 'Without b']); - $this->assertCount(3, $blame); + try { + // A conflict: a is deleted on one side, and changed on the other. + self::git($repository, 'merge', ['-q', '--no-edit', 'side']); + } catch (ProcessException) { + } + file_put_contents($dir.'/a', "L1\nL2x\nL3\n"); + self::git($repository, 'add', ['a']); + self::git($repository, 'commit', ['-q', '-m', 'Merge']); + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + + $lines = array_map(static fn (Line $line): array => [$line->getCommit()->getHash(), $line->getContent()], $repository->getBlame('HEAD', 'a')->getLines()); + + $this->assertSame([$both, 'L1'], $lines[1]); + $this->assertSame('L2x', $lines[2][1]); + $this->assertSame('L3', $lines[3][1]); + } + + public function testBlameOfAPathNormalizesIt(): void + { + $repository = self::createFoobarRepository(); + $lines = static fn (string $path): array => array_map(static fn (Line $line): array => [$line->getCommit()->getHash(), $line->getContent()], $repository->getBlame(self::LONGFILE_COMMIT, $path)->getLines()); - $this->assertEquals(self::INITIAL_COMMIT, $blame[0][0]->getHash()); + // Like git. + $this->assertSame($lines('README.md'), $lines('./README.md')); + $this->assertSame($lines('README.md'), $lines('dir/../README.md')); } } diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 8dbe2719..c6066389 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -1,6 +1,6 @@ @@ -12,59 +12,114 @@ namespace Gitonomy\Git\Tests; -class BlobTest extends AbstractTest +use Gitonomy\Git\Blob; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; + +class BlobTest extends AbstractTestCase { - const README_FRAGMENT = 'Foo Bar project'; + public const README_FRAGMENT = 'Foo Bar project'; - public function getReadmeBlob($repository) + #[DataProvider('provideBare')] + public function testGetContent(bool $bare): void { - return $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('README.md'); + $repository = self::createFoobarRepository($bare); + $blob = self::getReadmeBlob($repository); + + $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); } - /** - * @dataProvider provideFoobar - */ - public function testGetContent($repository) + #[DataProvider('provideBare')] + public function testGetContentSiblingToSubmodule(bool $bare): void { - $blob = $this->getReadmeBlob($repository); + $repository = self::createFoobarRepository($bare); + // Regression test for https://github.com/gitonomy/gitlib/issues/12: + // a submodule entry in a tree used to break parsing of that tree, + // making sibling blobs unreadable. + $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $this->assertNotEmpty($tree->getSubmodules(), 'Tree contains a submodule entry'); + + $blob = $tree->resolvePath('README.md'); - $this->assertContains(self::README_FRAGMENT, $blob->getContent()); + $this->assertInstanceOf(Blob::class, $blob); + $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); } - /** - * @dataProvider provideFoobar - * @expectedException RuntimeException - */ - public function testNotExisting($repository) + #[DataProvider('provideBare')] + public function testNotExisting(bool $bare): void { - $blob = $repository->getBlob('foobar'); + $repository = self::createFoobarRepository($bare); + $blob = $repository->getBlob(str_repeat('1', 40)); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The object "1111111111111111111111111111111111111111" does not exist.'); + $blob->getContent(); } - /** - * @dataProvider provideFoobar - */ - public function testGetMimetype($repository) + public function testNotAHash(): void { - $blob = $this->getReadmeBlob($repository); - $this->assertRegexp('#text/plain#', $blob->getMimetype()); + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('"master" is not a hash.'); + + self::createFoobarRepository()->getBlob('master'); } - /** - * @dataProvider provideFoobar - */ - public function testIsText($repository) + #[DataProvider('provideBare')] + public function testGetMimetype(bool $bare): void { - $blob = $this->getReadmeBlob($repository); - $this->assertTrue($blob->isText()); + $repository = self::createFoobarRepository($bare); + $blob = self::getReadmeBlob($repository); + + $this->assertSame('text/plain', $blob->getMimetype()); + } + + #[DataProvider('provideBare')] + public function testIsText(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $readmeBlob = self::getReadmeBlob($repository); + $this->assertTrue($readmeBlob->isText()); + $imageBlob = self::getImageBlob($repository); + $this->assertFalse($imageBlob->isText()); } - /** - * @dataProvider provideFoobar - */ - public function testIsBinary($repository) + #[DataProvider('provideBare')] + public function testIsBinary(bool $bare): void { - $blob = $this->getReadmeBlob($repository); - $this->assertFalse($blob->isBinary()); + $repository = self::createFoobarRepository($bare); + $readmeBlob = self::getReadmeBlob($repository); + $this->assertFalse($readmeBlob->isBinary()); + $imageBlob = self::getImageBlob($repository); + $this->assertTrue($imageBlob->isBinary()); + } + + #[DataProvider('provideBare')] + public function testAbbreviatedHash(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $hash = '412a0dad051163dcf9f9ce2fc04a141ffcb69479'; + + $this->assertSame($repository->getBlob($hash)->getContent(), $repository->getBlob(substr($hash, 0, 8))->getContent()); + } + + private static function getReadmeBlob(Repository $repository): Blob + { + return self::blob($repository, 'README.md'); + } + + private static function getImageBlob(Repository $repository): Blob + { + return self::blob($repository, 'image.jpg'); + } + + private static function blob(Repository $repository, string $path): Blob + { + $blob = $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath($path); + self::assertInstanceOf(Blob::class, $blob); + + return $blob; } } diff --git a/tests/Gitonomy/Git/Tests/BootstrapTest.php b/tests/Gitonomy/Git/Tests/BootstrapTest.php new file mode 100644 index 00000000..78289031 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/BootstrapTest.php @@ -0,0 +1,55 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Symfony\Component\Process\Process; + +/** + * The tests run git in an environment of their own, whatever the developer's. + */ +class BootstrapTest extends AbstractTestCase +{ + private const string BOOTSTRAP = __DIR__.'/../../../bootstrap.php'; + + public function testTheBootstrapRemovesTheVariablesOfGit(): void + { + // The variables git gives a hook, like pre-commit, running the tests. + $dir = self::createTempDir(); + $environment = [ + 'GIT_AUTHOR_NAME' => 'Hook', + 'GIT_COMMAND' => 'git', + 'GIT_CONFIG_NOSYSTEM' => '0', + 'GIT_DIR' => $dir.'/.git', + 'GIT_INDEX_FILE' => $dir.'/.git/index', + 'GIT_OBJECT_DIRECTORY' => $dir.'/.git/objects', + 'GIT_WORK_TREE' => $dir, + ]; + + $code = <<<'PHP' + require $argv[1]; + $variables = []; + foreach (['getenv' => getenv(), '_ENV' => $_ENV, '_SERVER' => $_SERVER] as $source => $values) { + $variables[$source] = array_filter($values, static fn (string $name): bool => str_starts_with($name, 'GIT_'), \ARRAY_FILTER_USE_KEY); + ksort($variables[$source]); + } + echo json_encode($variables); + PHP; + + // With $_ENV, which php.ini usually leaves empty. + $process = new Process([\PHP_BINARY, '-d', 'variables_order=EGPCS', '-r', $code, self::BOOTSTRAP], env: $environment); + $process->mustRun(); + + $expected = ['GIT_COMMAND' => 'git', 'GIT_CONFIG_NOSYSTEM' => '1']; + $this->assertSame(['getenv' => $expected, '_ENV' => $expected, '_SERVER' => $expected], json_decode($process->getOutput(), true)); + } +} diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index d33b478e..a37fcee3 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -1,6 +1,6 @@ @@ -12,291 +12,493 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Blob; use Gitonomy\Git\Commit; -use Gitonomy\Git\Diff\Diff; - -class CommitTest extends AbstractTest +use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Process\Process; + +class CommitTest extends AbstractTestCase { - /** - * @dataProvider provideFoobar - */ - public function testGetDiff($repository) + #[DataProvider('provideBare')] + public function testGetDiff(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $diff = $commit->getDiff(); - $this->assertTrue($diff instanceof Diff, 'getDiff() returns a Diff object'); + $this->assertSame(['long.php'], array_map(static fn (File $file): string => $file->getName(), $diff->getFiles())); + } + + public function testADateOutOfRangeThrows(): void + { + $repository = self::createFoobarRepository(); + $hash = self::writeObject($repository, 'commit', "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A 99999999999999999999 +0000\ncommitter A 99999999999999999999 +0000\n\nCorrupt\n"); + + // A RuntimeException of gitlib, not of PHP. + $this->expectException(RuntimeException::class); + + $repository->getCommit($hash)->getAuthorDate(); } /** - * @dataProvider provideFoobar + * Like git log, at the UTC offsets of the author and of the committer. */ - public function testGetHash($repository) + public function testTheDatesAreAtTheOffsetsOfTheAuthorAndOfTheCommitter(): void { - $commit = $repository->getCommit(self::LONGFILE_COMMIT); + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + $git = new Repository($repository->getPath(), driver: self::getCliDriver([ + 'GIT_AUTHOR_DATE' => '1700000000 +0530', + 'GIT_AUTHOR_EMAIL' => 'author@example.com', + 'GIT_AUTHOR_NAME' => 'Author', + 'GIT_COMMITTER_DATE' => '1700000001 -0330', + 'GIT_COMMITTER_EMAIL' => 'committer@example.com', + 'GIT_COMMITTER_NAME' => 'Committer', + ])); + $hash = trim($git->run('commit-tree', [$tree, '-m', 'Dates'])); + + $commit = $repository->getCommit($hash); + + $this->assertSame('2023-11-15T03:43:20+05:30', $commit->getAuthorDate()->format(\DATE_ATOM)); + $this->assertSame(1700000000, $commit->getAuthorDate()->getTimestamp()); + $this->assertSame('2023-11-14T18:43:21-03:30', $commit->getCommitterDate()->format(\DATE_ATOM)); + $this->assertSame(1700000001, $commit->getCommitterDate()->getTimestamp()); + } + + /** + * Old commits, imported from other tools, have signatures git wouldn't + * write anymore, which git and libgit2 read anyway: one of them made the + * whole log throw. + */ + #[DataProvider('provideMalformedSignatures')] + public function testAMalformedSignatureIsReadLikeGit(string $signature, string $name, string $email, string $date): void + { + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + $hash = self::writeObject($repository, 'commit', "tree {$tree}\nauthor {$signature}\ncommitter {$signature}\n\nMalformed\n"); + + $commits = $repository->getLog($hash)->getCommits(); - $this->assertEquals(self::LONGFILE_COMMIT, $commit->getHash()); + $this->assertCount(1, $commits); + $this->assertSame([$name, $email, $date], [$commits[0]->getAuthorName(), $commits[0]->getAuthorEmail(), $commits[0]->getAuthorDate()->format(\DATE_ATOM)]); + $this->assertSame([$name, $email, $date], [$commits[0]->getCommitterName(), $commits[0]->getCommitterEmail(), $commits[0]->getCommitterDate()->format(\DATE_ATOM)]); } /** - * @dataProvider provideFoobar - * - * @expectedException Gitonomy\Git\Exception\ReferenceNotFoundException - * @expectedExceptionMessage Reference not found: "that-hash-doest-not-exists" + * @return iterable */ - public function testInvalideHashThrowException($repository) + public static function provideMalformedSignatures(): iterable { - $commit = new Commit($repository, 'that-hash-doest-not-exists'); + yield 'no space before the email' => ['A 1700000000 +0200', 'A', 'a@example.com', '2023-11-15T00:13:20+02:00']; + yield 'no date' => ['A ', 'A', 'a@example.com', '1970-01-01T00:00:00+00:00']; + yield 'an offset of 5 digits' => ['A 1700000000 +05030', 'A', 'a@example.com', '2023-11-14T22:13:20+00:00']; + yield 'no name nor email' => ['<> 0 +0000', '', '', '1970-01-01T00:00:00+00:00']; } /** - * @dataProvider provideFoobar + * Like git log, which shows "-0000" as "+0000". An offset of more than 14 + * hours or 59 minutes, which no timezone has, gives UTC, like libgit2. */ - public function testGetShortHash($repository) + #[DataProvider('provideOffsets')] + public function testADateIsAtTheOffsetOfItsSignature(string $offset, string $date): void { - $commit = $repository->getCommit(self::LONGFILE_COMMIT); + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + $hash = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1700000000 {$offset}\ncommitter A 1700000000 {$offset}\n\nOffset\n"); + + $commit = $repository->getCommit($hash); - $this->assertEquals('4f17752', $commit->getShortHash(), 'Short hash'); + $this->assertSame($date, $commit->getAuthorDate()->format(\DATE_ATOM)); + $this->assertSame($date, $commit->getCommitterDate()->format(\DATE_ATOM)); + $this->assertSame(1700000000, $commit->getAuthorDate()->getTimestamp()); } /** - * @dataProvider provideFoobar + * @return iterable */ - public function testGetParentHashes_WithNoParent($repository) + public static function provideOffsets(): iterable { - $commit = $repository->getCommit(self::INITIAL_COMMIT); + yield 'half an hour' => ['+0530', '2023-11-15T03:43:20+05:30']; + yield 'three quarters of an hour' => ['+0545', '2023-11-15T03:58:20+05:45']; + yield 'negative' => ['-0700', '2023-11-14T15:13:20-07:00']; + yield 'negative, with minutes' => ['-0330', '2023-11-14T18:43:20-03:30']; + yield 'negative, under an hour' => ['-0030', '2023-11-14T21:43:20-00:30']; + yield 'unknown' => ['-0000', '2023-11-14T22:13:20+00:00']; + yield 'easternmost' => ['+1400', '2023-11-15T12:13:20+14:00']; + yield 'westernmost' => ['-1200', '2023-11-14T10:13:20-12:00']; + yield 'beyond 14 hours' => ['+1500', '2023-11-14T22:13:20+00:00']; + yield 'beyond 59 minutes' => ['+0560', '2023-11-14T22:13:20+00:00']; + } + + #[DataProvider('provideBare')] + public function testGetHash(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $commit = $repository->getCommit(self::LONGFILE_COMMIT); + + $this->assertSame(self::LONGFILE_COMMIT, $commit->getHash()); + } + + #[DataProvider('provideBare')] + public function testInvalideHashThrowException(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('"that-hash-doest-not-exists" is not the hash of a commit: get a commit by its name with getRevision(...)->getCommit().'); + + new Commit($repository, 'that-hash-doest-not-exists'); + } + + #[DataProvider('provideBare')] + public function testGetShortHash(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals(0, count($commit->getParentHashes()), 'No parent on initial commit'); + $this->assertSame('4f17752', $commit->getShortHash(), 'Short hash'); } /** - * @dataProvider provideFoobar + * With log.showSignature, which the configuration of the tests sets, git + * prints the verification of the signature before the hash. */ - public function testGetParentHashes_WithOneParent($repository) + public function testGetShortHashOfASignedCommit(): void + { + $repository = self::createFoobarRepository(); + + $this->assertSame('e1a83f1', $repository->getCommit(self::SIGNED_COMMIT)->getShortHash()); + } + + #[DataProvider('provideBare')] + public function testGetParentHashesWithNoParent(bool $bare): void { + $repository = self::createFoobarRepository($bare); + $commit = $repository->getCommit(self::INITIAL_COMMIT); + + $this->assertCount(0, $commit->getParentHashes(), 'No parent on initial commit'); + } + + #[DataProvider('provideBare')] + public function testGetParentHashesWithOneParent(bool $bare): void + { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $parents = $commit->getParentHashes(); - $this->assertEquals(1, count($parents), 'One parent found'); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $parents[0], 'Parent hash is correct'); + $this->assertCount(1, $parents, 'One parent found'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $parents[0], 'Parent hash is correct'); } - /** - * @dataProvider provideFoobar - */ - public function testGetParents_WithOneParent($repository) + #[DataProvider('provideBare')] + public function testGetParentsWithOneParent(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $parents = $commit->getParents(); - $this->assertEquals(1, count($parents), 'One parent found'); - $this->assertTrue($parents[0] instanceof Commit, 'First parent is a Commit object'); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $parents[0]->getHash(), "First parents's hash is correct"); + $this->assertCount(1, $parents, 'One parent found'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $parents[0]->getHash(), "First parents's hash is correct"); } - /** - * @dataProvider provideFoobar - */ - public function testGetTreeHash($repository) + #[DataProvider('provideBare')] + public function testGetTreeHash(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTreeHash(), 'Tree hash is correct'); + $this->assertSame('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTreeHash(), 'Tree hash is correct'); } - /** - * @dataProvider provideFoobar - */ - public function testGetTree($repository) + #[DataProvider('provideBare')] + public function testGetTree(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertInstanceOf('Gitonomy\Git\Tree', $commit->getTree(), 'Tree is a tree'); - $this->assertEquals('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTree()->getHash(), 'Tree hash is correct'); + $this->assertSame('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTree()->getHash(), 'Tree hash is correct'); } - /** - * @dataProvider provideFoobar - */ - public function testGetAuthorName($repository) + #[DataProvider('provideBare')] + public function testGetAuthorName(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('alice', $commit->getAuthorName(), 'Author name'); + $this->assertSame('alice', $commit->getAuthorName(), 'Author name'); } - /** - * @dataProvider provideFoobar - */ - public function testGetAuthorEmail($repository) + #[DataProvider('provideBare')] + public function testGetAuthorEmail(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('alice@example.org', $commit->getAuthorEmail(), 'Author email'); + $this->assertSame('alice@example.org', $commit->getAuthorEmail(), 'Author email'); } - /** - * @dataProvider provideFoobar - */ - public function testGetAuthorDate($repository) + #[DataProvider('provideBare')] + public function testGetAuthorDate(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('2012-12-31 14:21:03', $commit->getAuthorDate()->format('Y-m-d H:i:s'), 'Author date'); + $this->assertSame('2012-12-31T15:21:03+01:00', $commit->getAuthorDate()->format(\DATE_ATOM), 'Author date'); } - /** - * @dataProvider provideFoobar - */ - public function testGetCommitterName($repository) + #[DataProvider('provideBare')] + public function testGetCommitterName(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('alice', $commit->getCommitterName(), 'Committer name'); + $this->assertSame('alice', $commit->getCommitterName(), 'Committer name'); } - /** - * @dataProvider provideFoobar - */ - public function testGetCommitterEmail($repository) + #[DataProvider('provideBare')] + public function testGetCommitterEmail(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('alice@example.org', $commit->getCommitterEmail(), 'Committer email'); + $this->assertSame('alice@example.org', $commit->getCommitterEmail(), 'Committer email'); } - /** - * @dataProvider provideFoobar - */ - public function testGetCommitterDate($repository) + #[DataProvider('provideBare')] + public function testGetCommitterDate(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('2012-12-31 14:21:03', $commit->getCommitterDate()->format('Y-m-d H:i:s'), 'Committer date'); + $this->assertSame('2012-12-31T15:21:03+01:00', $commit->getCommitterDate()->format(\DATE_ATOM), 'Committer date'); } - /** - * @dataProvider provideFoobar - */ - public function testGetMessage($repository) + #[DataProvider('provideBare')] + public function testGetMessage(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('add a long file'."\n", $commit->getMessage()); + $this->assertSame('add a long file'."\n", $commit->getMessage()); + } + + #[DataProvider('provideBare')] + public function testGetEmptyMessage(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $commit = $repository->getCommit(self::NO_MESSAGE_COMMIT); + + $this->assertSame('', $commit->getMessage()); + } + + #[DataProvider('provideBare')] + public function testGetEmptyMessageFromLog(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $commit = $repository->getCommit(self::NO_MESSAGE_COMMIT); + $commitMessageFromLog = $commit->getLog()->getCommits()[0]->getMessage(); + + $this->assertSame('', $commitMessageFromLog); } /** * This test ensures that GPG signed commits does not break the reading of a commit * message. - * - * @dataProvider provideFoobar */ - public function testGetSignedMessage($repository) + #[DataProvider('provideBare')] + public function testGetSignedMessage(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::SIGNED_COMMIT); - $this->assertEquals('signed commit'."\n", $commit->getMessage()); + $this->assertSame('signed commit'."\n", $commit->getMessage()); } - /** - * @dataProvider provideFoobar - */ - public function testGetShortMessage($repository) + public function testSubjectIsTheFirstParagraph(): void { - // tests with a multi-line message - $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); + $repository = self::createFoobarRepository(); + $tree = $repository->getCommit(self::INITIAL_COMMIT)->getTreeHash(); + $hash = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\n\nSubject\nsecond line\n\nBody\n"); + $commit = $repository->getCommit($hash); - $this->assertEquals('Fixed perm...', $commit->getShortMessage(10)); - $this->assertEquals('Fixed perm!!!', $commit->getShortMessage(10, false, '!!!')); - $this->assertEquals('Fixed permissions!!!', $commit->getShortMessage(10, true, '!!!')); + $this->assertSame('Subject second line', $commit->getSubjectMessage()); + $this->assertSame("Body\n", $commit->getBodyMessage()); + } + + #[DataProvider('provideBare')] + public function testGetBodyMessage(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); + $nl = \chr(10); + $message = "If you want to know everything,{$nl}I ran something like `chmox +x test.sh`{$nl}{$nl}Hello and good bye.{$nl}"; + $this->assertSame($message, $commit->getBodyMessage()); - // tests with a single-line message $commit = $repository->getCommit(self::INITIAL_COMMIT); - $this->assertEquals('Add README', $commit->getShortMessage(20)); - $this->assertEquals('A', $commit->getShortMessage(1, false, '')); - $this->assertEquals('Add!!!', $commit->getShortMessage(1, true, '!!!')); + $this->assertSame('', $commit->getBodyMessage()); } - /** - * @dataProvider provideFoobar - */ - public function testGetBodyMessage($repository) + public function testTheLastModificationOfAPathNoCommitChanges(): void { - $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); - $message = <<<'EOL' -If you want to know everything, -I ran something like `chmox +x test.sh` + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage(\sprintf('No commit up to "%s" changes "nope".', self::LONGFILE_COMMIT)); + + self::createFoobarRepository()->getCommit(self::LONGFILE_COMMIT)->getLastModification('nope'); + } -Hello and good bye. + #[DataProvider('provideBare')] + public function testGetIncludingBranches(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $commit = $repository->getCommit(self::INITIAL_COMMIT); -EOL; + $this->assertSame($repository->getReferences()->getBranches(), $commit->getIncludingBranches()); + } - $this->assertEquals($message, $commit->getBodyMessage()); + public function testGetIncludingBranchesNamedLikeRemoteOnes(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'branch', ['remotes/origin/fake', self::INITIAL_COMMIT]); - $commit = $repository->getCommit(self::INITIAL_COMMIT); + $branches = $repository->getCommit(self::INITIAL_COMMIT)->getIncludingBranches(); - $this->assertEquals('', $commit->getBodyMessage()); + $this->assertContains($repository->getReferences()->getBranch('remotes/origin/fake'), $branches); } /** - * @expectedException InvalidArgumentException - * @dataProvider provideFoobar + * gitlib doesn't see the writes of another process, but doesn't fail on them. */ - public function testGetIncludingBranchesException($repository) + public function testGetIncludingBranchesCreatedByAnotherProcess(): void { - $commit = $repository->getCommit(self::INITIAL_COMMIT); + $repository = self::createFoobarRepository(); + $repository->getReferences()->getAll(); + new Process([self::gitCommand(), 'branch', 'elsewhere', self::INITIAL_COMMIT], $repository->getPath(), self::gitEnvs())->mustRun(); + + $fullnames = array_map(static fn (Branch $branch): string => $branch->getFullname(), $repository->getCommit(self::INITIAL_COMMIT)->getIncludingBranches()); - $commit->getIncludingBranches(false, false); + $this->assertContains('refs/heads/elsewhere', $fullnames); } /** - * @dataProvider provideFoobar + * A mirror leaves the default branch of a remote as a regular reference, + * which the branches leave out, like a symbolic one. */ - public function testGetIncludingBranches($repository) + public function testGetIncludingBranchesLeavesOutTheDefaultBranchOfARemote(): void { - $commit = $repository->getCommit(self::INITIAL_COMMIT); - - $branches = $commit->getIncludingBranches(true, false); - $this->assertCount(count($repository->getReferences()->getLocalBranches()), $branches); + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['--no-deref', 'refs/remotes/origin/HEAD', self::INITIAL_COMMIT]); - $branches = $commit->getIncludingBranches(true, true); - $this->assertCount(count($repository->getReferences()->getBranches()), $branches); + $fullnames = array_map(static fn (Branch $branch): string => $branch->getFullname(), $repository->getCommit(self::INITIAL_COMMIT)->getIncludingBranches()); - $branches = $commit->getIncludingBranches(false, true); - $this->assertCount(count($repository->getReferences()->getRemoteBranches()), $branches); + $this->assertNotContains('refs/remotes/origin/HEAD', $fullnames); + $this->assertContains('refs/heads/master', $fullnames); } - /** - * @dataProvider provideFoobar - */ - public function testGetLastModification($repository) + #[DataProvider('provideBare')] + public function testGetLastModification(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $lastModification = $commit->getLastModification('image.jpg'); - $this->assertTrue($lastModification instanceof Commit, 'Last modification is a Commit object'); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $lastModification->getHash(), 'Last modification is current commit'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $lastModification->getHash(), 'Last modification is current commit'); } - /** - * @dataProvider provideFoobar - */ - public function testMergeCommit($repository) + #[DataProvider('provideBare')] + public function testMergeCommit(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::MERGE_COMMIT); - $this->assertEquals("Merge branch 'authors'", $commit->getSubjectMessage()); + $this->assertSame("Merge branch 'authors'", $commit->getSubjectMessage()); + } + + #[DataProvider('provideBare')] + public function testEncoding(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $commit = $repository->getCommit(self::ENCODING_COMMIT); + + $this->assertSame('contribute to AUTHORS file', $commit->getSubjectMessage()); + } + + public function testReencodesCommitsToUtf8(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['i18n.commitEncoding', 'ISO-8859-1']); + self::git($repository, 'config', ['user.name', "Andr\xe9"]); + self::git($repository, 'config', ['user.email', 'andre@example.com']); + file_put_contents($repository->getWorkingDir().'/latin1.txt', 'latin1'); + self::git($repository, 'add', ['latin1.txt']); + self::git($repository, 'commit', ['-m', "Caf\xe9"]); + $hash = trim(self::git($repository, 'rev-parse', ['HEAD'])); + + $commit = new Repository($repository->getPath(), driver: self::getDriver())->getCommit($hash); + $this->assertSame("Caf\u{e9}\n", $commit->getMessage()); + $this->assertSame("Andr\u{e9}", $commit->getAuthorName()); + + $commit = new Repository($repository->getPath(), driver: self::getDriver())->getLog($hash, null, null, 1)->getSingleCommit(); + $this->assertSame("Caf\u{e9}\n", $commit->getMessage()); + $this->assertSame("Andr\u{e9}", $commit->getAuthorName()); + } + + public function testMessageIsReadLikeGitLogPrintsIt(): void + { + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + // Not cleaned up, unlike what `git commit` writes. + $hash = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\n\n\n \nSubject \t\n\nBody\r\n indented\n\n\n"); + + $this->assertSame("Subject\n\nBody\n indented\n", $repository->getCommit($hash)->getMessage()); + $this->assertSame("Subject\n\nBody\n indented\n", new Repository($repository->getPath(), driver: self::getDriver())->getLog([$hash])->getSingleCommit()->getMessage()); } /** - * @dataProvider provideFoobar + * Whoever can push refs/replace/* to a repository would change what an + * application shows for a hash: like libgit2, gitlib ignores them. */ - public function testEncoding($repository) + public function testReplacedObjectsAreReadAsTheyAre(): void { - $commit = $repository->getCommit(self::ENCODING_COMMIT); + $repository = self::createFoobarRepository(); + $commit = $repository->getCommit(self::LONGFILE_COMMIT); + $message = $commit->getMessage(); + $blob = $commit->getTree()->resolvePath('README.md'); + $this->assertInstanceOf(Blob::class, $blob); + $content = $blob->getContent(); + self::git($repository, 'replace', [self::LONGFILE_COMMIT, self::writeObject($repository, 'commit', "tree {$commit->getTreeHash()}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\n\nFake\n")]); + self::git($repository, 'replace', [$blob->getHash(), self::writeObject($repository, 'blob', "Fake\n")]); + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + + $this->assertSame($message, $repository->getCommit(self::LONGFILE_COMMIT)->getMessage()); + $this->assertSame($message, $repository->getLog(self::LONGFILE_COMMIT, null, null, 1)->getSingleCommit()->getMessage()); + $this->assertSame($content, $repository->getBlob($blob->getHash())->getContent()); + } + + /** + * Without "--", git reads a revision named like a file of the working + * tree as ambiguous. + */ + public function testAFileNamedLikeACommitChangesNothing(): void + { + $repository = self::createFoobarRepository(false); + $commit = $repository->getCommit(self::LONGFILE_COMMIT); + $parent = $commit->getParentHashes()[0]; + touch($repository->getWorkingDir().'/'.self::LONGFILE_COMMIT); + touch($repository->getWorkingDir().'/'.$parent); + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('contribute to AUTHORS file', $commit->getSubjectMessage()); + $this->assertSame('add a long file', $commit->getSubjectMessage()); + $this->assertSame(substr(self::LONGFILE_COMMIT, 0, 7), $commit->getShortHash()); + $this->assertSame(['long.php'], array_map(static fn (File $file): string => $file->getName(), $commit->getDiff()->getFiles())); + $this->assertNotSame([], $repository->getWorkingCopy()->getDiff($parent)->getFiles()); } } diff --git a/tests/Gitonomy/Git/Tests/CredentialsTest.php b/tests/Gitonomy/Git/Tests/CredentialsTest.php new file mode 100644 index 00000000..1df80bbd --- /dev/null +++ b/tests/Gitonomy/Git/Tests/CredentialsTest.php @@ -0,0 +1,231 @@ + + * (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\Cli\CliDriver; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\GitExceptionInterface; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ProcessTimedOutException; +use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\Credentials; +use PHPUnit\Framework\Attributes\DataProvider; +use Psr\Log\LoggerInterface; + +/** + * The credentials of a URL must not end up in the logs or in an error tracker. + */ +class CredentialsTest extends AbstractTestCase +{ + /** + * Nothing listens on port 1: the clone fails. + */ + private const string URL = 'http://user:secret@127.0.0.1:1/repository.git'; + + public function testAFailedCloneHidesTheCredentials(): void + { + try { + Repository::cloneFrom(self::URL, self::createTempDir().'/clone', driver: self::getDriver()); + $this->fail('The clone fails.'); + } catch (DriverException $e) { + // 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()); + } else { + $this->assertStringStartsWith('Unable to clone "http://***@127.0.0.1:1/repository.git": ', $e->getMessage()); + } + } + } + + /** + * git writes the name of a branch it refuses in its error output. + */ + public function testTheMessageOfAProcessExceptionHidesTheCredentials(): void + { + $this->skipUnlessCliDriver(); + + try { + self::createFoobarRepository()->run('branch', [self::URL]); + $this->fail('A URL is no valid branch name.'); + } catch (ProcessException $e) { + $this->assertStringStartsWith('The git command "branch" failed (exit 128): fatal: \'http://***@127.0.0.1:1/repository.git\' is not a valid branch name', $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + $this->assertStringContainsString(self::URL, $e->getErrorOutput(), 'The error output is the one of git'); + } + } + + /** + * The exception of Symfony Process holds the command line. + */ + public function testATimeoutHidesTheCredentials(): void + { + $this->skipUnlessCliDriver(); + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('The slow git is a shell script.'); + } + + $slowGit = self::createTempDir().'/git'; + file_put_contents($slowGit, "#!/bin/sh\nexec sleep 10\n"); + chmod($slowGit, 0o700); + + try { + Repository::cloneFrom(self::URL, self::createTempDir().'/clone', driver: new CliDriver($slowGit, self::gitEnvs(), processTimeout: 0.2)); + $this->fail('The clone times out.'); + } catch (ProcessTimedOutException $e) { + $this->assertSame('The git command "clone" ran longer than 0.2 seconds.', $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + } + } + + /** + * The exception of Symfony Process holds the command line: here, longer + * than what the system accepts. + */ + public function testAProcessThatCannotStartHidesTheCredentials(): void + { + $this->skipUnlessCliDriver(); + + try { + Repository::isValidRepository(self::URL.'/'.str_repeat('a', 4 * 1024 * 1024), self::getDriver()); + $this->fail('The command line is too long.'); + } catch (DriverException $e) { + // One line, the error of the system, like "posix_spawn() failed: Argument list too long", without the command line. + $this->assertMatchesRegularExpression('/^The git command "ls-remote" could not start: [^\n]+\.$/', $e->getMessage()); + $this->assertStringNotContainsString('/repository.git/aaa', $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + } + } + + /** + * The exceptions of the arguments gitlib refuses hide the credentials too. + */ + #[DataProvider('provideRefusedUrls')] + public function testARefusedUrlHidesTheCredentials(string $url, string $message): void + { + try { + Repository::isValidRepository($url, self::getDriver()); + $this->fail('The URL is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertSame($message, $e->getMessage()); + } + } + + /** + * @return iterable + */ + public static function provideRefusedUrls(): iterable + { + yield 'NUL byte' => [self::URL."\0", '"http://***@127.0.0.1:1/repository.git\000" contains a NUL byte, which git doesn\'t accept.']; + yield 'leading dash' => ['--upload-pack='.self::URL, '"--upload-pack=http://***@127.0.0.1:1/repository.git" starts with a dash, which git would read as an option.']; + } + + /** + * With "zend.exception_ignore_args" off, the default of php.ini-development, + * the traces of exceptions hold the arguments of the calls. + */ + #[DataProvider('provideFailingCalls')] + public function testTheTracesHideTheCredentials(\Closure $call): void + { + $ignoreArgs = ini_set('zend.exception_ignore_args', '0'); + + try { + $call(self::getDriver()); + $this->fail('The call fails.'); + } catch (GitExceptionInterface $e) { + for ($exception = $e; null !== $exception; $exception = $exception->getPrevious()) { + foreach ($exception->getTrace() as $frame) { + $this->assertNoSecretIn($frame['args'] ?? []); + } + } + } finally { + ini_set('zend.exception_ignore_args', (string) $ignoreArgs); + } + } + + /** + * @return iterable + */ + public static function provideFailingCalls(): iterable + { + yield 'clone' => [static function (DriverInterface $driver): void { Repository::cloneFrom(self::URL, self::createTempDir().'/clone', driver: $driver); }]; + yield 'bare clone' => [static function (DriverInterface $driver): void { Repository::cloneBareFrom(self::URL, self::createTempDir().'/clone', driver: $driver); }]; + yield 'mirror' => [static function (DriverInterface $driver): void { Repository::mirrorFrom(self::URL, self::createTempDir().'/clone', $driver); }]; + yield 'NUL byte' => [static function (DriverInterface $driver): void { Repository::isValidRepository(self::URL."\0", $driver); }]; + yield 'NUL byte in a branch' => [static function (DriverInterface $driver): void { Repository::isValidRepositoryAndBranch(self::URL, "master\0", $driver); }]; + yield 'ext' => [static function (DriverInterface $driver): void { Repository::cloneFrom('ext::'.self::URL, self::createTempDir().'/clone', driver: $driver); }]; + yield 'run' => [static function (DriverInterface $driver): void { new Repository(self::createFoobarRepository()->getPath(), driver: $driver)->run('remote', ['add', 'private', self::URL."\0"]); }]; + } + + public function testTheLogsHideTheCredentials(): void + { + $this->skipUnlessCliDriver(); + + $messages = []; + $log = static function (string $message) use (&$messages): void { + $messages[] = $message; + }; + $logger = $this->createStub(LoggerInterface::class); + $logger->method('info')->willReturnCallback($log); + $logger->method('debug')->willReturnCallback($log); + + $repository = new Repository(self::createFoobarRepository()->getPath(), driver: self::getCliDriver(logger: $logger)); + // The URL is in the command, then in its output. + $repository->run('remote', ['add', 'private', self::URL]); + $repository->run('remote', ['-v']); + + $this->assertStringContainsString("private\thttp://***@127.0.0.1:1/repository.git (fetch)\n", implode("\n", $messages)); + foreach ($messages as $message) { + $this->assertStringNotContainsString('secret', $message); + } + } + + #[DataProvider('provideTexts')] + public function testRedact(string $expected, string $text): void + { + $this->assertSame($expected, Credentials::redact($text)); + } + + /** + * @return iterable + */ + public static function provideTexts(): iterable + { + yield 'user and password' => ["clone 'https://***@example.com/a.git'", "clone 'https://user:password@example.com/a.git'"]; + yield 'token' => ['https://***@example.com', 'https://ghp_token@example.com']; + yield 'password holding an "@"' => ['https://***@example.com/a.git', 'https://user:p@ss@example.com/a.git']; + yield 'password holding a "#"' => ['https://***@example.com/a.git', 'https://user:p#ss@example.com/a.git']; + yield 'password holding a "?"' => ['https://***@example.com/a.git', 'https://user:p?ss@example.com/a.git']; + yield 'several URLs' => ['ssh://***@a.com/x and https://***@b.com/y', 'ssh://git@a.com/x and https://user:password@b.com/y']; + yield 'no user information' => ['https://example.com/a@b.git', 'https://example.com/a@b.git']; + yield 'no scheme' => ['git@example.com:a.git', 'git@example.com:a.git']; + } + + /** + * @param array $values + */ + private function assertNoSecretIn(array $values): void + { + foreach ($values as $value) { + if (\is_array($value)) { + $this->assertNoSecretIn($value); + } elseif (\is_string($value)) { + $this->assertStringNotContainsString('secret', $value); + } + } + } +} diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index d260c5ed..fab81f49 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -1,6 +1,6 @@ @@ -13,131 +13,679 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\DiffLineType; +use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; -class DiffTest extends AbstractTest +class DiffTest extends AbstractTestCase { - const DELETE_COMMIT = '519d5693c72c925cd59205d9f11e9fa1d550028b'; - const CREATE_COMMIT = 'e6fa3c792facc06faa049a6938c84c411954deb5'; - const RENAME_COMMIT = '6640e0ef31518054847a1876328e26ee64083e0a'; - const CHANGEMODE_COMMIT = '93da965f58170f13017477b9a608657e87e23230'; + public const DELETE_COMMIT = '519d5693c72c925cd59205d9f11e9fa1d550028b'; + public const CREATE_COMMIT = 'e6fa3c792facc06faa049a6938c84c411954deb5'; + public const RENAME_COMMIT = '6640e0ef31518054847a1876328e26ee64083e0a'; + public const CHANGEMODE_COMMIT = '93da965f58170f13017477b9a608657e87e23230'; + public const FILE_WITH_UMLAUTS_COMMIT = '8defb9217692dc1f4c18e05e343ca91cf5047702'; - /** - * @dataProvider provideFoobar - */ - public function testSerialization($repository) + #[DataProvider('provideBare')] + public function testSerialization(bool $bare): void { + $repository = self::createFoobarRepository($bare); $data = $repository->getCommit(self::CREATE_COMMIT)->getDiff()->toArray(); $diff = Diff::fromArray($data); $this->verifyCreateCommitDiff($diff); } - /** - * @dataProvider provideFoobar - */ - public function testGetFiles_Addition($repository) + #[DataProvider('provideBare')] + public function testGetFilesAddition(bool $bare): void { + $repository = self::createFoobarRepository($bare); $diff = $repository->getCommit(self::CREATE_COMMIT)->getDiff(); $this->verifyCreateCommitDiff($diff); } - protected function verifyCreateCommitDiff(Diff $diff) + #[DataProvider('provideBare')] + public function testGetFilesModification(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $files = $repository->getCommit(self::BEFORE_LONGFILE_COMMIT)->getDiff()->getFiles(); + + $this->assertCount(1, $files, '1 files in diff'); + + $this->assertTrue($files[0]->isModification(), 'image.jpg modified'); + + $this->assertSame('image.jpg', $files[0]->getOldName(), 'Old file name is image.jpg'); + $this->assertSame('image.jpg', $files[0]->getNewName(), 'New file name is image.jpg'); + $this->assertSame('100644', $files[0]->getOldMode(), 'Old file mode is a regular file'); + $this->assertSame('100644', $files[0]->getNewMode(), 'New file mode is a regular file'); + + $this->assertTrue($files[0]->isBinary(), 'binary file'); + $this->assertSame(0, $files[0]->getAdditions(), '0 lines added'); + $this->assertSame(0, $files[0]->getDeletions(), '0 lines deleted'); + } + + #[DataProvider('provideBare')] + public function testGetFilesDeletion(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $files = $repository->getCommit(self::DELETE_COMMIT)->getDiff()->getFiles(); + + $this->assertCount(1, $files, '1 files modified'); + + $this->assertTrue($files[0]->isDeletion(), 'File deletion'); + $this->assertSame('script_B.php', $files[0]->getOldName(), 'verify old filename'); + $this->assertSame(1, $files[0]->getDeletions(), '1 line deleted'); + } + + #[DataProvider('provideBare')] + public function testGetFilesRename(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $files = $repository->getCommit(self::RENAME_COMMIT)->getDiff()->getFiles(); + + $this->assertCount(1, $files, '1 files modified'); + + $this->assertTrue($files[0]->isModification()); + $this->assertTrue($files[0]->isRename()); + $this->assertFalse($files[0]->isDeletion()); + $this->assertFalse($files[0]->isCreation()); + $this->assertFalse($files[0]->isModeChange()); + } + + #[DataProvider('provideBare')] + public function testGetFilesChangemode(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $files = $repository->getCommit(self::CHANGEMODE_COMMIT)->getDiff()->getFiles(); + + $this->assertCount(1, $files, '1 files modified'); + + $this->assertTrue($files[0]->isModification()); + $this->assertTrue($files[0]->isModeChange()); + $this->assertFalse($files[0]->isDeletion()); + $this->assertFalse($files[0]->isCreation()); + $this->assertFalse($files[0]->isRename()); + } + + #[DataProvider('provideBare')] + public function testDiffRangeParse(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $files = $repository->getCommit(self::CREATE_COMMIT)->getDiff()->getFiles(); + + $changes = $files[0]->getChanges(); + + $this->assertSame(0, $changes[0]->getRangeOldStart()); + $this->assertSame(0, $changes[0]->getRangeOldCount()); + + $this->assertSame(1, $changes[0]->getRangeNewStart()); + $this->assertSame(1, $changes[0]->getRangeNewCount()); + } + + #[DataProvider('provideBare')] + public function testRootCommitDiffListsTheFilesItAdds(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $files = $repository->getCommit(self::INITIAL_COMMIT)->getDiff()->getFiles(); + + $this->assertCount(1, $files); + $this->assertTrue($files[0]->isCreation()); + $this->assertSame('README', $files[0]->getNewName()); + } + + #[DataProvider('provideBare')] + public function testWorksWithUmlauts(bool $bare): void { + $repository = self::createFoobarRepository($bare); + $files = $repository->getCommit(self::FILE_WITH_UMLAUTS_COMMIT)->getDiff()->getFiles(); + $this->assertSame('file_with_umlauts_äöü', $files[0]->getNewName()); + } + + public function testUnquotesNames(): void + { + $diff = Diff::parse(<<<'DIFF' + :000000 100644 0000000000000000000000000000000000000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 A "tab\there" + :100644 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 d00491fd7e5bb6fa28c517a0bb32b8b506539d4d M "caf\303\251 \"au lait\"" + + diff --git "a/tab\there" "b/tab\there" + new file mode 100644 + index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 + diff --git "a/caf\303\251 \"au lait\"" "b/caf\303\251 \"au lait\"" + index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d00491fd7e5bb6fa28c517a0bb32b8b506539d4d 100644 + Binary files "a/caf\303\251 \"au lait\"" and "b/caf\303\251 \"au lait\"" differ + + DIFF); $files = $diff->getFiles(); - $this->assertEquals(2, count($files), '1 file in diff'); + $this->assertSame("tab\there", $files[0]->getNewName()); + $this->assertSame('café "au lait"', $files[1]->getOldName()); + $this->assertSame('café "au lait"', $files[1]->getNewName()); + } - $this->assertTrue($files[0]->isCreation(), 'script_A.php created'); + public function testDeleteFile(): void + { + $diff = Diff::parse(<<<'DIFF' + :100644 000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 D test + + diff --git a/test b/test + deleted file mode 100644 + index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 - $this->assertEquals(null, $files[0]->getOldName(), 'First file name is a new file'); - $this->assertEquals('script_A.php', $files[0]->getNewName(), 'First file name is script_A.php'); - $this->assertEquals(null, $files[0]->getOldMode(), 'First file mode is a new file'); - $this->assertEquals('100644', $files[0]->getNewMode(), 'First file mode is correct'); + DIFF); + $firstFile = $diff->getFiles()[0]; - $this->assertEquals(1, $files[0]->getAdditions(), '1 line added'); - $this->assertEquals(0, $files[0]->getDeletions(), '0 lines deleted'); + $this->assertFalse($firstFile->isCreation()); + $this->assertTrue($firstFile->isDeletion()); + $this->assertFalse($firstFile->isModeChange()); + $this->assertSame('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', $firstFile->getOldIndex()); + $this->assertNull($firstFile->getNewIndex()); + } + + public function testModeChangeFile(): void + { + $diff = Diff::parse(<<<'DIFF' + :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M testfile + :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 M testfile2 + + diff --git a/testfile b/testfile + old mode 100644 + new mode 100755 + diff --git a/testfile2 b/testfile2 + old mode 100644 + new mode 100755 + + DIFF); + $files = $diff->getFiles(); + $firstFile = $files[0]; + $secondFile = $files[1]; + + $this->assertFalse($firstFile->isCreation()); + $this->assertFalse($firstFile->isDeletion()); + $this->assertTrue($firstFile->isModeChange()); + $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getOldIndex()); + $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getNewIndex()); + + $this->assertFalse($secondFile->isCreation()); + $this->assertFalse($secondFile->isDeletion()); + $this->assertTrue($secondFile->isModeChange()); + $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getOldIndex()); + $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getNewIndex()); + } + + public function testCopyFile(): void + { + $diff = Diff::parse(<<<'DIFF' + :100644 100644 1234567890123456789012345678901234567890 89abcdef0123456789abcdef0123456789abcdef C097 style.css common.css + + diff --git a/style.css b/common.css + similarity index 97% + copy from style.css + copy to common.css + index 1234567..89abcde 100644 + --- a/style.css + +++ b/common.css + @@ -1,3 +1,4 @@ + body { + + color: red; + } + + DIFF); + $firstFile = $diff->getFiles()[0]; + + $this->assertTrue($firstFile->isModification()); + $this->assertTrue($firstFile->isCopy()); + $this->assertFalse($firstFile->isRename()); + $this->assertFalse($firstFile->isDeletion()); + $this->assertFalse($firstFile->isCreation()); + $this->assertSame('style.css', $firstFile->getOldName()); + $this->assertSame('common.css', $firstFile->getNewName()); + $this->assertSame(1, $firstFile->getAdditions()); + } + + public function testPureCopyFile(): void + { + $diff = Diff::parse(<<<'DIFF' + :100644 100644 1234567890123456789012345678901234567890 1234567890123456789012345678901234567890 C100 style.css common.css + + diff --git a/style.css b/common.css + similarity index 100% + copy from style.css + copy to common.css + + DIFF); + $firstFile = $diff->getFiles()[0]; + + $this->assertTrue($firstFile->isCopy()); + $this->assertFalse($firstFile->isRename()); + $this->assertSame('style.css', $firstFile->getOldName()); + $this->assertSame('common.css', $firstFile->getNewName()); + } + + public function testParseRequiresTheRawInformation(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The diff has no raw information: Diff::parse() parses the output of "git diff --raw --patch".'); + + Diff::parse("diff --git a/a.out b/a.out\nold mode 100755\nnew mode 100644\n"); } /** - * @dataProvider provideFoobar + * What the parser doesn't know would be lost, with the files after it. + * The message has the start of its line only, which may come from a file. */ - public function testGetFiles_Modification($repository) + public function testParseRefusesWhatItDoesNotKnow(): void { - $files = $repository->getCommit(self::BEFORE_LONGFILE_COMMIT)->getDiff()->getFiles(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to parse the diff from "Submodule sub 1111111..2222222:".'); - $this->assertEquals(1, count($files), '1 files in diff'); + Diff::parse(":160000 160000 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 M\tsub\n\nSubmodule sub 1111111..2222222:\n > Second\n"); + } - $this->assertTrue($files[0]->isModification(), 'image.jpg modified'); + /** + * "git diff --staged" during a conflict, whose unmerged path comes last. + */ + public function testParseLeavesOutTheLastUnmergedPath(): void + { + $diff = Diff::parse(<<<'DIFF' + :000000 100644 0000000000000000000000000000000000000000 e45c9c2666d44e0327c1f9c239a74c508336053e A other + :100644 000000 ba2906d0666cf726c7eaadd2cd3db615dedfdf3a 0000000000000000000000000000000000000000 U zconflict - $this->assertEquals('image.jpg', $files[0]->getOldName(), 'Second file name is image.jpg'); - $this->assertEquals('image.jpg', $files[0]->getNewName(), 'Second file name is image.jpg'); - $this->assertEquals('100644', $files[0]->getOldMode(), 'Second file mode is a new file'); - $this->assertEquals('100644', $files[0]->getNewMode(), 'Second file mode is correct'); + diff --git a/other b/other + new file mode 100644 + index 0000000000000000000000000000000000000000..e45c9c2666d44e0327c1f9c239a74c508336053e + --- /dev/null + +++ b/other + @@ -0,0 +1 @@ + +other + * Unmerged path zconflict - $this->assertTrue($files[0]->isBinary(), 'binary file'); - $this->assertEquals(0, $files[0]->getAdditions(), '0 lines added'); - $this->assertEquals(0, $files[0]->getDeletions(), '0 lines deleted'); + DIFF); + + $this->assertSame([['other', [[0, 0, 1, 1, [['Addition', 'other']]]]]], self::changes($diff)); + } + + public function testParseAnEmptyDiff(): void + { + $this->assertSame([], Diff::parse('')->getFiles()); + } + + public function testDiffOfAMergeCommitIsAgainstItsFirstParent(): void + { + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'base', "base\n"); + self::git($repository, 'checkout', ['-q', '-b', 'side']); + self::commitFile($repository, 'side', "side\n"); + self::git($repository, 'checkout', ['-q', 'master']); + self::commitFile($repository, 'main', "main\n"); + self::git($repository, 'merge', ['-q', '--no-edit', 'side']); + + $files = $repository->getHeadCommit()->getDiff()->getFiles(); + + $this->assertSame(['side'], array_map(static fn (File $file): string => $file->getName(), $files)); + } + + public function testNamesWithSpaces(): void + { + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'Vol 1/chapter.txt', "a\n"); + self::commitFile($repository, 'cats and dogs.bin', "\0a"); + self::commitFile($repository, 'Vol 1/chapter.txt', "a\nb\n"); + self::commitFile($repository, 'cats and dogs.bin', "\0b"); + + $diff = $repository->getDiff('HEAD~2', 'HEAD'); + + $this->assertSame(['Vol 1/chapter.txt', 'cats and dogs.bin'], array_map(static fn (File $file): string => $file->getName(), $diff->getFiles())); + $this->assertTrue($diff->getFiles()[1]->isBinary()); + $this->assertSame("a\nb\n", $diff->getFiles()[0]->getNewBlob()->getContent()); + } + + public function testTypeChangeIsADeletionAndACreation(): void + { + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'link', "target\n"); + self::commitFile($repository, 'renamed', "moved\n"); + unlink($repository->getWorkingDir().'/link'); + symlink('target', $repository->getWorkingDir().'/link'); + self::git($repository, 'mv', ['renamed', 'moved']); + self::git($repository, 'add', ['-A']); + self::git($repository, 'commit', ['-q', '-m', 'Type change']); + + $files = $repository->getHeadCommit()->getDiff()->getFiles(); + + $this->assertCount(3, $files); + $this->assertTrue($files[0]->isDeletion()); + $this->assertSame("target\n", $files[0]->getOldBlob()->getContent()); + $this->assertTrue($files[1]->isCreation()); + $this->assertSame('120000', $files[1]->getNewMode()); + $this->assertSame('target', $files[1]->getNewBlob()->getContent()); + // The hashes of a rename without changes come from the raw information. + $this->assertTrue($files[2]->isRename()); + $this->assertSame("moved\n", $files[2]->getNewBlob()->getContent()); + } + + public function testDiffsOfAConflictLeaveUnmergedPathsOut(): void + { + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'conflict', "base\n"); + self::git($repository, 'checkout', ['-q', '-b', 'side']); + self::commitFile($repository, 'conflict', "side\n"); + self::git($repository, 'checkout', ['-q', 'master']); + self::commitFile($repository, 'conflict', "main\n"); + + try { + self::git($repository, 'merge', ['-q', 'side']); + } catch (ProcessException) { + // The conflict. + } + file_put_contents($repository->getWorkingDir().'/other', "other\n"); + self::git($repository, 'add', ['other']); + + $this->assertSame(['other'], array_map(static fn (File $file): string => $file->getName(), $repository->getWorkingCopy()->getDiffStaged()->getFiles())); + $this->assertSame([], $repository->getWorkingCopy()->getDiffPending()->getFiles()); + + // Like git diff HEAD, which compares the unmerged file, conflict markers included, to HEAD. + $files = $repository->getWorkingCopy()->getDiff()->getFiles(); + $this->assertSame(['conflict', 'other'], array_map(static fn (File $file): string => $file->getName(), $files)); + $this->assertContains('<<<<<<< HEAD', array_map(static fn (DiffLine $line): string => $line->getContent(), $files[0]->getChanges()[0]->getDiffLines())); + } + + public function testWorkingCopyDiff(): void + { + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'file', "a\n"); + file_put_contents($repository->getWorkingDir().'/file', "a\nb\n"); + + $files = $repository->getWorkingCopy()->getDiff()->getFiles(); + + $this->assertCount(1, $files); + $this->assertSame(1, $files[0]->getAdditions()); + $this->assertSame([DiffLineType::Context, DiffLineType::Addition], array_map(static fn (DiffLine $line): DiffLineType => $line->getType(), $files[0]->getChanges()[0]->getDiffLines())); + $this->assertSame('b', $files[0]->getChanges()[0]->getDiffLines()[1]->getContent()); + } + + public function testHunkWithOneLine(): void + { + $diff = Diff::parse(":100644 100644 7898192261 422c2b7ab3 M\tfile\n\ndiff --git a/file b/file\nindex 7898192261..422c2b7ab3 100644\n--- a/file\n+++ b/file\n@@ -1 +1,2 @@\n a\n+b\n"); + $change = $diff->getFiles()[0]->getChanges()[0]; + + $this->assertSame([1, 1, 1, 2], [$change->getRangeOldStart(), $change->getRangeOldCount(), $change->getRangeNewStart(), $change->getRangeNewCount()]); + } + + public function testParseNulSeparatedRawInformation(): void + { + $raw = ":100644 100644 7898192261 422c2b7ab3 R087\0old name\0new name\0:000000 100644 0000000000 e69de29bb2 A\0new\0"; + $files = Diff::parse($raw)->getFiles(); + + $this->assertSame(['old name', 'new name'], [$files[0]->getOldName(), $files[0]->getNewName()]); + $this->assertTrue($files[0]->isRename()); + $this->assertTrue($files[1]->isCreation()); + $this->assertSame('e69de29bb2', $files[1]->getNewIndex()); + } + + public function testParseQuotedNames(): void + { + $files = Diff::parse(":100644 100644 7898192261 422c2b7ab3 M\t\"caf\\303\\251\\t.txt\"\n")->getFiles(); + + $this->assertSame("caf\u{e9}\t.txt", $files[0]->getName()); + } + + public function testFromArrayRefusesAnUnknownTypeOfLine(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown type of line 2: the types are 1 (an addition), 0 (a context line) and -1 (a deletion).'); + + FileChange::fromArray(['range_old_start' => 1, 'range_old_count' => 1, 'range_new_start' => 1, 'range_new_count' => 1, 'lines' => [[2, 'line']]]); + } + + public function testRawDiffIsTheOutputOfGit(): void + { + $diff = self::createFoobarRepository()->getCommit(self::LONGFILE_COMMIT)->getDiff(); + + if (self::getDriver() instanceof LibGit2Driver) { + // No git command computed it. + $this->assertSame('', $diff->getRawDiff()); + } else { + $this->assertStringContainsString("\0diff --git a/long.php b/long.php\n", $diff->getRawDiff()); + } + } + + public function testGetBlobWithoutHashThrows(): void + { + $file = new File('sub', 'sub', '160000', '160000', null, null, false, repository: self::createEmptyRepository()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The diff doesn\'t know the hash of the new blob of "sub".'); + + $file->getNewBlob(); + } + + public function testGetBlobWithoutRepositoryThrows(): void + { + $file = Diff::parse(":100644 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M\tfile\n")->getFiles()[0]; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('A diff without repository can\'t read blobs: get it from a repository.'); + + $file->getOldBlob(); + } + + public function testMnemonicPrefix(): void + { + // With `diff.mnemonicPrefix` enabled, git replaces the default "a/" and "b/" + // prefixes with context-specific ones, e.g. "c/" (commit) and "i/" (index) + // for `git diff --cached`. See https://github.com/gitonomy/gitlib/issues/114. + $diff = Diff::parse(<<<'DIFF' + :100644 100644 0000000000000000000000000000000000000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 M composer.json + + diff --git c/composer.json i/composer.json + index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 + --- c/composer.json + +++ i/composer.json + + DIFF); + $firstFile = $diff->getFiles()[0]; + + $this->assertSame('composer.json', $firstFile->getOldName()); + $this->assertSame('composer.json', $firstFile->getNewName()); + } + + public function testEmptyNewFile(): void + { + $diff = Diff::parse(":000000 100644 0000000000000000000000000000000000000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 A\ttest\n\ndiff --git a/test b/test\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n"); + $firstFile = $diff->getFiles()[0]; + + $this->assertTrue($firstFile->isCreation()); + $this->assertFalse($firstFile->isDeletion()); + $this->assertSame('test', $firstFile->getNewName()); + $this->assertNull($firstFile->getOldName()); + } + + public function testEmptyOldFile(): void + { + $diff = Diff::parse(":100644 000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 D\ttest\n\ndiff --git a/test b/test\ndeleted file mode 100644\nindex e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000\n"); + $firstFile = $diff->getFiles()[0]; + + $this->assertFalse($firstFile->isCreation()); + $this->assertTrue($firstFile->isDeletion()); + $this->assertNull($firstFile->getNewName()); + $this->assertSame('test', $firstFile->getOldName()); + } + + public function testDirtySubmodule(): void + { + $diff = Diff::parse(<<<'DIFF' + :160000 160000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 M sub + + diff --git a/sub b/sub + --- a/sub + +++ b/sub + @@ -1 +1 @@ + -Subproject commit e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 + +Subproject commit e69de29bb2d1d6434b8b29ae775ad8c2e48c5391-dirty + + DIFF); + $firstFile = $diff->getFiles()[0]; + + $this->assertFalse($firstFile->isCreation()); + $this->assertFalse($firstFile->isDeletion()); + $this->assertSame('sub', $firstFile->getOldName()); + $this->assertSame('sub', $firstFile->getNewName()); } /** - * @dataProvider provideFoobar + * With diff.submodule=log, which the configuration of the tests sets, "git + * diff" writes the log of a submodule instead of its patch. */ - public function testGetFiles_Deletion($repository) + public function testTheFilesAfterASubmoduleKeepTheirPatch(): void { - $files = $repository->getCommit(self::DELETE_COMMIT)->getDiff()->getFiles(); + $repository = self::createWorkingCopy(); + $submodule = Repository::init($repository->getWorkingDir().'/sub', self::getCliDriver()); + self::git($submodule, 'config', ['user.name', 'Unit Test']); + self::git($submodule, 'config', ['user.email', 'unit_test@unit-test.com']); + self::git($submodule, 'commit', ['-q', '--allow-empty', '-m', 'First']); + $first = trim(self::git($submodule, 'rev-parse', ['HEAD'])); + self::git($repository, 'update-index', ['--add', '--cacheinfo', '160000', $first, 'sub']); + self::commitFile($repository, 'z.txt', "a\n"); - $this->assertEquals(1, count($files), '1 files modified'); + self::git($submodule, 'commit', ['-q', '--allow-empty', '-m', 'Second']); + $second = trim(self::git($submodule, 'rev-parse', ['HEAD'])); + file_put_contents($repository->getWorkingDir().'/z.txt', "b\n"); - $this->assertTrue($files[0]->isDeletion(), 'File deletion'); - $this->assertEquals('script_B.php', $files[0]->getOldName(), 'verify old filename'); - $this->assertEquals(1, $files[0]->getDeletions(), '1 line deleted'); + $diff = $repository->getWorkingCopy()->getDiffPending(); + + $this->assertSame([ + ['sub', [[1, 1, 1, 1, [['Deletion', 'Subproject commit '.$first], ['Addition', 'Subproject commit '.$second]]]]], + ['z.txt', [[1, 1, 1, 1, [['Deletion', 'a'], ['Addition', 'b']]]]], + ], self::changes($diff)); + $this->assertSame(sha1("blob 2\0b\n"), $diff->getFiles()[1]->getNewIndex()); } /** - * @dataProvider provideFoobar + * Unlike diff-tree, "git diff" reads diff.algorithm, diff.context, + * diff.orderFile... which the configuration of the tests changes (see + * fixtures/home/.gitconfig): the diff of the working tree is the one of + * its commit all the same. */ - public function testGetFiles_Rename($repository) + public function testTheDiffOfTheWorkingTreeIsTheOneOfItsCommit(): void { - $files = $repository->getCommit(self::RENAME_COMMIT)->getDiff()->getFiles(); + $repository = self::createWorkingCopy(); + // Ten lines between two changes: two hunks. + $lines = array_map(static fn (int $number): string => 'line '.$number, range(1, 20)); + self::commitFile($repository, 'notes.txt', implode("\n", $lines)."\n"); + // The diff algorithm of git keeps "{", histogram keeps "}". + self::commitFile($repository, 'code.c', "c\n}\n{\n"); - $this->assertEquals(1, count($files), '1 files modified'); + $lines[2] = 'line 3, changed'; + $lines[13] = 'line 14, changed'; + file_put_contents($repository->getWorkingDir().'/notes.txt', implode("\n", $lines)."\n"); + file_put_contents($repository->getWorkingDir().'/code.c', "c\n{\nc\n}\n"); - $this->assertTrue($files[0]->isModification()); - $this->assertTrue($files[0]->isRename()); - $this->assertFalse($files[0]->isDeletion()); - $this->assertFalse($files[0]->isCreation()); - $this->assertFalse($files[0]->isChangeMode()); + $workingTreeDiff = $repository->getWorkingCopy()->getDiff(); + self::git($repository, 'commit', ['-q', '-a', '-m', 'Changes']); + $commitDiff = $repository->getHeadCommit()->getDiff(); + + $this->assertSame(['code.c', 'notes.txt'], array_map(static fn (File $file): string => $file->getName(), $commitDiff->getFiles())); + $this->assertSame(self::changes($commitDiff), self::changes($workingTreeDiff)); } /** - * @dataProvider provideFoobar + * The indent heuristic of git, which diff.indentHeuristic turns off, + * places an added block after the blank line that ends it. */ - public function testGetFiles_Changemode($repository) + public function testTheIndentHeuristicIsGits(): void { - $files = $repository->getCommit(self::CHANGEMODE_COMMIT)->getDiff()->getFiles(); + if (version_compare(self::gitVersion(), '2.11.0', '<')) { + $this->markTestSkipped('git has an indent heuristic since 2.11.'); + } - $this->assertEquals(1, count($files), '1 files modified'); + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'code.c', "\n\nfoo();\n"); + self::commitFile($repository, 'code.c', "\n\nfoo();\n\nfoo();\n"); - $this->assertTrue($files[0]->isModification()); - $this->assertTrue($files[0]->isChangeMode()); - $this->assertFalse($files[0]->isDeletion()); - $this->assertFalse($files[0]->isCreation()); - $this->assertFalse($files[0]->isRename()); + $this->assertSame([['code.c', [[1, 3, 1, 5, [ + ['Context', ''], + ['Context', ''], + ['Addition', 'foo();'], + ['Addition', ''], + ['Context', 'foo();'], + ]]]]], self::changes($repository->getHeadCommit()->getDiff())); } /** - * @dataProvider provideFoobar + * "git diff" reads diff.noprefix, diff.mnemonicPrefix, diff.srcPrefix and + * diff.dstPrefix too: a patch to apply has the prefixes of git. */ - public function testDiffRangeParse($repository) + public function testTheRawDiffOfTheWorkingTreeHasThePrefixesOfGit(): void { - $files = $repository->getCommit(self::CREATE_COMMIT)->getDiff()->getFiles(); + $this->skipUnlessCliDriver(); - $changes = $files[0]->getChanges(); + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'code.c', "a\n"); + file_put_contents($repository->getWorkingDir().'/code.c', "b\n"); + + $rawDiff = $repository->getWorkingCopy()->getDiff()->getRawDiff(); + + $this->assertStringContainsString("diff --git a/code.c b/code.c\n", $rawDiff); + $this->assertStringContainsString("--- a/code.c\n+++ b/code.c\n", $rawDiff); + } + + protected function verifyCreateCommitDiff(Diff $diff): void + { + $files = $diff->getFiles(); + + $this->assertCount(2, $files, '2 files in diff'); + + $this->assertTrue($files[0]->isCreation(), 'script_A.php created'); + + $this->assertNull($files[0]->getOldName(), 'First file name is a new file'); + $this->assertSame('script_A.php', $files[0]->getNewName(), 'First file name is script_A.php'); + $this->assertNull($files[0]->getOldMode(), 'First file mode is a new file'); + $this->assertSame('100644', $files[0]->getNewMode(), 'First file mode is correct'); - $this->assertEquals(0, $changes[0]->getRangeOldStart()); - $this->assertEquals(0, $changes[0]->getRangeOldCount()); + $this->assertSame(1, $files[0]->getAdditions(), '1 line added'); + $this->assertSame(0, $files[0]->getDeletions(), '0 lines deleted'); + } - $this->assertEquals(1, $changes[0]->getRangeNewStart()); - $this->assertEquals(0, $changes[0]->getRangeNewCount()); + /** + * @return array}>}> The name of each file, with its hunks + */ + private static function changes(Diff $diff): array + { + return array_map(static fn (File $file): array => [$file->getName(), array_map(static fn (FileChange $change): array => [ + $change->getRangeOldStart(), + $change->getRangeOldCount(), + $change->getRangeNewStart(), + $change->getRangeNewCount(), + array_map(static fn (DiffLine $line): array => [$line->getType()->name, $line->getContent()], $change->getDiffLines()), + ], $file->getChanges())], $diff->getFiles()); + } + + private static function createWorkingCopy(): Repository + { + $repository = self::createEmptyRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + // Whatever the default of the git running the tests. + self::git($repository, 'symbolic-ref', ['HEAD', 'refs/heads/master']); + + return $repository; + } + + private static function commitFile(Repository $repository, string $name, string $content): void + { + $path = $repository->getWorkingDir().'/'.$name; + if (!is_dir(\dirname($path))) { + mkdir(\dirname($path), 0o777, true); + } + file_put_contents($path, $content); + self::git($repository, 'add', [$name]); + self::git($repository, 'commit', ['-q', '-m', $name]); } } diff --git a/tests/Gitonomy/Git/Tests/DriverParityTest.php b/tests/Gitonomy/Git/Tests/DriverParityTest.php new file mode 100644 index 00000000..c27752ef --- /dev/null +++ b/tests/Gitonomy/Git/Tests/DriverParityTest.php @@ -0,0 +1,179 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Driver\Data\DiffFileData; +use Gitonomy\Git\Driver\Data\ObjectType; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\Before; + +/** + * Both drivers read the same data from the same repository, but where + * doc/repository.md documents a difference. + */ +class DriverParityTest extends AbstractTestCase +{ + private RepositoryDriverInterface $cli; + + private RepositoryDriverInterface $libgit2; + + /** + * The objects compared already: most commits share them. + * + * @var array + */ + private array $compared = []; + + #[Before] + public function openWithBothDrivers(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not available.'); + } + + $dir = self::createFoobarRepository(false)->getPath(); + $this->cli = new Repository($dir, driver: self::getCliDriver())->getRepositoryDriver(); + $this->libgit2 = new Repository($dir, driver: new LibGit2Driver())->getRepositoryDriver(); + } + + public function testReferences(): void + { + $this->assertSameData($this->cli->getReferences(), $this->libgit2->getReferences()); + $this->assertSame($this->cli->getHeadReference(), $this->libgit2->getHeadReference()); + } + + public function testHistory(): void + { + $this->assertSameData($this->cli->getCommitLog(null, [], [], 0, null), $this->libgit2->getCommitLog(null, [], [], 0, null)); + $this->assertSame($this->cli->countCommits(null, [], []), $this->libgit2->countCommits(null, [], [])); + + $master = $this->cli->resolveRevision('master'); + $this->assertSameData($this->cli->getCommitLog([$master], [], ['README.md'], 0, null), $this->libgit2->getCommitLog([$master], [], ['README.md'], 0, null)); + + foreach ($this->cli->getCommitLog(null, [], [], 0, null) as $commit) { + $this->assertSame($this->cli->getIncludingBranches($commit->hash), $this->libgit2->getIncludingBranches($commit->hash), $commit->hash); + } + } + + public function testObjects(): void + { + foreach ($this->cli->getCommitLog(null, [], [], 0, null) as $commit) { + $this->assertSameTree($commit->treeHash); + } + + foreach ($this->cli->getReferences() as $reference) { + if (null !== $reference->tagHash) { + $this->assertSameData($this->cli->getTagData($reference->tagHash), $this->libgit2->getTagData($reference->tagHash)); + } + } + } + + public function testDiffs(): void + { + foreach ($this->cli->getCommitLog(null, [], [], 0, null) as $commit) { + $from = $commit->parentHashes[0] ?? null; + + // The raw diff is the output of git, which LibGit2Driver doesn't run. + $this->assertSameData($this->cli->getDiff($from, $commit->hash)->files, $this->libgit2->getDiff($from, $commit->hash)->files, $commit->hash); + } + } + + /** + * doc/repository.md: rename detection in diffs may pair files differently. + */ + public function testRenameDetectionMayPairFilesDifferently(): void + { + $master = $this->cli->resolveRevision('master'); + $cli = $this->cli->getDiff(self::INITIAL_COMMIT, $master)->files; + $libgit2 = $this->libgit2->getDiff(self::INITIAL_COMMIT, $master)->files; + + // git deletes README and creates README.md, which libgit2 finds similar enough for a rename. + $this->assertSame([['README', null], [null, 'README.md']], self::readmeNames($cli)); + $this->assertSame([['README', 'README.md']], self::readmeNames($libgit2)); + $this->assertCount(13, $cli); + $this->assertCount(12, $libgit2); + } + + public function testBlame(): void + { + $master = $this->cli->resolveRevision('master'); + + $this->assertSameData($this->cli->getBlameLines($master, 'README.md', null, null), $this->libgit2->getBlameLines($master, 'README.md', null, null)); + $this->assertSameData($this->cli->getBlameLines($master, 'README.md', 2, 3), $this->libgit2->getBlameLines($master, 'README.md', 2, 3)); + } + + private function assertSameTree(string $hash): void + { + if (isset($this->compared[$hash])) { + return; + } + $this->compared[$hash] = true; + + $entries = $this->cli->getTreeEntries($hash); + $this->assertSameData($entries, $this->libgit2->getTreeEntries($hash), $hash); + + foreach ($entries as $entry) { + match ($entry->type) { + ObjectType::Tree => $this->assertSameTree($entry->hash), + ObjectType::Blob => $this->assertSameBlob($entry->hash), + // A submodule: a commit of another repository. + default => null, + }; + } + } + + private function assertSameBlob(string $hash): void + { + if (!isset($this->compared[$hash])) { + $this->compared[$hash] = true; + $this->assertSame($this->cli->getBlobContent($hash), $this->libgit2->getBlobContent($hash), $hash); + } + } + + /** + * @param list $files + * + * @return list The old and new names of the files named README or README.md + */ + private static function readmeNames(array $files): array + { + $names = array_map(static fn (DiffFileData $file): array => [$file->oldName, $file->newName], $files); + + return array_values(array_filter($names, static fn (array $pair): bool => [] !== array_intersect($pair, ['README', 'README.md']))); + } + + private function assertSameData(mixed $expected, mixed $actual, string $message = ''): void + { + $this->assertSame(self::normalize($expected), self::normalize($actual), $message); + } + + /** + * Turns the data objects into arrays, which assertSame() compares by value. + */ + private static function normalize(mixed $data): mixed + { + return match (true) { + \is_array($data) => array_map(self::normalize(...), $data), + $data instanceof \DateTimeImmutable => $data->format(\DATE_ATOM), + $data instanceof \UnitEnum => $data->name, + $data instanceof FileChange => self::normalize($data->toArray()), + $data instanceof DiffLine => [$data->getType()->name, $data->getContent()], + \is_object($data) => self::normalize(get_object_vars($data)), + default => $data, + }; + } +} diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 86cb7606..8c780fca 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -1,6 +1,6 @@ @@ -12,31 +12,38 @@ namespace Gitonomy\Git\Tests; -class HooksTest extends AbstractTest +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\BeforeClass; +use PHPUnit\Framework\Attributes\DataProvider; + +class HooksTest extends AbstractTestCase { - private static $symlinkOnWindows = null; + private static ?bool $symlinkOnWindows = null; - public static function setUpBeforeClass() + #[BeforeClass] + public static function setUpWindows(): void { - if (defined('PHP_WINDOWS_VERSION_MAJOR')) { + if (\defined('PHP_WINDOWS_VERSION_MAJOR')) { self::$symlinkOnWindows = true; - $originDir = tempnam(sys_get_temp_dir(), 'sl'); - $targetDir = tempnam(sys_get_temp_dir(), 'sl'); - if (true !== @symlink($originDir, $targetDir)) { + $dir = self::createTempDir(); + touch($dir.'/origin'); + if (true !== @symlink($dir.'/origin', $dir.'/target')) { $report = error_get_last(); - if (is_array($report) && false !== strpos($report['message'], 'error code(1314)')) { + if (\is_array($report) && str_contains($report['message'], 'error code(1314)')) { self::$symlinkOnWindows = false; } } } } - public function hookPath($repository, $hook) + public function hookPath(Repository $repository, string $hook): string { return $repository->getGitDir().'/hooks/'.$hook; } - public function touchHook($repository, $hook, $content = '') + public function touchHook(Repository $repository, string $hook, string $content = ''): string { $path = $this->hookPath($repository, $hook); file_put_contents($path, $content); @@ -44,71 +51,132 @@ public function touchHook($repository, $hook, $content = '') return $path; } - public function assertHasHook($repository, $hook) + public function assertHasHook(Repository $repository, string $hook): void { $file = $this->hookPath($repository, $hook); - $this->assertTrue($repository->getHooks()->has($hook), "hook $hook in repository"); - $this->assertTrue(file_exists($file), "Hook $hook is present"); + $this->assertTrue($repository->getHooks()->has($hook), "hook {$hook} in repository"); + + $this->assertFileExists($file, "Hook {$hook} is present"); } - public function assertNoHook($repository, $hook) + public function assertNoHook(Repository $repository, string $hook): void { $file = $this->hookPath($repository, $hook); - $this->assertFalse($repository->getHooks()->has($hook), "No hook $hook in repository"); - $this->assertFalse(file_exists($file), "Hook $hook is not present"); + $this->assertFalse($repository->getHooks()->has($hook), "No hook {$hook} in repository"); + $this->assertFileDoesNotExist($file, "Hook {$hook} is not present"); + } + + /** + * A hook is a file of the hooks directory: "../config" would be the + * configuration of the repository. + */ + #[DataProvider('provideInvalidNames')] + public function testAnInvalidNameIsRefused(string $name): void + { + $repository = self::createFoobarRepository(); + $hooks = $repository->getHooks(); + $calls = [ + 'has' => static fn () => $hooks->has($name), + 'get' => static fn () => $hooks->get($name), + 'set' => static fn () => $hooks->set($name, 'content'), + 'setSymlink' => static fn () => $hooks->setSymlink($name, __FILE__), + 'remove' => static fn () => $hooks->remove($name), + ]; + + foreach ($calls as $method => $call) { + try { + $call(); + $this->fail(\sprintf('%s() refuses "%s".', $method, $name)); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('is not a valid hook name', $e->getMessage()); + } + } + + $this->assertFileExists($repository->getGitDir().'/config'); + $this->assertFileDoesNotExist($repository->getGitDir().'/evil'); } /** - * @dataProvider provideFoobar + * @return iterable */ - public function testHas($repository) + public static function provideInvalidNames(): iterable + { + yield 'parent directory' => ['../config']; + yield 'new file in the parent directory' => ['../evil']; + yield 'Windows separator' => ['..\config']; + yield 'sub-directory' => ['sub/pre-commit']; + yield 'dot' => ['.']; + yield 'dot dot' => ['..']; + yield 'empty' => ['']; + yield 'hidden file' => ['.pre-commit']; + yield 'NUL byte' => ["pre-commit\0.sample"]; + } + + #[DataProvider('provideBare')] + public function testHas(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertNoHook($repository, 'foo'); $this->touchHook($repository, 'foo'); $this->assertHasHook($repository, 'foo'); } - /** - * @dataProvider provideFoobar - * @expectedException InvalidArgumentException - */ - public function testGet_InvalidName_ThrowsException($repository) + #[DataProvider('provideBare')] + public function testGetInvalidNameThrowsException(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"../config" is not a valid hook name: it must be the name of a file of the hooks directory, like "pre-commit".'); + + $repository->getHooks()->get('../config'); + } + + #[DataProvider('provideBare')] + public function testGetMissingThrowsException(bool $bare): void { + $repository = self::createFoobarRepository($bare); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The hook "foo" does not exist.'); + $repository->getHooks()->get('foo'); } - /** - * @dataProvider provideFoobar - */ - public function testGet($repository) + #[DataProvider('provideBare')] + public function testGet(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->touchHook($repository, 'foo', 'foobar'); - $this->assertEquals('foobar', $repository->getHooks()->get('foo')); + $this->assertSame('foobar', $repository->getHooks()->get('foo')); } - /** - * @dataProvider provideFoobar - */ - public function testSymlink($repository) + #[DataProvider('provideBare')] + public function testSymlink(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->markAsSkippedIfSymlinkIsMissing(); $file = $this->touchHook($repository, 'bar', 'barbarbar'); $repository->getHooks()->setSymlink('foo', $file); $this->assertTrue(is_link($this->hookPath($repository, 'foo')), 'foo hook is a symlink'); - $this->assertEquals($file, readlink($this->hookPath($repository, 'foo')), 'target of symlink is correct'); + + $this->assertSame( + str_replace('\\', '/', $file), + str_replace('\\', '/', (string) readlink($this->hookPath($repository, 'foo'))), + 'target of symlink is correct' + ); } - /** - * @dataProvider provideFoobar - * @expectedException LogicException - */ - public function testSymlink_WithExisting_ThrowsLogicException($repository) + #[DataProvider('provideBare')] + public function testSymlinkWithExistingThrows(bool $bare): void { + $repository = self::createFoobarRepository($bare); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The hook "foo" exists already.'); + $this->markAsSkippedIfSymlinkIsMissing(); $file = $this->hookPath($repository, 'target-symlink'); @@ -120,59 +188,123 @@ public function testSymlink_WithExisting_ThrowsLogicException($repository) $repository->getHooks()->setSymlink('foo', $file); } - /** - * @dataProvider provideFoobar - */ - public function testSet($repository) + #[DataProvider('provideBare')] + public function testSet(bool $bare): void { + $repository = self::createFoobarRepository($bare); $file = $this->hookPath($repository, 'foo'); $repository->getHooks()->set('foo', 'bar'); - $this->assertEquals('bar', file_get_contents($file), 'Hook content is correct'); + $this->assertSame('bar', file_get_contents($file), 'Hook content is correct'); $perms = fileperms($file); - $this->assertEquals(defined('PHP_WINDOWS_VERSION_BUILD') ? 0666 : 0777, $perms & 0777, 'Hook permissions are correct'); + $this->assertSame(\defined('PHP_WINDOWS_VERSION_BUILD') ? 0o666 : 0o700, $perms & 0o777, 'Hook permissions are correct'); } - /** - * @dataProvider provideFoobar - */ - public function testSet_Existing_ThrowsLogicException($repository) + #[DataProvider('provideBare')] + public function testSetExistingThrows(bool $bare): void { + $repository = self::createFoobarRepository($bare); $repository->getHooks()->set('foo', 'bar'); - $this->expectException('LogicException'); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The hook "foo" exists already.'); + $repository->getHooks()->set('foo', 'bar'); } - /** - * @dataProvider provideFoobar - */ - public function testRemove($repository) + #[DataProvider('provideBare')] + public function testRemove(bool $bare): void { + $repository = self::createFoobarRepository($bare); $file = $this->hookPath($repository, 'foo'); touch($file); $repository->getHooks()->remove('foo'); - $this->assertFalse(file_exists($file)); + + $this->assertFileDoesNotExist($file); + } + + #[DataProvider('provideBare')] + public function testRemoveNotExistingThrows(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The hook "foo" does not exist.'); + + $repository->getHooks()->remove('foo'); } /** - * @dataProvider provideFoobar - * @expectedException LogicException + * A symbolic link to a file deployed later: set() would write through it, + * out of the hooks directory. */ - public function testRemove_NotExisting_ThrowsLogicException($repository) + public function testASymlinkToAMissingFileIsAHook(): void { - $repository->getHooks()->remove('foo'); + $this->markAsSkippedIfSymlinkIsMissing(); + + $repository = self::createFoobarRepository(); + $target = self::createTempDir().'/pre-receive'; + $hooks = $repository->getHooks(); + $hooks->setSymlink('pre-receive', $target); + + $this->assertTrue($hooks->has('pre-receive')); + + try { + $hooks->get('pre-receive'); + $this->fail('A symbolic link to a missing file can\'t be read.'); + } catch (RuntimeException $e) { + $this->assertSame('Unable to read the hook "pre-receive".', $e->getMessage()); + } + + try { + $hooks->set('pre-receive', "#!/bin/sh\nexit 1\n"); + $this->fail('The hook exists already.'); + } catch (RuntimeException $e) { + $this->assertSame('The hook "pre-receive" exists already.', $e->getMessage()); + } + $this->assertFileDoesNotExist($target, 'Nothing is written through the link'); + + $hooks->remove('pre-receive'); + + $this->assertFalse(is_link($this->hookPath($repository, 'pre-receive'))); + $this->assertFalse($hooks->has('pre-receive')); + } + + public function testSetCreatesTheHooksDirectory(): void + { + $repository = self::createEmptyRepository(); + self::deleteDir($repository->getGitDir().'/hooks'); + + $repository->getHooks()->set('pre-receive', "#!/bin/sh\nexit 1\n"); + + $this->assertSame("#!/bin/sh\nexit 1\n", $repository->getHooks()->get('pre-receive')); + } + + public function testALinkedWorktreeSharesTheHooksOfItsRepository(): void + { + if (version_compare(self::gitVersion(), '2.5.0', '<')) { + $this->markTestSkipped('git worktree requires git 2.5.'); + } + + $repository = self::createFoobarRepository(false); + $dir = self::createTempDir().'/worktree'; + self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); + + $worktree = new Repository($dir, driver: self::getDriver()); + $worktree->getHooks()->set('pre-commit', "#!/bin/sh\nexit 1\n"); + + $this->assertHasHook($repository, 'pre-commit'); + $this->assertTrue($worktree->getHooks()->has('pre-commit')); } - private function markAsSkippedIfSymlinkIsMissing() + private function markAsSkippedIfSymlinkIsMissing(): void { - if (!function_exists('symlink')) { + if (!\function_exists('symlink')) { $this->markTestSkipped('symlink is not supported'); } - if (defined('PHP_WINDOWS_VERSION_MAJOR') && false === self::$symlinkOnWindows) { + if (\defined('PHP_WINDOWS_VERSION_MAJOR') && false === self::$symlinkOnWindows) { $this->markTestSkipped('symlink requires "Create symbolic links" privilege on windows'); } } diff --git a/tests/Gitonomy/Git/Tests/LeadingDashTest.php b/tests/Gitonomy/Git/Tests/LeadingDashTest.php new file mode 100644 index 00000000..8eda91fe --- /dev/null +++ b/tests/Gitonomy/Git/Tests/LeadingDashTest.php @@ -0,0 +1,80 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * git reads an argument starting with a dash as an option: a revision from + * the URL of a web application, like "--output=/path", would write a file. + */ +class LeadingDashTest extends AbstractTestCase +{ + #[DataProvider('provideCalls')] + public function testARevisionStartingWithADashIsRefused(\Closure $call): void + { + $repository = self::createFoobarRepository(false); + $file = self::createTempDir().'/written'; + + try { + $call($repository, '--output='.$file); + $this->fail('A leading dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('starts with a dash', $e->getMessage()); + } + + $this->assertFileDoesNotExist($file); + $this->assertTrue($repository->getReferences()->hasBranch('master'), 'Nothing is done'); + } + + /** + * @return iterable + */ + public static function provideCalls(): iterable + { + yield 'revision' => [static function (Repository $repository, string $option): void { $repository->getRevision($option)->getCommit(); }]; + yield 'range of a log' => [static function (Repository $repository, string $option): void { $repository->getLog($option.'..master')->getCommits(); }]; + yield 'blame' => [static function (Repository $repository, string $option): void { $repository->getBlame($option, 'README.md')->getLines(); }]; + yield 'diff' => [static function (Repository $repository, string $option): void { $repository->getDiff($option, 'master'); }]; + yield 'checkout' => [static function (Repository $repository, string $option): void { $repository->getWorkingCopy()->checkout($option); }]; + yield 'new branch' => [static function (Repository $repository, string $option): void { $repository->getWorkingCopy()->checkoutNewBranch($option, 'master'); }]; + yield 'merge' => [static function (Repository $repository, string $option): void { $repository->getWorkingCopy()->merge($option); }]; + yield 'reference' => [static function (Repository $repository, string $option): void { new Branch($repository, $option, self::LONGFILE_COMMIT)->delete(); }]; + } + + /** + * Unlike a URL, a path may start with a dash: git reads it after "--". + */ + public function testAPathMayStartWithADash(): void + { + $dir = self::createTempDir(); + $previousCwd = getcwd(); + $this->assertIsString($previousCwd); + chdir($dir); + + try { + Repository::init('-repository', self::getDriver()); + Repository::initBare('-bare', self::getDriver()); + Repository::cloneFrom(self::repositoryUrl(), '-clone', driver: self::getDriver()); + } finally { + chdir($previousCwd); + } + + $this->assertDirectoryExists($dir.'/-repository/.git'); + $this->assertFileExists($dir.'/-bare/HEAD'); + $this->assertFileExists($dir.'/-clone/README.md'); + } +} diff --git a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php new file mode 100644 index 00000000..a69fecfa --- /dev/null +++ b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php @@ -0,0 +1,197 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use Symfony\Component\Process\Process; + +/** + * @author Grégoire Pineau + */ +class LibGit2DriverTest extends AbstractTestCase +{ + private const string AUTOLOAD = __DIR__.'/../../../../vendor/autoload.php'; + + #[RequiresPhpExtension('ffi')] + public function testRestrictedFfiApiIsReported(): void + { + // The setting only applies at startup: it needs a new PHP process. + $code = <<<'PHP' + require $argv[1]; + try { + new Gitonomy\Git\Driver\Ffi\LibGit2Driver(); + } catch (Gitonomy\Git\Exception\UnsupportedOperationException $e) { + echo $e->getMessage(); + } + PHP; + + $process = new Process([\PHP_BINARY, '-d', 'ffi.enable=0', '-r', $code, self::AUTOLOAD]); + $process->mustRun(); + + $this->assertSame('The "ffi.enable" setting restricts the FFI API: call LibGit2Driver::preload() from the opcache.preload script, set "ffi.enable" to "true", or use CliDriver.', $process->getOutput()); + } + + #[RequiresPhpExtension('ffi')] + #[RequiresPhpExtension('Zend OPcache')] + public function testPreloadKeepsLibgit2LoadedForTheProcess(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + + $preload = self::createTempDir().'/preload.php'; + file_put_contents($preload, \sprintf('getHeadCommit()->getHash(); + PHP; + + $repository = self::createFoobarRepository(); + $process = new Process([\PHP_BINARY, '-d', 'opcache.enable_cli=1', '-d', 'opcache.preload='.$preload, '-r', $code, self::AUTOLOAD, $repository->getGitDir()]); + $process->mustRun(); + + $this->assertSame($repository->getHeadCommit()->getHash(), $process->getOutput()); + } + + #[RequiresPhpExtension('ffi')] + public function testTheSystemConfigurationIsLeftOutWithGitConfigNoSystem(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + + // libgit2 is initialized once per process. 4 and 2 read the search + // path (GIT_OPT_GET_SEARCH_PATH) of the system configuration + // (GIT_CONFIG_LEVEL_SYSTEM), "/etc" on Linux. + $code = <<<'PHP' + require $argv[1]; + $ffi = Gitonomy\Git\Driver\Ffi\LibGit2::load(null); + $path = $ffi->new('git_buf'); + $ffi->git_libgit2_opts(4, 2, FFI::addr($path)); + echo FFI::string($path->ptr, $path->size); + $ffi->git_buf_dispose(FFI::addr($path)); + PHP; + + $process = new Process([\PHP_BINARY, '-r', $code, self::AUTOLOAD], env: ['GIT_CONFIG_NOSYSTEM' => '1']); + $process->mustRun(); + $this->assertSame('', $process->getOutput()); + + $process = new Process([\PHP_BINARY, '-r', $code, self::AUTOLOAD], env: ['GIT_CONFIG_NOSYSTEM' => false]); + $process->mustRun(); + $this->assertNotSame('', $process->getOutput()); + } + + public function testReftableRepositoriesAreUnsupported(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + if (version_compare(self::gitVersion(), '2.45.0', '<')) { + $this->markTestSkipped('Reftable repositories require git 2.45.'); + } + + $dir = self::createTempDir(); + new Process([self::gitCommand(), 'init', '-q', '--ref-format=reftable', $dir])->mustRun(); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('stores its references in the reftable format, which LibGit2Driver doesn\'t support; use CliDriver.'); + + new Repository($dir, driver: new LibGit2Driver()); + } + + /** + * @param \Closure(LibGit2Driver, string, string): void $clone + */ + #[DataProvider('provideBundleClones')] + public function testBundlesAreUnsupported(\Closure $clone, string $url): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage(\sprintf('"%s" is a bundle, which LibGit2Driver doesn\'t support; use CliDriver.', $url)); + + $clone(new LibGit2Driver(), self::createTempDir().'/clone', $url); + } + + /** + * @return iterable + */ + public static function provideBundleClones(): iterable + { + foreach (self::provideClones() as $name => [$clone]) { + yield $name => [$clone, self::REPOSITORY_URL]; + yield $name.', file:// URL' => [$clone, 'file://'.self::REPOSITORY_URL]; + } + } + + /** + * is_file() hands a URL to the stream wrapper of its scheme: the one of + * "ftp://" connects to the host, with the credentials of the URL. + * + * @param \Closure(LibGit2Driver, string, string): void $clone + */ + #[DataProvider('provideClones')] + public function testOnlyALocalPathIsLookedAtForABundle(\Closure $clone): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + + $wrapper = new class { + /** + * @var list + */ + public static array $urls = []; + + public mixed $context; + + public function url_stat(string $path, int $flags): false + { + self::$urls[] = $path; + + return false; + } + }; + stream_wrapper_register('gitlib-test', $wrapper::class); + + try { + $clone(new LibGit2Driver(), self::createTempDir().'/clone', 'gitlib-test://user:secret@example.com/repository.git'); + $this->fail('libgit2 knows no "gitlib-test" transport.'); + } catch (DriverException) { + } finally { + stream_wrapper_unregister('gitlib-test'); + } + + $this->assertSame([], $wrapper::$urls); + } + + /** + * @return iterable + */ + public static function provideClones(): iterable + { + yield 'clone' => [static fn (LibGit2Driver $driver, string $path, string $url) => $driver->cloneFrom($url, $path, null)]; + yield 'bare clone' => [static fn (LibGit2Driver $driver, string $path, string $url) => $driver->cloneBareFrom($url, $path, null)]; + yield 'mirror' => [static fn (LibGit2Driver $driver, string $path, string $url) => $driver->mirrorFrom($url, $path)]; + } +} diff --git a/tests/Gitonomy/Git/Tests/LibGit2HeaderTest.php b/tests/Gitonomy/Git/Tests/LibGit2HeaderTest.php new file mode 100644 index 00000000..f62ceeaa --- /dev/null +++ b/tests/Gitonomy/Git/Tests/LibGit2HeaderTest.php @@ -0,0 +1,38 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use PHPUnit\Framework\TestCase; + +/** + * PHPStan can't analyse FFI calls: this catches a libgit2 function called + * without being declared in libgit2.h (which only fails at runtime), and + * declarations left unused (each one must exist in the loaded libgit2). + */ +class LibGit2HeaderTest extends TestCase +{ + private const string FFI_DIR = __DIR__.'/../../../../src/Gitonomy/Git/Driver/Ffi'; + + public function testCalledFunctionsAreTheDeclaredOnes(): void + { + preg_match_all('/->(git_\w+)\(/', implode('', array_map(file_get_contents(...), glob(self::FFI_DIR.'/*.php') ?: [])), $called); + preg_match_all('/^[\w ]+?\**\b(git_\w+)\(/m', (string) file_get_contents(self::FFI_DIR.'/libgit2.h'), $declared); + + $called = array_values(array_unique($called[1])); + $declared = array_values(array_unique($declared[1])); + sort($called); + sort($declared); + + $this->assertSame($declared, $called); + } +} diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 2f123f9e..993b53db 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -1,6 +1,6 @@ @@ -12,68 +12,486 @@ namespace Gitonomy\Git\Tests; -class LogTest extends AbstractTest +use Gitonomy\Git\Commit; +use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Driver\Cli\Parser\LogParser; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Log; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; + +class LogTest extends AbstractTestCase { - /** - * @dataProvider provideFoobar - */ - public function testRevisionAndPath($repository) + #[DataProvider('provideBare')] + public function testRevisionAndPath(bool $bare): void { + $repository = self::createFoobarRepository($bare); $logReadme = $repository->getLog(self::LONGFILE_COMMIT, 'README'); $logImage = $repository->getLog(self::LONGFILE_COMMIT, 'image.jpg'); - $this->assertEquals(3, count($logReadme)); - $this->assertEquals(2, count($logImage)); + $this->assertCount(3, $logReadme); + $this->assertCount(2, $logImage); } - /** - * @dataProvider provideFoobar - */ - public function testGetCommits($repository) + #[DataProvider('provideBare')] + public function testGetCommits(bool $bare): void { + $repository = self::createFoobarRepository($bare); $log = $repository->getLog(self::LONGFILE_COMMIT, null, null, 3); $commits = $log->getCommits(); - $this->assertEquals(3, count($commits), '3 commits in log'); - $this->assertEquals(self::LONGFILE_COMMIT, $commits[0]->getHash(), 'First is requested one'); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commits[1]->getHash(), "Second is longfile parent\'s"); + $this->assertCount(3, $commits, '3 commits in log'); + $this->assertSame(self::LONGFILE_COMMIT, $commits[0]->getHash(), 'First is requested one'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $commits[1]->getHash(), "Second is longfile parent\\'s"); } - /** - * @dataProvider provideFoobar - */ - public function testCountCommits($repository) + #[DataProvider('provideBare')] + public function testCountCommits(bool $bare): void { + $repository = self::createFoobarRepository($bare); $log = $repository->getLog(self::LONGFILE_COMMIT, null, 2, 3); - $this->assertEquals(8, $log->countCommits(), '8 commits found in history'); + $this->assertSame(8, $log->countCommits(), '8 commits found in history'); } - /** - * @dataProvider provideFoobar - */ - public function testCountAllCommits($repository) + #[DataProvider('provideBare')] + public function testCountAllCommits(bool $bare): void { - $log = $log = $repository->getLog(); + $repository = self::createFoobarRepository($bare); + $log = $repository->getLog(); $this->assertGreaterThan(100, $log->countCommits(), 'Returns all commits from all branches'); } - /** - * @dataProvider provideFoobar - */ - public function testIterable($repository) + #[DataProvider('provideBare')] + public function testIterable(bool $bare): void { + $repository = self::createFoobarRepository($bare); $log = $repository->getLog(self::LONGFILE_COMMIT); $expectedHashes = [self::LONGFILE_COMMIT, self::BEFORE_LONGFILE_COMMIT]; foreach ($log as $entry) { $hash = array_shift($expectedHashes); - $this->assertEquals($hash, $entry->getHash()); - if (count($expectedHashes) == 0) { + $this->assertSame($hash, $entry->getHash()); + if (0 == \count($expectedHashes)) { break; } } } + + public function testFirstMessageEmpty(): void + { + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + // Edge case: first commit lacks a message. + $commit = self::writeObject($repository, 'commit', "tree {$tree}\nauthor Unit Test 1000000000 +0000\ncommitter Unit Test 1000000000 +0000\n\n"); + self::git($repository, 'update-ref', ['refs/heads/master', $commit]); + + $commits = $repository->getLog()->getCommits(); + $this->assertCount(1, $commits); + $this->assertSame('', $commits[0]->getMessage()); + } + + public function testParsesCommitsWithAndWithoutGitButlerHeaders(): void + { + $logContent = <<<'EOT' + commit 1111111111111111111111111111111111111111 + tree abcdefabcdefabcdefabcdefabcdefabcdefabcd + author John Doe 1620000000 +0000 + committer John Doe 1620000000 +0000 + + First commit message + + commit 2222222222222222222222222222222222222222 + tree abcdefabcdefabcdefabcdefabcdefabcdefabcd + parent 1111111111111111111111111111111111111111 + author Jane Smith 1620003600 +0000 + committer Jane Smith 1620003600 +0000 + gitbutler-headers-version: 2 + gitbutler-change-id: a7bd485c-bae6-45b2-910f-163c78aace81 + + Commit with GitButler headers + + commit 3333333333333333333333333333333333333333 + tree abcdefabcdefabcdefabcdefabcdefabcdefabcd + author John Doe 1620007200 +0000 + committer Jane Smith 1620007200 +0000 + + Another commit without GitButler headers + + EOT; + + $parser = new LogParser(); + $parser->parse($logContent); + + $log = $parser->commits; + $this->assertCount(3, $log); + + $this->assertSame("First commit message\n", $log[0]->message); + $this->assertSame("Commit with GitButler headers\n", $log[1]->message); + $this->assertSame("Another commit without GitButler headers\n", $log[2]->message); + } + + #[DataProvider('provideBare')] + public function testLogOnPathSkipsMergesThatDoNotChangeIt(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $log = $repository->getLog(null, 'README.md'); + + $expected = ['1917a09c23eb0129d38d4e6d6a492c7830f8d423', 'd8930cd3f7e343195b85d56408a701feb98b95a8']; + $this->assertSame($expected, array_values(array_map(static fn (Commit $commit) => $commit->getHash(), $log->getCommits()))); + $this->assertCount(2, $log); + } + + public function testLogOfRanges(): void + { + $repository = self::createFoobarRepository(false); + + $this->assertCount(21, $repository->getLog('master...origin/new-feature')); + $this->assertCount(21, $repository->getLog('master...origin/new-feature')->getCommits()); + $this->assertCount(19, $repository->getLog('origin/new-feature..master')->getCommits()); + } + + public function testLogOnPathSkipsTheHistoryAMergeDiscards(): void + { + $repository = self::createRepositoryToCommitTo(); + $initial = self::commit($repository, 'file', 'initial'); + self::git($repository, 'checkout', ['-b', 'side']); + self::commit($repository, 'file', 'side'); + self::git($repository, 'checkout', ['main']); + self::commit($repository, 'other', 'main'); + // Keeps the file of main: git doesn't walk the side branch for it. + self::git($repository, 'merge', ['-s', 'ours', '-m', 'Merge side', 'side']); + + $this->assertSame([$initial], self::hashes($repository->getLog('main', 'file'))); + $this->assertSame($initial, $repository->getHeadCommit()->getLastModification('file')->getHash()); + } + + public function testLogOfRangeOnPathOnlySimplifiesMergesToTheBottom(): void + { + $repository = self::createRepositoryToCommitTo(); + self::commit($repository, 'file', 'initial'); + self::git($repository, 'checkout', ['-b', 'topic']); + self::commit($repository, 'other', 'topic'); + $topic = self::commit($repository, 'file', 'topic'); + self::git($repository, 'checkout', ['main']); + self::commit($repository, 'file', 'main'); + self::git($repository, 'checkout', ['topic']); + self::git($repository, 'merge', ['-X', 'theirs', '-m', 'Merge main', 'main']); + $merge = $repository->getHeadCommit()->getHash(); + + // The merge has the file of main, the bottom of the range: git follows main only. + $this->assertSame([], self::hashes($repository->getLog('main..topic', 'file'))); + + self::git($repository, 'checkout', ['main']); + self::commit($repository, 'another', 'main'); + + // The merged commit is hidden, but no longer the bottom: git follows every parent. The + // first commit of topic is like its hidden parent on the file: git skips it. + $this->assertSame([$merge, $topic], self::hashes($repository->getLog('main..topic', 'file'))); + } + + /** + * @param list $paths + */ + #[TestWith([[]])] + #[TestWith([['file']])] + public function testLogOfSeveralRevisionsStartsFromTheNewest(array $paths): void + { + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', "100644 file\0".hex2bin(self::writeObject($repository, 'blob', 'content'))); + $commit = static fn (string $message, int $time): string => self::writeObject($repository, 'commit', "tree {$tree}\nauthor Unit Test {$time} +0000\ncommitter Unit Test {$time} +0000\n\n{$message}\n"); + $old = $commit('old', 1000000000); + $new = $commit('new', 1000000001); + $sameAsOld = $commit('same as old', 1000000000); + + $this->assertSame([$new, $old], self::hashes($repository->getLog([$old, $new], $paths))); + $this->assertSame([$new, $old], self::hashes($repository->getLog([$new, $old], $paths))); + // At equal dates, from the first given one. + $this->assertSame([$sameAsOld, $old], self::hashes($repository->getLog([$sameAsOld, $old], $paths))); + $this->assertSame([$old, $sameAsOld], self::hashes($repository->getLog([$old, $sameAsOld], $paths))); + } + + public function testLogOfAPathOutsideTheRepositoryThrows(): void + { + $repository = self::createFoobarRepository(); + + // Like git. + $this->expectException(DriverException::class); + + $repository->getLog('master', '/README.md')->getCommits(); + } + + public function testLogOfAnAbsolutePathInTheWorkingTree(): void + { + $repository = self::createFoobarRepository(false); + + $this->assertSame( + array_map(static fn (Commit $commit): string => $commit->getHash(), $repository->getLog('master', 'README.md')->getCommits()), + array_map(static fn (Commit $commit): string => $commit->getHash(), $repository->getLog('master', $repository->getWorkingDir().'/README.md')->getCommits()), + ); + } + + public function testLogOnWildcardPath(): void + { + $repository = self::createRepositoryToCommitTo(); + mkdir($repository->getWorkingDir().'/dir'); + self::commit($repository, 'dir/file.md', 'initial'); + $text = self::commit($repository, 'dir/file.txt', 'text'); + self::commit($repository, 'dir/file.md', 'markdown'); + + $this->assertSame([$text], self::hashes($repository->getLog('main', 'dir/*.txt'))); + } + + public function testLogOfAPathNormalizesIt(): void + { + $repository = self::createRepositoryToCommitTo(); + mkdir($repository->getWorkingDir().'/dir'); + $file = self::commit($repository, 'dir/file.txt', 'file'); + self::commit($repository, 'other.txt', 'other'); + + // Like git. + foreach (['dir/.', 'dir//file.txt', 'other/../dir/file.txt', './dir/', $repository->getWorkingDir().'/dir/../dir'] as $path) { + $this->assertSame([$file], self::hashes($repository->getLog('main', $path)), $path); + } + } + + public function testLogOfAPathAboveTheRootThrows(): void + { + $repository = self::createRepositoryToCommitTo(); + self::commit($repository, 'file.txt', 'file'); + + // Like git. + $this->expectException(DriverException::class); + if (self::getDriver() instanceof LibGit2Driver) { + $this->expectExceptionMessage('The path "dir/../../file.txt" is outside the repository.'); + } else { + // git 1.8.4 doesn't repeat the path first, and recent versions name the repository after it. + $this->expectExceptionMessageMatches('/^The git command "log" failed \(exit 128\): fatal: (?:dir\/\.\.\/\.\.\/file\.txt: )?\'dir\/\.\.\/\.\.\/file\.txt\' is outside repository/'); + } + + $repository->getLog('main', 'dir/../../file.txt')->getCommits(); + } + + public function testLogOfAPathWithMagic(): void + { + $repository = self::createRepositoryToCommitTo(); + mkdir($repository->getWorkingDir().'/dir'); + self::commit($repository, 'dir/file.txt', 'file'); + $other = self::commit($repository, 'other.txt', 'other'); + + if (self::getDriver() instanceof LibGit2Driver) { + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('The path ":/other.txt" uses the magic of git\'s pathspecs, which LibGit2Driver doesn\'t support; use CliDriver.'); + } + + // From the root of the repository, whatever the current directory. + $this->assertSame([$other], self::hashes($repository->getLog('main', ':/other.txt'))); + } + + public function testLogOfAllReferencesIgnoresTheOnesToOtherObjects(): void + { + $repository = self::createRepositoryToCommitTo(); + $commit = self::commit($repository, 'file', 'initial'); + self::git($repository, 'update-ref', ['refs/tags/tree', $repository->getCommit($commit)->getTreeHash()]); + self::git($repository, 'symbolic-ref', ['refs/heads/dangling', 'refs/heads/missing']); + + $this->assertSame([$commit], self::hashes($repository->getLog())); + $this->assertSame(1, $repository->getLog()->countCommits()); + } + + #[DataProvider('provideBare')] + public function testCountIsTheNumberOfCommitsIterated(bool $bare): void + { + $log = self::createFoobarRepository($bare)->getLog(self::LONGFILE_COMMIT); + $total = $log->countCommits(); + + $this->assertCount($total, $log); + $this->assertCount(3, $log->withLimit(3)); + $this->assertCount(3, $log->withLimit(3)); + $this->assertCount($total - 2, $log->withOffset(2)); + $this->assertCount(0, $log->withOffset($total + 1)); + $this->assertSame($total, $log->withLimit(3)->countCommits(), 'countCommits() ignores the offset and the limit'); + } + + #[DataProvider('provideBare')] + public function testWithersReturnAnotherLog(bool $bare): void + { + $log = self::createFoobarRepository($bare)->getLog(self::LONGFILE_COMMIT, offset: 1); + + $limited = $log->withLimit(2); + + $this->assertNull($log->getLimit()); + $this->assertSame(2, $limited->getLimit()); + $this->assertSame(1, $limited->getOffset()); + $this->assertSame(\array_slice(self::hashes($log), 0, 2), self::hashes($limited)); + } + + public function testLogOfExcludedAndSymmetricRevisions(): void + { + $repository = self::createRepositoryToCommitTo(); + $base = self::commit($repository, 'file', 'base'); + self::git($repository, 'checkout', ['-q', '-b', 'side']); + $side = self::commit($repository, 'file', 'side'); + self::git($repository, 'checkout', ['-q', 'main']); + $main = self::commit($repository, 'other', 'main'); + + $this->assertSame([$main], self::hashes($repository->getLog(['main', '^side']))); + $this->assertEqualsCanonicalizing([$main, $side], self::hashes($repository->getLog('main...side'))); + $this->assertSame([$side], self::hashes($repository->getLog('main..side'))); + $this->assertCount(3, $repository->getLog('--all')); + $this->assertSame([], self::hashes($repository->getLog('^main'))); + $this->assertSame([$main, $base], self::hashes($repository->getLog('main'))); + } + + /** + * Like "git log --all", which walks from HEAD too: a detached HEAD may + * name a commit no reference has, like after the checkout of a hash. + */ + public function testALogOfEveryReferenceIncludesADetachedHead(): void + { + $repository = self::createFoobarRepository(); + $master = $repository->getHeadCommit(); + $hash = self::writeObject($repository, 'commit', "tree {$master->getTreeHash()}\nparent {$master->getHash()}\nauthor A 1700000000 +0000\ncommitter A 1700000000 +0000\n\nDetached\n"); + self::git($repository, 'update-ref', ['--no-deref', 'HEAD', $hash]); + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + + $log = $repository->getLog(); + + $this->assertContains($hash, self::hashes($log)); + $this->assertCount(\count($log->getCommits()), $log); + } + + /** + * @param \Closure(Repository): Log $log + */ + #[DataProvider('provideNegativeBounds')] + public function testANegativeOffsetOrLimitIsRefused(\Closure $log, string $message): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $log($repository); + } + + /** + * @return iterable + */ + public static function provideNegativeBounds(): iterable + { + yield 'offset' => [static fn (Repository $repository): Log => $repository->getLog(null, null, -1), 'The offset of a log can\'t be negative, -1 given.']; + yield 'limit' => [static fn (Repository $repository): Log => $repository->getLog(null, null, null, -1), 'The limit of a log can\'t be negative, -1 given.']; + yield 'offset of a revision' => [static fn (Repository $repository): Log => $repository->getRevision('master')->getLog(null, -2), 'The offset of a log can\'t be negative, -2 given.']; + yield 'withOffset()' => [static fn (Repository $repository): Log => $repository->getLog()->withOffset(-5), 'The offset of a log can\'t be negative, -5 given.']; + yield 'withLimit()' => [static fn (Repository $repository): Log => $repository->getLog()->withLimit(-1), 'The limit of a log can\'t be negative, -1 given.']; + } + + public function testALimitOfZeroGivesNoCommit(): void + { + $log = self::createFoobarRepository()->getLog(null, null, null, 0); + + $this->assertSame([], $log->getCommits()); + $this->assertCount(0, $log); + } + + /** + * The command line of a process has a limit, 2 MB by default on Linux: a + * log of thousands of branches would exceed it. + */ + public function testLogOfMoreRevisionsThanACommandLineTakes(): void + { + $repository = self::createFoobarRepository(); + // 4 MB of hashes. + $log = $repository->getLog(array_fill(0, 100_000, $repository->getCommit(self::LONGFILE_COMMIT))); + + $hashes = self::hashes($repository->getLog(self::LONGFILE_COMMIT)); + $this->assertSame($hashes, self::hashes($log)); + $this->assertCount(\count($hashes), $log); + } + + public function testLogOfAnUnknownRevisionNamesIt(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('"unknown"'); + + $repository->getLog('master..unknown', 'README.md')->getCommits(); + } + + public function testLogRefusesOptions(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(InvalidArgumentException::class); + + $repository->getLog('--output=/dev/null')->getCommits(); + } + + public function testParsesCommitsMergingTagsAndSignedOnes(): void + { + $repository = self::createRepositoryToCommitTo(); + $tree = trim(self::git($repository, 'write-tree')); + $signed = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n signature\n -----END PGP SIGNATURE-----\n\nSigned\n"); + $other = self::writeObject($repository, 'commit', "tree {$tree}\nparent {$signed}\nauthor A 1000000001 +0000\ncommitter A 1000000001 +0000\n\nOther\n"); + // An unsigned tag: no "END PGP SIGNATURE" line in its "mergetag" header. + $merge = self::writeObject($repository, 'commit', "tree {$tree}\nparent {$other}\nparent {$signed}\nauthor A 1000000002 +0000\ncommitter A 1000000002 +0000\nmergetag object {$signed}\n type commit\n tag v1\n tagger A 1000000000 +0000\n \n Tag v1\n\nMerge tag v1\n"); + self::git($repository, 'update-ref', ['refs/heads/main', $merge]); + + $commits = $repository->getLog('main')->getCommits(); + + $this->assertSame([$merge, $other, $signed], array_map(static fn (Commit $commit): string => $commit->getHash(), $commits)); + $this->assertSame("Merge tag v1\n", $commits[0]->getMessage()); + $this->assertSame("Signed\n", $commits[2]->getMessage()); + } + + #[DataProvider('provideBare')] + public function testLogOfTheRoot(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + + $this->assertSame(self::hashes($repository->getLog(self::LONGFILE_COMMIT, ['README.md', 'long.php'])), self::hashes($repository->getLog(self::LONGFILE_COMMIT, ['./README.md', './long.php']))); + $this->assertCount(\count($repository->getLog(self::LONGFILE_COMMIT)), $repository->getLog(self::LONGFILE_COMMIT, '.')); + } + + private static function createRepositoryToCommitTo(): Repository + { + $repository = self::createEmptyRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + self::git($repository, 'symbolic-ref', ['HEAD', 'refs/heads/main']); + + return $repository; + } + + /** + * Commits $file, with $content as content and message. + * + * @return string The hash of the commit + */ + private static function commit(Repository $repository, string $file, string $content): string + { + // Ends with a line break: git 1.8 adds one when it merges a file without. + file_put_contents($repository->getWorkingDir().'/'.$file, $content."\n"); + self::git($repository, 'add', [$file]); + self::git($repository, 'commit', ['-m', $content]); + + return trim(self::git($repository, 'rev-parse', ['HEAD'])); + } + + /** + * @return list + */ + private static function hashes(Log $log): array + { + return array_values(array_map(static fn (Commit $commit): string => $commit->getHash(), $log->getCommits())); + } } diff --git a/tests/Gitonomy/Git/Tests/NulByteTest.php b/tests/Gitonomy/Git/Tests/NulByteTest.php new file mode 100644 index 00000000..a059d047 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/NulByteTest.php @@ -0,0 +1,68 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * libgit2 reads its arguments as C strings: "README.md\0.php" would name + * "README.md", where git refuses it. + */ +class NulByteTest extends AbstractTestCase +{ + #[DataProvider('provideCalls')] + public function testANulByteIsRefused(\Closure $call): void + { + $repository = self::createFoobarRepository(); + + try { + $call($repository); + $this->fail('A NUL byte is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('contains a NUL byte', $e->getMessage()); + } + + $this->assertTrue($repository->getReferences()->hasBranch('master'), 'Nothing is done'); + } + + /** + * @return iterable + */ + public static function provideCalls(): iterable + { + yield 'revision' => [static function (Repository $repository): void { $repository->getRevision("master\0junk")->getCommit(); }]; + yield 'blame' => [static function (Repository $repository): void { $repository->getBlame('master', "README.md\0.php")->getLines(); }]; + yield 'log' => [static function (Repository $repository): void { $repository->getLog('master', "README.md\0.php")->getCommits(); }]; + yield 'branch creation' => [static function (Repository $repository): void { $repository->getReferences()->createBranch("new\0master", 'master'); }]; + yield 'branch deletion' => [static function (Repository $repository): void { new Branch($repository, "refs/heads/master\0junk", self::LONGFILE_COMMIT)->delete(); }]; + } + + public function testADirectoryWithANulByteIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('contains a NUL byte'); + + new Repository(self::createTempDir()."\0junk", driver: self::getDriver()); + } + + public function testAUrlWithANulByteIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('contains a NUL byte'); + + Repository::isValidRepository(self::repositoryUrl()."\0junk", self::getDriver()); + } +} diff --git a/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php b/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php new file mode 100644 index 00000000..652319bd --- /dev/null +++ b/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php @@ -0,0 +1,141 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Process\Process; + +/** + * The message is a sentence, on one line: the outputs of git are whole in + * getErrorOutput() and getOutput(). + */ +class ProcessExceptionTest extends AbstractTestCase +{ + public function testTheMessageHasTheFirstLineOfTheErrorOutput(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/AUTHORS', 'local change'); + + try { + $repository->run('checkout', ['origin/pagination']); + $this->fail('The checkout would overwrite the local changes.'); + } catch (ProcessException $e) { + $this->assertSame('The git command "checkout" failed (exit 1): error: Your local changes to the following files would be overwritten by checkout.', $e->getMessage()); + $this->assertStringContainsString("\tAUTHORS\n", $e->getErrorOutput()); + } + } + + /** + * git warns about a HOME it can't read before its error. + */ + public function testTheMessageHasTheErrorAfterTheWarnings(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/AUTHORS', 'local change'); + $home = self::createTempDir(); + mkdir($home.'/.config'); + $repository = new Repository($repository->getPath(), driver: self::getCliDriver(['HOME' => $home])); + + chmod($home.'/.config', 0); + + try { + if (is_readable($home.'/.config')) { + $this->markTestSkipped('The user of the tests reads any directory, like root.'); + } + + $repository->getWorkingCopy()->checkout('origin/pagination'); + $this->fail('The checkout would overwrite the local changes.'); + } catch (ProcessException $e) { + $this->assertSame('The git command "checkout" failed (exit 1): error: Your local changes to the following files would be overwritten by checkout.', $e->getMessage()); + $this->assertStringStartsWith(\sprintf("warning: unable to access '%s/.config/git/", $home), $e->getErrorOutput()); + } finally { + // Or the directory can't be deleted. + chmod($home.'/.config', 0o700); + } + } + + #[DataProvider('provideErrorOutputs')] + public function testTheMessageHasTheFirstError(string $errorOutput, string $message): void + { + $process = new Process([\PHP_BINARY, '-r', 'fwrite(STDERR, $argv[1]); exit(128);', '--', $errorOutput]); + $process->run(); + + $this->assertSame($message, new ProcessException($process, 'merge')->getMessage()); + } + + /** + * @return iterable + */ + public static function provideErrorOutputs(): iterable + { + yield 'warning first' => ["warning: refname 'feature' is ambiguous.\nfatal: ambiguous object name: 'feature'\n", 'The git command "merge" failed (exit 128): fatal: ambiguous object name: \'feature\'.']; + yield 'errors' => ["error: first\nfatal: second\n", 'The git command "merge" failed (exit 128): error: first.']; + yield 'no error' => ["\nwarning: something\nmerge: nope - not something we can merge\n", 'The git command "merge" failed (exit 128): warning: something.']; + } + + /** + * git writes the conflicts of a merge on its standard output. + */ + public function testTheMessageOfAConflictHasIt(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + file_put_contents($repository->getWorkingDir().'/README.md', "mine\n"); + self::git($repository, 'commit', ['-q', '-a', '-m', 'Mine']); + self::git($repository, 'checkout', ['-q', '-b', 'theirs', 'HEAD~1']); + file_put_contents($repository->getWorkingDir().'/README.md', "theirs\n"); + self::git($repository, 'commit', ['-q', '-a', '-m', 'Theirs']); + self::git($repository, 'checkout', ['-q', 'master']); + + try { + $repository->getWorkingCopy()->merge('theirs'); + $this->fail('Both sides change README.md.'); + } catch (ProcessException $e) { + $this->assertSame('The git command "merge" failed (exit 1): CONFLICT (content): Merge conflict in README.md.', $e->getMessage()); + } + } + + public function testTheMessageOfAFailureWithoutErrorOutputEndsWithTheExitCode(): void + { + $this->skipUnlessCliDriver(); + + try { + self::createFoobarRepository()->run('diff', ['--exit-code', '--no-ext-diff', '--no-textconv', self::INITIAL_COMMIT, self::LONGFILE_COMMIT]); + $this->fail('The commits differ.'); + } catch (ProcessException $e) { + $this->assertSame('The git command "diff" failed (exit 1).', $e->getMessage()); + $this->assertSame('', $e->getErrorOutput()); + $this->assertStringContainsString('diff --git', $e->getOutput()); + } + } + + /** + * Like in 1.x, which had no other argument. + */ + public function testTheCommandIsOptional(): void + { + $process = new Process([self::gitCommand(), 'not-work'], self::createTempDir(), self::gitEnvs()); + $process->run(); + + $this->assertSame('The git command failed (exit 1): git: \'not-work\' is not a git command. See \'git --help\'.', new ProcessException($process)->getMessage()); + } +} diff --git a/tests/Gitonomy/Git/Tests/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index 65969d9e..583f533d 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -1,6 +1,6 @@ @@ -12,16 +12,31 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\PushReference; +use PHPUnit\Framework\Attributes\DataProvider; -class PushReferenceTest extends AbstractTest +class PushReferenceTest extends AbstractTestCase { - const CREATE = 1; - const DELETE = 2; - const FORCE = 4; - const FAST_FORWARD = 8; + public const CREATE = 1; + public const DELETE = 2; + public const FORCE = 4; + public const FAST_FORWARD = 8; - public function provideIsers() + #[DataProvider('provideIsers')] + public function testIsers(string $reference, string $before, string $after, int $mask): void + { + $reference = new PushReference(self::createFoobarRepository(), $reference, $before, $after); + $this->assertSame(0 !== ($mask & self::CREATE), $reference->isCreation(), 'Create value is correct.'); + $this->assertSame(0 !== ($mask & self::DELETE), $reference->isDeletion(), 'Delete value is correct.'); + $this->assertSame(0 !== ($mask & self::FORCE), $reference->isForce(), 'Force value is correct.'); + $this->assertSame(0 !== ($mask & self::FAST_FORWARD), $reference->isFastForward(), 'FastForward value is correct.'); + } + + /** + * @return list + */ + public static function provideIsers(): array { // mask: force fastforward create delete return [ @@ -32,52 +47,47 @@ public function provideIsers() ]; } - /** - * @dataProvider provideIsers - */ - public function testIsers($reference, $before, $after, $mask) + public function testUnknownBeforeIsAnErrorNotAForcePush(): void { - $reference = new PushReference(self::createFoobarRepository(), $reference, $before, $after); - $this->assertEquals($mask & self::CREATE, $reference->isCreate(), 'Create value is correct.'); - $this->assertEquals($mask & self::DELETE, $reference->isDelete(), 'Delete value is correct.'); - $this->assertEquals($mask & self::FORCE, $reference->isForce(), 'Force value is correct.'); - $this->assertEquals($mask & self::FAST_FORWARD, $reference->isFastForward(), 'FastForward value is correct.'); + $reference = new PushReference(self::createFoobarRepository(), 'foo', str_repeat('1', 40), self::LONGFILE_COMMIT); + + $this->expectException(DriverException::class); + + $reference->isForce(); } - /** - * @dataProvider provideFoobar - */ - public function testLog($repository) + #[DataProvider('provideBare')] + public function testLog(bool $bare): void { + $repository = self::createFoobarRepository($bare); $ref = new PushReference($repository, 'foo', self::INITIAL_COMMIT, self::LONGFILE_COMMIT); $log = $ref->getLog()->getCommits(); - $this->assertEquals(7, count($log), '7 commits in log'); - $this->assertEquals('add a long file', $log[0]->getShortMessage(), 'First commit is correct'); + $this->assertCount(7, $log, '7 commits in log'); + $this->assertSame('add a long file', $log[0]->getSubjectMessage(), 'First commit is correct'); } /** * This test ensures that GPG signed requests does not break the reading of commit logs. - * - * @dataProvider provideFoobar */ - public function testSignedLog($repository) + #[DataProvider('provideBare')] + public function testSignedLog(bool $bare): void { + $repository = self::createFoobarRepository($bare); $ref = new PushReference($repository, 'foo', self::INITIAL_COMMIT, self::SIGNED_COMMIT); $log = $ref->getLog()->getCommits(); - $this->assertEquals(16, count($log), '16 commits in log'); - $this->assertEquals('signed commit', $log[0]->getShortMessage(), 'Last commit is correct'); + $this->assertCount(16, $log, '16 commits in log'); + $this->assertSame('signed commit', $log[0]->getSubjectMessage(), 'Last commit is correct'); } - /** - * @dataProvider provideFoobar - */ - public function testLogWithExclude($repository) + #[DataProvider('provideBare')] + public function testLogWithExclude(bool $bare): void { + $repository = self::createFoobarRepository($bare); $ref = new PushReference($repository, 'foo', PushReference::ZERO, self::LONGFILE_COMMIT); $log = $ref->getLog([self::INITIAL_COMMIT])->getCommits(); - $this->assertEquals(7, count($log), '7 commits in log'); - $this->assertEquals('add a long file', $log[0]->getShortMessage(), 'First commit is correct'); + $this->assertCount(7, $log, '7 commits in log'); + $this->assertSame('add a long file', $log[0]->getSubjectMessage(), 'First commit is correct'); } } diff --git a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php new file mode 100644 index 00000000..fb50ec91 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php @@ -0,0 +1,88 @@ + + * (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\Reference\Branch; +use PHPUnit\Framework\Attributes\DataProvider; + +class ReferenceBagTest extends AbstractTestCase +{ + #[DataProvider('provideBare')] + public function testGetFirstBranch(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $branch = $repository->getReferences()->getFirstBranch(); + + $this->assertInstanceOf(Branch::class, $branch); + } + + public function testGetFirstBranchWithNoBranches(): void + { + $repository = self::createFoobarRepository(false); + $wc = $repository->getWorkingCopy(); + $wc->checkout('0.1'); + + foreach ($repository->getReferences()->getBranches() as $branch) { + $branch->delete(); + } + + $refs = $repository->getReferences(); + $this->assertFalse($refs->hasBranches()); + $this->assertNull($refs->getFirstBranch(), 'getFirstBranch() must not return a non-branch reference'); + } + + #[DataProvider('provideBare')] + public function testUnknownReference(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $hash = $repository->getLog()->getSingleCommit()->getHash(); + + self::git($repository, 'update-ref', ['refs/pipelines/1', $hash]); + self::git($repository, 'update-ref', ['refs/merge-request/1/head', $hash]); + self::git($repository, 'update-ref', ['refs/pull/1/head', $hash]); + self::git($repository, 'update-ref', ['refs/notes/gtm-data', $hash]); + + $refs = $repository->getReferences()->getAll(); + + // Check that at least it has the master ref + $this->assertArrayHasKey('refs/heads/master', $refs); + + // Check that our custom refs have been ignored + $this->assertArrayNotHasKey('refs/pipelines/1', $refs); + $this->assertArrayNotHasKey('refs/merge-request/1/head', $refs); + $this->assertArrayNotHasKey('refs/pull/1/head', $refs); + $this->assertArrayNotHasKey('refs/notes/gtm-data', $refs); + } + + #[DataProvider('provideBare')] + public function testEmptyRepo(bool $bare): void + { + $repository = self::createEmptyRepository($bare); + $refs = $repository->getReferences()->getAll(); + $this->assertSame([], $refs); + } + + public function testReferencesAreSortedByName(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'pack-refs', ['--all']); + self::git($repository, 'branch', ['aaa', 'master']); + self::git($repository, 'branch', ['zzz', 'master']); + + $names = array_keys($repository->getReferences()->getAll()); + $sorted = $names; + sort($sorted, \SORT_STRING); + + $this->assertSame($sorted, $names); + } +} diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 7025f9be..c9ab4785 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -1,6 +1,6 @@ @@ -12,173 +12,521 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Tag; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; -class ReferenceTest extends AbstractTest +class ReferenceTest extends AbstractTestCase { - private $references; - - /** - * @dataProvider provideEmpty - */ - public function testEmptyRepository($repository) + #[DataProvider('provideBare')] + public function testEmptyRepository(bool $bare): void { + $repository = self::createEmptyRepository($bare); $this->assertCount(0, $repository->getReferences()); - $this->assertEquals([], $repository->getReferences()->getAll()); + $this->assertSame([], $repository->getReferences()->getAll()); } - /** - * @dataProvider provideFoobar - */ - public function testGetBranch($repository) + #[DataProvider('provideBare')] + public function testGetBranch(bool $bare): void { + $repository = self::createFoobarRepository($bare); $branch = $repository->getReferences()->getBranch('master'); + $this->assertSame($branch->getCommitHash(), $branch->getCommit()->getHash(), 'Hash is correctly resolved'); + } - $this->assertTrue($branch instanceof Branch, 'Branch object is correct type'); - $this->assertEquals($branch->getCommitHash(), $branch->getCommit()->getHash(), 'Hash is correctly resolved'); + public function testEveryReferenceHasAName(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.email', 'test@example.com']); + self::git($repository, 'config', ['user.name', 'Test']); + file_put_contents($repository->getWorkingDir().'/README.md', "stashed\n"); + self::git($repository, 'stash'); + + $names = []; + foreach ($repository->getReferences() as $fullname => $reference) { + $names[$fullname] = $reference->getName(); + } + + $this->assertSame('master', $names['refs/heads/master']); + $this->assertSame('origin/master', $names['refs/remotes/origin/master']); + $this->assertSame('0.1', $names['refs/tags/0.1']); + $this->assertSame('stash', $names['refs/stash']); } - /** - * @dataProvider provideFoobar - */ - public function testHasBranch($repository) + #[DataProvider('provideBare')] + public function testHasBranch(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertTrue($repository->getReferences()->hasBranch('master'), 'Branch master exists'); $this->assertFalse($repository->getReferences()->hasBranch('foobar'), 'Branch foobar does not exists'); } - /** - * @dataProvider provideFoobar - */ - public function testHasTag($repository) + #[DataProvider('provideBare')] + public function testHasTag(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertTrue($repository->getReferences()->hasTag('0.1'), 'Tag 0.1 exists'); $this->assertFalse($repository->getReferences()->hasTag('foobar'), 'Tag foobar does not exists'); } - /** - * @dataProvider provideFoobar - * @expectedException Gitonomy\Git\Exception\ReferenceNotFoundException - */ - public function testGetBranch_NotExisting_Error($repository) + #[DataProvider('provideBare')] + public function testGetBranchNotExistingError(bool $bare): void { - $branch = $repository->getReferences()->getBranch('notexisting'); + $repository = self::createFoobarRepository($bare); + $this->expectException(ReferenceNotFoundException::class); + + $repository->getReferences()->getBranch('notexisting'); } - /** - * @dataProvider provideFoobar - */ - public function testGetTag($repository) + #[DataProvider('provideBare')] + public function testGetTag(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tag = $repository->getReferences()->getTag('0.1'); + $this->assertFalse($tag->isAnnotated(), 'Tag is not annotated'); - $this->assertTrue($tag instanceof Tag, 'Tag object is correct type'); + $this->assertSame(self::LONGFILE_COMMIT, $tag->getCommitHash(), 'Commit hash is correct'); + $this->assertSame(self::LONGFILE_COMMIT, $tag->getCommit()->getHash(), 'Commit hash is correct'); + } + + #[DataProvider('provideBare')] + public function testAnnotatedTag(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $tag = $repository->getReferences()->getTag('annotated'); + $this->assertTrue($tag->isAnnotated(), 'Tag is annotated'); + $this->assertFalse($tag->isSigned(), 'Tag is not signed'); - $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommitHash(), 'Commit hash is correct'); - $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommit()->getHash(), 'Commit hash is correct'); + $this->assertSame('Graham Campbell', $tag->getTaggerName(), 'Tagger name is correct'); + $this->assertSame('graham@alt-three.com', $tag->getTaggerEmail(), 'Tagger email is correct'); + $this->assertSame(1471428000, $tag->getTaggerDate()?->getTimestamp(), 'Tag date is correct'); + $this->assertSame('2016-08-17T12:00:00+02:00', $tag->getTaggerDate()->format(\DATE_ATOM), 'Tag date is at the offset of the tagger'); + + // Without an empty line after its first one, the message is one paragraph: its subject, like "git log --format=%s". + $this->assertSame('heading body body', $tag->getSubjectMessage(), 'Message heading is correct'); + $this->assertSame('', $tag->getBodyMessage(), 'Message body is correct'); + + // The commit the tag object points to, not the tag object. + $this->assertSame('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommitHash()); + $this->assertSame('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommit()->getHash()); + $this->assertSame([$tag], $repository->getReferences()->resolveTags($tag->getCommit())); + $this->assertContains($tag, $tag->getCommit()->resolveReferences()); } /** - * @dataProvider provideFoobar - * @expectedException Gitonomy\Git\Exception\ReferenceNotFoundException + * Like git log, at the UTC offset of the tagger, not of the author or of + * the committer of the commit. */ - public function testGetTag_NotExisting_Error($repository) + public function testTheTaggerDateIsAtTheOffsetOfTheTagger(): void { - $branch = $repository->getReferences()->getTag('notexisting'); + $repository = self::createFoobarRepository(); + $git = new Repository($repository->getPath(), driver: self::getCliDriver([ + 'GIT_COMMITTER_DATE' => '1700000002 -0700', + 'GIT_COMMITTER_EMAIL' => 'tagger@example.com', + 'GIT_COMMITTER_NAME' => 'Tagger', + ])); + $git->run('tag', ['-a', '-m', 'Dates', 'dates', self::LONGFILE_COMMIT]); + + $date = new Repository($repository->getPath(), driver: self::getDriver())->getReferences()->getTag('dates')->getTaggerDate(); + + $this->assertSame('2023-11-14T15:13:22-07:00', $date?->format(\DATE_ATOM)); + $this->assertSame(1700000002, $date->getTimestamp()); } - /** - * @dataProvider provideFoobar - */ - public function testResolve($repository) + #[DataProvider('provideBare')] + public function testAnnotatedTagBodyMessageWithBlankLineSeparator(bool $bare): void { + $repository = self::createFoobarRepository($bare); + self::git($repository, 'config', ['user.email', 'test@example.com']); + self::git($repository, 'config', ['user.name', 'Test']); + + $hash = $repository->getLog()->getSingleCommit()->getHash(); + self::git($repository, 'tag', ['-a', 'blank-sep', '-m', 'Subject line', '-m', "Body line 1\nBody line 2", $hash]); + + $tag = $repository->getReferences()->getTag('blank-sep'); + + $this->assertSame('Subject line', $tag->getSubjectMessage(), 'Message subject is correct'); + $this->assertSame("Body line 1\nBody line 2\n", $tag->getBodyMessage(), 'Message body has no leading blank line'); + } + + #[DataProvider('provideBare')] + public function testGetTagNotExistingError(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $this->expectException(ReferenceNotFoundException::class); + + $repository->getReferences()->getTag('notexisting'); + } + + #[DataProvider('provideBare')] + public function testResolve(bool $bare): void + { + $repository = self::createFoobarRepository($bare); $commit = $repository->getReferences()->getTag('0.1')->getCommit(); $resolved = $repository->getReferences()->resolve($commit->getHash()); - $this->assertEquals(1, count($resolved), '1 revision resolved'); - $this->assertTrue(reset($resolved) instanceof Tag, 'Resolved object is a tag'); + $this->assertCount(1, $resolved, '1 revision resolved'); + $this->assertSame('refs/tags/0.1', $resolved[0]->getFullname()); } - /** - * @dataProvider provideFoobar - */ - public function testResolveTags($repository) + #[DataProvider('provideBare')] + public function testResolveTags(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getReferences()->getTag('0.1')->getCommit(); $resolved = $repository->getReferences()->resolveTags($commit->getHash()); - $this->assertEquals(1, count($resolved), '1 revision resolved'); - $this->assertTrue(reset($resolved) instanceof Tag, 'Resolved object is a tag'); + $this->assertCount(1, $resolved, '1 revision resolved'); + $this->assertSame('refs/tags/0.1', $resolved[0]->getFullname()); } - /** - * @dataProvider provideFoobar - */ - public function testResolveBranches($repository) + #[DataProvider('provideBare')] + public function testResolveBranches(bool $bare): void { + $repository = self::createFoobarRepository($bare); $master = $repository->getReferences()->getBranch('master'); $resolved = $repository->getReferences()->resolveBranches($master->getCommitHash()); if ($repository->isBare()) { - $this->assertEquals(1, count($resolved), '1 revision resolved'); + $this->assertCount(1, $resolved, '1 revision resolved'); } else { - $this->assertEquals(2, count($resolved), '2 revision resolved'); + $this->assertCount(2, $resolved, '2 revision resolved'); } - $this->assertTrue(reset($resolved) instanceof Branch, 'Resolved object is a branch'); + $this->assertNotEmpty($resolved, 'A branch is resolved'); } - /** - * @dataProvider provideFoobar - */ - public function testCountable($repository) + #[DataProvider('provideBare')] + public function testCountable(bool $bare): void { - $this->assertGreaterThanOrEqual(2, count($repository->getReferences()), 'At least two references in repository'); + $repository = self::createFoobarRepository($bare); + $this->assertGreaterThanOrEqual(2, \count($repository->getReferences()), 'At least two references in repository'); } - /** - * @dataProvider provideFoobar - */ - public function testIterable($repository) + #[DataProvider('provideBare')] + public function testIterable(bool $bare): void { + $repository = self::createFoobarRepository($bare); $i = 0; foreach ($repository->getReferences() as $ref) { - $i++; + ++$i; } $this->assertGreaterThanOrEqual(2, $i, 'At least two references in repository'); } - /** - * @dataProvider provideFoobar - */ - public function testCreateAndDeleteTag($repository) + #[DataProvider('provideBare')] + public function testCreateAndDeleteTag(bool $bare): void { + $repository = self::createFoobarRepository($bare); $references = $repository->getReferences(); $tag = $references->createTag('0.0', self::INITIAL_COMMIT); $this->assertTrue($references->hasTag('0.0'), 'Tag 0.0 created'); - $this->assertEquals(self::INITIAL_COMMIT, $tag->getCommit()->getHash()); + $this->assertSame(self::INITIAL_COMMIT, $tag->getCommit()->getHash()); $this->assertSame($tag, $references->getTag('0.0')); $tag->delete(); $this->assertFalse($references->hasTag('0.0'), 'Tag 0.0 removed'); } - /** - * @dataProvider provideFoobar - */ - public function testCreateAndDeleteBranch($repository) + #[DataProvider('provideBare')] + public function testCreateAndDeleteBranch(bool $bare): void { + $repository = self::createFoobarRepository($bare); $references = $repository->getReferences(); $branch = $references->createBranch('foobar', self::INITIAL_COMMIT); $this->assertTrue($references->hasBranch('foobar'), 'Branch foobar created'); - $this->assertEquals(self::INITIAL_COMMIT, $branch->getCommit()->getHash()); + $this->assertSame(self::INITIAL_COMMIT, $branch->getCommit()->getHash()); $this->assertSame($branch, $references->getBranch('foobar')); $branch->delete(); $this->assertFalse($references->hasBranch('foobar'), 'Branch foobar removed'); } + + public function testIsBranchMergedToMaster(): void + { + $repository = self::createFoobarRepository(false); + + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + + $master = $repository->getReferences()->getBranch('master'); + $references = $repository->getReferences(); + + $branch = $references->createBranch('foobar-new', $master->getCommit()->getHash()); + + $this->assertTrue($branch->isMergedInto('master')); + $this->assertTrue($branch->isMergedInto($master)); + + $wc = $repository->getWorkingCopy(); + $wc->checkout('foobar-new'); + + $file = $repository->getWorkingDir().'/foobar-test.txt'; + file_put_contents($file, 'test'); + self::git($repository, 'add', [$file]); + self::git($repository, 'commit', ['-m', 'foobar-test.txt updated']); + + // A branch is read once: read it again to see the new commit. + $this->assertFalse($references->getBranch('foobar-new')->isMergedInto('master')); + $this->assertTrue($master->isMergedInto('foobar-new')); + } + + #[DataProvider('provideBare')] + public function testCreateRefusesAnExistingReference(bool $bare): void + { + $references = self::createFoobarRepository($bare)->getReferences(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The reference "refs/heads/master" exists already.'); + + $references->createBranch('master', self::INITIAL_COMMIT); + } + + #[DataProvider('provideBare')] + public function testCreatedReferencesAreListed(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $references = $repository->getReferences(); + $names = static fn (array $tags): array => array_map(static fn (Tag $tag): string => $tag->getFullname(), $tags); + $before = $names($references->getTags()); + + $tag = $references->createTag('0.0', self::INITIAL_COMMIT); + + $this->assertSame(['refs/tags/0.0', ...$before], $names($references->getTags())); + $this->assertContains($tag, $repository->getReferences()->resolveTags(self::INITIAL_COMMIT)); + + $tag->delete(); + $this->assertSame($before, $names($references->getTags())); + } + + public function testResolveTagsOfARevision(): void + { + $references = self::createFoobarRepository()->getReferences(); + + $this->assertSame([$references->getTag('annotated')], $references->resolveTags('annotated')); + } + + public function testBranchMoveTo(): void + { + $repository = self::createFoobarRepository(); + $branch = $repository->getReferences()->createBranch('moving', self::INITIAL_COMMIT); + + $moved = $branch->moveTo($repository->getCommit(self::LONGFILE_COMMIT)); + + $this->assertSame(self::LONGFILE_COMMIT, $moved->getCommitHash()); + $this->assertSame($moved, $repository->getReferences()->getBranch('moving')); + $this->assertSame(self::INITIAL_COMMIT, $branch->getCommitHash(), 'A branch is read once'); + } + + public function testBranchesEndingWithHead(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'branch', ['AHEAD', 'master']); + + $this->assertTrue($repository->getReferences()->hasBranch('AHEAD')); + } + + public function testDeletingADeletedReferenceDoesNothing(): void + { + $repository = self::createFoobarRepository(); + $branch = $repository->getReferences()->createBranch('deleted', 'master'); + self::git($repository, 'update-ref', ['-d', 'refs/heads/deleted']); + + // Like git update-ref -d. + $branch->delete(); + + $this->assertFalse($repository->getReferences()->hasBranch('deleted')); + } + + /** + * The symbolic reference is deleted, not the branch it points to. + */ + public function testDeletingASymbolicReference(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['refs/remotes/origin/main', self::LONGFILE_COMMIT]); + self::git($repository, 'symbolic-ref', ['refs/remotes/origin/HEAD', 'refs/remotes/origin/main']); + + $repository->getReferences()->delete('refs/remotes/origin/HEAD'); + + $this->assertSame(\sprintf("%s refs/remotes/origin/main\n", self::LONGFILE_COMMIT), self::git($repository, 'for-each-ref', ['--format=%(objectname) %(refname)', 'refs/remotes/origin/'])); + } + + /** + * The symbolic reference moves, not the branch it points to. + * ReferenceBag leaves it out: through the driver, which Branch::moveTo() + * calls. + */ + public function testUpdatingASymbolicReference(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['refs/remotes/origin/main', self::LONGFILE_COMMIT]); + self::git($repository, 'symbolic-ref', ['refs/remotes/origin/HEAD', 'refs/remotes/origin/main']); + + $repository->getRepositoryDriver()->updateReference('refs/remotes/origin/HEAD', self::INITIAL_COMMIT); + + $this->assertSame(\sprintf("%s refs/remotes/origin/HEAD\n%s refs/remotes/origin/main\n", self::INITIAL_COMMIT, self::LONGFILE_COMMIT), self::git($repository, 'for-each-ref', ['--format=%(objectname) %(refname)', 'refs/remotes/origin/'])); + } + + /** + * "git update-ref -d HEAD" deletes HEAD: git no longer sees a repository. + */ + #[DataProvider('provideBare')] + public function testDeletingHeadIsRefused(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + + try { + $repository->getReferences()->delete('HEAD'); + $this->fail('HEAD is no reference under "refs/".'); + } catch (InvalidArgumentException $e) { + $this->assertSame('"HEAD" is not the full name of a reference under "refs/", like "refs/heads/master".', $e->getMessage()); + } + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + } + + /** + * "git update-ref" writes any name: HEAD, or a short name next to it. + */ + #[DataProvider('provideWritesOfANameOutsideRefs')] + public function testTheDriverWritesNoReferenceOutsideRefs(\Closure $write, string $fullname): void + { + $repository = self::createFoobarRepository(); + + try { + $write($repository, $fullname); + $this->fail('Only a reference under "refs/" is written.'); + } catch (InvalidArgumentException $e) { + $this->assertSame(\sprintf('"%s" is not the full name of a reference under "refs/", like "refs/heads/master".', $fullname), $e->getMessage()); + } + + $this->assertSame('ref: refs/heads/master', trim((string) file_get_contents($repository->getGitDir().'/HEAD'))); + $this->assertFileDoesNotExist($repository->getGitDir().'/master'); + } + + /** + * @return iterable + */ + public static function provideWritesOfANameOutsideRefs(): iterable + { + $writes = [ + 'create' => static function (Repository $repository, string $fullname): void { $repository->getRepositoryDriver()->createReference($fullname, self::INITIAL_COMMIT); }, + 'update' => static function (Repository $repository, string $fullname): void { $repository->getRepositoryDriver()->updateReference($fullname, self::INITIAL_COMMIT); }, + 'delete' => static function (Repository $repository, string $fullname): void { $repository->getRepositoryDriver()->deleteReference($fullname); }, + ]; + + foreach ($writes as $writeName => $write) { + foreach (['HEAD', 'master'] as $fullname) { + yield $writeName.' '.$fullname => [$write, $fullname]; + } + } + } + + /** + * Nothing is created through a symbolic reference to a missing branch: + * git 2.x and libgit2 refuse it, git 1.x replaces it. + */ + public function testCreatingABranchNamedLikeADanglingSymbolicReference(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'symbolic-ref', ['refs/heads/dangling', 'refs/heads/missing']); + + try { + $repository->getReferences()->createBranch('dangling', self::LONGFILE_COMMIT); + } catch (DriverException) { + } + + $this->assertFalse($repository->getReferences()->hasBranch('missing')); + } + + public function testTheHeadOfARemoteNamedWithASlashIsNoBranch(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['refs/remotes/foo/bar/main', self::LONGFILE_COMMIT]); + self::git($repository, 'symbolic-ref', ['refs/remotes/foo/bar/HEAD', 'refs/remotes/foo/bar/main']); + + $names = array_map(static fn (Branch $branch): string => $branch->getFullname(), $repository->getReferences()->getRemoteBranches()); + + $this->assertContains('refs/remotes/foo/bar/main', $names); + $this->assertNotContains('refs/remotes/foo/bar/HEAD', $names); + } + + public function testABranchPointingToAnAnnotatedTagPointsToItsCommit(): void + { + $repository = self::createFoobarRepository(); + // Like a fetch of the tags of a remote under refs/remotes/. + self::git($repository, 'update-ref', ['refs/remotes/origin/tags/annotated', trim(self::git($repository, 'rev-parse', ['refs/tags/annotated']))]); + + $branch = $repository->getReferences()->getRemoteBranch('origin/tags/annotated'); + + $this->assertSame('fbde681b329a39e08b63dc54b341a3274c0380c0', $branch->getCommitHash()); + $this->assertContains($branch, $repository->getCommit(self::INITIAL_COMMIT)->getIncludingBranches()); + } + + /** + * Old tags (e.g. in git.git or linux) have no "tagger" line. + */ + public function testAnnotatedTagWithoutTagger(): void + { + $repository = self::createFoobarRepository(false); + $hash = self::writeObject($repository, 'tag', \sprintf("object %s\ntype commit\ntag notagger\n\nNo tagger.\n", self::LONGFILE_COMMIT)); + self::git($repository, 'update-ref', ['refs/tags/notagger', $hash]); + + $tag = $repository->getReferences()->getTag('notagger'); + + $this->assertTrue($tag->isAnnotated()); + $this->assertSame(self::LONGFILE_COMMIT, $tag->getCommit()->getHash()); + $this->assertNull($tag->getTaggerName()); + $this->assertNull($tag->getTaggerEmail()); + $this->assertNull($tag->getTaggerDate()); + $this->assertSame("No tagger.\n", $tag->getMessage()); + } + + public function testSignedTag(): void + { + $repository = self::createFoobarRepository(false); + $hash = self::writeObject($repository, 'tag', \sprintf("object %s\ntype commit\ntag signed\ntagger Alice 1471428000 +0000\n\nSigned.\n-----BEGIN PGP SIGNATURE-----\n\nsignature\n-----END PGP SIGNATURE-----\n", self::LONGFILE_COMMIT)); + self::git($repository, 'update-ref', ['refs/tags/signed', $hash]); + + $tag = $repository->getReferences()->getTag('signed'); + + $this->assertTrue($tag->isSigned()); + $this->assertSame("\n\nsignature\n", $tag->getGpgSignature()); + $this->assertSame("Signed.\n", $tag->getMessage()); + } + + public function testSshSignedTag(): void + { + $repository = self::createFoobarRepository(false); + $hash = self::writeObject($repository, 'tag', \sprintf("object %s\ntype commit\ntag signed\ntagger Alice 1471428000 +0000\n\nSigned.\n-----BEGIN SSH SIGNATURE-----\nsignature\n-----END SSH SIGNATURE-----\n", self::LONGFILE_COMMIT)); + self::git($repository, 'update-ref', ['refs/tags/signed', $hash]); + + $tag = $repository->getReferences()->getTag('signed'); + + $this->assertTrue($tag->isSigned()); + $this->assertSame("\nsignature\n", $tag->getGpgSignature()); + $this->assertSame("Signed.\n", $tag->getMessage()); + } + + #[DataProvider('provideBare')] + public function testLightweightTag(bool $bare): void + { + $tag = self::createFoobarRepository($bare)->getReferences()->getTag('0.1'); + + $this->assertFalse($tag->isAnnotated()); + $this->assertFalse($tag->isSigned()); + $this->assertNull($tag->getTaggerName()); + $this->assertNull($tag->getMessage()); + $this->assertNull($tag->getSubjectMessage()); + $this->assertNull($tag->getBodyMessage()); + $this->assertNull($tag->getGpgSignature()); + } } diff --git a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php new file mode 100644 index 00000000..4c05b7e3 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php @@ -0,0 +1,178 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\Credentials; +use Gitonomy\Git\Util\RemoteUrl; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * An application may check or clone a URL from its users: git must run no + * command for it. + */ +class RemoteUrlTest extends AbstractTestCase +{ + /** + * In a repository with a remote, "git ls-remote --upload-pack=..." runs + * the command to list the references of the remote. + */ + #[DataProvider('provideCalls')] + public function testAUrlStartingWithADashIsRefused(\Closure $call): void + { + $repository = self::createFoobarRepository(false); + $previousCwd = getcwd(); + $this->assertIsString($previousCwd); + chdir($repository->getPath()); + + try { + // A colon before any slash: a URL, never a local path. + $call('--upload-pack=touch pwned;:;git-upload-pack', self::createTempDir().'/clone'); + $this->fail('A leading dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('starts with a dash', $e->getMessage()); + } finally { + chdir($previousCwd); + } + + $this->assertFileDoesNotExist($repository->getPath().'/pwned'); + } + + /** + * git runs the command of an "ext::" URL, unless its protocol.allow + * setting forbids it, as it does by default since git 2.12. + */ + #[DataProvider('provideCalls')] + public function testAnExtUrlIsRefused(\Closure $call): void + { + $file = self::createTempDir().'/pwned'; + + try { + // "% " is a space in the command of an "ext::" URL. + $call('ext::sh -c touch% '.$file, self::createTempDir().'/clone'); + $this->fail('An "ext::" URL is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('uses the "ext::" transport', $e->getMessage()); + } + + $this->assertFileDoesNotExist($file); + } + + /** + * git talks through the file descriptors of an "fd::" URL, which it + * inherits from PHP, like the socket of a database. + */ + #[DataProvider('provideCalls')] + public function testAnFdUrlIsRefused(\Closure $call): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"fd::0" uses the "fd::" transport, which talks through a file descriptor of PHP.'); + + $call('fd::0', self::createTempDir().'/clone'); + } + + /** + * @return iterable + */ + public static function provideCalls(): iterable + { + yield 'valid repository' => [static function (string $url): void { Repository::isValidRepository($url, self::getDriver()); }]; + yield 'valid branch' => [static function (string $url): void { Repository::isValidRepositoryAndBranch($url, 'master', self::getDriver()); }]; + yield 'clone' => [static function (string $url, string $path): void { Repository::cloneFrom($url, $path, driver: self::getDriver()); }]; + yield 'bare clone' => [static function (string $url, string $path): void { Repository::cloneBareFrom($url, $path, driver: self::getDriver()); }]; + yield 'mirror' => [static function (string $url, string $path): void { Repository::mirrorFrom($url, $path, self::getDriver()); }]; + } + + /** + * Before 2.14.1 (CVE-2017-1000117), git passes a host starting with a dash + * to ssh, which reads it as an option: "-oProxyCommand=..." runs the + * command. Recent versions of OpenSSH refuse such a host. + */ + #[DataProvider('provideHostsStartingWithADash')] + public function testAHostStartingWithADashIsRefused(\Closure $call, string $url): void + { + $dir = self::createTempDir(); + $previousCwd = getcwd(); + $this->assertIsString($previousCwd); + chdir($dir); + + try { + $call($url, $dir.'/clone'); + $this->fail('A host starting with a dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertSame(\sprintf('"%s" has a host or a user starting with a dash, which ssh, or the command of core.gitProxy, would read as an option.', Credentials::redact($url)), $e->getMessage()); + } finally { + chdir($previousCwd); + } + + $this->assertFileDoesNotExist($dir.'/pwned'); + } + + /** + * @return iterable + */ + public static function provideHostsStartingWithADash(): iterable + { + // "${IFS}": the space of a command in a host. + $command = '-oProxyCommand=touch${IFS}pwned'; + $urls = [ + 'ssh' => 'ssh://'.$command.'/repository', + 'git+ssh' => 'git+ssh://'.$command.'/repository', + 'ssh+git' => 'ssh+git://'.$command.'/repository', + 'host between brackets' => 'ssh://['.$command.']/repository', + 'user' => 'ssh://'.$command.'@example.com/repository', + 'host after a user' => 'ssh://git@'.$command.'/repository', + 'address' => 'git@'.$command.':repository', + 'address between brackets' => '['.$command.']:repository', + // Passed to the command of the core.gitProxy setting. + 'git' => 'git://'.$command.'/repository', + // git decodes a URL before it reads its host. + 'encoded dash' => 'ssh://%2D'.substr($command, 1).'/repository', + 'encoded dash, lowercase' => 'git+ssh://%2d'.substr($command, 1).'/repository', + 'encoded dash of a user' => 'ssh://%2D'.substr($command, 1).'@example.com/repository', + 'encoded brackets' => 'ssh://%5B'.$command.'%5D/repository', + ]; + + foreach (self::provideCalls() as $callName => [$call]) { + foreach ($urls as $urlName => $url) { + yield $callName.', '.$urlName => [$call, $url]; + } + } + } + + #[DataProvider('provideUrlsGitReachesWithoutRunningACommand')] + public function testAUrlGitReachesWithoutRunningACommandIsAccepted(string $url): void + { + RemoteUrl::assertSafe($url); + + $this->addToAssertionCount(1); + } + + /** + * @return iterable + */ + public static function provideUrlsGitReachesWithoutRunningACommand(): iterable + { + yield 'ssh' => ['ssh://git@example.com:2222/repository.git']; + yield 'host between brackets' => ['ssh://git@[::1]/repository.git']; + yield 'address' => ['git@example.com:repository.git']; + yield 'dash in the path of an address' => ['git@example.com:-repository.git']; + yield 'dash in the host' => ['ssh://git@my-host.example.com/repository.git']; + yield 'encoded character in the user' => ['ssh://first%2Dlast@example.com/repository.git']; + // Not reached through ssh: a token may start with a dash. + yield 'https' => ['https://-token@example.com/repository.git']; + yield 'local path' => ['/path/to/repository.git']; + yield 'local path with a colon' => ['./a:b']; + } +} diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index d00a102e..21202642 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -1,6 +1,6 @@ @@ -13,92 +13,940 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Blob; -use Prophecy\Argument; +use Gitonomy\Git\Commit; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ProcessTimedOutException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; +use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Process; -class RepositoryTest extends AbstractTest +class RepositoryTest extends AbstractTestCase { + public function testInitBare(): void + { + $dir = self::createTempDir(); + $repository = Repository::initBare($dir, driver: self::getDriver()); + + $this->assertTrue($repository->isBare(), 'Repository is bare'); + $this->assertDirectoryExists($dir.'/objects', 'objects/ folder is present'); + $this->assertSame($dir, $repository->getGitDir(), 'The folder passed as argument is git dir'); + $this->assertNull($repository->getWorkingDir(), 'No working dir in bare repository'); + } + + public function testInit(): void + { + $dir = self::createTempDir(); + $repository = Repository::init($dir, driver: self::getDriver()); + + $this->assertFalse($repository->isBare(), 'Repository is not bare'); + $this->assertDirectoryExists($dir.'/.git/objects', 'objects/ folder is present'); + $this->assertSame($dir.'/.git', $repository->getGitDir(), 'git dir as subfolder of argument'); + $this->assertSame($dir, $repository->getWorkingDir(), 'The folder passed as argument is the working dir'); + } + + public function testOpensALinkedWorktree(): void + { + if (version_compare(self::gitVersion(), '2.5.0', '<')) { + $this->markTestSkipped('git worktree requires git 2.5.'); + } + + $repository = self::createFoobarRepository(false); + $dir = self::createTempDir().'/worktree'; + self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); + + // Its ".git" is a file pointing to a directory of the main repository. + $worktree = new Repository($dir, driver: self::getDriver()); + + $this->assertSame(realpath($dir), $worktree->getWorkingDir()); + $this->assertSame(realpath($repository->getGitDir().'/worktrees/worktree'), $worktree->getGitDir()); + $this->assertFalse($worktree->isBare()); + $head = $worktree->getHead(); + $this->assertInstanceOf(Branch::class, $head); + $this->assertSame('in-worktree', $head->getName()); + $this->assertSame($repository->getHeadCommit()->getHash(), $worktree->getHeadCommit()->getHash()); + } + /** - * @dataProvider provideFoobar + * The ".git" file of a submodule points to its git directory, in the one + * of the superproject, with a relative path. */ - public function testGetBlob_WithExisting_Works($repository) + public function testOpensASubmodule(): void { - $blob = $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('README.md'); + $superproject = self::createFoobarRepository(false); + $source = self::createFoobarRepository(); + // git refuses to clone a local submodule since 2.38.1, unless protocol.file.allow allows it. + new Process([self::gitCommand(), '-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', $source->getGitDir(), 'sub'], $superproject->getWorkingDir(), self::gitEnvs())->mustRun(); + $dir = $superproject->getWorkingDir().'/sub'; + $this->assertFileMatchesFormat('gitdir: ../.git/modules/sub', $dir.'/.git'); + + $submodule = new Repository($dir, driver: self::getDriver()); - $this->assertTrue($blob instanceof Blob, 'getBlob() returns a Blob object'); - $this->assertContains('Foo Bar project', $blob->getContent(), 'file is correct'); + $this->assertSame(realpath($dir), $submodule->getWorkingDir()); + $this->assertSame(realpath($superproject->getGitDir().'/modules/sub'), $submodule->getGitDir()); + $this->assertFalse($submodule->isBare()); + $this->assertSame($source->getHeadCommit()->getHash(), $submodule->getHeadCommit()->getHash()); } /** - * @dataProvider provideFoobar + * "git clone --separate-git-dir" writes a ".git" file with the absolute + * path of the git directory. */ - public function testGetSize($repository) + public function testOpensACloneWithASeparateGitDir(): void { - $size = $repository->getSize(); - $this->assertGreaterThan(70, $size, 'Repository is greater than 70KB'); + $source = self::createFoobarRepository(); + [$dir, $gitDir] = self::cloneWithASeparateGitDir($source); + + $repository = new Repository($dir, driver: self::getDriver()); + + $this->assertSame(realpath($dir), $repository->getWorkingDir()); + $this->assertSame(realpath($gitDir), $repository->getGitDir()); + $this->assertFalse($repository->isBare()); + $this->assertSame($source->getHeadCommit()->getHash(), $repository->getHeadCommit()->getHash()); } - public function testIsBare() + /** + * Like git, which leaves out the line ending of a ".git" file only: a + * path may end with a space. + */ + #[DataProvider('provideLineEndings')] + public function testOpensAGitFileWhateverItsLineEnding(string $lineEnding): void { - $bare = self::createFoobarRepository(true); - $this->assertTrue($bare->isBare(), 'Lib repository is bare'); + [$dir, $gitDir] = self::cloneWithASeparateGitDir(self::createFoobarRepository()); + file_put_contents($dir.'/.git', 'gitdir: '.$gitDir.$lineEnding); - $notBare = self::createFoobarRepository(false); - $this->assertFalse($notBare->isBare(), 'Working copy is not bare'); + $this->assertSame(realpath($gitDir), new Repository($dir, driver: self::getDriver())->getGitDir()); } /** - * @dataProvider provideFoobar + * @return iterable */ - public function testGetDescription($repository) + public static function provideLineEndings(): iterable { - $this->assertSame("Unnamed repository; edit this file 'description' to name the repository.\n", $repository->getDescription()); + yield 'none' => ['']; + yield 'LF' => ["\n"]; + yield 'CRLF, written on Windows' => ["\r\n"]; + } + + #[DataProvider('provideGitFilesPointingToNoGitDirectory')] + public function testAGitFilePointingToNoGitDirectoryThrows(string $content, string $message): void + { + $dir = self::createTempDir(); + file_put_contents($dir.'/.git', $content); + + $this->expectException(DriverException::class); + $this->expectExceptionMessage(\sprintf($message, $dir)); + + new Repository($dir, driver: self::getDriver()); } /** - * @dataProvider provideFoobar + * @return iterable */ - public function testLoggerOk($repository) + public static function provideGitFilesPointingToNoGitDirectory(): iterable { - if (!interface_exists('Psr\Log\LoggerInterface')) { - $this->markTestSkipped(); + $invalid = 'The "%s/.git" file doesn\'t point to a git directory.'; + + yield 'another format' => ["ref: refs/heads/master\n", $invalid]; + // Like git, which reads "gitdir: " at the start of the file only. + yield 'gitdir on the second line' => ["\ngitdir: missing\n", $invalid]; + yield 'no path' => ["gitdir: \n", $invalid]; + yield 'NUL byte' => ["gitdir: a\0b\n", $invalid]; + yield 'missing directory' => ["gitdir: missing\n", 'The git directory "missing" of "%s" does not exist.']; + } + + public function testAnUnreadableGitFileThrows(): void + { + $dir = self::createTempDir(); + file_put_contents($dir.'/.git', "gitdir: missing\n"); + chmod($dir.'/.git', 0); + + try { + if (is_readable($dir.'/.git')) { + $this->markTestSkipped('The user of the tests reads any file, like root.'); + } + + $this->expectException(DriverException::class); + $this->expectExceptionMessage(\sprintf('Unable to read the "%s/.git" file.', $dir)); + + new Repository($dir, driver: self::getDriver()); + } finally { + // Or the file can't be deleted on Windows. + chmod($dir.'/.git', 0o600); } + } - $loggerProphecy = $this->prophesize('Psr\Log\LoggerInterface'); - $loggerProphecy - ->info('run command: remote "" ') - ->shouldBeCalledTimes(1); - $loggerProphecy - ->debug(Argument::type('string')) // duration, return code and output - ->shouldBeCalledTimes(3); + public function testOpeningADirectoryThatIsNotARepositoryThrows(): void + { + $dir = self::createTempDir(); - $repository->setLogger($loggerProphecy->reveal()); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Directory "%s" is not a git repository.', $dir)); - $repository->run('remote'); + new Repository($dir, driver: self::getDriver()); + } + + public function testReadsTheHeadOfAReftableRepository(): void + { + // libgit2 1.9 doesn't read reftable repositories. + $this->skipUnlessCliDriver(); + if (version_compare(self::gitVersion(), '2.45.0', '<')) { + $this->markTestSkipped('Reftable repositories require git 2.45.'); + } + + $dir = self::createTempDir(); + new Process([self::gitCommand(), 'init', '-q', '--ref-format=reftable', '--initial-branch=trunk', $dir])->mustRun(); + + $repository = new Repository($dir, driver: self::getDriver()); + $this->assertTrue($repository->isHeadAttached()); + + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + self::git($repository, 'commit', ['--allow-empty', '-m', 'First']); + + // The HEAD file of a reftable repository holds "ref: refs/heads/.invalid". + $this->assertSame('trunk', self::headBranch($repository)->getName()); + $this->assertSame('First', $repository->getHeadCommit()->getSubjectMessage()); + } + + public function testDetachedHead(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'checkout', ['-q', self::LONGFILE_COMMIT]); + + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); + } + + public function testTheHeadOfARepositoryWithoutCommitsThrows(): void + { + $repository = self::createEmptyRepository(); + + // HEAD points to a branch without commits yet. + $this->assertTrue($repository->isHeadAttached()); + + try { + $repository->getHeadCommit(); + $this->fail('A repository without commits has no HEAD commit.'); + } catch (ReferenceNotFoundException) { + } + + $this->expectException(ReferenceNotFoundException::class); + + $repository->getHead(); + } + + /** + * gitlib doesn't see the writes of another process, but doesn't fail on them. + */ + public function testTheHeadAnotherProcessChecksOut(): void + { + $repository = self::createFoobarRepository(false); + $repository->getReferences()->getAll(); + new Process([self::gitCommand(), 'checkout', '-q', '-b', 'elsewhere'], $repository->getWorkingDir(), self::gitEnvs())->mustRun(); + + $this->assertSame('refs/heads/elsewhere', self::headBranch($repository)->getFullname()); + } + + public function testHeadPointingToATagIsNoBranch(): void + { + $repository = self::createFoobarRepository(); + // Like a branch for git, which commits to the tag. + self::git($repository, 'symbolic-ref', ['HEAD', 'refs/tags/0.1']); + + $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('HEAD points to "refs/tags/0.1", which is not a branch.'); + + $repository->getHead(); + } + + public function testObjectsAreTheSameWhileUsed(): void + { + $repository = self::createFoobarRepository(); + $commit = $repository->getCommit(self::INITIAL_COMMIT); + + $this->assertSame($commit, $repository->getCommit(self::INITIAL_COMMIT)); + $this->assertSame($repository->getBlob('412a0dad051163dcf9f9ce2fc04a141ffcb69479'), $repository->getBlob('412a0dad'), 'An abbreviated hash names the same blob'); + } + + public function testRefusesSha256Repositories(): void + { + if (version_compare(self::gitVersion(), '2.29.0', '<')) { + $this->markTestSkipped('SHA-256 repositories require git 2.29.'); + } + + $dir = self::createTempDir(); + new Process([self::gitCommand(), 'init', '-q', '--object-format=sha256', $dir])->mustRun(); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('uses SHA-256 hashes'); + + new Repository($dir, driver: self::getDriver()); + } + + public function testTimeout(): void + { + $repository = self::createFoobarRepository(false); + $repository->getHooks()->set('pre-commit', "#!/bin/sh\nsleep 10\n"); + $repository = new Repository($repository->getPath(), driver: self::getCliDriver(['GIT_AUTHOR_NAME' => 'A', 'GIT_AUTHOR_EMAIL' => 'a@example.com', 'GIT_COMMITTER_NAME' => 'A', 'GIT_COMMITTER_EMAIL' => 'a@example.com'], processTimeout: 0.5)); + + $this->expectException(ProcessTimedOutException::class); + $this->expectExceptionMessage('The git command "commit" ran longer than 0.5 seconds.'); + + $repository->run('commit', ['--allow-empty', '-m', 'Waits for its hook']); + } + + public function testATimeoutOfOneSecond(): void + { + $repository = self::createFoobarRepository(false); + $repository->getHooks()->set('pre-commit', "#!/bin/sh\nsleep 10\n"); + $repository = new Repository($repository->getPath(), driver: self::getCliDriver(['GIT_AUTHOR_NAME' => 'A', 'GIT_AUTHOR_EMAIL' => 'a@example.com', 'GIT_COMMITTER_NAME' => 'A', 'GIT_COMMITTER_EMAIL' => 'a@example.com'], processTimeout: 1)); + + $this->expectException(ProcessTimedOutException::class); + $this->expectExceptionMessage('The git command "commit" ran longer than 1 second.'); + + $repository->run('commit', ['--allow-empty', '-m', 'Waits for its hook']); } /** - * @dataProvider provideFoobar - * @expectedException RuntimeException + * Symfony Process checks the timeout when git starts too: its exception + * held the command line. */ - public function testLoggerNOk($repository) + public function testATimeoutWhileGitStarts(): void + { + $repository = new Repository(self::createFoobarRepository()->getPath(), driver: self::getCliDriver(processTimeout: 0.000001)); + + try { + $repository->run('log', ['-n', '1']); + $this->markTestSkipped('git started within the timeout.'); + } catch (ProcessTimedOutException $e) { + $this->assertSame('The git command "log" ran longer than 1.0E-6 seconds.', $e->getMessage()); + } + } + + public function testIsValidRepositoryAndBranchMatchesTheNameExactly(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['refs/heads/feature/release', self::LONGFILE_COMMIT]); + $url = $repository->getGitDir(); + + $this->assertTrue(Repository::isValidRepositoryAndBranch($url, 'feature/release', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'release', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'rel*', self::getDriver())); + } + + public function testIsValidRepositoryReadsANameAsAPath(): void + { + // A clone, with an "origin" remote. + $repository = self::createFoobarRepository(false); + $cwd = getcwd(); + self::assertIsString($cwd); + + chdir($repository->getPath()); + + try { + $this->assertFalse(Repository::isValidRepository('origin', self::getDriver())); + $this->assertTrue(Repository::isValidRepository('.', self::getDriver())); + } finally { + chdir($cwd); + } + } + + /** + * git reads the configuration of the repository it runs in, like its + * rewrites of URLs: an application may run from a checkout. + */ + public function testTheWorkingDirectoryOfPhpRewritesNoUrl(): void + { + $url = self::createFoobarRepository()->getGitDir(); + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['url.'.self::createTempDir().'/missing.git.insteadOf', $url]); + $cwd = getcwd(); + self::assertIsString($cwd); + + chdir($repository->getPath()); + + try { + $this->assertTrue(Repository::isValidRepository($url, self::getDriver())); + $this->assertTrue(Repository::isValidRepositoryAndBranch($url, 'master', self::getDriver())); + } finally { + chdir($cwd); + } + } + + /** + * git ran in the working directory of PHP, which may be a repository. + */ + #[DataProvider('provideEmptyUrlCalls')] + public function testAnEmptyUrlIsRefused(\Closure $call): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The URL of a repository can\'t be empty.'); + + $call(); + } + + /** + * @return iterable + */ + public static function provideEmptyUrlCalls(): iterable + { + yield 'valid repository' => [static fn (): bool => Repository::isValidRepository('', self::getDriver())]; + yield 'valid branch' => [static fn (): bool => Repository::isValidRepositoryAndBranch('', 'master', self::getDriver())]; + yield 'clone' => [static fn (): Repository => Repository::cloneFrom('', self::createTempDir().'/clone', driver: self::getDriver())]; + } + + public function testIsValidRepositoryThrowsWhenGitCantRun(): void + { + $this->skipUnlessCliDriver(); + + try { + Repository::isValidRepository(self::repositoryUrl(), new CliDriver('not-git')); + $this->fail('git is missing.'); + } catch (DriverException $e) { + // One line, the error of the shell, like "sh: 1: exec: not-git: not found". + $this->assertMatchesRegularExpression('/^The git command "ls-remote" could not start: [^\n]+\.$/', $e->getMessage()); + } + } + + public function testCloneATagAsBranchDetachesHead(): void + { + $repository = Repository::cloneFrom(self::repositoryUrl(), self::createTempDir().'/clone', 'annotated', self::getDriver()); + + // Like git, at the commit of the tag, without any local branch. + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame($repository->getReferences()->getTag('annotated')->getCommitHash(), $repository->getHeadCommit()->getHash()); + $this->assertSame([], $repository->getReferences()->getLocalBranches()); + $this->assertFileExists($repository->getWorkingDir().'/README.md'); + $this->assertSame([], $repository->getWorkingCopy()->getDiff()->getFiles(), 'The files of the tag are checked out'); + $this->assertFileDoesNotExist($repository->getGitDir().'/FETCH_HEAD'); + } + + public function testCloneBareATagAsBranchDetachesHead(): void + { + $repository = Repository::cloneBareFrom(self::repositoryUrl(), self::createTempDir(), '0.1', self::getDriver()); + + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); + $this->assertFileDoesNotExist($repository->getGitDir().'/FETCH_HEAD'); + } + + public function testCloneOfAMissingBranchThrows(): void + { + $this->expectException(DriverException::class); + + Repository::cloneFrom(self::repositoryUrl(), self::createTempDir().'/clone', 'missing', self::getDriver()); + } + + public function testClonesWriteNoFetchHead(): void + { + $dir = self::createTempDir(); + + $this->assertFileDoesNotExist(Repository::cloneFrom(self::repositoryUrl(), $dir.'/clone', driver: self::getDriver())->getGitDir().'/FETCH_HEAD'); + $this->assertFileDoesNotExist(Repository::cloneBareFrom(self::repositoryUrl(), $dir.'/bare', driver: self::getDriver())->getGitDir().'/FETCH_HEAD'); + $this->assertFileDoesNotExist(Repository::mirrorFrom(self::repositoryUrl(), $dir.'/mirror', self::getDriver())->getGitDir().'/FETCH_HEAD'); + } + + public function testReadingAMovedRepositoryThrows(): void + { + $dir = self::createTempDir(); + $repository = Repository::cloneBareFrom(self::repositoryUrl(), $dir.'/repository', driver: self::getDriver()); + rename($dir.'/repository', $dir.'/moved'); + + $this->expectException(DriverException::class); + + $repository->getReferences()->getBranches(); + } + + public function testAnObjectOfAnotherTypeThrows(): void + { + $repository = self::createFoobarRepository(); + $treeHash = $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->getHash(); + + foreach ([ + 'blob' => static fn () => $repository->getBlob(self::LONGFILE_COMMIT)->getContent(), + 'tree' => static fn () => iterator_to_array($repository->getTree(self::LONGFILE_COMMIT)), + 'commit' => static fn () => $repository->getCommit($treeHash)->getMessage(), + ] as $type => $read) { + try { + $read(); + $this->fail(\sprintf('A %s of another type is refused.', $type)); + } catch (ReferenceNotFoundException $e) { + $this->assertMatchesRegularExpression(\sprintf('/^The object "[0-9a-f]{40}" is not a %s\.$/', $type), $e->getMessage()); + } + } + } + + public function testCloneRefusesADirectoryWithFiles(): void + { + $dir = self::createTempDir(); + touch($dir.'/file'); + + $this->expectException(DriverException::class); + + Repository::cloneBareFrom(self::repositoryUrl(), $dir, driver: self::getDriver()); + } + + public function testFailedCloneLeavesNothing(): void + { + $dir = self::createTempDir().'/clone'; + + try { + Repository::cloneBareFrom(self::repositoryUrl(), $dir, 'unknown', self::getDriver()); + $this->fail('The clone should have failed.'); + } catch (DriverException) { + } + + $this->assertDirectoryDoesNotExist($dir); + } + + public function testMirrorDoesNotFetchTagsTwice(): void + { + if (!self::getDriver() instanceof LibGit2Driver) { + $this->markTestSkipped('The configuration CliDriver writes depends on the version of git.'); + } + + $mirror = Repository::mirrorFrom(self::repositoryUrl(), self::createTempDir(), self::getDriver()); + + $this->assertSame('--no-tags', trim(self::git($mirror, 'config', ['remote.origin.tagopt']))); + } + + public function testGetMergeBase(): void + { + $repository = self::createFoobarRepository(); + + $this->assertSame(self::LONGFILE_COMMIT, $repository->getMergeBase(self::LONGFILE_COMMIT, 'master')?->getHash()); + } + + public function testAnAbbreviatedHashNamesAnObjectOfTheRightType(): void + { + $repository = self::createFoobarRepository(); + $hash = substr($repository->getCommit(self::LONGFILE_COMMIT)->getTreeHash(), 0, 8); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage(\sprintf('The object "%s" is not a blob.', $hash)); + + $repository->getBlob($hash); + } + + public function testAnAbbreviatedHashOfNoObject(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The object "12345678" does not exist.'); + + $repository->getTree('12345678'); + } + + public function testAnAbbreviatedCommitHashNamesNoTree(): void + { + $repository = self::createFoobarRepository(); + $hash = substr(self::LONGFILE_COMMIT, 0, 8); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage(\sprintf('The object "%s" is not a tree.', $hash)); + + $repository->getTree($hash); + } + + public function testInitOnAnExistingFileFails(): void + { + $file = self::createTempDir().'/test'; + touch($file); + + $this->expectException(DriverException::class); + // git and libgit2 name the file they can't make a directory of. + $this->expectExceptionMessage($file); + + Repository::initBare($file, driver: self::getDriver()); + } + + public function testCliDriverLogsTheCommandsCreatingRepositories(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('info') + ->with($this->stringStartsWith('run command: init "-q --bare ')) + ; + + Repository::initBare(self::createTempDir(), self::getCliDriver(logger: $logger)); + } + + public function testCloneBareFrom(): void + { + $repository = self::createFoobarRepository(true); + + $newDir = self::createTempDir(); + $new = Repository::cloneBareFrom($repository->getGitDir(), $newDir, driver: self::getDriver()); + + $newRefs = array_keys($new->getReferences()->getAll()); + + $this->assertContains('refs/heads/master', $newRefs); + $this->assertContains('refs/tags/0.1', $newRefs); + $this->assertSame($newDir, $new->getGitDir()); + $this->assertContains('refs/heads/new-feature', $newRefs); + // Like `git clone --bare`: the remote has no fetch refspec. + $this->assertStringNotContainsString('fetch', (string) file_get_contents($newDir.'/config')); + } + + public function testCloneFrom(): void + { + $repository = self::createFoobarRepository(false); + + $newDir = self::createTempDir(); + $new = Repository::cloneFrom($repository->getGitDir(), $newDir, driver: self::getDriver()); + + $newRefs = array_keys($new->getReferences()->getAll()); + + $this->assertContains('refs/heads/master', $newRefs); + $this->assertContains('refs/tags/0.1', $newRefs); + $this->assertSame($newDir.'/.git', $new->getGitDir()); + $this->assertSame($newDir, $new->getWorkingDir()); + } + + public function testCloneBareFromABranch(): void + { + // we can't use AbstractTestCase::createFoobarRepository() + // because it does not clone other branches than "master" + // so we test it directly against the remote repository + + $newDir = self::createTempDir(); + $new = Repository::cloneBareFrom(self::repositoryUrl(), $newDir, 'new-feature', driver: self::getDriver()); + + $head = $new->getHead(); + $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); + $this->assertSame('new-feature', $head->getName(), 'HEAD is branch new-feature'); + } + + public function testCloneFromABranch(): void + { + // we can't use AbstractTestCase::createFoobarRepository() + // because it does not clone other branches than "master" + // so we test it directly against remote repository + + $newDir = self::createTempDir(); + $new = Repository::cloneFrom(self::repositoryUrl(), $newDir, 'new-feature', driver: self::getDriver()); + + $head = $new->getHead(); + $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); + $this->assertSame('new-feature', $head->getName(), 'HEAD is branch new-feature'); + } + + public function testCloneFromAnUnknownBranchFails(): void + { + $this->expectException(DriverException::class); + + Repository::cloneBareFrom(self::repositoryUrl(), self::createTempDir().'/clone', 'unknown', driver: self::getDriver()); + } + + #[DataProvider('provideBare')] + public function testMirrorFrom(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $newDir = self::createTempDir(); + $new = Repository::mirrorFrom($repository->getGitDir(), $newDir, driver: self::getDriver()); + + $newRefs = array_keys($new->getReferences()->getAll()); + + $this->assertContains('refs/heads/master', $newRefs); + $this->assertContains('refs/tags/0.1', $newRefs); + $this->assertSame($newDir, $new->getGitDir()); + + if ($repository->isBare()) { + $this->assertContains('refs/heads/new-feature', $newRefs); + } else { + $this->assertContains('refs/remotes/origin/new-feature', $newRefs); + } + + $this->assertSame('true', trim(self::git($new, 'config', ['remote.origin.mirror']))); + $this->assertSame('+refs/*:refs/*', trim(self::git($new, 'config', ['remote.origin.fetch']))); + $this->assertSame('refs/heads/master', trim(self::git($new, 'symbolic-ref', ['HEAD']))); + } + + #[DataProvider('provideBare')] + public function testCheckValidRepository(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $url = $repository->getGitDir(); + $this->assertTrue(Repository::isValidRepository($url, self::getDriver())); + } + + public function testCheckInvalidRepository(): void + { + $url = self::createTempDir().'/invalid.git'; + mkdir($url); + + $this->assertFalse(Repository::isValidRepository($url, self::getDriver())); + } + + #[DataProvider('provideBare')] + public function testCheckValidRepositoryAndBranch(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $url = $repository->getGitDir(); + $this->assertTrue(Repository::isValidRepositoryAndBranch($url, 'master', self::getDriver())); + } + + #[DataProvider('provideBare')] + public function testCheckInvalidRepositoryAndBranch(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $url = $repository->getGitDir(); + $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'invalid-branch-name', self::getDriver())); + } + + #[DataProvider('provideBare')] + public function testCheckRepositoryAndBranchOnlyMatchesBranches(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + foreach (['0.1', 'HEAD', 'feature'] as $name) { + $this->assertFalse(Repository::isValidRepositoryAndBranch($repository->getGitDir(), $name, self::getDriver()), $name); + } + } + + public function testNullProcessTimeoutDisablesTheTimeout(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(true); + $repository = new Repository($repository->getPath(), driver: self::getCliDriver(processTimeout: null)); + + $process = $repository->runProcess('rev-parse', ['HEAD']); + $this->assertNull($process->getTimeout()); + $this->assertNull($process->getIdleTimeout()); + } + + public function testRunThrowsWhenTheCommandFails(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(true); + + $this->expectException(ProcessException::class); + + $repository->run('not-a-command'); + } + + public function testRunProcessGivesAccessToStderrOnSuccess(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(false); + + // `git checkout` reports the switched branch on stderr, even on success. + $process = $repository->runProcess('checkout', ['master']); + + $this->assertTrue($process->isSuccessful()); + $this->assertStringContainsString('master', $process->getErrorOutput()); + } + + public function testGetShortHashOfAnUnknownCommitThrowsReferenceNotFound(): void { - if (!interface_exists('Psr\Log\LoggerInterface')) { - $this->markTestSkipped(); + $repository = self::createFoobarRepository(true); + + $commit = new Commit($repository, str_repeat('a', 40)); + + $this->expectException(ReferenceNotFoundException::class); + + $commit->getShortHash(); + } + + #[DataProvider('provideBare')] + public function testGetBlobWithExistingWorks(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $blob = $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('README.md'); + + $this->assertInstanceOf(Blob::class, $blob, 'getBlob() returns a Blob object'); + $this->assertStringContainsString('Foo Bar project', $blob->getContent(), 'file is correct'); + } + + #[DataProvider('provideBare')] + public function testGetSizeInBytes(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $size = $repository->getSizeInBytes(); + // The exact figure depends on git's packing/compression, which varies across versions. + $this->assertGreaterThanOrEqual(40_000, $size, 'Repository is at least 40KB'); + $this->assertLessThan(84_000, $size, 'Repository is less than 84KB'); + } + + public function testGetSizeInBytesFollowsNoLink(): void + { + $repository = self::createEmptyRepository(); + $size = $repository->getSizeInBytes(); + + file_put_contents($repository->getGitDir().'/file', str_repeat('a', 1000)); + // Like a hook linking to a file not deployed yet. + symlink($repository->getGitDir().'/missing', $repository->getGitDir().'/link'); + + $this->assertSame($size + 1000, $repository->getSizeInBytes()); + } + + public function testGetSizeInBytesOfAnUnreadableDirectoryThrows(): void + { + $repository = self::createEmptyRepository(); + $dir = $repository->getGitDir().'/unreadable'; + mkdir($dir); + chmod($dir, 0); + + try { + if (is_readable($dir)) { + $this->markTestSkipped('The user of the tests reads any directory, like root.'); + } + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(\sprintf('Unable to read the directory "%s".', $dir)); + + $repository->getSizeInBytes(); + } finally { + // Or the directory can't be deleted. + chmod($dir, 0o700); } + } + + public function testIsBare(): void + { + $bare = self::createFoobarRepository(true); + $this->assertTrue($bare->isBare(), 'Lib repository is bare'); + + $notBare = self::createFoobarRepository(false); + $this->assertFalse($notBare->isBare(), 'Working copy is not bare'); + } + + #[DataProvider('provideBare')] + public function testGetDescription(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $this->assertSame("Unnamed repository; edit this file 'description' to name the repository.\n", $repository->getDescription()); + } + + #[DataProvider('provideBare')] + public function testLoggerOk(bool $bare): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('info') + ->with('run command: remote "" ') + ; + $logger->expects($this->exactly(3)) // duration, return code and output + ->method('debug') + ->with($this->isString()) + ; + + $repository = new Repository(self::createFoobarRepository($bare)->getPath(), driver: self::getCliDriver(logger: $logger)); + + $repository->run('remote'); + } + + #[DataProvider('provideBare')] + public function testLoggerNOk(bool $bare): void + { + $this->expectException(ProcessException::class); + $this->expectExceptionMessage('The git command "not-work" failed (exit 1): git: \'not-work\' is not a git command. See \'git --help\'.'); - $loggerProphecy = $this->prophesize('Psr\Log\LoggerInterface'); - $loggerProphecy - ->info(Argument::type('string')) - ->shouldBeCalledTimes(1); - $loggerProphecy - ->debug(Argument::type('string')) // duration, return code and output - ->shouldBeCalledTimes(3); - $loggerProphecy - ->error(Argument::type('string')) - ->shouldBeCalledTimes(1); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('info') + ->with($this->isString()) + ; + $logger->expects($this->exactly(4)) // duration, return code, output and error output + ->method('debug') + ->with($this->isString()) + ; + // The exception reports the failure: the caller may well expect it. + $logger->expects($this->never()) + ->method('error') + ; - $repository->setLogger($loggerProphecy->reveal()); + $repository = new Repository(self::createFoobarRepository($bare)->getPath(), driver: self::getCliDriver(logger: $logger)); $repository->run('not-work'); } + + /** + * @see https://github.com/gitonomy/gitlib/issues/67 + */ + public function testRunResolvesRelativePathsAgainstTheRepositoryRegardlessOfCwd(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(false); + + $patchFile = tempnam(sys_get_temp_dir(), 'gitlib_patch_'); + file_put_contents($patchFile, <<<'PATCH' + diff --git a/applied.txt b/applied.txt + new file mode 100644 + --- /dev/null + +++ b/applied.txt + @@ -0,0 +1 @@ + +Applied line. + + PATCH); + + $previousCwd = getcwd(); + $this->assertIsString($previousCwd); + chdir(sys_get_temp_dir()); + + try { + // "applied.txt" is relative to the repository work-tree, not to the + // process cwd (which is an unrelated directory here). This only + // works if the git process is run with its cwd set to the repository. + $repository->run('apply', [$patchFile]); + } finally { + chdir($previousCwd); + unlink($patchFile); + } + + $this->assertSame("Applied line.\n", file_get_contents($repository->getWorkingDir().'/applied.txt')); + } + + public function testRunIsOnlySupportedByCliDriver(): void + { + $repository = new Repository(self::createTempDir(), driver: $this->createStub(DriverInterface::class)); + + $this->expectException(UnsupportedOperationException::class); + + $repository->run('status'); + } + + public function testDriverOpensTheRepository(): void + { + $dir = self::createTempDir(); + $repositoryDriver = $this->createStub(RepositoryDriverInterface::class); + $repositoryDriver->method('getReferences')->willReturn([new ReferenceData('refs/heads/main', self::INITIAL_COMMIT)]); + + $driver = $this->createMock(DriverInterface::class); + $driver->expects($this->once())->method('open')->with($dir, '/elsewhere')->willReturn($repositoryDriver); + + $repository = new Repository($dir, '/elsewhere', $driver); + + $this->assertSame(self::INITIAL_COMMIT, $repository->getReferences()->getBranch('main')->getCommitHash()); + } + + /** + * @return array{string, string} The working tree of the clone, and its git directory + */ + private static function cloneWithASeparateGitDir(Repository $source): array + { + $dir = self::createTempDir().'/clone'; + $gitDir = self::createTempDir().'/git'; + new Process([self::gitCommand(), 'clone', '-q', '--separate-git-dir='.$gitDir, $source->getGitDir(), $dir], null, self::gitEnvs())->mustRun(); + + return [$dir, $gitDir]; + } } diff --git a/tests/Gitonomy/Git/Tests/RevisionTest.php b/tests/Gitonomy/Git/Tests/RevisionTest.php index 2f8428df..05532b36 100644 --- a/tests/Gitonomy/Git/Tests/RevisionTest.php +++ b/tests/Gitonomy/Git/Tests/RevisionTest.php @@ -1,6 +1,6 @@ @@ -13,49 +13,95 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Commit; -use Gitonomy\Git\Log; -use Gitonomy\Git\Revision; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use PHPUnit\Framework\Attributes\DataProvider; -class RevisionTest extends AbstractTest +class RevisionTest extends AbstractTestCase { - /** - * @dataProvider provideFoobar - */ - public function testGetCommit($repository) + #[DataProvider('provideBare')] + public function testGetCommit(bool $bare): void { + $repository = self::createFoobarRepository($bare); $revision = $repository->getRevision(self::LONGFILE_COMMIT.'^'); - $this->assertTrue($revision instanceof Revision, 'Revision object type'); - $commit = $revision->getCommit(); - $this->assertTrue($commit instanceof Commit, 'getCommit returns a Commit'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $commit->getHash(), 'Resolution is correct'); + } + + #[DataProvider('provideBare')] + public function testGetFailingReference(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The revision "non-existent-commit" does not exist, or names no commit.'); + + $repository->getRevision('non-existent-commit')->getCommit(); + } + + /** + * "@" is HEAD for git since 1.8.5, and for libgit2 alone only. + */ + #[DataProvider('provideAtRevisions')] + public function testAtIsHead(string $revision, string $expected): void + { + if (self::getDriver() instanceof CliDriver && version_compare(self::gitVersion(), '1.8.5', '<')) { + $this->markTestSkipped('git reads "@" as HEAD since 1.8.5.'); + } + + $repository = self::createFoobarRepository(); + $head = $repository->getHeadCommit(); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commit->getHash(), 'Resolution is correct'); + $this->assertSame('HEAD' === $expected ? $head->getHash() : $head->getParentHashes()[0], $repository->getRevision($revision)->getCommit()->getHash()); } /** - * @dataProvider provideFoobar - * @expectedException Gitonomy\Git\Exception\ReferenceNotFoundException - * @expectedExceptionMessage Can not find revision "non-existent-commit" + * @return iterable */ - public function testGetFailingReference($repository) + public static function provideAtRevisions(): iterable + { + yield 'alone' => ['@', 'HEAD']; + yield 'ancestor' => ['@~1', 'parent']; + yield 'parent' => ['@^', 'parent']; + yield 'peeled' => ['@^{commit}', 'HEAD']; + } + + public function testALogOfARangeFromAt(): void { - $revision = $repository->getRevision('non-existent-commit')->getCommit(); + if (self::getDriver() instanceof CliDriver && version_compare(self::gitVersion(), '1.8.5', '<')) { + $this->markTestSkipped('git reads "@" as HEAD since 1.8.5.'); + } + + $repository = self::createFoobarRepository(); + + $hashes = static fn (string $range): array => array_map(static fn (Commit $commit): string => $commit->getHash(), $repository->getLog($range)->getCommits()); + + $this->assertNotSame([], $hashes('HEAD~2..HEAD')); + $this->assertSame($hashes('HEAD~2..HEAD'), $hashes('@~2..@')); } /** - * @dataProvider provideFoobar + * The message names the revision, without the "^{commit}" gitlib adds. */ - public function testGetLog($repository) + public function testARevisionStartingWithADashIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"--output=/x" starts with a dash, which git would read as an option.'); + + self::createFoobarRepository()->getRevision('--output=/x')->getCommit(); + } + + #[DataProvider('provideBare')] + public function testGetLog(bool $bare): void { + $repository = self::createFoobarRepository($bare); $revision = $repository->getRevision(self::LONGFILE_COMMIT); $log = $revision->getLog(null, 2, 3); - - $this->assertTrue($log instanceof Log, 'Log type object'); - $this->assertEquals(2, $log->getOffset(), 'Log offset is passed'); - $this->assertEquals(3, $log->getLimit(), 'Log limit is passed'); - $this->assertEquals([$revision], $log->getRevisions()->getAll(), 'Revision is passed'); + $this->assertSame(2, $log->getOffset(), 'Log offset is passed'); + $this->assertSame(3, $log->getLimit(), 'Log limit is passed'); + $this->assertSame([$revision], $log->getRevisions()?->getAll(), 'Revision is passed'); } } diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index e6cbc025..1dab3e00 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -1,6 +1,6 @@ @@ -13,38 +13,130 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Blob; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Submodule; +use Gitonomy\Git\Tree; +use PHPUnit\Framework\Attributes\DataProvider; -class TreeTest extends AbstractTest +class TreeTest extends AbstractTestCase { - const PATH_RESOLVING_COMMIT = 'cc06ac171d884282202dff88c1ded499a1f89420'; + public const PATH_RESOLVING_COMMIT = 'cc06ac171d884282202dff88c1ded499a1f89420'; - /** - * @dataProvider provideFooBar - */ - public function testCase($repository) + #[DataProvider('provideBare')] + public function testIterate(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::LONGFILE_COMMIT)->getTree(); - $entries = $tree->getEntries(); + $entries = iterator_to_array($tree); - $this->assertTrue(isset($entries['long.php']), 'long.php is present'); - $this->assertTrue($entries['long.php'][1] instanceof Blob, 'long.php is a Blob'); + $this->assertInstanceOf(Blob::class, $entries['long.php']->getObject()); + $this->assertSame('long.php', $entries['long.php']->getName()); + $this->assertSame('100644', $entries['long.php']->getMode()); + $this->assertInstanceOf(Blob::class, $entries['README.md']->getObject()); + } + + #[DataProvider('provideBare')] + public function testIterateWithNonAsciiNames(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $entries = iterator_to_array($repository->getCommit(DiffTest::FILE_WITH_UMLAUTS_COMMIT)->getTree()); + + $this->assertArrayHasKey('file_with_umlauts_äöü', $entries); + } + + public function testIterateWithSpecialCharactersInNames(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + file_put_contents($repository->getWorkingDir().'/back\slash'."\ttab\"quote", 'special'); + self::git($repository, 'add', ['--all']); + self::git($repository, 'commit', ['-m', 'Add a file with special characters in its name']); + + $entries = iterator_to_array($repository->getHeadCommit()->getTree()); + + $this->assertArrayHasKey('back\slash'."\ttab\"quote", $entries); + } + + #[DataProvider('provideBare')] + public function testGetSubmodules(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $submodules = $tree->getSubmodules(); + + $this->assertSame(['barbaz'], array_keys($submodules)); + $this->assertInstanceOf(Submodule::class, $submodules['barbaz']->getObject()); + $this->assertSame('160000', $submodules['barbaz']->getMode()); + } + + public function testResolvePathThroughASubmoduleThrows(): void + { + $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to resolve "barbaz/file": "barbaz" is a submodule, not a tree.'); + + $tree->resolvePath('barbaz/file'); + } + + public function testResolveAMissingPathThrows(): void + { + $tree = self::createFoobarRepository()->getCommit(self::PATH_RESOLVING_COMMIT)->getTree(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to resolve "test/a/missing/c": "test/a/missing" doesn\'t exist.'); + + $tree->resolvePath('test/a/missing/c'); + } + + public function testResolveTheRootPath(): void + { + $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $this->assertSame($tree, $tree->resolvePath('/')); + $this->assertSame($tree, $tree->resolvePath('')); + } + + #[DataProvider('provideBare')] + public function testGetTrees(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + + $this->assertSame([], $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree()->getTrees()); + + $trees = $repository->getCommit(self::PATH_RESOLVING_COMMIT)->getTree()->getTrees(); + + $this->assertSame(['test'], array_keys($trees)); + $this->assertInstanceOf(Tree::class, $trees['test']->getObject()); + $this->assertSame('040000', $trees['test']->getMode()); + } + + #[DataProvider('provideBare')] + public function testGetBlobs(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $blobs = $tree->getBlobs(); - $this->assertTrue(isset($entries['README.md']), 'README.md is present'); - $this->assertTrue($entries['README.md'][1] instanceof Blob, 'README.md is a Blob'); + $this->assertArrayNotHasKey('barbaz', $blobs); + $this->assertInstanceOf(Blob::class, $blobs['README.md']->getObject()); } - /** - * @dataProvider provideFooBar - */ - public function testResolvePath($repository) + #[DataProvider('provideBare')] + public function testResolvePath(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::PATH_RESOLVING_COMMIT)->getTree(); $path = 'test/a/b/c'; $resolved = $tree->resolvePath($path); - $entries = $resolved->getEntries(); + $this->assertInstanceOf(Tree::class, $resolved); + $entries = iterator_to_array($resolved); - $this->assertTrue(isset($entries['d']), 'Successfully resolved source folder'); + $this->assertArrayHasKey('d', $entries, 'Successfully resolved source folder'); } } diff --git a/tests/Gitonomy/Git/Tests/WeakCacheTest.php b/tests/Gitonomy/Git/Tests/WeakCacheTest.php new file mode 100644 index 00000000..0347ece7 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/WeakCacheTest.php @@ -0,0 +1,48 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Util\WeakCache; +use PHPUnit\Framework\TestCase; + +class WeakCacheTest extends TestCase +{ + public function testKeepsAnObjectWhileItIsUsed(): void + { + /** @var WeakCache<\stdClass> $cache */ + $cache = new WeakCache(); + $object = new \stdClass(); + $cache->set('used', $object); + $cache->set('freed', new \stdClass()); + + $this->assertSame($object, $cache->get('used')); + $this->assertNull($cache->get('freed')); + $this->assertNull($cache->get('unknown')); + } + + public function testForgetsTheKeysOfTheFreedObjects(): void + { + /** @var WeakCache<\stdClass> $cache */ + $cache = new WeakCache(); + $object = new \stdClass(); + $cache->set('used', $object); + for ($i = 0; $i < 10_000; ++$i) { + $cache->set('freed'.$i, new \stdClass()); + } + + $references = new \ReflectionProperty(WeakCache::class, 'references')->getValue($cache); + $this->assertIsArray($references); + $this->assertLessThan(64, \count($references), 'Only the keys of the objects used, and the last ones'); + $this->assertSame($object, $cache->get('used')); + } +} diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index a26002f5..77971430 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -1,6 +1,6 @@ @@ -12,34 +12,54 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Admin; +use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\FastForward; use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; -class WorkingCopyTest extends AbstractTest +class WorkingCopyTest extends AbstractTestCase { - /** - * @expectedException LogicException - */ - public function testNoWorkingCopyInBare() + public function testNoWorkingCopyInBare(): void { + $this->expectException(LogicException::class); + $path = self::createTempDir(); - $repo = Admin::init($path, true, self::getOptions()); + $repo = Repository::initBare($path, driver: self::getDriver()); $repo->getWorkingCopy(); } - public function testCheckout() + public function testCheckout(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); - $wc->checkout('origin/new-feature', 'new-feature'); + $wc->checkoutNewBranch('new-feature', 'origin/new-feature'); $head = $repository->getHead(); - $this->assertTrue($head instanceof Branch, 'HEAD is a branch'); - $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); + $this->assertSame('new-feature', $head->getName(), 'HEAD is branch new-feature'); } - public function testDiffStaged() + public function testCheckoutOfABranchObjectAttachesHead(): void + { + $repository = self::createFoobarRepository(false); + $branch = $repository->getReferences()->createBranch('twin', 'master'); + $wc = $repository->getWorkingCopy(); + + $wc->checkout($repository->getRevision(self::INITIAL_COMMIT)); + $this->assertTrue($repository->isHeadDetached()); + + $wc->checkout($branch); + $this->assertSame('refs/heads/twin', self::headBranch($repository)->getFullname()); + } + + public function testDiffStaged(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); @@ -49,13 +69,13 @@ public function testDiffStaged() $file = $repository->getWorkingDir().'/foobar-test'; file_put_contents($file, 'test'); - $repository->run('add', [$file]); + self::git($repository, 'add', [$file]); $diffStaged = $wc->getDiffStaged(); $this->assertCount(1, $diffStaged->getFiles()); } - public function testDiffPending() + public function testDiffPending(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); @@ -70,15 +90,209 @@ public function testDiffPending() $this->assertCount(1, $diffPending->getFiles()); } + public function testFilesAddedWithIntentToAddAreCreatedNotStaged(): void + { + $this->skipUnlessGitHidesIntentToAddFromTheIndex(); + + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/new.txt', "new\n"); + touch($repository->getWorkingDir().'/empty.txt'); + self::git($repository, 'add', ['-N', 'new.txt', 'empty.txt']); + $wc = $repository->getWorkingCopy(); + + $this->assertSame([], $wc->getDiffStaged()->getFiles()); + $files = $wc->getDiffPending()->getFiles(); + $this->assertSame(['empty.txt', 'new.txt'], array_map(static fn (File $file): string => $file->getName(), $files)); + $this->assertTrue($files[0]->isCreation()); + $this->assertTrue($files[1]->isCreation()); + $this->assertSame(1, $files[1]->getAdditions()); + } + + public function testAFileAddedAgainWithIntentToAddIsDeletedFromTheIndex(): void + { + $this->skipUnlessGitHidesIntentToAddFromTheIndex(); + + $repository = self::createFoobarRepository(false); + self::git($repository, 'rm', ['-q', '--cached', 'AUTHORS']); + self::git($repository, 'add', ['-N', 'AUTHORS']); + $wc = $repository->getWorkingCopy(); + + $staged = $wc->getDiffStaged()->getFiles(); + $this->assertCount(1, $staged); + $this->assertTrue($staged[0]->isDeletion()); + $pending = $wc->getDiffPending()->getFiles(); + $this->assertCount(1, $pending); + $this->assertTrue($pending[0]->isCreation()); + $this->assertSame([], $wc->getDiff()->getFiles()); + } + + /** + * Like any version of git: deleted from the empty file the index has for it. + */ + public function testAFileAddedWithIntentToAddThenDeletedIsDeleted(): void + { + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/gone.txt', "gone\n"); + self::git($repository, 'add', ['-N', 'gone.txt']); + unlink($repository->getWorkingDir().'/gone.txt'); + $wc = $repository->getWorkingCopy(); + + $files = $wc->getDiffPending()->getFiles(); + $this->assertSame(['gone.txt'], array_map(static fn (File $file): string => $file->getName(), $files)); + $this->assertTrue($files[0]->isDeletion()); + $this->assertSame('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', $files[0]->getOldIndex()); + $this->assertSame([], $wc->getDiff()->getFiles()); + } + /** - * @expectedException RuntimeException + * The configuration of a repository may name a command in core.fsmonitor, + * which git runs to read the working tree, and even to diff a commit. */ - public function testCheckoutUnexisting() + public function testTheCommandOfCoreFsmonitorDoesNotRun(): void { + $repository = self::createFoobarRepository(false); + $marker = self::createTempDir().'/ran'; + self::git($repository, 'config', ['core.fsmonitor', 'touch '.$marker.'; false']); + file_put_contents($repository->getWorkingDir().'/README.md', 'local change'); + $wc = $repository->getWorkingCopy(); + + $wc->getUntrackedFiles(); + $wc->getDiffPending(); + $wc->getDiffStaged(); + $wc->getDiff(); + $repository->getHeadCommit()->getDiff(); + + $this->assertFileDoesNotExist($marker); + } + + public function testDiffLeavesOutAFileTheWorkingTreeChangesBack(): void + { + $repository = self::createFoobarRepository(false); + $file = $repository->getWorkingDir().'/AUTHORS'; + $content = file_get_contents($file); + file_put_contents($file, 'staged'); + self::git($repository, 'add', ['AUTHORS']); + file_put_contents($file, $content); + + $this->assertSame([], $repository->getWorkingCopy()->getDiff()->getFiles()); + } + + public function testDiffPendingWithAbbreviatedHashesLikeOldGit(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/test.sh', 'test'); + // Before 2.16, git always wrote "..." after the abbreviated hashes of --raw. + $repository = new Repository($repository->getPath(), driver: self::getCliDriver(['GIT_PRINT_SHA1_ELLIPSIS' => 'yes'])); + + $files = $repository->getWorkingCopy()->getDiffPending()->getFiles(); + + $this->assertCount(1, $files); + $this->assertSame(40, \strlen((string) $files[0]->getOldIndex())); + } + + public function testCheckoutUnexisting(): void + { + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The revision "foobar" does not exist, or names no commit.'); + self::createFoobarRepository(false)->getWorkingCopy()->checkout('foobar'); } - public function testAttachedHead() + /** + * "rev-parse --verify" refuses a range, which names two commits, after + * printing them. + */ + public function testCheckoutOfARangeThrows(): void + { + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The revision "master...origin/new-feature" does not exist, or names no commit.'); + + self::createFoobarRepository(false)->getWorkingCopy()->checkout('master...origin/new-feature'); + } + + /** + * Like "git checkout :/text": the youngest commit whose message matches. + */ + public function testCheckoutOfACommitByItsMessage(): void + { + $repository = self::createFoobarRepository(false); + + $repository->getWorkingCopy()->checkout(':/add a long file'); + + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); + } + + /** + * "git checkout README.md" restores the file, discarding its changes. + */ + #[DataProvider('providePaths')] + public function testCheckoutOfAPathIsRefused(string $path): void + { + $repository = self::createFoobarRepository(false); + $file = $repository->getWorkingDir().'/README.md'; + file_put_contents($file, 'local change'); + + try { + $repository->getWorkingCopy()->checkout($path); + $this->fail('A path is no revision.'); + } catch (ReferenceNotFoundException $e) { + $this->assertSame(\sprintf('The revision "%s" does not exist, or names no commit.', $path), $e->getMessage()); + } + + $this->assertSame('local change', file_get_contents($file)); + } + + /** + * The driver refuses a path too, whatever its caller resolved. + */ + #[DataProvider('providePaths')] + public function testTheDriverChecksOutNoPath(string $path): void + { + $repository = self::createFoobarRepository(false); + $file = $repository->getWorkingDir().'/README.md'; + file_put_contents($file, 'local change'); + + try { + $repository->getRepositoryDriver()->checkout($path); + $this->fail('A path is no revision.'); + } catch (DriverException) { + } + + $this->assertSame('local change', file_get_contents($file)); + } + + /** + * @return iterable + */ + public static function providePaths(): iterable + { + yield 'file' => ['README.md']; + yield 'working tree' => ['.']; + } + + /** + * Unlike "git checkout feature", which creates the local branch of the + * only remote one of this name. + */ + public function testCheckoutOfTheNameOfARemoteBranchIsRefused(): void + { + $repository = self::createFoobarRepository(false); + + try { + $repository->getWorkingCopy()->checkout('new-feature'); + $this->fail('No revision has this name.'); + } catch (ReferenceNotFoundException $e) { + $this->assertSame('The revision "new-feature" does not exist, or names no commit.', $e->getMessage()); + } + + $this->assertFalse($repository->getReferences()->hasBranch('new-feature')); + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + } + + public function testAttachedHead(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); @@ -89,7 +303,7 @@ public function testAttachedHead() $this->assertFalse($repository->isHeadDetached(), 'HEAD is not detached'); } - public function testDetachedHead() + public function testDetachedHead(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); @@ -100,7 +314,7 @@ public function testDetachedHead() $this->assertTrue($repository->isHeadDetached(), 'HEAD is detached'); } - public function testGetUntracked() + public function testGetUntracked(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); @@ -110,4 +324,389 @@ public function testGetUntracked() $this->assertContains('untracked.txt', $wc->getUntrackedFiles()); } + + public function testMerge(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + $wc = $repository->getWorkingCopy(); + $wc->checkout('master'); + + $wc->merge('origin/diff-features'); + + $head = $repository->getHeadCommit(); + $this->assertCount(2, $head->getParents(), 'Merge produced a merge commit'); + $this->assertSame("Merge remote-tracking branch 'origin/diff-features'", $head->getSubjectMessage()); + $this->assertFileExists($repository->getWorkingDir().'/script_A.php'); + } + + public function testMergeOfAnUnknownRevisionThrows(): void + { + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The revision "foobar" does not exist, or names no commit.'); + + self::createFoobarRepository(false)->getWorkingCopy()->merge('foobar'); + } + + public function testMergeConflict(): void + { + $repository = self::createFoobarRepository(false); + $master = $repository->getHeadCommit()->getHash(); + $wc = $repository->getWorkingCopy(); + + try { + $wc->merge('origin/new-feature'); + $this->fail('The merge should have been refused.'); + } catch (DriverException) { + } + + $this->assertSame($master, $repository->getHeadCommit()->getHash(), 'No merge commit is created'); + } + + public function testMergeOnlyFastForwards(): void + { + $repository = self::createFoobarRepository(false); + $wc = $repository->getWorkingCopy(); + + $this->expectException(DriverException::class); + + $wc->merge('origin/diff-features', FastForward::Only); + } + + public function testMergeFastForwardsTheCurrentBranch(): void + { + $repository = self::createFoobarRepository(false); + $master = $repository->getHeadCommit(); + $repository->getReferences()->createBranch('behind', $master->getParentHashes()[0]); + $wc = $repository->getWorkingCopy(); + $wc->checkout('behind'); + + $wc->merge('master'); + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame('refs/heads/behind', self::headBranch($repository)->getFullname(), 'HEAD stays on the branch'); + $this->assertSame($master->getHash(), $repository->getHeadCommit()->getHash()); + $this->assertCount(0, $repository->getWorkingCopy()->getDiffPending()->getFiles()); + $this->assertCount(0, $repository->getWorkingCopy()->getDiffStaged()->getFiles()); + } + + public function testMergeOfAnAncestorDoesNothing(): void + { + $repository = self::createFoobarRepository(false); + $head = $repository->getHeadCommit(); + $wc = $repository->getWorkingCopy(); + + $wc->merge($head->getParentHashes()[0]); + $wc->merge($head->getHash(), FastForward::Never); + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame($head->getHash(), $repository->getHeadCommit()->getHash()); + } + + public function testMergeOfAnAnnotatedTagAddsItsMessage(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + // With whitespace "git tag" would have cleaned up. + $commit = $repository->getReferences()->getRemoteBranch('origin/diff-features')->getCommitHash(); + $tag = self::writeObject($repository, 'tag', "object {$commit}\ntype commit\ntag features\ntagger A 1000000000 +0000\n\nFeatures \n\n\nThe diff ones."); + self::git($repository, 'update-ref', ['refs/tags/features', $tag]); + + $repository->getWorkingCopy()->merge('features'); + + // Cleaned up like git does. Then, with CliDriver, the merged commits: the configuration of the tests sets merge.log. + $this->assertStringStartsWith("Merge tag 'features'\n\nFeatures\n\nThe diff ones.\n", $repository->getHeadCommit()->getMessage()); + } + + #[TestWith(['refs/heads/feature', "Merge branch 'refs/heads/feature'"])] + #[TestWith(['feature~0', "Merge branch 'feature'"])] + #[TestWith(['feature~1', "Merge branch 'feature' (early part)"])] + #[TestWith(['refs/remotes/origin/diff-features', "Merge remote-tracking branch 'refs/remotes/origin/diff-features'"])] + #[TestWith(['@{-1}', "Merge branch 'feature'"])] + public function testMergeNamesTheRevisionLikeGit(string $revision, string $message): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + $repository->getReferences()->createBranch('feature', 'origin/diff-features'); + $wc = $repository->getWorkingCopy(); + $wc->checkout('feature'); + $wc->checkout('master'); + + $wc->merge($revision); + + $this->assertSame($message, $repository->getHeadCommit()->getSubjectMessage()); + } + + public function testMergeIntoABranchYetToBeBornFastForwardsIt(): void + { + $repository = self::createFoobarRepository(false); + $master = $repository->getHeadCommit()->getHash(); + self::git($repository, 'checkout', ['-q', '--orphan', 'unborn']); + self::git($repository, 'rm', ['-q', '-r', '-f', '.']); + $wc = $repository->getWorkingCopy(); + + try { + $wc->merge('master', FastForward::Never); + $this->fail('A merge commit should have been refused.'); + } catch (DriverException $e) { + $this->assertSame(self::getDriver() instanceof CliDriver + ? 'The git command "merge" failed (exit 128): fatal: Non-fast-forward commit does not make sense into an empty head.' + : 'Unable to merge "master" without a fast-forward: the branch "refs/heads/unborn" has no commit yet.', $e->getMessage()); + } + $this->assertSame('refs/heads/unborn', $repository->getRepositoryDriver()->getHeadReference()); + $this->assertFalse($repository->getReferences()->hasBranch('unborn'), 'The branch has no commit yet'); + $this->assertSame([], $wc->getUntrackedFiles()); + + $wc->merge('master'); + + $this->assertSame('refs/heads/unborn', self::headBranch($repository)->getFullname()); + $this->assertSame($master, $repository->getHeadCommit()->getHash()); + $this->assertSame([], $wc->getDiffStaged()->getFiles()); + $this->assertSame([], $wc->getDiffPending()->getFiles()); + } + + public function testCheckoutKeepsLocalChangesItDoesNotConflictWith(): void + { + $repository = self::createFoobarRepository(false); + $repository->getReferences()->createBranch('twin', $repository->getHeadCommit()->getHash()); + $file = $repository->getWorkingDir().'/README.md'; + file_put_contents($file, 'local change'); + + $repository->getWorkingCopy()->checkout('twin'); + + $this->assertSame('local change', file_get_contents($file)); + } + + public function testCheckoutRefusesToOverwriteLocalChanges(): void + { + $repository = self::createFoobarRepository(false); + $file = $repository->getWorkingDir().'/AUTHORS'; + file_put_contents($file, 'local change'); + + try { + $repository->getWorkingCopy()->checkoutNewBranch('pagination', 'origin/pagination'); + $this->fail('The checkout should have been refused.'); + } catch (DriverException) { + } + + $this->assertSame('local change', file_get_contents($file)); + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertFalse($repository->getReferences()->hasBranch('pagination'), 'No branch is created when the checkout fails'); + $this->assertSame([], self::branchConfiguration($repository, 'pagination'), 'Nor its upstream'); + } + + public function testUntrackedFilesInNewDirectories(): void + { + $repository = self::createFoobarRepository(false); + mkdir($repository->getWorkingDir().'/newdir/sub', 0o777, true); + file_put_contents($repository->getWorkingDir().'/newdir/a.txt', 'a'); + file_put_contents($repository->getWorkingDir().'/newdir/sub/b.txt', 'b'); + + $files = $repository->getWorkingCopy()->getUntrackedFiles(); + sort($files); + + $this->assertSame(['newdir/a.txt', 'newdir/sub/b.txt'], $files); + } + + public function testCheckoutPrefersTheBranchToATagOfTheSameName(): void + { + $repository = self::createFoobarRepository(false); + $references = $repository->getReferences(); + $references->createTag('twin', self::INITIAL_COMMIT); + $references->createBranch('twin', 'master'); + + $repository->getWorkingCopy()->checkout('twin'); + + $this->assertSame('refs/heads/twin', self::headBranch($repository)->getFullname()); + $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); + } + + #[TestWith(['HEAD'])] + #[TestWith(['@'])] + public function testCheckoutOfHeadLeavesItAttached(string $head): void + { + // git 2.43 detaches HEAD for "@", 2.53 doesn't: git 2.45 learned that "@" is HEAD in checkout. + if ('@' === $head && self::getDriver() instanceof CliDriver && version_compare(self::gitVersion(), '2.45.0', '<')) { + $this->markTestSkipped('git checks "@" out like HEAD since 2.45.'); + } + + $repository = self::createFoobarRepository(false); + + $repository->getWorkingCopy()->checkout($head); + + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + } + + public function testCheckoutOfThePriorBranchPrefersItToATagOfTheSameName(): void + { + $repository = self::createFoobarRepository(false); + $references = $repository->getReferences(); + $references->createTag('twin', self::INITIAL_COMMIT); + $references->createBranch('twin', 'master'); + $wc = $repository->getWorkingCopy(); + $wc->checkout('twin'); + $wc->checkout('master'); + + $wc->checkout('@{-1}'); + + $this->assertSame('refs/heads/twin', self::headBranch($repository)->getFullname()); + } + + /** + * Like "git checkout -b", whatever the configuration: the one of the + * tests sets branch.autoSetupMerge and branch.autoSetupRebase to "always". + * + * @param array $upstream + */ + #[DataProvider('provideStartPoints')] + public function testCheckoutNewBranchTracksARemoteBranch(string $startPoint, array $upstream): void + { + $repository = self::createFoobarRepository(false); + + $repository->getWorkingCopy()->checkoutNewBranch('feature', $startPoint); + + $this->assertSame('refs/heads/feature', self::headBranch($repository)->getFullname()); + $this->assertSame($upstream, self::branchConfiguration($repository, 'feature')); + } + + /** + * @return iterable}> + */ + public static function provideStartPoints(): iterable + { + $upstream = ['branch.feature.merge' => 'refs/heads/new-feature', 'branch.feature.remote' => 'origin']; + + yield 'remote branch' => ['origin/new-feature', $upstream]; + yield 'remote branch by its full name' => ['refs/remotes/origin/new-feature', $upstream]; + yield 'local branch' => ['master', []]; + yield 'tag' => ['0.1', []]; + yield 'hash' => [self::INITIAL_COMMIT, []]; + } + + public function testCheckoutNewBranchFromARemoteBranchObjectTracksIt(): void + { + $repository = self::createFoobarRepository(false); + + $repository->getWorkingCopy()->checkoutNewBranch('feature', $repository->getReferences()->getRemoteBranch('origin/new-feature')); + + $this->assertSame(['branch.feature.merge' => 'refs/heads/new-feature', 'branch.feature.remote' => 'origin'], self::branchConfiguration($repository, 'feature')); + } + + /** + * git_branch_set_upstream() prefers a local branch of the name of the + * remote one, unlike git. + */ + public function testCheckoutNewBranchTracksARemoteBranchByItsFullName(): void + { + $repository = self::createFoobarRepository(false); + $repository->getReferences()->createBranch('origin/new-feature', 'master'); + + $repository->getWorkingCopy()->checkoutNewBranch('feature', 'refs/remotes/origin/new-feature'); + + $this->assertSame('refs/heads/feature', self::headBranch($repository)->getFullname()); + $this->assertSame(['branch.feature.merge' => 'refs/heads/new-feature', 'branch.feature.remote' => 'origin'], self::branchConfiguration($repository, 'feature')); + } + + /** + * Like git, which refuses a start point several references have the + * name of. "git checkout -b" checks it out before, and leaves its files. + */ + public function testCheckoutNewBranchRefusesAnAmbiguousStartPoint(): void + { + $repository = self::createFoobarRepository(false); + $head = $repository->getHeadCommit()->getHash(); + $repository->getReferences()->createBranch('origin/new-feature', self::INITIAL_COMMIT); + + try { + $repository->getWorkingCopy()->checkoutNewBranch('feature', 'origin/new-feature'); + $this->fail('The start point is ambiguous.'); + } catch (DriverException $e) { + if (self::getDriver() instanceof CliDriver) { + // Capitalized, with a period, by older versions of git. + $this->assertMatchesRegularExpression('/^The git command "branch" failed \(exit 128\): fatal: [Aa]mbiguous object name: \'origin\/new-feature\'\.$/', $e->getMessage()); + } else { + $this->assertSame('The name "origin/new-feature" is ambiguous: "refs/heads/origin/new-feature", "refs/remotes/origin/new-feature".', $e->getMessage()); + } + } + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + $this->assertSame($head, $repository->getHeadCommit()->getHash()); + $this->assertFalse($repository->getReferences()->hasBranch('feature')); + $this->assertSame([], self::branchConfiguration($repository, 'feature')); + $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); + $this->assertSame([], $repository->getWorkingCopy()->getDiffPending()->getFiles()); + } + + public function testCheckoutNewBranchWithAnInvalidNameChangesNothing(): void + { + $repository = self::createFoobarRepository(false); + $head = $repository->getHeadCommit()->getHash(); + + try { + $repository->getWorkingCopy()->checkoutNewBranch('bad..name', self::INITIAL_COMMIT); + $this->fail('The checkout should have been refused.'); + } catch (DriverException) { + } + + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + $this->assertSame($head, $repository->getHeadCommit()->getHash()); + $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); + } + + public function testMergeRefusesUnrelatedHistories(): void + { + $repository = self::createFoobarRepository(false); + $tree = $repository->getHeadCommit()->getTreeHash(); + $orphan = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\n\nOrphan\n"); + + $this->expectException(DriverException::class); + + $repository->getWorkingCopy()->merge($orphan); + } + + public function testMergeRefusesStagedChanges(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + file_put_contents($repository->getWorkingDir().'/staged', 'staged'); + self::git($repository, 'add', ['staged']); + $head = $repository->getHeadCommit()->getHash(); + + try { + $repository->getWorkingCopy()->merge('origin/diff-features'); + $this->fail('The merge should have been refused.'); + } catch (DriverException) { + } + + $this->assertSame($head, $repository->getHeadCommit()->getHash()); + } + + /** + * @return array The configuration of the branch $branch, sorted by key + */ + private static function branchConfiguration(Repository $repository, string $branch): array + { + $configuration = []; + foreach (explode("\n", self::git($repository, 'config', ['--list'])) as $line) { + if (str_starts_with($line, 'branch.'.$branch.'.')) { + [$key, $value] = explode('=', $line, 2); + $configuration[$key] = $value; + } + } + ksort($configuration); + + return $configuration; + } + + private function skipUnlessGitHidesIntentToAddFromTheIndex(): void + { + if (self::getDriver() instanceof CliDriver && version_compare(self::gitVersion(), '2.19.0', '<')) { + $this->markTestSkipped('Before 2.19, git diffs a file added with "git add -N" as an empty file of the index.'); + } + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a988f4ab..00793165 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,9 +1,41 @@ + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + require __DIR__.'/../vendor/autoload.php'; +// git and libgit2 read the configuration of fixtures/home only, whatever the +// developer's: see fixtures/home/.gitconfig. No other variable of git reaches +// them either: run from a hook, the tests would get GIT_DIR or GIT_INDEX_FILE, +// and write the repository of the hook. GIT_COMMAND is the git binary of the +// tests. Symfony Process passes on the variables of getenv() that $_SERVER has +// too, $_ENV taking precedence. +$gitVariables = array_filter(array_keys([...getenv(), ...$_ENV]), static fn (string $name): bool => str_starts_with($name, 'GIT_') && 'GIT_COMMAND' !== $name); +foreach ([ + ...array_fill_keys($gitVariables, null), + 'GIT_CONFIG_NOSYSTEM' => '1', + 'HOME' => __DIR__.'/fixtures/home', + 'XDG_CONFIG_HOME' => null, +] as $name => $value) { + if (null === $value) { + putenv($name); + unset($_ENV[$name], $_SERVER[$name]); + } else { + putenv($name.'='.$value); + $_ENV[$name] = $_SERVER[$name] = $value; + } +} + if (defined('PHP_WINDOWS_VERSION_BUILD')) { - $server = array_change_key_case($_SERVER, true); + $server = array_change_key_case($_SERVER, \CASE_UPPER); $_SERVER['GIT_ENVS'] = []; foreach (['PATH', 'SYSTEMROOT'] as $key) { if (isset($server[$key])) { diff --git a/tests/fixtures/bundle-heads.txt b/tests/fixtures/bundle-heads.txt new file mode 100644 index 00000000..e7b18c46 --- /dev/null +++ b/tests/fixtures/bundle-heads.txt @@ -0,0 +1,8 @@ +7e9ba14939b0ce67ab95d83e03bd64710db91ef4 HEAD +7e9ba14939b0ce67ab95d83e03bd64710db91ef4 refs/heads/master +543dc7c3983b6a99b43e274d4823e4f8a9bda7a9 refs/heads/new-feature +519d5693c72c925cd59205d9f11e9fa1d550028b refs/heads/diff-features +10b5d7125a5da15eb350430b6f0504fccff26d71 refs/heads/pagination +cc06ac171d884282202dff88c1ded499a1f89420 refs/heads/path-resolving +4f17752acc9b7c54ba679291bf24cb7d354f0f4f refs/tags/0.1 +b61d8c2cac78a443729ca16cad12b96e3d98365d refs/tags/annotated diff --git a/tests/fixtures/bundle-refs.txt b/tests/fixtures/bundle-refs.txt new file mode 100644 index 00000000..4fb23c2e --- /dev/null +++ b/tests/fixtures/bundle-refs.txt @@ -0,0 +1,8 @@ +HEAD +refs/heads/master +refs/heads/new-feature +refs/heads/diff-features +refs/heads/pagination +refs/heads/path-resolving +refs/tags/0.1 +refs/tags/annotated diff --git a/tests/fixtures/foobar.bundle b/tests/fixtures/foobar.bundle new file mode 100644 index 00000000..bbd71de5 Binary files /dev/null and b/tests/fixtures/foobar.bundle differ diff --git a/tests/fixtures/generate-bundle.php b/tests/fixtures/generate-bundle.php new file mode 100755 index 00000000..59040b5a --- /dev/null +++ b/tests/fixtures/generate-bundle.php @@ -0,0 +1,133 @@ +#!/usr/bin/env php + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +require dirname(__DIR__, 2).'/vendor/autoload.php'; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Process\Process; + +const BUNDLE_PATH = __DIR__.'/foobar.bundle'; +const REFS_PATH = __DIR__.'/bundle-refs.txt'; + +function git(array $args, ?string $cwd = null): Process +{ + $process = new Process(['git', ...$args], $cwd); + $process->mustRun(); + + return $process; +} + +function bundleRefs(): array +{ + return array_values(array_filter(explode("\n", trim(file_get_contents(REFS_PATH))))); +} + +#[AsCommand(name: 'extract', description: 'Clone the fixture bundle to a working directory, ready for new commits, branches or tags')] +final class ExtractCommand extends Command +{ + protected function configure(): void + { + $this->addArgument('path', InputArgument::OPTIONAL, 'Where to clone the fixture', sys_get_temp_dir().'/foobar-fixture-'.bin2hex(random_bytes(4))); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dest = $input->getArgument('path'); + + if (file_exists($dest)) { + $io->error(sprintf('Destination "%s" already exists.', $dest)); + + return Command::FAILURE; + } + + git(['clone', '--quiet', BUNDLE_PATH, $dest]); + + $localBranches = explode("\n", trim(git(['branch', '--format=%(refname:short)'], $dest)->getOutput())); + + // Full ref names, not `--format=%(refname:short)`: git shortens the symbolic + // refs/remotes/origin/HEAD pointer to a bare "origin" on some git versions, which + // would otherwise be mistaken for a real branch called "origin". + $refs = trim(git(['for-each-ref', '--format=%(refname)', 'refs/remotes/origin'], $dest)->getOutput()); + foreach (explode("\n", $refs) as $ref) { + if ('' === $ref) { + continue; + } + $local = preg_replace('#^refs/remotes/origin/#', '', $ref); + if ('HEAD' === $local || in_array($local, $localBranches, true)) { + // Symbolic HEAD pointer, or already checked out as the clone's default branch. + continue; + } + git(['branch', '--track', $local, "origin/{$local}"], $dest); + } + + $io->success('Fixture extracted.'); + $io->writeln([ + sprintf('Path: %s', $dest), + '', + 'Make your changes there (commits, branches, tags), then run:', + sprintf(' %s build %s', $_SERVER['argv'][0], $dest), + ]); + + return Command::SUCCESS; + } +} + +#[AsCommand(name: 'build', description: 'Rebuild tests/fixtures/foobar.bundle from a working directory produced by "extract"')] +final class BuildCommand extends Command +{ + protected function configure(): void + { + $this->addArgument('path', InputArgument::REQUIRED, 'The working directory produced by "extract"'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $src = $input->getArgument('path'); + + if (!is_dir($src)) { + $io->error(sprintf('Source "%s" does not exist. Run "extract" first.', $src)); + + return Command::FAILURE; + } + + $tmpBundle = tempnam(sys_get_temp_dir(), 'foobar_bundle_'); + git(['bundle', 'create', $tmpBundle, ...bundleRefs()], $src); + git(['bundle', 'verify', $tmpBundle]); + + copy($tmpBundle, BUNDLE_PATH); + unlink($tmpBundle); + + $io->success('Bundle rebuilt.'); + $io->writeln([ + sprintf('Path: %s', BUNDLE_PATH), + '', + sprintf('If you added a ref not in %s, update that file first and re-run build.', REFS_PATH), + 'Otherwise, update the commit constants in AbstractTestCase if needed, then run:', + ' tests/fixtures/verify-bundle.sh', + ]); + + return Command::SUCCESS; + } +} + +$app = new Application('gitlib fixture bundle tool'); +$app->addCommands([new ExtractCommand(), new BuildCommand()]); +exit($app->run()); diff --git a/tests/fixtures/home/.gitconfig b/tests/fixtures/home/.gitconfig new file mode 100644 index 00000000..e6de08bd --- /dev/null +++ b/tests/fixtures/home/.gitconfig @@ -0,0 +1,72 @@ +# The git configuration of the tests, whatever the developer's: the bootstrap +# points HOME to this directory. It changes what git prints or reads wherever +# gitlib must set it back to git's defaults, so that every test checks it. +# +# gitlib follows the rest of the configuration of the user, which isn't here: +# core.abbrev, blame.ignoreRevsFile, init.defaultBranch... + +[blame] + blankBoundary = true + coloring = repeatedLines + date = relative + markIgnoredLines = true + markUnblamableLines = true + showEmail = true + showRoot = true +[branch] + autoSetupMerge = always + autoSetupRebase = always + sort = -committerdate +[color] + branch = always + diff = always + status = always + ui = always +[column] + ui = always +[core] + attributesFile = ~/attributes + quotePath = true +[diff] + algorithm = histogram + colorMoved = zebra + context = 1 + dstPrefix = DST/ + external = false + indentHeuristic = false + interHunkContext = 10 + mnemonicPrefix = true + noprefix = true + orderFile = ~/orderfile + relative = true + renames = copies + srcPrefix = SRC/ + submodule = log + suppressBlankEmpty = true + wsErrorHighlight = all +[diff "hostile"] + textconv = false +[format] + pretty = oneline +[i18n] + logOutputEncoding = ISO-8859-1 +[log] + abbrevCommit = true + date = relative + decorate = full + follow = true + showRoot = false + showSignature = true +[maintenance] + # Not hostile: git 2.29 and later run their maintenance after a commit, in + # the background since 2.46, which would write in the repositories the tests delete. + auto = false +[merge] + ff = false + log = true +[status] + showUntrackedFiles = no +[submodule] + recurse = true +[tag] + sort = -version:refname diff --git a/tests/fixtures/home/attributes b/tests/fixtures/home/attributes new file mode 100644 index 00000000..bf534795 --- /dev/null +++ b/tests/fixtures/home/attributes @@ -0,0 +1 @@ +* diff=hostile diff --git a/tests/fixtures/home/orderfile b/tests/fixtures/home/orderfile new file mode 100644 index 00000000..ae7364c5 --- /dev/null +++ b/tests/fixtures/home/orderfile @@ -0,0 +1,2 @@ +*.txt +* diff --git a/tests/fixtures/verify-bundle.sh b/tests/fixtures/verify-bundle.sh new file mode 100755 index 00000000..a1b0d3ee --- /dev/null +++ b/tests/fixtures/verify-bundle.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# +# Guards tests/fixtures/foobar.bundle against unexpected changes: any pull +# request touching it should be reviewed by hand (see CONTRIBUTING.md), but +# this catches the obvious cases automatically: +# - a corrupted or incomplete bundle +# - refs that were not in the original fixture, or that moved: the tests +# rely on the commits they point to (bundle-heads.txt pins them) +# - a file that has silently grown well past its expected size +# +# Run it locally with: tests/fixtures/verify-bundle.sh + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" > /dev/null + +BUNDLE="foobar.bundle" +MAX_SIZE_KB=200 +ALLOWED_REFS="$(cat bundle-refs.txt)" + +echo "== Verifying $BUNDLE ==" + +VERIFY_OUTPUT="$(git bundle verify "$BUNDLE")" +echo "$VERIFY_OUTPUT" + +SIZE_KB=$(( $(wc -c < "$BUNDLE") / 1024 )) +echo "Size: ${SIZE_KB}KB (limit: ${MAX_SIZE_KB}KB)" +if [ "$SIZE_KB" -gt "$MAX_SIZE_KB" ]; then + echo "ERROR: $BUNDLE is ${SIZE_KB}KB, which exceeds the ${MAX_SIZE_KB}KB limit." >&2 + echo "If this growth is expected, bump MAX_SIZE_KB in $0 as part of the same PR." >&2 + exit 1 +fi + +ACTUAL_REFS="$(grep -oE '(refs/[^ ]+|HEAD)$' <<< "$VERIFY_OUTPUT" | sort -u)" +UNEXPECTED_REFS="$(comm -23 <(echo "$ACTUAL_REFS") <(sort -u <<< "$ALLOWED_REFS"))" + +if [ -n "$UNEXPECTED_REFS" ]; then + echo "ERROR: $BUNDLE contains refs that are not in the allow-list:" >&2 + echo "$UNEXPECTED_REFS" >&2 + echo "If this is expected, update tests/fixtures/bundle-refs.txt as part of the same PR." >&2 + exit 1 +fi + +if ! diff -u bundle-heads.txt <(git bundle list-heads "$BUNDLE"); then + echo "ERROR: the refs of $BUNDLE differ from tests/fixtures/bundle-heads.txt." >&2 + echo "Existing refs must not move: add commits on new branches or tags, then add their lines to bundle-heads.txt." >&2 + exit 1 +fi + +echo "OK: bundle structure, refs and size are within expected bounds." diff --git a/tools/php-cs-fixer/.gitignore b/tools/php-cs-fixer/.gitignore new file mode 100644 index 00000000..57872d0f --- /dev/null +++ b/tools/php-cs-fixer/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/tools/php-cs-fixer/castor.php b/tools/php-cs-fixer/castor.php new file mode 100644 index 00000000..d3a8346b --- /dev/null +++ b/tools/php-cs-fixer/castor.php @@ -0,0 +1,50 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace qa\cs; + +use Castor\Attribute\AsTask; + +use function Castor\context; +use function Castor\exit_code; +use function Castor\run; + +#[AsTask(description: 'Fix CS', aliases: ['cs'])] +function cs(bool $dryRun = false): int +{ + if (!file_exists(__DIR__.'/vendor/autoload.php')) { + install(); + } + + $command = [ + __DIR__.'/vendor/bin/php-cs-fixer', + 'fix', + ]; + + if ($dryRun) { + $command[] = '--dry-run'; + } + + return exit_code($command); +} + +#[AsTask(description: 'install dependencies')] +function install(): void +{ + run(['composer', 'install'], context()->withWorkingDirectory(__DIR__)); +} + +#[AsTask(description: 'Update dependencies')] +function update(): void +{ + run(['composer', 'update'], context()->withWorkingDirectory(__DIR__)); +} diff --git a/tools/php-cs-fixer/composer.json b/tools/php-cs-fixer/composer.json new file mode 100644 index 00000000..2eddc874 --- /dev/null +++ b/tools/php-cs-fixer/composer.json @@ -0,0 +1,11 @@ +{ + "require": { + "friendsofphp/php-cs-fixer": "^3.95.24" + }, + "config": { + "bump-after-update": true, + "platform": { + "php": "8.4" + } + } +} diff --git a/tools/php-cs-fixer/composer.lock b/tools/php-cs-fixer/composer.lock new file mode 100644 index 00000000..f3bfd23b --- /dev/null +++ b/tools/php-cs-fixer/composer.lock @@ -0,0 +1,2767 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c092a0c10c3cc01eb773e9c4efe0ccbb", + "packages": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/semver", + "version": "3.5.1", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.5.1" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-10-05T13:12:12+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "ergebnis/agent-detector", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-05-07T08:19:07+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/d384a7d0d6e67aa497327260ca95023e92dc2032", + "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0 || ^1.1.2", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "symfony/polyfill-mbstring": "^1.33", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.4.1" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2026-09-29T22:11:39+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.95.27", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/1d0c68ee5f37446d8ad5970b82384cd1bb0a3317", + "reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.12.0", + "keradus/cli-executor": "^2.3", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.35 || ^13.3.3", + "symfony/polyfill-php85": "^1.41", + "symfony/var-dumper": "^5.4.48 || ^6.4.45 || ^7.4.18 || ^8.1.7", + "symfony/yaml": "^5.4.53 || ^6.4.45 || ^7.4.18 || ^8.1.6" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.27" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-09-22T09:54:55+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2df6626c1baf31d5a88674882a3072f151b5a26", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.1", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:38:55+00:00" + }, + { + "name": "symfony/console", + "version": "v8.0.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "7327288efcaf02a3838d2de0ae23915d64713e14" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/7327288efcaf02a3838d2de0ae23915d64713e14", + "reference": "7327288efcaf02a3838d2de0ae23915d64713e14", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4|^8.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.0.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T13:52:45+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.0.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "4ad08142e63219fdcb75ea1a0ac1c9e5c0fc7686" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4ad08142e63219fdcb75ea1a0ac1c9e5c0fc7686", + "reference": "4ad08142e63219fdcb75ea1a0ac1c9e5c0fc7686", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T15:20:43+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.0.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "3d190c51f717870eed9aa5e9c7cb927ffc911d05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/3d190c51f717870eed9aa5e9c7cb927ffc911d05", + "reference": "3d190c51f717870eed9aa5e9c7cb927ffc911d05", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T15:20:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.0.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "4a93f8c95fabc35fcfd2f5dbb29d138d3fe98f43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/4a93f8c95fabc35fcfd2f5dbb29d138d3fe98f43", + "reference": "4a93f8c95fabc35fcfd2f5dbb29d138d3fe98f43", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T08:56:37+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:02:09+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T15:19:04+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:01:09+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/4f23d9c7637ead9ed19f697fe93cb87fd9b379d4", + "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T18:05:53+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3", + "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "1a6a4245943af4dabe57d269bd0903f9d140a15e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/1a6a4245943af4dabe57d269bd0903f9d140a15e", + "reference": "1a6a4245943af4dabe57d269bd0903f9d140a15e", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.0.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:34:23+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "platform-overrides": { + "php": "8.4" + }, + "plugin-api-version": "2.9.0" +} diff --git a/tools/phpstan/.gitignore b/tools/phpstan/.gitignore new file mode 100644 index 00000000..57872d0f --- /dev/null +++ b/tools/phpstan/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/tools/phpstan/castor.php b/tools/phpstan/castor.php new file mode 100644 index 00000000..d862f1e1 --- /dev/null +++ b/tools/phpstan/castor.php @@ -0,0 +1,50 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace qa\phpstan; + +use Castor\Attribute\AsTask; + +use function Castor\context; +use function Castor\exit_code; +use function Castor\run; + +#[AsTask(description: 'Run PHPStan', aliases: ['phpstan'])] +function phpstan(bool $generateBaseline = false): int +{ + if (!file_exists(__DIR__.'/vendor/autoload.php')) { + install(); + } + + $command = [ + __DIR__.'/vendor/bin/phpstan', + '-v', + ]; + + if ($generateBaseline) { + $command[] = '-b'; + } + + return exit_code($command); +} + +#[AsTask(description: 'install dependencies')] +function install(): void +{ + run(['composer', 'install'], context()->withWorkingDirectory(__DIR__)); +} + +#[AsTask(description: 'update dependencies')] +function update(): void +{ + run(['composer', 'update'], context()->withWorkingDirectory(__DIR__)); +} diff --git a/tools/phpstan/composer.json b/tools/phpstan/composer.json new file mode 100644 index 00000000..e66b3802 --- /dev/null +++ b/tools/phpstan/composer.json @@ -0,0 +1,12 @@ +{ + "require": { + "phpstan/phpstan": "^2.2.12", + "phpstan/phpstan-phpunit": "^2.0" + }, + "config": { + "bump-after-update": true, + "platform": { + "php": "8.4" + } + } +} diff --git a/tools/phpstan/composer.lock b/tools/phpstan/composer.lock new file mode 100644 index 00000000..7304ae16 --- /dev/null +++ b/tools/phpstan/composer.lock @@ -0,0 +1,195 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c5d318150410709bc4ee6eeb07693fc4", + "packages": [ + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.3.2", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/18bc23a0682f08591529377d271b5c4dfe9ed1dd", + "reference": "18bc23a0682f08591529377d271b5c4dfe9ed1dd", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-10-24T11:36:00+00:00" + }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "8d16b8478e523a47f8c02e83bce8981b5b5a3b03" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/8d16b8478e523a47f8c02e83bce8981b5b5a3b03", + "reference": "8d16b8478e523a47f8c02e83bce8981b5b5a3b03", + "shasum": "" + }, + "require": { + "phar-io/version": "^3.2", + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.3.0" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.1.1" + }, + "time": "2026-10-06T18:12:32+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "platform-overrides": { + "php": "8.4" + }, + "plugin-api-version": "2.9.0" +} diff --git a/tools/zensical/assets/favicon.svg b/tools/zensical/assets/favicon.svg new file mode 100644 index 00000000..cfdd625d --- /dev/null +++ b/tools/zensical/assets/favicon.svg @@ -0,0 +1 @@ + diff --git a/tools/zensical/pages.py b/tools/zensical/pages.py new file mode 100644 index 00000000..b0af78d9 --- /dev/null +++ b/tools/zensical/pages.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Write the pages of the documentation site, as GitHub shows them, to the docs_dir of zensical.toml. + +The site is doc/, with README.md, UPGRADE-2.0.md and CHANGELOG.md, which live at the root of the +repository. Zensical reads its pages from a single directory, which can't be the root, and runs +no hooks: this script copies them there, with the assets of the site. The links between the +pages are relative paths of the repository, like "doc/blob.md" or "../UPGRADE-2.0.md#errors": +they are rewritten to the pages of the site. + +Usage, from the root of the repository: python3 tools/zensical/pages.py && zensical build +""" + +import posixpath +import re +import shutil +import sys +from pathlib import Path + +REPOSITORY_DIR = Path(__file__).resolve().parents[2] +# The docs_dir of zensical.toml: /var/ is ignored by git. +OUTPUT_DIR = REPOSITORY_DIR / 'var/docs' +ASSETS_DIR = Path(__file__).resolve().parent / 'assets' + +# The files of the root of the repository published as pages, and their page on the site. +ROOT_PAGES = { + 'CHANGELOG.md': 'changelog.md', + 'README.md': 'index.md', + 'UPGRADE-2.0.md': 'upgrade-2.0.md', +} + +# A relative link to a Markdown file: "](doc/blob.md)", "](../UPGRADE-2.0.md#errors)"... +MARKDOWN_LINK = re.compile(r'\]\((?P(?![a-z]+:)[^)\s#]+\.md)(?P#[^)\s]*)?\)') + + +def main() -> None: + if OUTPUT_DIR.exists(): + shutil.rmtree(OUTPUT_DIR) + shutil.copytree(REPOSITORY_DIR / 'doc', OUTPUT_DIR) + shutil.copytree(ASSETS_DIR, OUTPUT_DIR / 'assets') + + pages = dict(ROOT_PAGES) + for file in sorted((REPOSITORY_DIR / 'doc').rglob('*.md')): + path = file.relative_to(REPOSITORY_DIR).as_posix() + pages[path] = path.removeprefix('doc/') + + for path, site_path in pages.items(): + markdown = (REPOSITORY_DIR / path).read_text(encoding='utf-8') + (OUTPUT_DIR / site_path).write_text(rewrite_links(markdown, path, site_path), encoding='utf-8') + + print(f'{len(pages)} pages written to {OUTPUT_DIR.relative_to(REPOSITORY_DIR)}/.', file=sys.stderr) + + +def rewrite_links(markdown: str, path: str, site_path: str) -> str: + """Rewrite the links of the file path of the repository, published as site_path.""" + + def rewrite(match: re.Match[str]) -> str: + target = posixpath.normpath(posixpath.join(posixpath.dirname(path), match['path'])) + if target in ROOT_PAGES: + target_site_path = ROOT_PAGES[target] + elif target.startswith('doc/'): + target_site_path = target.removeprefix('doc/') + else: + # Not a page: the strict build reports the link. + return match[0] + + link = posixpath.relpath(target_site_path, posixpath.dirname(site_path) or '.') + + return '](' + link + (match['fragment'] or '') + ')' + + return MARKDOWN_LINK.sub(rewrite, markdown) + + +if __name__ == '__main__': + main() diff --git a/tools/zensical/requirements.txt b/tools/zensical/requirements.txt new file mode 100644 index 00000000..0dadf2cc --- /dev/null +++ b/tools/zensical/requirements.txt @@ -0,0 +1,13 @@ +# The documentation site (zensical.toml), with Python 3.14: `pip install zensical` in a new +# virtualenv, then `pip freeze`. Dependabot updates them. +click==8.5.0 +deepmerge==3.0.1 +jinja2==3.1.6 +markdown==3.11.2 +markupsafe==3.0.4 +pathspec==1.1.1 +pygments==2.21.0 +pymdown-extensions==12.3 +pyyaml==6.0.3 +tomli==2.5.0 +zensical==0.0.72 diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 00000000..654552d5 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,95 @@ +# The documentation site, published on GitHub Pages by .github/workflows/docs.yml. Its pages are +# doc/, README.md, UPGRADE-2.0.md and CHANGELOG.md, which tools/zensical/pages.py copies to +# docs_dir first. With the Python packages of tools/zensical/requirements.txt: +# +# python3 tools/zensical/pages.py && zensical serve + +[project] +site_name = "gitlib" +site_url = "https://gitonomy.github.io/gitlib/" +site_description = "Access git repositories from PHP, through the git binary or libgit2." +copyright = "gitlib is released under the MIT license." +repo_url = "https://github.com/gitonomy/gitlib" +repo_name = "gitonomy/gitlib" +docs_dir = "var/docs" +site_dir = "site" +strict = true + +nav = [ + { "Getting started" = "index.md" }, + { "Repository and drivers" = [ + { "Opening a repository" = "repository.md" }, + { "Creating repositories" = "admin.md" }, + ] }, + { "Objects" = [ + { "Revisions" = "revision.md" }, + { "Commits" = "commit.md" }, + { "Log" = "log.md" }, + { "Trees" = "tree.md" }, + { "Blobs" = "blob.md" }, + { "Diffs" = "diff.md" }, + { "Blame" = "blame.md" }, + { "Tags and branches" = "references.md" }, + { "Branches" = "branch.md" }, + ] }, + { "Working copy" = "workingcopy.md" }, + { "Hooks" = "hooks.md" }, + { "Upgrade and changelog" = [ + { "Upgrading to 2.0" = "upgrade-2.0.md" }, + { "Changelog" = "changelog.md" }, + ] }, +] + +[project.theme] +language = "en" +favicon = "assets/favicon.svg" +# The fonts of the system: no request to Google Fonts. +font = false +features = [ + "content.code.copy", + "navigation.footer", + "navigation.instant", + "navigation.path", + "navigation.sections", + "navigation.top", + "navigation.tracking", + "search.highlight", + "toc.follow", +] + +[project.theme.icon] +logo = "lucide/git-branch" +repo = "fontawesome/brands/github" + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "lucide/sun-moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +primary = "teal" +accent = "teal" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "teal" +accent = "teal" +toggle.icon = "lucide/moon" +toggle.name = "Switch to system preference" + +[project.markdown_extensions] +admonition = {} +attr_list = {} +toc.permalink = true +# The examples start without "