diff --git a/.gitattributes b/.gitattributes index 20a19621..f8886cc3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,16 @@ * text=auto /tests/fixtures/*.bundle binary + +# Left out of the archive Composer installs: only src/, the docs and the license ship. +/.gitattributes export-ignore +/.github export-ignore +/.gitignore export-ignore +/.php-cs-fixer.php export-ignore +/benchmark.php export-ignore +/castor.php export-ignore +/phpstan-baseline.neon export-ignore +/phpstan.neon export-ignore +/phpunit.xml.dist export-ignore +/test-git-versions.php export-ignore +/tests export-ignore +/tools export-ignore diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ed81c27d..827452f7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,7 +6,7 @@ We accept contributions via pull requests on GitHub. Please review these guideli ## Guidelines -* Please follow the [PSR-12 Coding Style Guide](https://www.php-fig.org/psr/psr-12/). +* Follow the coding standards of PHP-CS-Fixer, configured in `.php-cs-fixer.php` (the Symfony and PhpCsFixer rule sets): `castor cs` applies them. * Ensure that the current tests pass, and if you've added something new, add the tests where relevant. * Send a coherent commit history, making sure each commit in your pull request is meaningful. * You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts. @@ -62,10 +62,13 @@ locally, ready to receive new commits. `build` rebuilds `tests/fixtures/foobar.b from that directory, restricted to the refs listed in `tests/fixtures/bundle-refs.txt` — add your new branch or tag there first if you introduced one. -Then update the commit SHA constants in `AbstractTestCase` to match, and run -`tests/fixtures/verify-bundle.sh`. It checks the bundle's integrity, its ref list against -`bundle-refs.txt`, and its size, since GitHub renders any change to this binary file as an -opaque diff. If your change intentionally adds a ref or grows the file, update +Existing refs must not move, since the tests rely on the commits they point to: add your +commits on new branches or tags, and constants in `AbstractTestCase` for the ones your +tests use. Then add the new refs to `tests/fixtures/bundle-heads.txt` (the output of +`git bundle list-heads tests/fixtures/foobar.bundle`), and run +`tests/fixtures/verify-bundle.sh`. It checks the bundle's integrity, its refs against +`bundle-refs.txt` and `bundle-heads.txt`, and its size, since GitHub renders any change to +this binary file as an opaque diff. If your change intentionally adds a ref or grows the file, update `tests/fixtures/bundle-refs.txt` or `MAX_SIZE_KB` in that script as part of the same pull request, so the reason for the change is explicit and reviewable rather than a silent binary diff. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..27976684 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + + - package-ecosystem: composer + directories: + - /tools/php-cs-fixer + - /tools/phpstan + schedule: + interval: monthly diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 73634aab..23a1fb82 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,9 @@ on: branches: [ main ] pull_request: +permissions: + contents: read + jobs: verify-fixture: name: Verify Test Fixture Bundle @@ -34,6 +37,16 @@ jobs: - name: Run CS check run: castor cs --dry-run + check-archive: + name: Check the Archive + runs-on: ubuntu-24.04 + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Ship only the code, the docs and the license + run: diff <(git archive HEAD | tar -t | cut -d/ -f1 | sort -u) <(printf '%s\n' CHANGELOG.md LICENSE README.md UPGRADE-2.0.md composer.json doc src | sort) + phpstan: name: Static Analysis runs-on: ubuntu-24.04 @@ -90,3 +103,100 @@ jobs: - name: Execute PHPUnit run: vendor/bin/phpunit + + tests-git: + name: Test git ${{ matrix.git }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + # The oldest supported git, and the one of Ubuntu 20.04: the other jobs test the latest one. + include: + - git: '1.8.4' + sha256: '5e0efe7cd30e258bc3be9ae023790743974e76eb1b24ecac24de6fc2af2b2786' + - git: '2.25.1' + sha256: 'aaef0cd609a7730cfbae530d7c91214a941d7e26054d2314cc99794880156b33' + + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Cache git + id: cache-git + uses: actions/cache@v6 + with: + path: ~/git + key: git-${{ matrix.git }}-ubuntu-24.04 + + - name: Build git + if: steps.cache-git.outputs.cache-hit != 'true' + run: | + curl -fsSL "https://github.com/git/git/archive/refs/tags/v${{ matrix.git }}.tar.gz" -o git.tar.gz + echo "${{ matrix.sha256 }} git.tar.gz" | sha256sum --check + tar xzf git.tar.gz + make -C "git-${{ matrix.git }}" prefix="$HOME/git" CFLAGS='-std=gnu17 -O2' NO_OPENSSL=1 NO_CURL=1 NO_EXPAT=1 NO_GETTEXT=1 NO_PERL=1 NO_PYTHON=1 NO_TCLTK=1 --jobs="$(nproc)" install + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + + - name: Setup Problem Matchers + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install Composer dependencies + run: composer update --prefer-dist --no-interaction + + - name: Execute PHPUnit + run: GIT_COMMAND="$HOME/git/bin/git" vendor/bin/phpunit + + tests-ffi: + name: Test PHP ${{ matrix.php }} (LibGit2Driver) + runs-on: ubuntu-24.04 + 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@v2 + with: + php-version: ${{ matrix.php }} + extensions: ffi + + - name: Setup Problem Matchers + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install Composer dependencies + run: composer update --prefer-dist --no-interaction + + - name: Execute PHPUnit + run: LD_LIBRARY_PATH="$HOME/libgit2/lib" vendor/bin/phpunit + env: + GITLIB_DRIVER: ffi diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index e9dffd22..84e2387c 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -26,6 +26,7 @@ __FILE__, 'castor.php', ]) + ->notPath('git-builds') ->notPath('var') ; @@ -38,6 +39,7 @@ '@Symfony' => true, '@Symfony:risky' => true, 'php_unit_internal_class' => false, // From @PhpCsFixer but we don't want it + 'php_unit_strict' => true, // assertSame() instead of assertEquals() 'php_unit_test_class_requires_covers' => false, // From @PhpCsFixer but we don't want it 'phpdoc_add_missing_param_annotation' => false, // From @PhpCsFixer but we don't want it 'header_comment' => ['header' => $fileHeaderComment], diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d7a417..081545f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,63 @@ All notable changes to this project are documented in this file. ## [Unreleased] -Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). +Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). To upgrade, see +[UPGRADE-2.0.md](UPGRADE-2.0.md): most of the API of 1.x that 2.0 replaces keeps working, +deprecated until 3.0. + +### Breaking changes + +- Run every git operation through a driver (`Gitonomy\Git\Driver`): the domain objects call a + `Driver\RepositoryDriverInterface`, typed (hashes, enums, value objects of `Driver\Data`), + instead of building processes and parsing their output. A failing operation always throws, a + missing revision or object a `ReferenceNotFoundException`, a timeout a + `ProcessTimedOutException`; dates are `\DateTimeImmutable`; names in diffs and trees are + UTF-8; references are read again after every write gitlib makes, and peel annotated tags; + the diff of a merge commit is against its first parent; blame lines, tree entries, diff + lines and logs are typed objects; the subject of a message is its first paragraph. See + [UPGRADE-2.0.md](UPGRADE-2.0.md) for every change. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Require PHP 8.4 or later, Symfony 6.4/7.4/8.1+: most classes are `final` (some `readonly`), + and every method has native types. ([#238](https://github.com/gitonomy/gitlib/pull/238)) +- Remove `Gitonomy\Git\Util\StringHelper`, replaced by `symfony/string`, and its configurable + encoding: gitlib reads everything in UTF-8. ([#252](https://github.com/gitonomy/gitlib/pull/252)) +- Require git 1.8.4 or later, the oldest version the test suite passes with: it simplifies the + log of a range filtered by path like the current versions, and `Log::count()` needs git + 1.7.2. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- `Diff::parse()` throws for a diff without its raw information, deprecated since 1.6.0 + ([#229](https://github.com/gitonomy/gitlib/pull/229)): parse the output of + `git diff --raw --patch`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) ### Features -- Add `WorkingCopy::merge()` to support `git merge`: accepts a `Commit`, `Reference` or - string revision, plus optional extra CLI args (e.g. `['--no-ff']`), mirroring the existing - `checkout()` method. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) +- Add 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` compares both drivers + on a repository. With php-fpm, `LibGit2Driver::preload()` keeps libgit2 loaded from + `opcache.preload`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- `Repository` takes the working directory and the driver as arguments: a `Driver\Cli\CliDriver` + by default, configured through its constructor (git binary, environment, timeout and logger, + which gets every command, clones included), or any `Driver\DriverInterface`, to replace it in + tests; see ["Using your own driver"](doc/repository.md#using-your-own-driver). The driver + finds the repository: linked worktrees, submodules and reftable repositories open, and a + directory that isn't a repository throws right away. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Add static methods to `Repository` creating repositories, `init()`, `initBare()`, + `cloneFrom()`, `cloneBareFrom()` and `mirrorFrom()`, and checking a repository URL, + `isValidRepository()` and `isValidRepositoryAndBranch()`; see + ["Create and access git repositories"](doc/admin.md). + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Add `Repository::getDiff($from, $to)`, `Repository::getMergeBase()` and `WorkingCopy::getDiff()`, + `WorkingCopy::checkoutNewBranch()`, `Branch::moveTo()` and `Branch::isMergedInto()`, + `Log::withOffset()` and `withLimit()`, `Blame::getHunks()` and the line numbers of + `Blame\Line`, `TreeEntry` and `Submodule`, `Diff\DiffLine` and `Diff\DiffLineType`, + `File::isModeChange()`, `FileChange::getAdditions()` and `getDeletions()`, + `PushReference::getFullname()` and `getRange()`, and the SSH and X.509 signatures of tags. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Add `WorkingCopy::merge()` to support `git merge`, fast-forwarding or not, with a + `FastForward` enum. ([#248](https://github.com/gitonomy/gitlib/pull/248), fixes #118) - Add `Repository::runProcess()` to expose stderr from successful commands: `run()` only ever returned stdout, even on success, but some git commands (e.g. `push`) write meaningful output to stderr even when they succeed. `run()` now delegates to it and keeps @@ -26,8 +76,69 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). still exists afterward. ([#244](https://github.com/gitonomy/gitlib/pull/244), fixes #14) +### Deprecations + +All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). +([#253](https://github.com/gitonomy/gitlib/pull/253)) + +- `Admin`, use the static methods of `Repository`, and `Repository::cloneTo()`. +- The array of options of `Repository` and `Admin` (`working_dir`, `logger`, `command`, + `environment_variables`, `inherit_environment_variables`, `process_timeout`, `debug`): pass + the working directory and a `CliDriver` instead; `inherit_environment_variables` has no + counterpart, pass the variables git needs instead. `Repository::setLogger()` and `getLogger()`. +- Passing `$reload` to `Repository::getReferences()`, `ReferenceBag::update()`, + `Branch::isMergedTo()`, and arguments to `Commit::getIncludingBranches()`. +- Calling `Repository::getDiff()` with one argument, `Log::getDiff()`, `Diff::setRepository()`, + `File::setRepository()`, `addChange()`, `isChangeMode()`, `isDelete()` and `getAnchor()`, + `FileChange::getCount()` and its `LINE_*` constants, reading diff lines as arrays. +- `Log::setOffset()` and `setLimit()`. +- A range of lines as a string in `Repository::getBlame()`, `Blame::getGroupedLines()` and + `Blame\Line::getLine()`. +- `CommitReference`, `Tree::getCommitReferenceEntries()`, reading tree entries as arrays. +- The branch to create of `WorkingCopy::checkout()`, the arguments of `git merge` of + `WorkingCopy::merge()`. +- `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, + `PushReference::getReference()` and `getRevision()`. + ### Bug fixes +- Fix `Commit::getDiff()` returning an empty diff for a root commit, 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 blame lines starting with a TAB character (`Blame\Line::getContent()`): code removing it + must stop doing so. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix reading a commit whose `encoding` header isn't UTF-8, which threw: its message, author and + committer are now re-encoded to UTF-8, like `git log` does. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix file names holding non-ASCII or special characters (tab, quote, backslash) in trees + (`Tree::getEntries()`), and special characters in diffs, which kept git's C-style quoting. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix relative paths not resolving against the repository in `Repository::run()`: the git process spawned by `run()` never had its working directory set, so commands such as `apply` resolved paths against the calling script's cwd instead of `--work-tree`, failing @@ -43,9 +154,9 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). branch existed. - `Tag::getBodyMessage()` dropped or truncated a line for annotated tags using the standard `subject\n\nbody` convention. - - `Repository::run()` can return `null` on failure when `debug=false`; several call sites - (`Tag::isAnnotated()`, `PushReference::isForce()`, `Blob::getContent()`, ...) did not - handle that case and produced wrong data or crashed. All call sites now check for `null`. + - With the `debug` option set to `false`, several call sites (`Tag::isAnnotated()`, + `PushReference::isForce()`, `Blob::getContent()`, ...) produced wrong data or crashed + when git failed. - `Repository::shell()` interpolated the repository path into the shell command without escaping it; it is now passed through `escapeshellarg()`. - `Hooks::set()` created hook scripts world-writable (`0777`); they are now created @@ -54,16 +165,9 @@ Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). ### Minor -- Replace `Gitonomy\Git\Util\StringHelper` with `symfony/string`'s `CodePointString`, which - covers the same UTF-8-aware `strlen`/`substr`/`strpos`/`strrpos` operations. - **Breaking:** `StringHelper` is removed, along with its configurable encoding - (`getEncoding()`/`setEncoding()`); the library now consistently targets `UTF-8`. - ([#252](https://github.com/gitonomy/gitlib/pull/252)) -- Modernize for PHP 8.4+, Symfony 6.4/7.4/8.1+ and PHPUnit 12: full property/parameter/return - type coverage, `readonly`/`final` where applicable, PHPUnit 12 attributes, and new - php-cs-fixer/PHPStan/Castor tooling. - **Breaking:** the minimum supported PHP version is now 8.4. - ([#238](https://github.com/gitonomy/gitlib/pull/238)) +- CI tests git 1.8.4 and 2.25.1, analyses the tests and the libgit2 driver with PHPStan, + checks the archive Composer installs, and the suite deletes what it creates, in any order. + ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Add a regression test for reading a blob's content in a tree that also contains a submodule. ([#243](https://github.com/gitonomy/gitlib/pull/243)) - Replace the network-dependent test fixture (cloned from GitHub on every run) with a local, diff --git a/README.md b/README.md index 24b1c76e..7005d8b1 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,12 @@ Gitlib for Gitonomy This library provides methods to access Git repository from PHP 8.4+. -It makes shell calls, which makes it less performant than any solution. - -Anyway, it's convenient and don't need to build anything to use it. That's how we love it. +By default, it runs the `git` binary (1.8.4 or later) for every operation: convenient, and it +doesn't need anything built to use. It can instead run [libgit2](https://libgit2.org/) in-process +via PHP's `FFI` extension, an experimental driver which is faster for small reads but slower on +long histories: see +["Choosing a driver"](doc/repository.md#choosing-a-driver) and +["Performance"](doc/repository.md#performance). Quick Start ----------- @@ -21,11 +24,13 @@ or edit your `composer.json` file by hand: ```json { "require": { - "gitonomy/gitlib": "^1.3" + "gitonomy/gitlib": "^2.0" } } ``` +Upgrading from 1.x? Read [UPGRADE-2.0.md](UPGRADE-2.0.md). + Example Usage ------------- @@ -40,22 +45,36 @@ foreach ($repository->getReferences()->getBranches() as $branch) { echo '- '.$branch->getName().PHP_EOL; } +echo $repository->getHeadCommit()->getSubjectMessage().PHP_EOL; + +// Any git command, with the default driver only $repository->run('fetch', ['--all']); ``` +The last argument of `Repository` is the driver running git. It is `CliDriver`, which runs the +`git` binary, by default. To run libgit2 instead, pass a `LibGit2Driver`: everything above works +the same, except `run()`, which needs the `git` binary. + +```php +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository', driver: new LibGit2Driver()); +``` + API Documentation ----------------- -+ [Admin](doc/admin.md) + [Blame](doc/blame.md) + [Blob](doc/blob.md) + [Branch](doc/branch.md) + [Commit](doc/commit.md) ++ [Creating repositories](doc/admin.md) + [Diff](doc/diff.md) + [Hooks](doc/hooks.md) + [Log](doc/log.md) + [References](doc/references.md) -+ [Repository](doc/repository.md) ++ [Repository](doc/repository.md): opening a repository, drivers, errors + [Revision](doc/revision.md) + [Tree](doc/tree.md) + [Working Copy](doc/workingcopy.md) diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md new file mode 100644 index 00000000..5a611d5f --- /dev/null +++ b/UPGRADE-2.0.md @@ -0,0 +1,473 @@ +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)). + +Most of the API of 1.x that 2.0 replaces keeps working, deprecated: each +call triggers a deprecation (`E_USER_DEPRECATED`, through +`symfony/deprecation-contracts`) naming its replacement, and 3.0 removes +them. Log the deprecations of your application to find them. The other +changes, listed below too, break code that relies on them. + +Requirements +------------ + + * PHP 8.4 or later is required. + * git 1.8.4 or later is required by `CliDriver`, the default driver: before, + git simplifies the log of a range filtered by path differently, and it + can't count commits (`Log::count()`) before 1.7.2. + * `symfony/process` must be `^6.4`, `^7.4` or `^8.1`. + * `symfony/string` `^7.4.6` or `^8.0.6`, `symfony/deprecation-contracts` + `^2.5` or `^3`, and `psr/log` `^1.1`, `^2.0` or `^3.0` are new + dependencies. + * Repositories using SHA-256 hashes are refused, with an + `UnsupportedOperationException`, by both drivers. + +Opening a repository +-------------------- + + * The second argument of `Repository` is the working directory, when the + git directory is kept apart from it. An array of options there is + deprecated: its `working_dir` option is this argument, and its `logger`, + `command`, `environment_variables` and `process_timeout` options configure + a `Gitonomy\Git\Driver\Cli\CliDriver`, which `Repository` takes as its last + argument. They can't be combined with this argument. + * The `inherit_environment_variables` option has no counterpart in + `CliDriver`: git inherits the environment variables of PHP anyway, and + `$_SERVER` holds, under php-fpm, the headers and the credentials of the + request. Pass the variables git needs to the `environmentVariables` + argument instead. + + Before: + + ```php + $repository = new Repository($path, [ + 'command' => '/usr/bin/git', + 'logger' => $logger, + 'process_timeout' => 60, + ]); + ``` + + After: + + ```php + use Gitonomy\Git\Driver\Cli\CliDriver; + + $repository = new Repository($path, driver: new CliDriver(command: '/usr/bin/git', processTimeout: 60, logger: $logger)); + ``` + + * The logger is an argument of `CliDriver`, which logs every git command it + runs, cloning and initializing included: `Repository::setLogger()` and + `Repository::getLogger()` are deprecated. `LibGit2Driver` runs no command, + and logs nothing. + * An unknown option throws an `InvalidArgumentException`, instead of being + ignored. + * The `debug` option is deprecated, and setting it to `false` throws an + `InvalidArgumentException`: see [Errors](#errors). + * `$path` may be a linked worktree or a submodule, whose `.git` is a file. + * A directory that isn't a repository throws an `InvalidArgumentException` + right away, instead of a `ProcessException` at the first operation. + +Creating repositories +--------------------- + + * `Admin` is deprecated: use the static methods of `Repository`. They take + the URL before the path, have a method for each kind of repository + instead of the `$bare` argument, whose default was a bare repository, and + take the driver as last argument: + + | 1.x | 2.0 | + |-----------------------------------------------------|--------------------------------------------------------------| + | `Admin::init($path)` | `Repository::initBare($path)` | + | `Admin::init($path, false)` | `Repository::init($path)` | + | `Admin::cloneTo($path, $url)` | `Repository::cloneBareFrom($url, $path)` | + | `Admin::cloneTo($path, $url, false)` | `Repository::cloneFrom($url, $path)` | + | `Admin::cloneBranchTo($path, $url, $branch)` | `Repository::cloneBareFrom($url, $path, $branch)` | + | `Admin::cloneBranchTo($path, $url, $branch, false)` | `Repository::cloneFrom($url, $path, $branch)` | + | `Admin::mirrorTo($path, $url)` | `Repository::mirrorFrom($url, $path)` | + | `Admin::isValidRepository($url)` | `Repository::isValidRepository($url)` | + | `Admin::isValidRepositoryAndBranch($url, $branch)` | `Repository::isValidRepositoryAndBranch($url, $branch)` | + | `$repository->cloneTo($path)` | `Repository::cloneBareFrom($repository->getGitDir(), $path)` | + | `$repository->cloneTo($path, false)` | `Repository::cloneFrom($repository->getGitDir(), $path)` | + + Until 3.0, `Admin` keeps working like in 1.x, with `CliDriver` and the + options of 1.x. + * `isValidRepositoryAndBranch()` checks the exact name of the branch: + `release` no longer matches `feature/release`, nor `rel*` `release`. + * `isValidRepository()` and `isValidRepositoryAndBranch()` read a relative + path as a path, never as the name of a remote of PHP's working directory, + and throw a `ProcessException` when git can't run, instead of returning + `false`. + + Before: + + ```php + $repository = Admin::cloneTo($path, $url, false, ['process_timeout' => 60]); + ``` + + After: + + ```php + use Gitonomy\Git\Driver\Cli\CliDriver; + + $repository = Repository::cloneFrom($url, $path, driver: new CliDriver(processTimeout: 60)); + ``` + + * `Repository::cloneTo()` cloned with the driver of the repository: pass it + to `cloneFrom()` or `cloneBareFrom()` as well. + * `Admin::cloneRepository()` is deprecated, with no replacement for the + arguments of `git clone` other than `--bare`, `--branch` and `--mirror`: + run `git clone` yourself, then open the repository with + `new Repository($path)`. + +References +---------- + + * `Repository::getReferences()` reads the references again after every + write gitlib makes (creating or deleting a reference, a checkout, a merge, + `run()`...): passing `true` to read them again is deprecated. Open a new + `Repository` to see the writes of other processes. A `Reference` object + keeps the commit it pointed to when it was read. + * `Reference::getCommitHash()` returns the commit an annotated tag points + to, instead of the hash of the tag object: `ReferenceBag::resolve()`, + `resolveTags()` and `Commit::resolveReferences()` find annotated tags. + * `ReferenceBag::createBranch()` and `createTag()` throw an + `InvalidArgumentException` when the reference exists, instead of moving + it, and take a `Revision` or a revision name as second argument, instead + of a commit hash only. + * `ReferenceBag::update()` is deprecated: use `Branch::moveTo()`. + + Before: + + ```php + $repository->getReferences()->update(new Branch($repository, 'refs/heads/main', $hash)); + ``` + + After: + + ```php + $repository->getReferences()->getBranch('main')->moveTo($hash); + ``` + + * `Branch::isMergedTo()` is deprecated: use `isMergedInto()`, which takes + the destination as a `Revision` or a revision name, without a default + (`'master'` before) nor `$compareOnlyWithRemote`. + * Passing arguments to `Commit::getIncludingBranches()` is deprecated: it + returns the local and remote branches, which `Branch::isLocal()` and + `isRemote()` filter. + + Before: + + ```php + $branches = $commit->getIncludingBranches(true, false); + ``` + + After: + + ```php + $branches = array_filter($commit->getIncludingBranches(), static fn (Branch $branch): bool => $branch->isLocal()); + ``` + + * `ReferenceBag::getFirstBranch()` returns `null` instead of `false` when + there is no branch, and only branches. + * The getters of `Reference\Tag` (`getTaggerName()`, `getTaggerEmail()`, + `getTaggerDate()`, `getMessage()`, `getSubjectMessage()`, + `getBodyMessage()`, `getGpgSignature()`) return `null` instead of `false` + for a lightweight tag. The tagger is `null` for an annotated tag without + one too, where gitlib threw. `getGpgSignature()` returns `null` for an + unsigned tag instead of throwing, and finds X.509 and SSH signatures too. + * A branch whose name ends with `HEAD` is listed: only the `HEAD` of a + remote is left out. + +History +------- + + * `Log::setOffset()` and `setLimit()` are deprecated: `withOffset()` and + `withLimit()` return another `Log`. + * `count($log)` returns the number of commits the log iterates, after its + offset and up to its limit; `countCommits()` still counts them all. + * The revisions of a log are names, ranges (`a..b`, `a...b`), names to + exclude (`^a`) or `--all`: any other option of `git log` throws an + `InvalidArgumentException`, instead of being passed to git. An unknown + revision throws a `ReferenceNotFoundException` naming it. + * `Log::getDiff()` is deprecated: it ignored the paths of the log. Use + `Repository::getDiff()`. + +Diffs +----- + + * `Repository::getDiff()` takes the commits to compare: `$from` and `$to`. + Calling it with one argument is deprecated. + + | 1.x | 2.0 | + |------------------------------------|------------------------------------------------------------------| + | `$repository->getDiff('a..b')` | `$repository->getDiff('a', 'b')` | + | `$repository->getDiff(['a', 'b'])` | `$repository->getDiff('a', 'b')` | + | `$repository->getDiff('a...b')` | `$repository->getDiff($repository->getMergeBase('a', 'b'), 'b')` | + | `$repository->getDiff('a')` | `$repository->getWorkingCopy()->getDiff('a')` | + + * The diff of a merge commit, `Commit::getDiff()`, is against its first + parent, like GitHub shows it, with both drivers. + * `Diff`, `Diff\File` and `Diff\FileChange` get their repository and their + changes when constructed: `Diff::setRepository()`, + `File::setRepository()` and `File::addChange()` are deprecated. + `Diff::parse()` and `fromArray()` take the repository to read blobs from. + * The lines of a hunk, `FileChange::getLines()`, are `Diff\DiffLine` + objects, with `getType()`, a `Diff\DiffLineType`, and `getContent()`: + reading them as arrays is deprecated, as the `FileChange::LINE_*` + constants and `FileChange::getCount()`, replaced by `getAdditions()` and + `getDeletions()`. + + Before: + + ```php + foreach ($change->getLines() as [$type, $content]) { + if (FileChange::LINE_ADD === $type) { + // ... + } + } + ``` + + After: + + ```php + use Gitonomy\Git\Diff\DiffLineType; + + foreach ($change->getLines() as $line) { + if (DiffLineType::Addition === $line->getType()) { + // ... $line->getContent() + } + } + ``` + + * `File::isChangeMode()`, `isDelete()` and `getAnchor()` are deprecated: + use `isModeChange()`, `isDeletion()`, and + `substr($file->getNewIndex(), 0, 12)`. + * `File::getOldIndex()` and `getNewIndex()` return `null` for a missing + hash, never an empty string; `getName()` returns a `string`. + * `File::getOldBlob()` and `getNewBlob()` throw a `LogicException` without + repository, and a `RuntimeException` without hash. + * `Diff::parse()` throws an `InvalidArgumentException` for a diff without + its raw information, deprecated since 1.6.0: parse the output of + `git diff --raw --patch`, with `-z` or not. It reads the names of the + files from the raw information: names with spaces are no longer split at + the wrong place. + * During a merge conflict, `WorkingCopy::getDiffPending()` and + `getDiffStaged()` leave the unmerged files out, where they threw, and + `WorkingCopy::getDiff()` compares them, conflict markers included, to the + revision, like `git diff HEAD`. + +Blame +----- + + * `Repository::getBlame()` takes the first and the last lines to blame: + a range as a string (`'3,5'`, `'3,+3'`) is deprecated, and a regular + expression is no longer supported. + * `Blame::getLines()` and `getLine()` number the lines like the file, from + the first blamed line, instead of from 1. + * `Blame\Line::getNumber()` and `getOriginalNumber()` return the number of + the line in the file and in the commit that changed it, as integers: + `Line::getLine()` is deprecated. + * `Blame::getGroupedLines()` is deprecated: `getHunks()` returns + `Blame\Hunk` objects. + +Trees +----- + + * `Tree::getEntries()`, `getTreeEntries()`, `getBlobEntries()` and + `getSubmoduleEntries()` return `TreeEntry` objects, with `getName()`, + `getMode()` and `getObject()`: reading them as arrays + (`[$mode, $object]`) is deprecated. + * `CommitReference` is deprecated: it is `Submodule`. So is + `Tree::getCommitReferenceEntries()`: use `getSubmoduleEntries()`. + * `Tree::resolvePath()` throws an `InvalidArgumentException` explaining + why a path doesn't resolve, and resolves `/` to the tree. + +Working copy +------------ + + * `WorkingCopy::checkout()` takes a `Revision` or a revision name: a local + `Branch` object attaches `HEAD` to the branch, instead of detaching it. + Its second argument, a branch to create, is deprecated: use + `checkoutNewBranch($branch, $startPoint)`. + * `WorkingCopy::merge()` takes a `FastForward` instead of arguments of + `git merge`, deprecated. + + Before: + + ```php + $workingCopy->merge('feature', ['--no-ff']); + ``` + + After: + + ```php + use Gitonomy\Git\FastForward; + + $workingCopy->merge('feature', FastForward::Never); + ``` + +Commits, trees and blobs +------------------------ + + * `Repository::getCommit()` takes a full hash. `getTree()` and `getBlob()` + take a hash, abbreviated or not, instead of any revision: use + `getRevision($name)->getCommit()` for a name. + * Reading an object of another type throws a `ReferenceNotFoundException` + ("The object ... is not a tree."): `getTree()` read the tree of a commit, + and `getBlob()` threw a `ProcessException`. + * The subject of a commit or a tag is its first paragraph, on one line, and + its body what follows, like `git log --format=%s` and `%b`: a message + without an empty line after its first line has no body, where its second + line was lost. + * `Commit::getShortMessage()` and `getFixedShortHash()` are deprecated: use + `symfony/string` (`u($commit->getSubjectMessage())->truncate(50, '...')`) + and `substr()`. + * `Blob::getMimetype()` returns the mimetype alone (`text/plain`, instead + of `text/plain; charset=us-ascii`), and throws instead of returning + `false`. + * `Repository` keeps the objects it returns only while your application + uses them: memory no longer grows with every commit read. + +Errors +------ + + * 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. + * `ProcessException` extends the new + `Gitonomy\Git\Exception\DriverException`, itself a `RuntimeException`: + catching `RuntimeException` or `GitExceptionInterface` still works. + * These methods throw instead of returning a fallback value: + * `Commit::getIncludingBranches()` threw nothing and returned `[]` when git + failed; + * `ReferenceBag` (`Repository::getReferences()`) only returns an empty bag + for a repository without references, and throws for any other failure. + * `Commit::getShortHash()` throws a `ReferenceNotFoundException` for an + unknown commit, instead of a `ProcessException`. + * `Hooks` throws a `RuntimeException` when a hook exists already, doesn't + exist, or can't be written, instead of a `LogicException` or a silent + failure, and creates the hooks directory. + * The `Admin` methods throw a `ProcessException`, still a + `RuntimeException`, with git's error output as message. + * An argument holding a NUL byte throws an `InvalidArgumentException`, + where PHP threw a `ValueError`: git and libgit2 read their arguments as C + strings, which end at the first NUL byte. + * Every message is a sentence ending with a dot. + +Dates +----- + + * `Commit::getAuthorDate()`, `Commit::getCommitterDate()` and + `Reference\Tag::getTaggerDate()` return a `\DateTimeImmutable` instead of + a `\DateTime`. Code that modifies the returned date must use the returned + value of `modify()`, `setTimezone()`... + +Extending gitlib +---------------- + + * These classes are `final`: `Admin`, `Blame`, `Blob`, `Commit`, + `Diff\File`, `Log`, `Parser\DiffParser`, `PushReference`, + `Reference\Branch`, `Reference\Stash`, `Reference\Tag`, `ReferenceBag`, + `Repository`, `Tree`, and the exceptions `LogicException`, + `ProcessException`, `ReferenceNotFoundException` and + `UnexpectedValueException`. + * These classes are `final readonly`: `Blame\Line`, `Diff\Diff`, + `Diff\FileChange`, `Hooks`, `RevisionList`, `WorkingCopy`. + * Every method has native parameter and return types. A class extending + `Revision`, `Reference` or `Parser\ParserBase`, which remain extendable, + must declare the same types, and must not redeclare their properties. + The constructor of `Reference` requires the commit hash. + * To change how gitlib runs git, for example in tests, implement + `Gitonomy\Git\Driver\DriverInterface` and pass it as the driver of + `Repository`, instead of extending `Repository`: see + ["Using your own driver"](doc/repository.md#using-your-own-driver). + * `Commit::__construct()` takes a `Gitonomy\Git\Driver\Data\CommitData` + instead of an array, and `Commit::setData()` is internal. + * `Gitonomy\Git\Util\StringHelper` is removed, with its configurable + encoding: use `symfony/string`. Logs are always read in UTF-8. + * The parsers of the git output moved to `Gitonomy\Git\Driver\Cli\Parser` + and are `final`, except `Parser\DiffParser` and `Parser\ParserBase`. + `CommitParser` is merged into `LogParser`, and their results are objects + of `Gitonomy\Git\Driver\Data`: `LogParser::$commits` (instead of `$log`), + `TagParser::$data`, `ReferenceParser::$references`, `TreeParser::$entries` + and `BlameParser::$lines`. `TreeParser` parses `git ls-tree -z`, and + `BlameParser` no longer takes a `Repository`. `ParserBase::parse()` + takes a `string`. + * Objects serialized with 1.x (a `Commit` or a `Diff` in a cache...) can't + be unserialized: clear such caches. `Diff::toArray()` and + `Diff::fromArray()` remain compatible. + +Data returned +------------- + + * File names in diffs (`Diff\File`) and tree entries (`Tree`) are returned + in UTF-8, instead of git's quoted form (`"caf\303\251.txt"`) for names + with non-ASCII or special characters. + * `Blame\Line::getContent()` no longer starts with a TAB. + * `Commit::getDiff()` lists the files of a root commit, instead of + returning an empty diff. + * A commit read through `Repository::getCommit()` has its message as + `git log` prints it, like a commit of a `Log` already had: without blank + lines around it, nor whitespace at the end of its lines. It also comes + re-encoded to UTF-8 when the commit has an `encoding` header, instead of + throwing. + * `Reference\Tag::isSigned()` returns `false` for a lightweight tag. + * `PushReference::isForce()` returns `true` for histories without a common + ancestor, where the constructor threw. `PushReference` runs git when + `isForce()` needs it, not when constructed. `getReference()` and + `getRevision()` are deprecated: use `getFullname()` and `getRange()`. + * `Blob::getContent()` only reads blobs, where `git cat-file -p` also + printed trees and commits. + * `WorkingCopy::getUntrackedFiles()` and `RevisionList::getAll()` return + lists, without gaps in their keys. + * `Diff\File::toArray()` has a new `is_copy` key. + +Other changes +------------- + + * Relative paths, in the arguments of `Repository::run()` and in the paths + of a log, are relative to the repository instead of the current directory + of PHP. + * `Hooks::set()` makes hooks executable by their owner only (`0700`, + instead of `0777`): change their permissions if another user runs them. + * `Repository::shell()` is deprecated: it ignored the driver. Run + `git shell` with Symfony Process instead. diff --git a/benchmark.php b/benchmark.php new file mode 100644 index 00000000..eb610af9 --- /dev/null +++ b/benchmark.php @@ -0,0 +1,158 @@ +#!/usr/bin/env php + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +require __DIR__.'/vendor/autoload.php'; + +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Repository; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\SingleCommandApplication; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Process\Exception\ProcessTimedOutException; +use Symfony\Component\Process\Process; + +/** + * @return array The operations to compare + */ +function operations(string $head, string $commit, string $file): array +{ + return [ + 'Resolve HEAD' => static fn (Repository $repository) => $repository->getHeadCommit()->getHash(), + 'Read a commit' => static fn (Repository $repository) => $repository->getCommit($commit)->getMessage(), + 'Log 100 commits' => static fn (Repository $repository) => $repository->getLog($head, null, 0, 100)->getCommits(), + 'Log 100 commits of a directory' => static fn (Repository $repository) => $repository->getLog($head, dirname($file), 0, 100)->getCommits(), + 'Last commit of a file' => static fn (Repository $repository) => $repository->getCommit($head)->getLastModification($file)->getHash(), + 'Count the commits of HEAD' => static fn (Repository $repository) => $repository->getLog($head)->countCommits(), + 'List the branches' => static fn (Repository $repository) => $repository->getReferences()->getBranches(), + 'Read a file' => static fn (Repository $repository) => $repository->getCommit($head)->getTree()->resolvePath($file)->getContent(), + 'Diff a commit' => static fn (Repository $repository) => $repository->getCommit($commit)->getDiff()->getFiles(), + 'List the branches including a commit' => static fn (Repository $repository) => $repository->getCommit($commit)->getIncludingBranches(), + 'Blame a file' => static fn (Repository $repository) => $repository->getBlame($head, $file)->getLines(), + ]; +} + +/** + * @param list $values + */ +function median(array $values): float +{ + sort($values); + $middle = intdiv(count($values), 2); + + return count($values) % 2 ? $values[$middle] : ($values[$middle - 1] + $values[$middle]) / 2; +} + +// One measure, in the process measure() starts. +if (false !== $measure = getenv('GITLIB_BENCHMARK_MEASURE')) { + /** @var class-string $driver */ + [$path, $driver, $operation, $head, $commit, $file, $iterations] = json_decode($measure, true, flags: \JSON_THROW_ON_ERROR); + $run = operations($head, $commit, $file)[$operation]; + + // Warms up, and fails before timing anything when the operation throws. + $run(new Repository($path, driver: new $driver())); + + $durations = []; + for ($i = 0; $i < $iterations; ++$i) { + // A new Repository every time, like a web request would open it. + $start = hrtime(true); + $run(new Repository($path, driver: new $driver())); + $durations[] = hrtime(true) - $start; + } + + echo median($durations) / 1e6; + + exit(0); +} + +/** + * Measures an operation in a process of its own: libgit2's caches don't + * carry over from one operation to the next, and an operation that takes too + * long can be stopped. + * + * @param class-string $driver + * + * @return float|null The median duration, in milliseconds, or null when the measure took more than $timeout seconds + */ +function measure(string $path, string $driver, string $operation, string $head, string $commit, string $file, int $iterations, int $timeout): ?float +{ + $process = new Process([\PHP_BINARY, __FILE__], env: ['GITLIB_BENCHMARK_MEASURE' => json_encode([$path, $driver, $operation, $head, $commit, $file, $iterations])], timeout: $timeout); + + try { + return (float) $process->mustRun()->getOutput(); + } catch (ProcessTimedOutException) { + return null; + } +} + +/** + * Times the loading of libgit2, in new PHP processes: a process pays it once. + * + * @return float The median duration, in milliseconds + */ +function measureLibraryLoading(int $iterations): float +{ + $code = <<<'PHP' + require $argv[1]; + $start = hrtime(true); + new Gitonomy\Git\Driver\Ffi\LibGit2Driver(); + echo hrtime(true) - $start; + PHP; + + $durations = []; + for ($i = 0; $i < $iterations; ++$i) { + $durations[] = (int) new Process([\PHP_BINARY, '-r', $code, __DIR__.'/vendor/autoload.php'])->mustRun()->getOutput(); + } + + return median($durations) / 1e6; +} + +new SingleCommandApplication() + ->setName('Compare CliDriver and LibGit2Driver on read operations') + ->addArgument('repository', InputArgument::REQUIRED, 'The repository to read') + ->addArgument('file', InputArgument::REQUIRED, 'A file of HEAD, to log, read and blame') + ->addOption('iterations', null, InputOption::VALUE_REQUIRED, 'The number of timed runs of each operation, of which the median is kept', '10') + ->addOption('timeout', null, InputOption::VALUE_REQUIRED, 'The time after which the measure of an operation is stopped, in seconds', '60') + ->setCode(static function (InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + $path = $input->getArgument('repository'); + $file = $input->getArgument('file'); + $iterations = (int) $input->getOption('iterations'); + $timeout = (int) $input->getOption('timeout'); + + $repository = new Repository($path); + $head = $repository->getHeadCommit()->getHash(); + // The last commit that changed $file: its diff isn't empty. + $commit = $repository->getHeadCommit()->getLastModification($file)->getHash(); + + $io->title(sprintf('%s (HEAD: %s, %d commits, %d references)', $path, $head, $repository->getLog($head)->countCommits(), count($repository->getReferences()))); + $io->writeln(sprintf('PHP %s, %s, median of %d runs.', \PHP_VERSION, trim(new Process(['git', '--version'])->mustRun()->getOutput()), $iterations)); + + $format = static fn (?float $duration): string => null === $duration ? sprintf('> %d s', $timeout) : sprintf('%.1f ms', $duration); + $rows = [['Load libgit2 (once per process)', '-', $format(measureLibraryLoading($iterations)), '-']]; + foreach (array_keys(operations($head, $commit, $file)) as $operation) { + $cli = measure($path, CliDriver::class, $operation, $head, $commit, $file, $iterations, $timeout); + $libGit2 = measure($path, LibGit2Driver::class, $operation, $head, $commit, $file, $iterations, $timeout); + $rows[] = [$operation, $format($cli), $format($libGit2), null === $cli || null === $libGit2 ? '-' : sprintf('%.1f', $cli / $libGit2)]; + } + + $io->table(['Operation', 'CliDriver', 'LibGit2Driver', 'Ratio'], $rows); + + return 0; + }) + ->run() +; diff --git a/composer.json b/composer.json index 53b61963..7db091c7 100644 --- a/composer.json +++ b/composer.json @@ -37,18 +37,23 @@ "require": { "php": "^8.4", "ext-pcre": "*", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3", "symfony/process": "^6.4 || ^7.4 || ^8.1", "symfony/string": "^7.4.6 || ^8.0.6" }, "require-dev": { "ext-fileinfo": "*", + "ext-zlib": "*", "phpunit/phpunit": "^12.0", - "psr/log": "^3.0", "symfony/console": "^8.1" }, + "suggest": { + "ext-ffi": "To use the experimental LibGit2Driver (in-process libgit2) instead of the default CliDriver — also requires libgit2 1.9 to be installed on the system.", + "ext-fileinfo": "To read the mimetype of a blob with Blob::getMimetype()." + }, "config": { "preferred-install": "dist", "sort-packages": true - }, - "prefer-stable": true + } } diff --git a/doc/admin.md b/doc/admin.md index d4776804..3aff0a53 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -1,24 +1,25 @@ Create and access git repositories ================================== -gitlib provides methods to initialize new repositories. +The static methods of `Repository` create new repositories, and return them. +They replace `Admin`, deprecated since 2.0: see +[UPGRADE-2.0.md](../UPGRADE-2.0.md#creating-repositories). + +To open a repository that exists already, use `new Repository($path)`: see +[Repository](repository.md). Create a repository ------------------- -To initialize a new repository, use method `Admin::init`. - ```php -// Initialize a bare repository -$repository = Gitonomy\Git\Admin::init('/path/to/repository'); +use Gitonomy\Git\Repository; -// Initialize a non-bare repository -$repository = Gitonomy\Git\Admin::init('/path/to/repository', false); -``` +// Initialize a repository with a working directory +$repository = Repository::init('/path/to/repository'); -Default behavior is to create a bare repository. If you want to -initialize a repository with a working copy,pass `false` as third -argument of Repository constructor. +// Initialize a bare repository +$repository = Repository::initBare('/path/to/repository'); +``` Cloning repositories -------------------- @@ -26,52 +27,107 @@ 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 = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git'); +$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: -// Clone to a non-bare repository -$repository = Gitonomy\Git\Admin::cloneTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', false); +```php +$repository = Repository::cloneFrom('https://github.com/gitonomy/gitlib.git', '/tmp/gitlib', 'a-branch'); ``` -Default behavior is to clone in a bare repository. +Like `git clone --branch`, the branch may also be a tag: the clone checks +its commit out, `HEAD` detached. -You can also clone a repository and point it to a specific branch. In a -non-bare repository, this branch will be checked out: +Like `git clone`, the methods refuse a directory with files in it. When a +clone fails, they remove what they created. -```php -// Clone to a bare repository -$repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch'); +To clone a `Repository` object, use its git directory as URL: -// Clone to a non-bare repository -$repository = Gitonomy\Git\Admin::cloneBranchTo('/tmp/gitlib', 'https://github.com/gitonomy/gitlib.git', 'a-branch', false); +```php +$new = Repository::cloneBareFrom($repository->getGitDir(), '/tmp/clone'); ``` -Clone a Repository object -------------------------- - -If you already have a Repository instance and want to clone it, you can -use this shortcut: +These methods support no other argument of `git clone`. With `CliDriver`, +run `git clone` yourself, then open the repository: ```php -$new = $repository->cloneTo('/tmp/clone'); +use Symfony\Component\Process\Process; + +new Process(['git', 'clone', '--depth', '1', $url, '/tmp/gitlib'])->mustRun(); + +$repository = new Repository('/tmp/gitlib'); ``` Mirror a repository ------------------- If you want to mirror fully a repository and all references, use the -`mirrorTo` method. This method takes only two arguments, where to mirror -and what to mirror: +`mirrorFrom` method. It clones into a bare repository, set up to mirror the +remote when fetching, like `git clone --mirror`: + +```php +$mirror = 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 `ProcessException` when git can't +run, like a missing binary. See [Errors](repository.md#errors). + +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 -// Mirror to a bare repository -$mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git'); +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; -// Mirror to a non-bare repository -$mirror = Gitonomy\Git\Admin::mirrorTo('/tmp/mirror', 'https://github.com/gitonomy/gitlib.git', false); +$repository = Repository::cloneFrom($url, '/tmp/gitlib', driver: new CliDriver(logger: $logger)); ``` ### References -- -- +- +- +- diff --git a/doc/blame.md b/doc/blame.md index 80816c9b..37556af0 100644 --- a/doc/blame.md +++ b/doc/blame.md @@ -1,58 +1,90 @@ Blaming files ============= +`Repository::getBlame()` returns, for each line of a file, the commit that +last changed it, like `git blame`. It takes a revision (a name or a +`Revision` object) and the path of the file, relative to the root of the +repository: + +```php +$blame = $repository->getBlame('master', 'README.md'); +``` + +To blame some lines only, pass the first and the last ones, numbered from +1: + +```php +// Lines 2 to 3 +$blame = $repository->getBlame('master', 'README.md', 2, 3); + +// From line 2 to the end of the file +$blame = $repository->getBlame('master', 'README.md', 2); +``` + +Like git, the blame stops at the end of the file, and a first line after it +throws a `DriverException`. A last line before the first one blames the lines +in order, like `git blame -L 5,3`. + +Passing a range as a string (`'2,3'` or `'2,+2'`, like `git blame -L`) is +deprecated since 2.0: pass the first and the last lines instead. + Line-per-line iteration ----------------------- -To iterate on lines of a blame: +`getLines()` returns the lines of the blame, `Blame\Line` objects, keyed by +their number in the file: ```php -$blame = $repository->getBlame('master', 'README.md'); - -foreach ($blame->getLines() as $lineNumber => $line) { - $commit = $line->getCommit(); - echo $lineNumber.': '.$line->getContent().' - '.$commit->getAuthorName().PHP_EOL; +foreach ($blame->getLines() as $number => $line) { + echo $number.': '.$line->getContent().' - '.$line->getCommit()->getAuthorName().PHP_EOL; } ``` -The *getLines* method returns an array indexed starting from 1. +With a range, the keys start at its first line, not at 1. -As you can see, you can access the commit object related to the line you -are iterating on. - -If you want to access directly a line: +To get a line by its number in the file: ```php -$line = $blame->getLine(32); +$line = $blame->getLine(2); ``` +It throws an `InvalidArgumentException` when the blame doesn't have this +line. `count($blame)` returns the number of lines blamed. + The Line object --------------- -LineObject represents an item of the blame file. It is composed of those -informations: +A `Blame\Line` is a line of the blamed file: ```php -$line->getCommit(); // returns a Commit -$line->getContent(); // returns text +$line->getNumber(); // Its number in the blamed file +$line->getOriginalNumber(); // Its number in the file of the commit that last changed it +$line->getContent(); // Its text, without its line break +$line->getCommit(); // The commit that last changed it -// you can access author from commmit: +// The author of the commit $author = $line->getCommit()->getAuthorName(); ``` +`Line::getLine()` is deprecated since 2.0: use `getOriginalNumber()`, which +returns an integer. + Group reading by commit ----------------------- -If you plan to display it, you'll probably need a version where lines -from same commit are grouped. - -To do so, use the *getGroupedLines* method that will return an array -like this: +To display a blame, you'll probably group the consecutive lines last +changed by the same commit. `getHunks()` returns these groups, `Blame\Hunk` +objects, with their commit and their lines, keyed by their number in the +file: ```php -$blame = array( - array(Commit, array(1 => Line, 2 => Line, 3 => Line)), - array(Commit, array(4 => Line)), - array(Commit, array(5 => Line, 6 => Line)) -) +foreach ($blame->getHunks() as $hunk) { + echo $hunk->getCommit()->getShortHash().' '.$hunk->getCommit()->getSubjectMessage().PHP_EOL; + + foreach ($hunk->getLines() as $number => $line) { + echo ' '.$number.': '.$line->getContent().PHP_EOL; + } +} ``` + +`getGroupedLines()` is deprecated since 2.0: use `getHunks()`. diff --git a/doc/blob.md b/doc/blob.md index 2ed3bd41..52e7815c 100644 --- a/doc/blob.md +++ b/doc/blob.md @@ -1,24 +1,35 @@ Blob ==== -In git, a blob represents a file content. You can't access the file name -directly from the *Blob* object; the filename information is stored -within the tree, not in the blob. +In git, a blob is the content of a file. A `Blob` object doesn't know the +name of its file: names are stored in trees, not in blobs. Two files with +different names but the same content have the same blob. -It means that for git, two files with different names but same content -will have the same hash. +To get the blob of a file, resolve its path in the tree of a commit (see +[tree.md](tree.md)): -To access a repository *Blob*, you need the hash identifier: +```php +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository'); +$blob = $repository->getRevision('master')->getCommit()->getTree()->resolvePath('README.md'); +``` + +`Repository::getBlob()` takes the hash of a blob, abbreviated or not: ```php -$repository = new Gitonomy\Git\Repository('/path/to/repository'); -$blob = $repository->getBlob('a7c8d2b4'); +$blob = $repository->getBlob('07eeea9'); + +echo $blob->getHash().PHP_EOL; // "07eeea94584fb6f1d8fe55e5b78adc4668c02508", always the full hash ``` +It throws a `ReferenceNotFoundException` for anything else than a hash, of +4 characters at least, and for an abbreviated hash naming nothing. + Get content ----------- -To get content from a *Blob* object: +To get the content of a `Blob` object: ```php echo $blob->getContent(); @@ -27,18 +38,21 @@ echo $blob->getContent(); File informations ----------------- -To get mimetype of a *Blob* object using finfo extension: +`getMimetype()` returns the mimetype of the content, alone, like +`text/plain` or `image/jpeg`. It needs the `fileinfo` extension of PHP, and +throws a `RuntimeException` when it can't read the mimetype: ```php -echo $blob->getMimetype(); +echo $blob->getMimetype().PHP_EOL; ``` -You can also test if *Blob* is a text of a binary file: +`isText()` and `isBinary()` tell if the blob is a text or a binary file. +They check its first 8000 bytes for a NUL character, like git does: ```php if ($blob->isText()) { - echo $blob->getContent(), PHP_EOL; + echo $blob->getContent().PHP_EOL; } elseif ($blob->isBinary()) { - echo 'File is binary', PHP_EOL; + echo 'File is binary'.PHP_EOL; } ``` diff --git a/doc/branch.md b/doc/branch.md index d6e0872c..7752e2c8 100644 --- a/doc/branch.md +++ b/doc/branch.md @@ -1,16 +1,69 @@ Branch ====== -To access a *Branch*, starting from a repository object: +To access a `Branch`, starting from a repository object: ```php -$repository = new Gitonomy\Git\Repository('/path/to/repository'); +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository'); $branch = $repository->getReferences()->getBranch('master'); +$remoteBranch = $repository->getReferences()->getRemoteBranch('origin/master'); +``` + +See [references.md](references.md) to list, create and delete branches. + +You can check if the branch is a local or a remote one: + +```php +$branch->isLocal(); // A branch of refs/heads/ +$branch->isRemote(); // A branch of refs/remotes/, like origin/master + +$branch->getName(); // "master", or "origin/master" for a remote branch +``` + +Like every reference, a branch gives its commit: + +```php +$commit = $branch->getCommit(); +$hash = $branch->getCommitHash(); ``` -You can check is the branch is a local or remote one: +Moving a branch +--------------- + +`moveTo()` points the branch to a revision: a name, a hash, or a `Revision` +object. It returns the branch, read again: the object you called it on +keeps the commit it pointed to. ```php -$branch->isLocal(); -$branch->isRemote(); +$branch = $branch->moveTo('master~2'); ``` + +Like `git update-ref`, it doesn't touch the working tree, even for the +branch checked out. `ReferenceBag::update()` is deprecated since 2.0: use +`moveTo()`. + +Merged branches +--------------- + +`isMergedInto()` tells if the history of a revision (a name, a hash, or a +`Revision` object) contains the branch: + +```php +if ($branch->isMergedInto('master')) { + echo $branch->getName().' is merged into master'.PHP_EOL; +} +``` + +`isMergedTo()` is deprecated since 2.0: use `isMergedInto()`, which has no +default destination (`master`) nor flag to compare remote branches only. + +Branches containing a commit +---------------------------- + +To find the branches whose history contains a commit, use +`Commit::getIncludingBranches()`: see +[commit.md](commit.md#branches-containing-a-commit). + +To check a branch out, see [workingcopy.md](workingcopy.md). diff --git a/doc/commit.md b/doc/commit.md index 08e9b1e3..f75b119f 100644 --- a/doc/commit.md +++ b/doc/commit.md @@ -1,171 +1,194 @@ Commit ====== -To access a *Commit*, starting from a repository object: +To get a `Commit`, resolve a revision: a branch, a tag, `HEAD`, `master~2`, +an abbreviated hash... ```php -$repository = new Gitonomy\Git\Repository('/path/to/repository'); -$commit = $repository->getCommit('a7c8d2b4'); +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repository'); +$commit = $repository->getRevision('master')->getCommit(); +``` + +`Repository::getCommit()` takes the full hash of a commit, its 40 +characters. It throws a `ReferenceNotFoundException` for anything else, an +abbreviated hash included: + +```php +$commit = $repository->getCommit('7e9ba14939b0ce67ab95d83e03bd64710db91ef4'); ``` +The commit is read when you first need it: a hash naming no commit throws a +`ReferenceNotFoundException` then. + +Hashes +------ + +```php +$commit->getHash(); // "7e9ba14939b0ce67ab95d83e03bd64710db91ef4" +$commit->getShortHash(); // "7e9ba14", abbreviated and unique in the repository +``` + +`getFixedShortHash()` is deprecated since 2.0: use +`substr($commit->getHash(), 0, 6)`. + Browsing parents ---------------- -A *Commit* can have a natural number of parents: +A commit has: -- **no parent**: it's an initial commit, the root of a tree -- **one parent**: it means it's not a merge, just a regular commit -- **many parents**: it's a merge-commit - -You have 2 methods available for accessing parents: +- **no parent**: it's a root commit, the first one of a history; +- **one parent**: it's a regular commit; +- **several parents**: it's a merge commit. Its first parent is the + commit the other ones were merged into. ```php -// Access parent hashes +// The hashes of the parents $hashes = $commit->getParentHashes(); -// Access parent commit objects -$commits = $commit->getParents(); +// The parents, Commit objects +$parents = $commit->getParents(); ``` -For example, if you want to display all parents, starting from a commit: +For example, to display the first parents of a commit, like +`git log --first-parent`: ```php -function displayLog(Gitonomy\Git\Commit $commit) { - echo '- '.$commit->getShortMessage().PHP_EOL; - foreach ($commit->getParents() as $parent) { - displayLog($parent); - } +for ($current = $commit; null !== $current; $current = $current->getParents()[0] ?? null) { + echo $current->getShortHash().' '.$current->getSubjectMessage().PHP_EOL; } ``` -Notice that this function will first display all commits from first -merged branch and then display all commits from next branch, and so on. - -Accessing tree --------------- +To list a history in order, with every parent, use a log: see +[log.md](log.md). -The tree object contains the reference to the files associated to a -given commit. Every commit has one and only one tree, referencing all -files and folders of a given state for a project. For more informations -about the tree, see the chapter dedicated to it. +Accessing the tree +------------------ -To access a tree starting from a commit: +The tree of a commit holds its files and folders, as the commit recorded +them. Every commit has exactly one tree. See [tree.md](tree.md) to browse +it. ```php -// Returns the tree hash -$tree = $commit->getTreeHash(); +// The hash of the tree +$hash = $commit->getTreeHash(); -// Returns the tree object +// The Tree object $tree = $commit->getTree(); ``` -Author & Committer informations -------------------------------- +Author and committer +-------------------- -Each commit has two authoring informations: an author and a committer. -The author is the creator of the modification, authoring a modification -in the repository. The committer is responsible of introducing this -modification to the repository. - -You can access informations from author and committer using those -methods: +A commit has an author and a committer. The author wrote the change. The +committer recorded it in the repository: they can differ when a commit is +rebased, cherry-picked or applied from a patch. ```php // Author $commit->getAuthorName(); $commit->getAuthorEmail(); -$commit->getAuthorDate(); // returns a DateTime object +$commit->getAuthorDate(); // A DateTimeImmutable // Committer $commit->getCommitterName(); $commit->getCommitterEmail(); -$commit->getCommitterDate(); // returns a DateTime object +$commit->getCommitterDate(); // A DateTimeImmutable ``` -Commit message and short message --------------------------------- - -Each commit also has a message, associated to the modification. This -message can be multilined. +Message +------- -To access the message, you can use the *getMessage* method: +The message of a commit can have several lines. Like git, gitlib splits it +in a subject and a body: ```php +// The whole message $commit->getMessage(); -``` - -For your convenience, this library provides a shortcut method to keep -only the first line or first 50 characters if the first line is too -long: - -```php -$commit->getShortMessage(); -``` -You can customize it like this: +// The first paragraph, on one line, like "git log --format=%s" +$commit->getSubjectMessage(); -```php -$commit->getShortMessage(45, true, '.'); +// What follows the first paragraph and its empty lines, like "git log --format=%b" +$commit->getBodyMessage(); ``` -- The first parameter is the max length of the message. -- The second parameter determine if the last word should be cut or - preserved -- The third parameter is the separator +For a message `"Fix the parser\nof dates\n\nIt failed on leap years.\n"`, +the subject is `"Fix the parser of dates"`, and the body +`"It failed on leap years.\n"`. A message without an empty line has no +body. -There are also two other methods for your convenience: +`getShortMessage()` is deprecated since 2.0: truncate the subject with +`symfony/string`, a dependency of gitlib, instead: ```php -// The first line -$commit->getSubjectMessage(); +use function Symfony\Component\String\u; -// The body (rest of the message) -$commit->getBodyMessage(); +echo u($commit->getSubjectMessage())->truncate(50, '...').PHP_EOL; ``` Diff of a commit ---------------- -You can check the modifications introduced by a commit using the -*getDiff* method. When you request a diff for a commit, depending of the -number of parents, the strategy will be different: +`getDiff()` returns the changes a commit made: -- If you have *no parent*, the diff will be the content of the tree -- If you only have *one parent*, the diff will be between the commit - and his parent -- If you have *multiple parents*, the diff will be the difference - between the commit and the first common ancestor of all parents - -For more informations about the diff API, read the related chapter. - -To access the *Diff* object of a commit, use the method *getDiff*: +- against its first parent, for a regular commit or a merge commit. For a + merge, these are the changes it brought into the branch it was merged + into, like GitHub shows a merge commit; +- against nothing, for a root commit: every file is a creation. ```php $diff = $commit->getDiff(); ``` +To compare any two commits, use `Repository::getDiff($from, $to)`. See +[diff.md](diff.md) for the diff API. + Last modification of a file --------------------------- -To know the last modification of a file, you can use the -*getLastModification* method on a commit. - -Here is a very straightforward example: +`getLastModification()` returns the last commit, in the history of this +one, that changed a path. It throws a `ReferenceNotFoundException` when no +commit changed it. The path is relative to the root of the repository. ```php -$last = $commit->getLastModification('README'); +$last = $commit->getLastModification('README.md'); -echo 'Last README modification'.PHP_EOL; +echo 'Last README.md modification'.PHP_EOL; echo ' Author: '.$last->getAuthorName().PHP_EOL; echo ' Date: '.$last->getAuthorDate()->format('d/m/Y').PHP_EOL; -echo ' Message: '.$last->getMessage(); +echo ' Message: '.$last->getSubjectMessage().PHP_EOL; +``` + +References pointing to a commit +------------------------------- + +`resolveReferences()` returns the branches and tags pointing to the commit, +annotated tags included: + +```php +foreach ($commit->resolveReferences() as $reference) { + echo $reference->getFullname().PHP_EOL; // "refs/heads/master", "refs/tags/1.0"... +} ``` -Find every branches containing a commit ---------------------------------------- +See [references.md](references.md#resolution-from-a-commit). + +Branches containing a commit +---------------------------- + +`getIncludingBranches()` returns the local and remote branches whose +history contains the commit, as `Branch` objects. Filter them with +`isLocal()` or `isRemote()`: ```php -$branches = $commit->getIncludingBranches($includeLocalBranches, $includeRemoteBranches); -$localBranches = $commit->getIncludingBranches(true, false); -$remoteBranches = $commit->getIncludingBranches(false, true); +use Gitonomy\Git\Reference\Branch; + +$branches = $commit->getIncludingBranches(); +$localBranches = array_filter($branches, static fn (Branch $branch): bool => $branch->isLocal()); +$remoteBranches = array_filter($branches, static fn (Branch $branch): bool => $branch->isRemote()); ``` + +Passing arguments to `getIncludingBranches()`, to keep the local or the +remote branches only, is deprecated since 2.0. diff --git a/doc/diff.md b/doc/diff.md index 2ce7870a..415816a9 100644 --- a/doc/diff.md +++ b/doc/diff.md @@ -1,104 +1,210 @@ -Computing diff -============== +Computing diffs +=============== -Even if git is a diff-less storage engine, it's possible to compute -them. +Git stores snapshots of files, not diffs: gitlib computes them, like +`git diff`. -To compute a diff in git, you need to specify a *revision*. This -revision can be a commit (*2bc7a8*) or a range (*2bc7a8..ff4c21b*). - -For more informations about git revisions: *man gitrevisions*. +Comparing two commits +--------------------- -When you have decided the revision you want and have your *Repository* -object, you can call the *getDiff* method on the repository: +`Repository::getDiff($from, $to)` returns the changes from the commit +`$from` to the commit `$to`. Each one is a revision name (a branch, a tag, a +hash, `master~2`...) or a `Revision` object (a `Commit`, a `Branch`...): ```php -$diff = $repository->getDiff('master@{2 days ago}..master'); +$diff = $repository->getDiff('0.1', 'master'); ``` -You can also access it from a *Log* object: +Like `git diff 0.1 master`, it compares the files of both commits. To compare +a branch with the commit it forked from, like `git diff master...feature`, +start from their best common ancestor: ```php -$log = $repository->getLog('master@{2 days ago}..master'); -$diff = $log->getDiff(); +$diff = $repository->getDiff($repository->getMergeBase('master', 'feature'), 'feature'); ``` +Calling `getDiff()` with one argument (a range like `a..b` or `a...b`, two +revisions in an array, or one revision to compare with the working tree) +is deprecated since 2.0: see [UPGRADE-2.0.md](../UPGRADE-2.0.md). +`Log::getDiff()` is deprecated too: it ignored the paths of the log. + +Other diffs +----------- + +- `Commit::getDiff()` returns the changes of a commit, against its first + parent: see [commit.md](commit.md#diff-of-a-commit). +- `WorkingCopy::getDiff($revision)` returns the changes of the working + tree, staged or not, since a revision (`HEAD` by default). +- `WorkingCopy::getDiffPending()` returns the changes of the working tree + not staged yet. +- `WorkingCopy::getDiffStaged()` returns the changes staged in the index. + +See [workingcopy.md](workingcopy.md) for the working copy. + +Every diff detects renames, like `git diff -M`. `LibGit2Driver` computes +the similarity of files like libgit2 does: it can find a rename where git +finds a deletion and a creation (see +[Choosing a driver](repository.md#choosing-a-driver)). During a merge +conflict, `WorkingCopy::getDiffStaged()` and `getDiffPending()` leave the +unmerged files out, and `WorkingCopy::getDiff()` compares them, conflict +markers included, to the revision, like `git diff HEAD`. + Iterating a diff ---------------- -When you have a *Diff* object, you can iterate over files using method -*getFiles()*. This method returns a list of *File* objects, who -represents the modifications for a single file. +`getFiles()` returns the files of a diff, `File` objects: ```php $files = $diff->getFiles(); -echo sprintf('%s files modified%s', count($files), PHP_EOL); +echo count($files).' files changed'.PHP_EOL; -foreach ($files as $fileDiff) { - echo sprintf('Old name: (%s) %s%s', $fileDiff->getOldMode(), $fileDiff->getOldName(), PHP_EOL); - echo sprintf('New name: (%s) %s%s', $fileDiff->getNewMode(), $fileDiff->getNewName(), PHP_EOL); +foreach ($files as $file) { + echo $file->getName().': +'.$file->getAdditions().' -'.$file->getDeletions().PHP_EOL; } ``` The File object --------------- -Here is an exhaustive list of the *File* class methods: +A `File` tells what happened to a file: ```php -$file->getOldName(); -$file->getNewName(); -$file->getOldDiff(); -$file->getNewDiff(); +$file->isCreation(); // There is no old file +$file->isDeletion(); // There is no new file +$file->isModification(); // There are both: a rename, a copy or a mode change included +$file->isRename(); +$file->isCopy(); // Only for a diff detecting copies, like "git diff -C" +$file->isModeChange(); // From "100644" to "100755", for example +$file->isBinary(); // A binary file has no changes +``` -$file->isCreation(); -$file->isDeletion(); -$file->isModification(); +A type change, like a file replaced by a symbolic link, is a deletion +followed by a creation, like git shows it. -$file->isRename(); -$file->isChangeMode(); +Its names, modes and blob hashes: + +```php +$file->getName(); // The new name, or the old one for a deletion +$file->getOldName(); // null for a creation +$file->getNewName(); // null for a deletion + +$file->getOldMode(); // "100644", "100755", "120000" (a symbolic link)... null for a creation +$file->getNewMode(); // null for a deletion + +$file->getOldIndex(); // The hash of the old blob, null for a creation +$file->getNewIndex(); // The hash of the new blob, null for a deletion +``` + +`getOldBlob()` and `getNewBlob()` return the `Blob` objects, to read the +content of the file before and after (see [blob.md](blob.md)): + +```php +if (!$file->isCreation() && !$file->isBinary()) { + $before = $file->getOldBlob()->getContent(); +} +``` + +They throw a `LogicException` when there is no such file (`getOldBlob()` +for a creation, `getNewBlob()` for a deletion), or when the diff has no +repository. The diffs gitlib returns have one: see +[Parsing a diff](#parsing-a-diff) for the others. They throw a +`RuntimeException` when the diff doesn't know the hash of the blob. -$file->getAdditions(); // Number of added lines -$file->getDeletions(); // Number of deleted lines +Two cases have no blob in the repository, and reading the content of their +`Blob` throws a `ReferenceNotFoundException`: -$file->isBinary(); // Binary files have no "lines" +- a submodule, of mode `160000`: its hashes are commits of the submodule; +- a file of the working tree not staged yet: read it from the working + tree instead. -$file->getChanges(); // See next chapter +The number of lines added and deleted, and the changes themselves: + +```php +$file->getAdditions(); +$file->getDeletions(); + +$file->getChanges(); // FileChange objects: see below ``` +`isChangeMode()`, `isDelete()` and `getAnchor()` are deprecated since 2.0: +use `isModeChange()`, `isDeletion()` and `substr($file->getNewIndex(), 0, 12)`. + The FileChange object --------------------- -> **note** -> -> This part of API is not very clean, very consistent. If you have any -> idea or suggestion on how to enhance this, your comment would be -> appreciated. +The changes of a file are its hunks, the blocks starting with `@@` in a +patch. Each one is a `FileChange` object, with the lines of the hunk, +`DiffLine` objects: -A *File* object is composed of many changes. For each of those changes, -a *FileChange* object is associated. +```php +use Gitonomy\Git\Diff\DiffLineType; -To access changes from a file, use the *getChanges* method: +foreach ($file->getChanges() as $change) { + echo sprintf('@@ -%d,%d +%d,%d @@', $change->getRangeOldStart(), $change->getRangeOldCount(), $change->getRangeNewStart(), $change->getRangeNewCount()).PHP_EOL; -```php -$changes = $file->getChanges(); -foreach ($changes as $change) { - foreach ($lines as $data) { - list ($type, $line) = $data; - if ($type === FileChange::LINE_CONTEXT) { - echo ' '.$line.PHP_EOL; - } elseif ($type === FileChange::LINE_ADD) { - echo '+'.$line.PHP_EOL; - } else { - echo '-'.$line.PHP_EOL; - } + foreach ($change->getLines() as $line) { + echo match ($line->getType()) { + DiffLineType::Addition => '+', + DiffLineType::Context => ' ', + DiffLineType::Deletion => '-', + }.$line->getContent().PHP_EOL; } } ``` -To get line numbers, use the range methods: +- `getRangeOldStart()` and `getRangeOldCount()` are the first line of the + hunk in the old file, and its number of lines there; +- `getRangeNewStart()` and `getRangeNewCount()` are the same in the new + file; +- `DiffLine::getType()` returns a `DiffLineType`: `Addition`, `Context` or + `Deletion`; +- `DiffLine::getContent()` returns the line, without its prefix (`+`, + `-` or a space) nor its line break. + +`getAdditions()` and `getDeletions()` count the lines the hunk adds and +deletes. + +Reading a line as an array (`[$type, $content]`), the `FileChange::LINE_ADD`, +`LINE_CONTEXT` and `LINE_REMOVE` constants, and `FileChange::getCount()` are +deprecated since 2.0: use `getType()` and `getContent()`, `DiffLineType`, +and `getAdditions()` or `getDeletions()`. + +Parsing a diff +-------------- + +`Diff::parse()` reads the output of `git diff --raw --patch`, with `-z` or +not. The raw information gives the names, modes, hashes and status of the +files: a patch without it throws an `InvalidArgumentException`. Pass the +repository to read the blobs of the files. Here, the output comes from +`Repository::run()`, which needs `CliDriver`: ```php -echo sprintf('Previously from line %s to %s%s', $change->getOldRangeStart(), $change->getOldRangeEnd(), PHP_EOL); -echo sprintf('Now from line %s to %s%s', $change->getNewRangeStart(), $change->getNewRangeEnd(), PHP_EOL); +use Gitonomy\Git\Diff\Diff; + +$output = $repository->run('diff', ['--raw', '--patch', '0.1', 'master']); +$diff = Diff::parse($output, $repository); ``` + +`getRawDiff()` returns the output a diff was parsed from. It is empty for +the diffs of `LibGit2Driver`, which runs no git command. + +Storing a diff +-------------- + +`toArray()` exports a diff as an array of scalars, to store it in a cache +or as JSON. `Diff::fromArray()` reads it back, with the repository to read +the blobs from: + +```php +use Gitonomy\Git\Diff\Diff; + +$array = $diff->toArray(); + +$diff = Diff::fromArray($array, $repository); +``` + +The arrays of 1.x are read too. + +`Diff::setRepository()`, `File::setRepository()` and `File::addChange()` are +deprecated since 2.0: pass the repository to `Diff::parse()` or +`Diff::fromArray()`, and the changes to the constructor of `File`. diff --git a/doc/hooks.md b/doc/hooks.md index eb5c8ab4..8bc11895 100644 --- a/doc/hooks.md +++ b/doc/hooks.md @@ -2,10 +2,18 @@ Hooks ===== It's possible to define custom hooks on any repository with git. Those -hooks are located in the *.git/hooks* folder. +hooks are located in the *hooks* folder of the git directory: *.git/hooks*, +or *hooks* in a bare repository. A linked worktree (`git worktree add`) runs +the hooks of the repository it belongs to. gitlib ignores the `core.hooksPath` +setting of git. -Those files need to be executable. For convenience, gitlib will set them -to *777*. +Those files need to be executable: gitlib makes them executable by their +owner only (*0700*). If another user runs them (a git daemon, a web +server...), change their permissions yourself. + +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. @@ -26,8 +34,9 @@ To read the content of a hook, use the *get* method like this: $content = $hooks->get('pre-receive'); // returns a string ``` -If the hook does not exist, an exception will be thrown -(*InvalidArgumentException*). +If the hook does not exist, a `Gitonomy\Git\Exception\InvalidArgumentException` +is thrown, and a `Gitonomy\Git\Exception\RuntimeException` if it can't be +read. You can test if a hook is present using the method *has*: @@ -47,10 +56,6 @@ To create the hook using a symlink: $hooks->setSymlink('pre-receive', '/path/to/file-to-link'); ``` -If the hook already exist, a *LogicException* will be thrown. If an -error occured during symlink creation, a *RuntimeException* will be -thrown. - If you want to directly create a new file in hooks directory, use the method *set*. This method will create a new file, put content in it and make it executable: @@ -68,7 +73,10 @@ HOOK; $hooks->set('pre-receive', $content); ``` -If the hook already exists, a *LogicException* will be thrown. +Both methods create the hooks directory when the repository has none, like +one initialized with an empty template. They throw a +`Gitonomy\Git\Exception\RuntimeException` if the hook already exists, or if +it can't be created. Removing hooks -------------- @@ -78,3 +86,6 @@ To remove a hook from a repository, use the function *remove*: ```php $hooks->remove('pre-receive'); ``` + +It throws a `Gitonomy\Git\Exception\RuntimeException` if the hook does not +exist, or if it can't be removed. diff --git a/doc/log.md b/doc/log.md index 4dc5a465..29bdea25 100644 --- a/doc/log.md +++ b/doc/log.md @@ -1,55 +1,138 @@ Getting log history =================== -Crawling manually commits and parents to browse history is surely a good -solution. But when it comes to ordering them or aggregate them from -multiple branches, we tend to use `git log`. +Walking the parents of commits browses a history. To order the commits, or +to gather the history of several branches, use a log, like `git log`. -To get a *Log* object from a repository: +To get a `Log` object from a repository: ```php $log = $repository->getLog(); ``` -You can pass four arguments to *getLog* method: +`getLog()` takes four arguments, all optional: the revisions, the paths, +the offset and the limit. ```php -// Global log for repository +// The history of every reference $log = $repository->getLog(); -// Log for master branch +// The history of the master branch $log = $repository->getLog('master'); -// Returns last 10 commits on README file -$log = $repository->getLog('master', 'README', 0, 10); +// The last 10 commits of master changing the README.md file +$log = $repository->getLog('master', 'README.md', 0, 10); -// Returns last 10 commits on README or UPGRADE files -$log = $repository->getLog('master', ['README', 'UPGRADE'], 0, 10); +// The last 10 commits of master changing README or README.md +$log = $repository->getLog('master', ['README', 'README.md'], 0, 10); +``` + +A `Revision` object, like a `Commit` or a `Branch`, has a shortcut, taking +the paths, the offset and the limit: + +```php +$log = $repository->getRevision('master')->getLog('README.md', 0, 10); +``` + +Revisions +--------- + +The revisions are a string, a `Revision` object, or an array of them. Each +one can be: + +- a name: a branch, a tag, a hash, `HEAD~3`..., for its history; +- a range, `a..b`: the history of `b` without the history of `a`; +- a symmetric range, `a...b`: the commits in the history of `a` or `b`, + but not in both; +- a name to exclude, `^a`: the history of the other revisions, without the + history of `a`; +- `--all`: every reference and `HEAD`, like no revision at all. + +An empty end of a range is `HEAD`: `..master` is `HEAD..master`. + +```php +// The commits of master since the tag 0.1 +$log = $repository->getLog('0.1..master'); + +// The same +$log = $repository->getLog(['master', '^0.1']); + +// The commits of feature and master since they diverged +$log = $repository->getLog('feature...master'); +``` + +A revision naming no commit throws a `ReferenceNotFoundException` naming +it. Other options of `git log` are not supported: they throw an +`InvalidArgumentException`. + +Paths +----- + +The paths filter the log: it lists the commits changing them. They are +relative to the root of the repository, and support the wildcards of git: + +```php +// The commits changing a PHP file, in any folder +$log = $repository->getLog('master', '*.php'); + +// The commits changing a file in the src folder +$log = $repository->getLog('master', 'src'); +``` + +Iterating +--------- + +A log is iterable: + +```php +foreach ($log as $commit) { + echo $commit->getShortHash().' '.$commit->getSubjectMessage().PHP_EOL; +} +``` + +`getCommits()` returns them as an array. `getSingleCommit()` returns the +first commit of the log, and throws a `ReferenceNotFoundException` when the +log is empty: + +```php +$commits = $log->getCommits(); + +$lastChange = $repository->getLog('master', 'README.md')->getSingleCommit(); ``` Counting -------- -If you want to count overall commits, without offset or limit, use the -*countCommits* method: +`count($log)` returns the number of commits the log iterates, after its +offset and up to its limit. To count every commit of the log, without offset +nor limit, use `countCommits()`: ```php -echo sprintf('This log contains %s commits%s', $log->countCommits(), PHP_EOL); - -// Countable interface -echo sprintf('This log contains %s commits%s', count($log), PHP_EOL); +echo sprintf('This page has %d commits%s', count($log), PHP_EOL); +echo sprintf('This log has %d commits%s', $log->countCommits(), PHP_EOL); ``` Offset and limit ---------------- -Use those methods: +A log is immutable: `withOffset()` and `withLimit()` return another log. ```php -$log->setOffset(32); -$log->setLimit(40); +$log = $repository->getLog('master'); -// or read it: -$log->getOffset(); -$log->getLimit(); +$firstPage = $log->withLimit(10); +$secondPage = $firstPage->withOffset(10); + +// To read them +$secondPage->getOffset(); // 10 +$secondPage->getLimit(); // 10 ``` + +`setOffset()` and `setLimit()`, which changed the log, are deprecated since +2.0: use `withOffset()` and `withLimit()`. + +Diff of a log +------------- + +`Log::getDiff()` is deprecated since 2.0: it ignored the paths of the log. +Use `Repository::getDiff($from, $to)`, see [diff.md](diff.md). diff --git a/doc/references.md b/doc/references.md index 64430929..ec658d6e 100644 --- a/doc/references.md +++ b/doc/references.md @@ -4,89 +4,147 @@ Tags and branches Accessing tags and branches --------------------------- -With *gitlib*, you can access them via the *ReferenceBag* object. To get -this object from a *Repository*, use the *getReferences* method: +Branches and tags are references. To get them, call `getReferences()` on a +`Repository`. It returns a `ReferenceBag` object: ```php $references = $repository->getReferences(); ``` -First, you can test existence of tags and branches like this: +The bag reads the references again after every write gitlib makes: creating +or deleting a reference, a checkout, a merge, `Repository::run()`... To see +the writes of other processes, open a new `Repository`. Passing an argument +to `getReferences()`, to read them again, is deprecated since 2.0. + +First, you can test the existence of tags and branches like this: ```php if ($references->hasBranch('master') && $references->hasTag('0.1')) { echo 'Good start!'.PHP_EOL; } + +$references->hasRemoteBranch('origin/master'); +$references->hasBranches(); // A branch at least, local or remote +$references->has('refs/heads/master'); // A reference, by its full name ``` -If you want to access all branches or all tags: +To get all branches or all tags, sorted by full name (local branches come +before remote ones): ```php -$branches = $references->getBranches(); +$branches = $references->getBranches(); // Branch objects, local and remote $localBranches = $references->getLocalBranches(); $remoteBranches = $references->getRemoteBranches(); -$tags = $references->getTags(); -$all = $references->getAll(); +$tags = $references->getTags(); // Tag objects +$all = $references->getAll(); // Every reference, keyed by its full name ``` -To get a given branch or tag, call *getBranch* or *getTag* on the -*ReferenceBag*. Those methods return *Branch* and *Tag* objects: +The bag is also iterable and countable, like `getAll()`. The default branch +of a remote (`refs/remotes/origin/HEAD`) is left out, and the stash is a +`Reference\Stash` object. + +To get a given branch or tag, call `getBranch()`, `getRemoteBranch()` or +`getTag()` on the `ReferenceBag`. They return `Branch` and `Tag` objects: ```php $master = $references->getBranch('master'); -$feat123 = $references->getLocalBranch('feat123'); -$feat456 = $references->getRemoteBranch('origin/feat456'); +$feature = $references->getRemoteBranch('origin/feature'); $v0_1 = $references->getTag('0.1'); + +// A reference by its full name +$reference = $references->get('refs/heads/master'); ``` -If the reference cannot be resolved, a *ReferenceNotFoundException* will -be thrown. +If the reference doesn't exist, they throw a `ReferenceNotFoundException`. +`getFirstBranch()` returns the first branch of `getBranches()`, or `null` +when there is none. -On each of those objects, you can access those informations: +Every reference is a `Revision`. On each one, you can access: ```php -// Get the associated commit -$commit = $master->getCommit(); +// The name: "master", "origin/feature" for a remote branch, "0.1" for a tag +$name = $master->getName(); -// Get the commit hash +// The full name: "refs/heads/master", "refs/remotes/origin/feature", "refs/tags/0.1" +$fullname = $master->getFullname(); + +// The commit it points to +$commit = $master->getCommit(); $hash = $master->getCommitHash(); -// Get the last modification -$lastModification = $master->getLastModification(); +// The last commit changing a path, in its history +$lastModification = $master->getLastModification('README.md'); ``` -Create and delete reference ---------------------------- +`getCommitHash()` is always a commit: for an annotated tag, the commit the +tag object points to. A reference object keeps the commit it pointed to when +it was read: get it again from the bag after a change. -You can create new tags and branches on repository, using helper methods -on ReferenceBag object: +Create and delete references +---------------------------- + +You can create new tags and branches, using methods of the `ReferenceBag`. +They take the revision to point to: a name, a hash, or a `Revision` object +like a `Commit` or a `Branch`. ```php -// create a branch -$references = $repository->getReferences(); -$branch = $references->createBranch('foobar', 'a8b7e4...'); // commit to reference +// Create a branch +$branch = $references->createBranch('foobar', 'master'); -// create a tag -$references = $repository->getReferences(); -$tag = $references->createTag('0.3', 'a8b7e4...'); // commit to reference +// Create a lightweight tag +$tag = $references->createTag('0.3', $repository->getHeadCommit()); -// delete a branch or a tag +// Delete a branch or a tag $branch->delete(); +$tag->delete(); +``` + +`createBranch()` and `createTag()` throw an `InvalidArgumentException` when +the reference exists: they never move it. To move a branch, use +`Branch::moveTo()`: see [branch.md](branch.md). `ReferenceBag::update()` is +deprecated since 2.0: use `Branch::moveTo()`. + +Tags +---- + +A tag is lightweight, a name pointing to a commit, or annotated: a tag +object, with a tagger, a message, and maybe a signature. + +```php +$tag = $references->getTag('1.0'); + +if ($tag->isAnnotated()) { + echo $tag->getTaggerName().' <'.$tag->getTaggerEmail().'>'.PHP_EOL; + echo $tag->getTaggerDate()?->format('Y-m-d').PHP_EOL; + echo $tag->getSubjectMessage().PHP_EOL; +} ``` +- `getTaggerName()`, `getTaggerEmail()` and `getTaggerDate()` (a + `DateTimeImmutable`) return `null` for a lightweight tag, and for an + annotated tag without tagger; +- `getMessage()` returns the message, without the signature, `null` for a + lightweight tag; +- `getSubjectMessage()` and `getBodyMessage()` split the message like + for a commit (see [commit.md](commit.md#message)), `null` for a + lightweight tag; +- `isSigned()` tells if the tag is signed, with GPG, X.509 or SSH; +- `getGpgSignature()` returns the signature, without its armor lines, + `null` for an unsigned or lightweight tag. + Resolution from a commit ------------------------ -To resolve a branch or a commit from a commit, you can use the -*resolveTags* and *resolveBranches* methods on it: +To find the branches or the tags pointing to a commit, use the +`resolveBranches()` and `resolveTags()` methods: ```php $branches = $references->resolveBranches($commit); -$tags = $references->resolveTags($commit); +$tags = $references->resolveTags($commit); // Annotated tags included // Resolve branches and tags $all = $references->resolve($commit); ``` -You can pass a *Commit* object or a hash to the method, gitlib will -handle it. +You can pass a `Commit` object, a hash or any revision name. +`Commit::resolveReferences()` is a shortcut for `resolve()`. diff --git a/doc/repository.md b/doc/repository.md index 05b4a4e7..d70f5231 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -1,147 +1,489 @@ Repository methods ================== -Creating a *Repository* object is possible, providing a *path* argument -to the constructor: +A `Repository` object opens a repository on the disk. It is the entry point +to everything else: commits, references, working copy... + +To create a repository, or to clone one, see +[Creating repositories](admin.md). + +Opening a repository +-------------------- + +Give the path of the repository to the constructor: ```php +use Gitonomy\Git\Repository; + $repository = new Repository('/path/to/repo'); ``` -Repository options ------------------- +The path may be: -The constructor of Repository takes an additional parameter: `$options`. -This parameter can be used used to tune behavior of library. +- a bare repository; +- a working tree, with its `.git` directory; +- a working tree whose `.git` is a file pointing to its git directory: a + linked worktree (`git worktree add`) or a submodule. -Available options are: - -- **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: +The constructor takes two more arguments: ```php -$repository = new Repository('/path/to/repo', [ - 'debug' => true, - 'logger' => new Monolog\Logger(), -]); +public function __construct(string $dir, ?string $workingDir = null, DriverInterface $driver = new CliDriver()) ``` -Test if a repository is bare ----------------------------- - -On a *Repository* object, you can call method *isBare* to test if your -repository is bare or not: +- `$workingDir`: the working tree, when `$dir` is a git directory kept apart + from it, like `git --git-dir=$dir --work-tree=$workingDir`; +- `$driver`: the backend running git. See + ["Choosing a driver"](#choosing-a-driver), + ["Configuring `CliDriver`"](#configuring-clidriver) and + ["Logger"](#logger). ```php -$repository->isBare(); +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/srv/project.git', '/srv/project', new CliDriver(processTimeout: 60)); ``` -Compute size of a repository ----------------------------- +An array of options as second argument (`working_dir`, `logger`, `command`, +`process_timeout`...) is deprecated since 2.0: pass the working directory, +and configure a `CliDriver` instead. See +[UPGRADE-2.0.md](../UPGRADE-2.0.md#opening-a-repository). -To know how much size a repository is using on your drive, you can use -`getSize` method on a *Repository* object. +A directory that doesn't exist, or that isn't a repository, throws an +`InvalidArgumentException`. -> **warning** -> -> This command was only tested with linux. +gitlib doesn't support the repositories using SHA-256 hashes yet: opening one +throws an `UnsupportedOperationException`. -The returned size is in kilobytes: +Paths +----- ```php -$size = $repository->getSize(); - -echo 'Your repository size is '.$size.'KB'; +$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 (using a reference) or detached -(using a commit). +tree). Your `HEAD` can be attached (pointing to a branch) or detached +(pointing to a commit). ```php -$head = $repository->getHead(); // Commit or Reference -$head = $repository->getHeadCommit(); // Commit +$head = $repository->getHead(); // the Branch HEAD points to, or the Commit when it is detached +$commit = $repository->getHeadCommit(); // the Commit, in both cases + +if ($repository->isHeadAttached()) { + echo 'On branch '.$head->getName().PHP_EOL; +} if ($repository->isHeadDetached()) { - echo 'Sorry man'.PHP_EOL; + echo 'HEAD detached at '.$commit->getShortHash().PHP_EOL; } ``` -Options for repository ----------------------- +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`. -### Logger +Commits, trees and blobs +------------------------ -If you are developing, you may appreciate to have a logger inside -repository, telling you every executed command. +```php +// A revision: a branch, a tag, a hash, "HEAD~2"... +$commit = $repository->getRevision('master')->getCommit(); + +// A full hash only +$commit = $repository->getCommit('7e9ba14939b0ce67ab95d83e03bd64710db91ef4'); -You call method `setLogger` as an option on repository creation: +// 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->setLogger(new Monolog\Logger('repository')); +$repository->getCommit($hash) === $repository->getCommit($hash); // true +``` -$repository->run('fetch', ['--all']); +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 ``` -You can also specify as an option on repository creation: +References +---------- + +`getReferences()` returns the branches, tags... of the repository: see +[References](references.md). ```php -$logger = new MonologLogger('repository'); -$repository = new Repository('/path/foo', ['logger' => $logger]); -$repository->run('fetch', ['--all']); +$references = $repository->getReferences(); ``` -This will output: +gitlib reads them again after every write it makes: creating or deleting a +reference, a checkout, a merge, `run()`... It doesn't see the writes of other +processes (a push to the repository, another worker...): open a new +`Repository` to see them. The `$reload` argument of `getReferences()` is +deprecated since 2.0. + +Description +----------- +```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'); ``` -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 + +Compute size of a repository +---------------------------- + +`getSize()` returns the size of the files of the git directory, working tree +excluded, in kilobytes (1,000 bytes): + +```php +$size = $repository->getSize(); + +echo 'Your repository size is '.$size.' KB'; +``` + +Running git commands +-------------------- + +With `CliDriver`, `run()` runs any git command on the repository, and returns +its standard output: + +```php +$output = $repository->run('log', ['--oneline', '-n', '5']); +``` + +Some commands, like `push`, write their meaningful output to the error +output, even when they succeed. `runProcess()` returns the Symfony `Process`, +already run: + +```php +$process = $repository->runProcess('push', ['origin', 'main']); + +echo $process->getErrorOutput(); ``` -### Disable debug-mode +Both throw a `ProcessException` when git fails, and an +`UnsupportedOperationException` with another driver than `CliDriver`. The +relative paths of the arguments are relative to the repository. gitlib reads +the references again after the command, which may have written them. + +`shell()` is deprecated since 2.0: run `git shell` with Symfony Process +instead. + +Errors +------ + +Gitlib throws an exception when an operation fails. It never returns `null` +or empty data instead. Every exception of gitlib implements +`Gitonomy\Git\Exception\GitExceptionInterface`, and lives in the +`Gitonomy\Git\Exception` namespace: + +- `ReferenceNotFoundException`, an `InvalidArgumentException`: a revision, + a reference or an object doesn't exist (`getRevision('unknown')->getCommit()`, + `getReferences()->getBranch('unknown')`...). +- `DriverException`, a `RuntimeException`: git reports a failure, like a + merge conflict or a clone of an unknown repository. With `CliDriver`, it + is mostly a `ProcessException`, whose `getErrorOutput()` and + `getOutput()` return the output of git, or a `ProcessTimedOutException`, + when git runs longer than [the timeout](#configuring-clidriver). +- `UnsupportedOperationException`, a `RuntimeException`: the driver can't + perform the operation at all, where another driver may: `run()` with + `LibGit2Driver`, libgit2 that isn't installed... +- `InvalidArgumentException`: an argument gitlib doesn't support, like an + option of `git log` in the revisions of a log, or a string holding a NUL + byte, which git doesn't accept. +- `LogicException`: a call that makes no sense, like the working copy of a + bare repository. -Gitlib throws an exception when something seems wrong. If a `git` command exits -with a non-zero code, then execution will be stopped, and a `RuntimeException` -will be thrown. If you want to prevent this, set the `debug` option to` false`. -This will make `Repository` log errors and return empty data instead of -throwing exceptions. +```php +use Gitonomy\Git\Exception\ReferenceNotFoundException; + +try { + $commit = $repository->getRevision($name)->getCommit(); +} catch (ReferenceNotFoundException) { + echo 'No commit named '.$name.PHP_EOL; +} +``` + +Choosing a driver +----------------- + +By default, gitlib spawns a `git` process for every operation, with +`CliDriver`, which requires git 1.8.4 or later. You can instead run +[libgit2](https://libgit2.org/) in-process, through PHP's `FFI` extension, +by passing a `LibGit2Driver`: ```php -$repository = new Repository('/tmp/foo', ['debug' => false, 'logger' => $logger]); +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repo', driver: new LibGit2Driver()); ``` -> **note** +> **warning** > -> If you plan to disable debug, you should rely on the logger to keep a trace -> of the failing cases. +> `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'); +``` + +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. +- Repositories storing their references in the reftable format can't be + opened. +- Neither hooks nor external filters (Git LFS...) are run: a checkout or a + merge writes the files without them. +- `Diff::getRawDiff()` returns an empty string. +- Short hashes (`Commit::getShortHash()`) have 7 characters, more when 7 + are ambiguous: git adapts their length to the size of the repository. +- `WorkingCopy::checkout()` doesn't create a tracking branch for a name + that only exists on a remote, like `git checkout` does: it throws. +- `WorkingCopy::merge()` only supports fast-forward and conflict-free + merges: a conflict throws instead of leaving conflict markers in the + working copy. A merge commit is never signed, whatever + `commit.gpgSign`, and its message never lists the merged commits, + whatever `merge.log`. +- Blame is much slower on a long history: see + ["Performance"](#performance). +- Rename detection in diffs may pair files differently. +- 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: + +```bash +php benchmark.php /path/to/repository path/to/a/file +``` + +Configuring `CliDriver` +----------------------- -### Specify git command to use +The arguments of `CliDriver` are: -You can pass the option `command` to specify which command to use to run git -calls. If you have a git binary located somewhere else, use this option to -specify to gitlib path to your git binary: +- `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 -$repository = new Gitonomy\Git\Repository('/tmp/foo', ['command' => '/home/alice/bin/git']); +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, +)); +``` + +The `command`, `environment_variables` and `process_timeout` options of +`Repository` and `Admin`, from 1.x, are deprecated since 2.0 in favor of these +arguments. So is `inherit_environment_variables`, which passed the entries of +`$_SERVER` to git, the headers and credentials of an HTTP request included: +pass the variables git needs to `environmentVariables`. + +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 ``` -### Environment variables +A failing command also logs its error output, at the `debug` level: the +exception it throws reports the failure. `LibGit2Driver` logs nothing. -It is possible to send environment variables to the `git` commands. +`Repository::setLogger()` and `Repository::getLogger()` are deprecated since +2.0: give the logger to `CliDriver`. + +Using your own driver +--------------------- + +`$driver` accepts any `Gitonomy\Git\Driver\DriverInterface` implementation. +Its `open($dir, $workingDir)` method returns the +`Gitonomy\Git\Driver\RepositoryDriverInterface` the repository runs its +operations with: `getGitDir()`, `getWorkingDir()`, `getHeadReference()`, +`getReferences()`, `getCommitData()`... In tests, a stub of both lets you +exercise code built on gitlib without any git repository: ```php -$repository = new Gitonomy\Git\Repository('/tmp/foo', ['environment_variables' => ['GIT_']]) +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()`. diff --git a/doc/revision.md b/doc/revision.md index 81370c80..39848cf9 100644 --- a/doc/revision.md +++ b/doc/revision.md @@ -1,22 +1,17 @@ Revision ======== +A revision is a name git resolves to a commit: a branch, a tag, a hash, +abbreviated or not, `HEAD~2`, `master@{2 days ago}`... + To get a revision from a *Repository* object: ```php $revision = $repository->getRevision('master@{2 days ago}'); ``` -Getting the log ---------------- - -You can access a *Log* object starting from a revision using the -*getLog* method. This method takes two parameters: *offset* and *limit*: - -```php -// Returns 100 lasts commits -$log = $revision->getLog(null, 100); -``` +`Commit` and `Reference` (a branch, a tag) are revisions too: you can pass +them wherever gitlib takes a revision. Resolve a revision ------------------ @@ -26,3 +21,31 @@ To resolve a revision to a commit: ```php $commit = $revision->getCommit(); ``` + +It throws a `Gitonomy\Git\Exception\ReferenceNotFoundException` when the +revision names no commit. A tag, annotated or not, resolves to the commit it +points to. + +`Repository::getCommit()` only takes a full hash: use +`getRevision($name)->getCommit()` for anything else. + +Getting the log +--------------- + +You can access a *Log* object starting from a revision using the +*getLog* method. This method takes three parameters: *paths*, *offset* and +*limit*: + +```php +// Returns the 100 last commits +$log = $revision->getLog(limit: 100); + +// Returns the 100 last commits changing README.md +$log = $revision->getLog('README.md', limit: 100); + +// Skips the 100 last commits +$log = $revision->getLog(null, 100); +``` + +The first argument filters on paths: `getLog(null, 100)` is an offset, not a +limit. See [Log](log.md). diff --git a/doc/tree.md b/doc/tree.md index 0c5c8ef7..47abe374 100644 --- a/doc/tree.md +++ b/doc/tree.md @@ -1,35 +1,67 @@ Tree and files ============== -To organize folders, git uses trees. In gitlib, those trees are -represented via *Tree* object. +Git stores folders as trees. In gitlib, a tree is a `Tree` object. -To get the root tree associated to a commit, use the *getTree* method on -the commit object: +The root tree of a commit is the entry point of all its files: ```php $tree = $commit->getTree(); ``` -This tree is the entry point of all of your files. +`Repository::getTree()` takes the hash of a tree, abbreviated or not. -The main method for a tree is the *getEntries* method. This method will -return an array, indexed by name. Each of those elements will be the -entry mode and the entry object. +Entries of a tree +----------------- -Let's understand how it works with a concrete example: +`getEntries()` returns the entries of a tree, keyed by their name. Each one +is a `TreeEntry` object, with the name of the entry, its mode, and its +object: a `Blob` for a file (see [blob.md](blob.md)), a `Tree` for a folder, +or a `Submodule`. ```php -function displayTree(Tree $tree, $indent = 0) +foreach ($tree->getEntries() as $name => $entry) { + $entry->getName(); // The name of the file or the folder, the same as $name + $entry->getMode(); // The mode git stores: "100644", "100755" (executable), "120000" (symbolic link), "040000" (tree), "160000" (submodule) + $entry->getObject(); // A Blob, a Tree or a Submodule +} +``` + +To keep one kind of entries only, also keyed by their name: + +```php +$folders = $tree->getTreeEntries(); +$files = $tree->getBlobEntries(); +$submodules = $tree->getSubmoduleEntries(); +``` + +To get the object of an entry by its name: + +```php +$readme = $tree->getEntry('README.md'); // A Blob +``` + +It throws an `InvalidArgumentException` when the tree has no such entry. + +Here is a function displaying a tree and its folders, recursively: + +```php +use Gitonomy\Git\Submodule; +use Gitonomy\Git\Tree; + +function displayTree(Tree $tree, int $depth = 0): void { - $indent = str_repeat(' ', $indent); - foreach ($tree->getEntries() as $name => $data) { - list($mode, $entry) = $data; - if ($entry instanceof Tree) { - echo $indent.$name.'/'.PHP_EOL; - displayTree($tree, $indent + 1); - } else { - echo $indent.$name.PHP_EOL; + foreach ($tree->getEntries() as $name => $entry) { + $object = $entry->getObject(); + + echo str_repeat(' ', $depth).match (true) { + $object instanceof Tree => $name.'/', + $object instanceof Submodule => $name.' @ '.$object->getHash(), + default => $name, + }.PHP_EOL; + + if ($object instanceof Tree) { + displayTree($object, $depth + 1); } } } @@ -37,18 +69,38 @@ function displayTree(Tree $tree, $indent = 0) displayTree($commit->getTree()); ``` -This method will recursively display all entries of a tree. +Reading an entry as an array (`[$mode, $object]`) is deprecated since 2.0: +use `getMode()` and `getObject()`. + +Submodules +---------- + +A submodule is a commit of another repository. Its `Submodule` object only +holds the hash of this commit: gitlib doesn't read the submodule. Open it as +a repository, from its folder in the working tree, to read it. + +```php +foreach ($tree->getSubmoduleEntries() as $name => $entry) { + echo $name.' is at '.$entry->getObject()->getHash().PHP_EOL; +} +``` + +`CommitReference` is deprecated since 2.0: it is `Submodule`. So is +`Tree::getCommitReferenceEntries()`: use `getSubmoduleEntries()`. Resolve a path -------------- -To access directly a sub-file, the easier is probably to use the -*resolvePath* method. - -An example: +To access a file or a folder from its path, use `resolvePath()`. It returns +a `Blob`, a `Tree` or a `Submodule`: ```php -$source = $tree->resolvePath('src/Gitonomy/Git'); - -$source instanceof Tree; +$folder = $tree->resolvePath('src/Gitonomy/Git'); // A Tree +$file = $tree->resolvePath('src/Gitonomy/Git/Repository.php'); // A Blob ``` + +- The path is relative to the tree. +- `""` and `"/"` resolve to the tree itself. Leading, trailing and + repeated slashes are ignored. +- A path that doesn't exist throws an `InvalidArgumentException`, as a + path going through a file or a submodule (`README.md/foo`). diff --git a/doc/workingcopy.md b/doc/workingcopy.md index 375694a2..c2b34471 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -6,43 +6,120 @@ you can access this object using the *getWorkingCopy* on a *Repository* object: ```php +use Gitonomy\Git\Repository; + $repo = new Repository('/path/to/working-dir'); $wc = $repo->getWorkingCopy(); ``` +A bare repository has no working copy: `getWorkingCopy()` throws a +`LogicException`. + Checkout a revision ------------------- -You can checkout any revision using *checkout* method. You can also pass -a second argument, which will be passed as argument with `-b`: +You can checkout any revision using *checkout* method. Like `git checkout`, +it attaches `HEAD` to a local branch, and detaches it on anything else: a +tag, a commit, a remote branch... ```php // git checkout master $wc->checkout('master'); -// git checkout origin/master -b master -$wc->checkout('origin/master', 'master'); +// git checkout v1.0 (HEAD is detached) +$wc->checkout('v1.0'); +``` + +You can also pass a *Revision*: a *Commit*, a *Reference*... A local *Branch* +object attaches `HEAD` to the branch. + +```php +$wc->checkout($repo->getReferences()->getBranch('master')); +``` + +A name that only exists as a remote branch, like `feature` for +`origin/feature`, creates the local tracking branch with `CliDriver`, like +`git checkout` does. `LibGit2Driver` throws instead. + +To create a branch and check it out, use *checkoutNewBranch*. Its second +argument is the revision to start the branch from, `HEAD` by default: + +```php +// git checkout -b feature +$wc->checkoutNewBranch('feature'); + +// git checkout -b develop origin/develop +$wc->checkoutNewBranch('develop', 'origin/develop'); ``` -You can also pass a *Reference* or a *Commit*. +Like `git checkout`, both methods keep the local changes, and refuse to +overwrite the ones the checkout would change: they throw a +`DriverException`. + +Passing the branch to create as second argument of *checkout* is deprecated +since 2.0: use *checkoutNewBranch*. Merge a revision ---------------- You can merge any revision into the current branch using the *merge* method. -A second argument can be used to pass extra options to the underlying -`git merge` command: +A second argument, a `FastForward`, tells whether the merge fast-forwards +the branch when its history allows it: ```php +use Gitonomy\Git\FastForward; + // git merge origin/develop $wc->merge('origin/develop'); // git merge --no-ff origin/develop -$wc->merge('origin/develop', ['--no-ff']); +$wc->merge('origin/develop', FastForward::Never); + +// git merge --ff-only origin/develop +$wc->merge('origin/develop', FastForward::Only); +``` + +`FastForward::Allow`, the default, fast-forwards when possible and creates a +merge commit otherwise. `FastForward::Never` always creates a merge commit. +`FastForward::Only` throws instead of creating one. + +You can also pass a *Revision*. A `DriverException` is thrown if the merge +fails: a conflict, staged changes, a revision whose history is unrelated to +the branch... + +With `CliDriver`, a conflict leaves the working copy merging, with conflict +markers in the files, like `git merge`. To cancel the merge: + +```php +$repo->run('merge', ['--abort']); +``` + +> **note** +> +> With `LibGit2Driver` (see [Choosing a driver](repository.md#choosing-a-driver)), +> `merge()` only supports fast-forward and conflict-free merges: a conflict +> throws without changing anything, instead of leaving conflict markers in +> the working copy. + +Passing arguments of `git merge` as an array (`[]`, `['--no-ff']` or +`['--ff-only']`) is deprecated since 2.0: pass a `FastForward`. + +Modifications since a revision +------------------------------ + +You can get the modifications of the working copy, staged or not, since a +revision, `HEAD` by default: + +```php +// git diff HEAD +$diff = $wc->getDiff(); + +// git diff master +$diff = $wc->getDiff('master'); ``` -You can also pass a *Reference* or a *Commit*. A `RuntimeException` is thrown -if the merge fails (for instance because of a conflict). +Calling `Repository::getDiff()` with one revision, to compare it with the +working copy, is deprecated since 2.0: use `WorkingCopy::getDiff()`. Staged modifications -------------------- @@ -57,9 +134,26 @@ $diff = $wc->getDiffStaged(); Pending modifications --------------------- -You can get pending modifications on tracked files by calling method -`getDiffPending()`: +You can get pending modifications on tracked files, not staged yet, by +calling method `getDiffPending()`: ```php $diff = $wc->getDiffPending(); ``` + +During a merge conflict, `getDiffStaged()` and `getDiffPending()` leave the +unmerged files out, and `getDiff()` compares them, conflict markers included, +to the revision, like `git diff HEAD`. + +See [Diff](diff.md) to read these diffs. + +Untracked files +--------------- + +To list the files git doesn't track, ignored files excluded, call +`getUntrackedFiles()`. It returns their paths, relative to the root of the +working copy: + +```php +$files = $wc->getUntrackedFiles(); // ['new-file.txt', 'new-directory/other-file.txt'] +``` diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8a951503..60e89c10 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,227 +1,11 @@ parameters: ignoreErrors: - - - message: '#^Method Gitonomy\\Git\\Admin\:\:cloneBranchTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:cloneRepository\(\) has parameter \$args with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:cloneRepository\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:cloneTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:getProcess\(\) has parameter \$args with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:getProcess\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:init\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:isValidRepository\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:isValidRepositoryAndBranch\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Admin\:\:mirrorTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Admin.php - - - - message: '#^Method Gitonomy\\Git\\Blame\:\:getGroupedLines\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Blame.php - - - - message: '#^Property Gitonomy\\Git\\Blame\\Line\:\:\$blockLine is never read, only written\.$#' - identifier: property.onlyWritten - count: 1 - path: src/Gitonomy/Git/Blame/Line.php - - - - message: '#^Property Gitonomy\\Git\\Blame\\Line\:\:\$targetLine is never read, only written\.$#' - identifier: property.onlyWritten - count: 1 - path: src/Gitonomy/Git/Blame/Line.php - - - - message: '#^Method Gitonomy\\Git\\Diff\\Diff\:\:fromArray\(\) has parameter \$array with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Diff/Diff.php - - - - message: '#^Method Gitonomy\\Git\\Diff\\Diff\:\:toArray\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Diff/Diff.php - - - - message: '#^Method Gitonomy\\Git\\Diff\\File\:\:fromArray\(\) has parameter \$array with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Diff/File.php - - - - message: '#^Method Gitonomy\\Git\\Diff\\File\:\:toArray\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Diff/File.php - - - - message: '#^Parameter \#1 \$hash of method Gitonomy\\Git\\Repository\:\:getBlob\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: src/Gitonomy/Git/Diff/File.php - - - - message: '#^Method Gitonomy\\Git\\Diff\\FileChange\:\:fromArray\(\) has parameter \$array with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Diff/FileChange.php - - - - message: '#^Method Gitonomy\\Git\\Diff\\FileChange\:\:toArray\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Diff/FileChange.php - - - - message: '#^Method Gitonomy\\Git\\Hooks\:\:get\(\) should return string but returns string\|false\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Hooks.php - - - - message: '#^Class Gitonomy\\Git\\Log implements generic interface IteratorAggregate but does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Method Gitonomy\\Git\\Log\:\:__construct\(\) has parameter \$paths with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Method Gitonomy\\Git\\Log\:\:__construct\(\) has parameter \$revisions with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Method Gitonomy\\Git\\Log\:\:count\(\) should return int\<0, max\> but returns int\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Method Gitonomy\\Git\\Log\:\:getIterator\(\) return type with generic class ArrayIterator does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Method Gitonomy\\Git\\Log\:\:getPaths\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Property Gitonomy\\Git\\Log\:\:\$paths type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Log.php - - - - message: '#^Property Gitonomy\\Git\\Parser\\LogParser\:\:\$log type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Parser/LogParser.php - - message: '#^Method Gitonomy\\Git\\Parser\\ParserBase\:\:consumeRegexp\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Gitonomy/Git/Parser/ParserBase.php - - - message: '#^Method Gitonomy\\Git\\Reference\\Branch\:\:isLocal\(\) should return bool but returns bool\|null\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Branch.php - - - - message: '#^Class Gitonomy\\Git\\ReferenceBag implements generic interface IteratorAggregate but does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/Gitonomy/Git/ReferenceBag.php - - - - message: '#^Method Gitonomy\\Git\\ReferenceBag\:\:getIterator\(\) return type with generic class ArrayIterator does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/Gitonomy/Git/ReferenceBag.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:__construct\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:cloneTo\(\) has parameter \$options with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:getDescription\(\) should return string but returns string\|false\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:getDiff\(\) has parameter \$revisions with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - message: '#^Method Gitonomy\\Git\\Repository\:\:getLog\(\) has parameter \$paths with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -234,12 +18,6 @@ parameters: count: 1 path: src/Gitonomy/Git/Repository.php - - - message: '#^Method Gitonomy\\Git\\Repository\:\:getProcess\(\) has parameter \$args with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - message: '#^Method Gitonomy\\Git\\Repository\:\:run\(\) has parameter \$args with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -258,18 +36,6 @@ parameters: count: 1 path: src/Gitonomy/Git/Repository.php - - - message: '#^Parameter \#1 \$string of function trim expects string, string\|false given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Property Gitonomy\\Git\\Repository\:\:\$environmentVariables type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - message: '#^Method Gitonomy\\Git\\Revision\:\:getLog\(\) has parameter \$paths with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -294,14 +60,3 @@ parameters: count: 1 path: src/Gitonomy/Git/RevisionList.php - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Tree.php - - - - message: '#^Method Gitonomy\\Git\\WorkingCopy\:\:run\(\) has parameter \$args with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/WorkingCopy.php diff --git a/phpstan.neon b/phpstan.neon index 6d1c7337..7a65f6c4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,11 +1,24 @@ includes: - phpstan-baseline.neon + - tools/phpstan/vendor/phpstan/phpstan-phpunit/extension.neon + - tools/phpstan/vendor/phpstan/phpstan-phpunit/rules.neon parameters: level: 9 paths: - src + - tests/Gitonomy + - tests/bootstrap.php tmpDir: 'var/phpstan/tmp' inferPrivatePropertyTypeFromConstructor: true - excludePaths: - analyse: [] + 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 b6df36eb..be4cabfb 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -3,6 +3,10 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="tests/bootstrap.php" colors="true" + displayDetailsOnTestsThatTriggerDeprecations="true" + failOnDeprecation="true" + failOnNotice="true" + failOnPhpunitDeprecation="true" failOnRisky="true" failOnWarning="true" beStrictAboutOutputDuringTests="true" @@ -14,7 +18,8 @@ ./tests/Gitonomy/Git/Tests - + + ./src/Gitonomy/Git diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 35d4875d..3799838c 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -12,36 +12,49 @@ namespace Gitonomy\Git; -use Gitonomy\Git\Exception\RuntimeException; -use Symfony\Component\Process\Process; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\Cli\LegacyOptions; /** * Administration class for Git repositories. * + * Its methods behave like in 1.x: the git options of 1.x (command, + * environment_variables, inherit_environment_variables and process_timeout) + * configure the CliDriver they run, and the repositories they return; + * inherit_environment_variables only applies to these repositories. + * * @author Alexandre Salomé + * + * @deprecated since gitonomy/gitlib 2.0, use the static methods of Repository instead + * + * @phpstan-import-type Options from LegacyOptions */ final class Admin { /** * Initializes a repository and returns the instance. * - * @param string $path path to the repository - * @param bool $bare indicate to create a bare repository - * @param array $options options for Repository creation + * @param string $path path to the repository + * @param bool $bare indicate to create a bare repository + * @param Options $options options for Repository creation + * + * @throws Exception\DriverException Directory exists or not writable * - * @throws RuntimeException Directory exists or not writable (only if debug=true) + * @deprecated since gitonomy/gitlib 2.0, use Repository::initBare() or Repository::init() instead */ public static function init(string $path, bool $bare = true, array $options = []): Repository { - $process = self::getProcess('init', array_merge(['-q'], $bare ? ['--bare'] : [], [$path]), $options); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" instead.', __METHOD__, Repository::class, $bare ? 'initBare' : 'init'); - $process->run(); - - if (!$process->isSuccessful()) { - throw new RuntimeException(\sprintf("Error on repository initialization, command wasn't successful (%s). Error output:\n%s", $process->getCommandLine(), $process->getErrorOutput())); + $options = new LegacyOptions($options); + $driver = $options->adminDriver() ?? new CliDriver(); + if ($bare) { + $driver->initBare($path); + } else { + $driver->init($path); } - return new Repository($path, $options); + return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); } /** @@ -51,18 +64,18 @@ public static function init(string $path, bool $bare = true, array $options = [] * Usually, this command returns 0 when successful, and 128 when the * repository is not found. * - * @param string $url url of repository to check - * @param array $options options for Repository creation + * @param string $url url of repository to check + * @param Options $options options for Repository creation * * @return bool true if url is valid + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::isValidRepository() instead */ public static function isValidRepository(string $url, array $options = []): bool { - $process = self::getProcess('ls-remote', [$url], $options); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class); - $process->run(); - - return $process->isSuccessful(); + return (new LegacyOptions($options)->adminDriver() ?? new CliDriver())->isValidRepository($url); } /** @@ -73,106 +86,110 @@ public static function isValidRepository(string $url, array $options = []): bool * Usually, this command returns 0 when successful, and 128 when the * repository is not found. * - * @param string $url url of repository to check - * @param string $branchName name of branch to check - * @param array $options options for Repository creation + * @param string $url url of repository to check + * @param string $branchName name of branch to check + * @param Options $options options for Repository creation * * @return bool true if url is valid and branch exists + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::isValidRepositoryAndBranch() instead */ public static function isValidRepositoryAndBranch(string $url, string $branchName, array $options = []): bool { - $process = self::getProcess('ls-remote', ['--heads', $url, $branchName], $options); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class); - $process->run(); - $processOutput = $process->getOutput(); - - return $process->isSuccessful() && str_contains($processOutput, $branchName); + return (new LegacyOptions($options)->adminDriver() ?? new CliDriver())->isValidRepositoryAndBranch($url, $branchName); } /** * Clone a repository to a local path. * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param bool $bare indicates if repository should be bare or have a working copy - * @param array $options options for Repository creation + * @param string $path indicates where to clone repository + * @param string $url url of repository to clone + * @param bool $bare indicates if repository should be bare or have a working copy + * @param Options $options options for Repository creation + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() instead */ public static function cloneTo(string $path, string $url, bool $bare = true, array $options = []): Repository { - $args = $bare ? ['--bare'] : []; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + + $options = new LegacyOptions($options); + $driver = $options->adminDriver() ?? new CliDriver(); + if ($bare) { + $driver->cloneBareFrom($url, $path, null); + } else { + $driver->cloneFrom($url, $path, null); + } - return self::cloneRepository($path, $url, $args, $options); + return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); } /** * Clone a repository branch to a local path. * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param string $branch branch to clone - * @param bool $bare indicates if repository should be bare or have a working copy - * @param array $options options for Repository creation + * @param string $path indicates where to clone repository + * @param string $url url of repository to clone + * @param string $branch branch to clone + * @param bool $bare indicates if repository should be bare or have a working copy + * @param Options $options options for Repository creation + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() with their "branch" argument instead */ public static function cloneBranchTo(string $path, string $url, string $branch, bool $bare = true, array $options = []): Repository { - $args = ['--branch', $branch]; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); + + $options = new LegacyOptions($options); + $driver = $options->adminDriver() ?? new CliDriver(); if ($bare) { - $args[] = '--bare'; + $driver->cloneBareFrom($url, $path, $branch); + } else { + $driver->cloneFrom($url, $path, $branch); } - return self::cloneRepository($path, $url, $args, $options); + return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); } /** * Mirrors a repository (fetch all revisions, not only branches). * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param array $options options for Repository creation + * @param string $path indicates where to clone repository + * @param string $url url of repository to clone + * @param Options $options options for Repository creation + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::mirrorFrom() instead */ public static function mirrorTo(string $path, string $url, array $options = []): Repository { - return self::cloneRepository($path, $url, ['--mirror'], $options); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class); + + $options = new LegacyOptions($options); + $driver = $options->adminDriver() ?? new CliDriver(); + $driver->mirrorFrom($url, $path); + + return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); } /** * Internal method to launch effective ``git clone`` command. * - * @param string $path indicates where to clone repository - * @param string $url url of repository to clone - * @param array $args arguments to be added to the command-line - * @param array $options options for Repository creation + * @param string $path indicates where to clone repository + * @param string $url url of repository to clone + * @param string[] $args arguments to be added to the command-line + * @param Options $options options for Repository creation + * + * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneFrom(), Repository::cloneBareFrom() or Repository::mirrorFrom() instead */ public static function cloneRepository(string $path, string $url, array $args = [], array $options = []): Repository { - $process = self::getProcess('clone', array_merge(['-q'], $args, [$url, $path]), $options); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class); - $process->run(); + $options = new LegacyOptions($options); + $driver = $options->adminDriver() ?? new CliDriver(); + $driver->cloneRepository($path, $url, $args); - if (!$process->isSuccessful()) { - throw new RuntimeException(\sprintf('Error while initializing repository: %s', $process->getErrorOutput())); - } - - return new Repository($path, $options); - } - - /** - * This internal method is used to create a process object. - */ - private static function getProcess(string $command, array $args = [], array $options = []): Process - { - $is_windows = \defined('PHP_WINDOWS_VERSION_BUILD'); - $options = array_merge([ - 'environment_variables' => $is_windows ? ['PATH' => getenv('PATH')] : [], - 'command' => 'git', - 'process_timeout' => 3600, - ], $options); - - $process = new Process(array_merge([$options['command'], $command], $args)); - $process->setEnv($options['environment_variables']); - $process->setTimeout($options['process_timeout']); - $process->setIdleTimeout($options['process_timeout']); - - return $process; + return new Repository($path, $options->workingDir, $options->repositoryDriver() ?? $driver); } } diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index 35dc2e8f..7901ed38 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -12,9 +12,9 @@ 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é @@ -22,71 +22,87 @@ final class Blame implements \Countable { /** - * @var Line[]|null + * @var array|null By number */ private ?array $lines = null; + private readonly ?int $firstLine; + + private readonly ?int $lastLine; + /** - * @param string $file the file the blame is about - * @param string|null $lineRange argument to pass to git blame (-L). - * Can be a line range (40,60 or 40,+21) - * or a regexp ('/^function$/') + * Like "git blame -L 5,3", a last line before the first one blames the lines 3 to 5. + * + * @param string $file the file the blame is about + * @param int|null $firstLine The first line to blame, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null + * + * @throws InvalidArgumentException An invalid range of lines */ public function __construct( private readonly Repository $repository, private readonly Revision $revision, private readonly string $file, - private readonly ?string $lineRange = null, + ?int $firstLine = null, + ?int $lastLine = null, ) { + if ((null !== $firstLine && $firstLine < 1) || (null !== $lastLine && $lastLine < 1)) { + throw new InvalidArgumentException(\sprintf('Invalid range of lines "%s,%s": the lines are numbered from 1.', $firstLine, $lastLine)); + } + + [$this->firstLine, $this->lastLine] = null !== $firstLine && null !== $lastLine && $lastLine < $firstLine ? [$lastLine, $firstLine] : [$firstLine, $lastLine]; } + /** + * @param int $number The number of the line in the file + * + * @throws InvalidArgumentException The blame has no line $number + */ public function getLine(int $number): Line { - if ($number < 1) { - throw new InvalidArgumentException('Line number should be at least 1'); - } + return $this->getLines()[$number] ?? throw new InvalidArgumentException(\sprintf('The blame of "%s" has no line %d.', $this->file, $number)); + } - $lines = $this->getLines(); + /** + * @return list The consecutive lines last changed by the same commit + */ + public function getHunks(): array + { + $hunks = []; + $commit = null; + $lines = []; + foreach ($this->getLines() as $number => $line) { + if ($commit !== $line->getCommit() && [] !== $lines) { + $hunks[] = new Hunk($commit, $lines); + $lines = []; + } + $commit = $line->getCommit(); + $lines[$number] = $line; + } - if (!isset($lines[$number])) { - throw new InvalidArgumentException('Line does not exist'); + if (null !== $commit) { + $hunks[] = new Hunk($commit, $lines); } - return $lines[$number]; + return $hunks; } /** * Returns lines grouped by commit. * - * @return array a list of two-elements array (commit, lines) + * @return list}> + * + * @deprecated since gitonomy/gitlib 2.0, use getHunks() instead */ public function getGroupedLines(): array { - $result = []; - $commit = null; - $current = []; - - foreach ($this->getLines() as $lineNumber => $line) { - if ($commit !== $line->getCommit()) { - if (\count($current)) { - $result[] = [$commit, $current]; - } - $commit = $line->getCommit(); - $current = []; - } + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getHunks()" instead.', __METHOD__); - $current[$lineNumber] = $line; - } - - if (\count($current)) { - $result[] = [$commit, $current]; - } - - return $result; + return array_map(static fn (Hunk $hunk): array => [$hunk->getCommit(), $hunk->getLines()], $this->getHunks()); } /** - * @return Line[] all lines of the blame + * @return array The lines of the blame, by number */ public function getLines(): array { @@ -94,21 +110,12 @@ public function getLines(): array 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->finalLine] = new Line($this->repository->getCommit($data->commitHash), $data->finalLine, $data->originalLine, $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; } diff --git a/src/Gitonomy/Git/Blame/Hunk.php b/src/Gitonomy/Git/Blame/Hunk.php new file mode 100644 index 00000000..457e8952 --- /dev/null +++ b/src/Gitonomy/Git/Blame/Hunk.php @@ -0,0 +1,45 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Blame; + +use Gitonomy\Git\Commit; + +/** + * Consecutive lines of a blame, last changed by the same commit. + * + * @author Grégoire Pineau + */ +final readonly class Hunk +{ + /** + * @param array $lines By number + */ + public function __construct( + private Commit $commit, + private array $lines, + ) { + } + + public function getCommit(): Commit + { + return $this->commit; + } + + /** + * @return array By number + */ + public function getLines(): array + { + return $this->lines; + } +} diff --git a/src/Gitonomy/Git/Blame/Line.php b/src/Gitonomy/Git/Blame/Line.php index d252e3ea..6254b2cd 100644 --- a/src/Gitonomy/Git/Blame/Line.php +++ b/src/Gitonomy/Git/Blame/Line.php @@ -20,13 +20,13 @@ final readonly class Line { /** - * Instanciates a new Line object. + * @param int $number The number of the line in the blamed file + * @param int $originalNumber The number of the line in the file of $commit */ public function __construct( private Commit $commit, - private string $sourceLine, - private string $targetLine, - private ?string $blockLine, + private int $number, + private int $originalNumber, private string $content, ) { } @@ -36,11 +36,35 @@ public function getContent(): string return $this->content; } + /** + * @return int The number of the line in the blamed file + */ + public function getNumber(): int + { + return $this->number; + } + + /** + * @return int The number of the line in the file of the commit that last changed it + */ + public function getOriginalNumber(): int + { + return $this->originalNumber; + } + + /** + * @deprecated since gitonomy/gitlib 2.0, use getOriginalNumber() instead + */ public function getLine(): string { - return $this->sourceLine; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getOriginalNumber()" instead.', __METHOD__); + + return (string) $this->originalNumber; } + /** + * @return Commit The commit that last changed the line + */ public function getCommit(): Commit { return $this->commit; diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index 60555cb4..c857a2ae 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -12,7 +12,7 @@ namespace Gitonomy\Git; -use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\RuntimeException; /** * Representation of a Blob commit. @@ -28,7 +28,7 @@ final class Blob private ?string $content = null; - private string|false|null $mimetype = null; + private ?string $mimetype = null; private ?bool $text = null; @@ -50,33 +50,25 @@ public function getHash(): string /** * @return string content of the blob * - * @throws Exception\ProcessException Error occurred while getting content of blob + * @throws Exception\DriverException Error occurred while getting content of blob */ public function getContent(): string { if (null === $this->content) { - $content = $this->repository->run('cat-file', ['-p', $this->hash]); - if (null === $content) { - throw new ReferenceNotFoundException($this->hash); - } - - $this->content = $content; + $this->content = $this->repository->getRepositoryDriver()->getBlobContent($this->hash); } return $this->content; } /** - * Determine the mimetype of the blob. + * @return string The mimetype of the content, like "text/plain" + * + * @throws RuntimeException fileinfo can't read the content */ - public function getMimetype(): string|false + public function getMimetype(): string { - if (null === $this->mimetype) { - $finfo = new \finfo(\FILEINFO_MIME); - $this->mimetype = $finfo->buffer($this->getContent()); - } - - return $this->mimetype; + return $this->mimetype ??= new \finfo(\FILEINFO_MIME_TYPE)->buffer($this->getContent()) ?: throw new RuntimeException(\sprintf('Unable to read the mimetype of the blob "%s".', $this->hash)); } /** diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index a82c5738..4a1af38f 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -13,10 +13,11 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Driver\Data\CommitData; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Util\Message; use Symfony\Component\String\CodePointString; /** @@ -27,86 +28,44 @@ final class Commit extends Revision { /** - * Whether the fields below have been populated (either lazily fetched - * from the repository, or provided upfront through setData()). + * Raw commit data, as returned by the Driver. Lazily loaded. */ - private bool $loaded = false; - - private ?string $treeHash = null; - - /** - * @var string[]|null - */ - private ?array $parentHashes = null; - - private ?string $authorName = null; - private ?string $authorEmail = null; - private ?\DateTime $authorDate = null; - private ?string $committerName = null; - private ?string $committerEmail = null; - private ?\DateTime $committerDate = null; - private ?string $message = null; + private ?CommitData $data; // Values derived from the fields above, computed and cached independently. private ?string $shortHash = null; private ?Tree $tree = null; - private ?string $subjectMessage = null; - private ?string $bodyMessage = null; /** - * @param Repository $repository Repository of the commit - * @param string $hash Hash of the commit - * @param array $data + * @param Repository $repository Repository of the commit + * @param string $hash Hash of the commit */ - public function __construct(Repository $repository, string $hash, array $data = []) + public function __construct(Repository $repository, string $hash, ?CommitData $data = null) { if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { - throw new ReferenceNotFoundException($hash); + 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; } /** - * @param array $data + * @internal */ - public function setData(array $data): void + public function setData(CommitData $data): void { - foreach ($data as $name => $value) { - match ($name) { - 'treeHash' => $this->treeHash = self::asString($name, $value), - 'parentHashes' => $this->parentHashes = self::asStringList($name, $value), - 'authorName' => $this->authorName = self::asString($name, $value), - 'authorEmail' => $this->authorEmail = self::asString($name, $value), - 'authorDate' => $this->authorDate = self::asDateTime($name, $value), - 'committerName' => $this->committerName = self::asString($name, $value), - 'committerEmail' => $this->committerEmail = self::asString($name, $value), - 'committerDate' => $this->committerDate = self::asDateTime($name, $value), - 'message' => $this->message = self::asString($name, $value), - default => throw new InvalidArgumentException(\sprintf('Unknown commit data "%s".', $name)), - }; - } - - if ([] !== $data) { - $this->loaded = true; - } + $this->data = $data; } + /** + * Returns the changes of the commit: against its first parent, like GitHub + * shows a merge commit, or against nothing for a root commit. + */ public function getDiff(): Diff { - $args = ['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index', $this->revision]; - - $result = $this->repository->run('diff-tree', $args); - if (null === $result) { - throw new ReferenceNotFoundException($this->revision); - } - - $diff = Diff::parse($result); - $diff->setRepository($this->repository); - - return $diff; + return Diff::fromData($this->repository->getRepositoryDriver()->getDiff($this->getParentHashes()[0] ?? null, $this->revision), $this->repository); } /** @@ -122,23 +81,18 @@ public function getHash(): string */ public function getShortHash(): string { - if (null === $this->shortHash) { - $result = $this->repository->run('log', ['--abbrev-commit', '--format=%h', '-n', 1, $this->revision]); - if (null === $result) { - throw new ReferenceNotFoundException($this->revision); - } - - $this->shortHash = trim($result); - } - - return $this->shortHash; + return $this->shortHash ??= $this->repository->getRepositoryDriver()->getShortHash($this->revision); } /** * Returns a fixed-with short hash. + * + * @deprecated since gitonomy/gitlib 2.0, use substr($commit->getHash(), 0, $length) instead */ public function getFixedShortHash(int $length = 6): string { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "substr($commit->getHash(), 0, $length)" instead.', __METHOD__); + return new CodePointString($this->revision)->slice(0, $length)->toString(); } @@ -149,9 +103,7 @@ public function getFixedShortHash(int $length = 6): string */ public function getParentHashes(): array { - $this->ensureLoaded(); - - return $this->parentHashes ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->parentHashes; } /** @@ -162,7 +114,7 @@ public function getParentHashes(): array public function getParents(): array { $result = []; - foreach ($this->getParentHashes() as $parentHash) { + foreach ($this->data()->parentHashes as $parentHash) { $result[] = $this->repository->getCommit($parentHash); } @@ -174,46 +126,34 @@ public function getParents(): array */ public function getTreeHash(): string { - $this->ensureLoaded(); - - return $this->treeHash ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->treeHash; } public function getTree(): Tree { - if (null === $this->tree) { - $this->tree = $this->repository->getTree($this->getTreeHash()); - } - - return $this->tree; + return $this->tree ??= $this->repository->getTree($this->getTreeHash()); } public function getLastModification(?string $path = null): self { if (null !== $path && str_starts_with($path, '/')) { - $path = new CodePointString($path)->slice(1)->toString(); - } - - if ($getWorkingDir = $this->repository->getWorkingDir()) { - $path = $getWorkingDir.'/'.$path; + $path = substr($path, 1); } - $result = $this->repository->run('log', ['--format=%H', '-n', 1, $this->revision, '--', $path]); - - if (null === $result) { - throw new ReferenceNotFoundException($this->revision); - } - - return $this->repository->getCommit(trim($result)); + return $this->getLog($path, null, 1)->getSingleCommit(); } /** * Returns the first line of the commit, and the first 50 characters. * * Ported from https://github.com/fabpot/Twig-extensions/blob/d67bc7e69788795d7905b52d31188bbc1d390e01/lib/Twig/Extensions/Extension/Text.php#L52-L109 + * + * @deprecated since gitonomy/gitlib 2.0, use symfony/string instead: u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve) */ public function getShortMessage(int $length = 50, bool $preserve = false, string $separator = '...'): string { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".', __METHOD__); + $message = $this->getSubjectMessage(); $codePointMessage = new CodePointString($message); @@ -240,51 +180,29 @@ public function resolveReferences(): array } /** - * Find branch containing the commit. + * Returns the local and remote branches whose history contains the commit. * - * @param bool $local set true to try to locate a commit on local repository - * @param bool $remote set true to try to locate a commit on remote repository + * Passing arguments, to keep local or remote branches only, is deprecated. * - * @return Reference[]|Branch[] An array of Reference\Branch + * @return Branch[] */ public function getIncludingBranches(bool $local = true, bool $remote = true): array { - $arguments = ['--contains', $this->revision]; - - if ($local && $remote) { - $arguments[] = '-a'; - } elseif (!$local && $remote) { - $arguments[] = '-r'; - } elseif (!$local) { - throw new InvalidArgumentException('You should a least set one argument to true'); - } - - try { - $result = $this->repository->run('branch', $arguments); - } catch (ProcessException $e) { - return []; + if (\func_num_args() > 0) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing arguments to "%s()" is deprecated, filter the branches it returns with "%s::isLocal()" or "isRemote()" instead.', __METHOD__, Branch::class); } - if (!$result) { - return []; + if (!$local && !$remote) { + throw new InvalidArgumentException('You should a least set one argument to true.'); } - $branchesName = explode("\n", trim(str_replace('*', '', $result))); - $branchesName = array_filter($branchesName, static function ($v) { - return null === new CodePointString($v)->indexOf('->'); - }); - $branchesName = array_map('trim', $branchesName); - $references = $this->repository->getReferences(); $branches = []; - foreach ($branchesName as $branchName) { - if (false === $local) { - $branches[] = $references->getRemoteBranch($branchName); - } elseif (0 === new CodePointString($branchName)->indexOfLast('remotes/')) { - $branches[] = $references->getRemoteBranch(str_replace('remotes/', '', $branchName)); - } else { - $branches[] = $references->getBranch($branchName); + foreach ($this->repository->getRepositoryDriver()->getIncludingBranches($this->revision) as $fullname) { + $branch = $references->get($fullname); + if ($branch instanceof Branch && ($branch->isLocal() ? $local : $remote)) { + $branches[] = $branch; } } @@ -296,9 +214,7 @@ public function getIncludingBranches(bool $local = true, bool $remote = true): a */ public function getAuthorName(): string { - $this->ensureLoaded(); - - return $this->authorName ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->authorName; } /** @@ -306,19 +222,15 @@ public function getAuthorName(): string */ public function getAuthorEmail(): string { - $this->ensureLoaded(); - - return $this->authorEmail ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->authorEmail; } /** * Returns the authoring date. */ - public function getAuthorDate(): \DateTime + public function getAuthorDate(): \DateTimeImmutable { - $this->ensureLoaded(); - - return $this->authorDate ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->authorDate; } /** @@ -326,9 +238,7 @@ public function getAuthorDate(): \DateTime */ public function getCommitterName(): string { - $this->ensureLoaded(); - - return $this->committerName ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->committerName; } /** @@ -336,19 +246,15 @@ public function getCommitterName(): string */ public function getCommitterEmail(): string { - $this->ensureLoaded(); - - return $this->committerEmail ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->committerEmail; } /** - * Returns the authoring date. + * Returns the committing date. */ - public function getCommitterDate(): \DateTime + public function getCommitterDate(): \DateTimeImmutable { - $this->ensureLoaded(); - - return $this->committerDate ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->committerDate; } /** @@ -356,39 +262,23 @@ public function getCommitterDate(): \DateTime */ public function getMessage(): string { - $this->ensureLoaded(); - - return $this->message ?? throw new ReferenceNotFoundException($this->revision); + return $this->data()->message; } /** - * Returns the subject message (the first line). + * @return string The first paragraph of the message, on one line, like "git log --format=%s" */ public function getSubjectMessage(): string { - if (null === $this->subjectMessage) { - $lines = explode("\n", $this->getMessage()); - $this->subjectMessage = reset($lines); - } - - return $this->subjectMessage; + return Message::subject($this->getMessage()); } /** - * Return the body message. + * @return string What follows the subject and its empty lines, like "git log --format=%b" */ public function getBodyMessage(): string { - if (null === $this->bodyMessage) { - $lines = explode("\n", $this->getMessage()); - - array_shift($lines); - array_shift($lines); - - $this->bodyMessage = implode("\n", $lines); - } - - return $this->bodyMessage; + return Message::body($this->getMessage()); } public function getCommit(): self @@ -396,75 +286,8 @@ public function getCommit(): self return $this; } - private function ensureLoaded(): void - { - if ($this->loaded) { - return; - } - - $parser = new Parser\CommitParser(); - - try { - $result = $this->repository->run('cat-file', ['commit', $this->revision]); - } catch (ProcessException $e) { - throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision)); - } - - if (null === $result) { - throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision)); - } - - $parser->parse($result); - - $this->treeHash = $parser->tree; - $this->parentHashes = $parser->parents; - $this->authorName = $parser->authorName; - $this->authorEmail = $parser->authorEmail; - $this->authorDate = $parser->authorDate; - $this->committerName = $parser->committerName; - $this->committerEmail = $parser->committerEmail; - $this->committerDate = $parser->committerDate; - $this->message = $parser->message; - - $this->loaded = true; - } - - private static function asString(string $name, mixed $value): string + private function data(): CommitData { - if (!\is_string($value)) { - throw new InvalidArgumentException(\sprintf('Commit data "%s" must be a string.', $name)); - } - - return $value; - } - - private static function asDateTime(string $name, mixed $value): \DateTime - { - if (!$value instanceof \DateTime) { - throw new InvalidArgumentException(\sprintf('Commit data "%s" must be a DateTime.', $name)); - } - - return $value; - } - - /** - * @return string[] - */ - private static function asStringList(string $name, mixed $value): array - { - if (!\is_array($value)) { - throw new InvalidArgumentException(\sprintf('Commit data "%s" must be an array.', $name)); - } - - $result = []; - foreach ($value as $item) { - if (!\is_string($item)) { - throw new InvalidArgumentException(\sprintf('Commit data "%s" must be an array of strings.', $name)); - } - - $result[] = $item; - } - - return $result; + return $this->data ??= $this->repository->getRepositoryDriver()->getCommitData($this->revision); } } diff --git a/src/Gitonomy/Git/CommitReference.php b/src/Gitonomy/Git/CommitReference.php index 6233e2b4..5a10b217 100644 --- a/src/Gitonomy/Git/CommitReference.php +++ b/src/Gitonomy/Git/CommitReference.php @@ -12,14 +12,17 @@ namespace Gitonomy\Git; -final readonly class CommitReference -{ - public function __construct(private string $hash) - { - } +trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s" class is deprecated, use "%s" instead.', CommitReference::class, Submodule::class); + +// Declares the alias, unless Submodule already did. +class_exists(Submodule::class); - public function getHash(): string +// @phpstan-ignore if.alwaysFalse (Declares the class for IDEs only.) +if (false) { + /** + * @deprecated since gitonomy/gitlib 2.0, use Submodule instead + */ + final readonly class CommitReference { - return $this->hash; } } diff --git a/src/Gitonomy/Git/Diff/Diff.php b/src/Gitonomy/Git/Diff/Diff.php index c94dc427..43e13920 100644 --- a/src/Gitonomy/Git/Diff/Diff.php +++ b/src/Gitonomy/Git/Diff/Diff.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Diff; +use Gitonomy\Git\Driver\Data\DiffData; +use Gitonomy\Git\Driver\Data\DiffFileData; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Parser\DiffParser; use Gitonomy\Git\Repository; @@ -19,13 +22,13 @@ * Representation of a diff. * * @author Alexandre Salomé + * + * @phpstan-import-type FileArray from File */ final readonly class Diff { /** - * Constructs a new diff for a given revision. - * - * @param File[] $files The files + * @param File[] $files */ public function __construct( private array $files, @@ -33,18 +36,39 @@ public function __construct( ) { } - public static function parse(string $rawDiff): self + /** + * Parses the output of ``git diff --raw --patch``, ``-z`` or not. + * + * @param Repository|null $repository The repository to read the blobs of the files from + * + * @throws InvalidArgumentException The diff has no raw information + */ + public static function parse(string $rawDiff, ?Repository $repository = null): self { $parser = new DiffParser(); $parser->parse($rawDiff); - return new self($parser->files, $rawDiff); + return new self(array_map(static fn (DiffFileData $file): File => File::fromData($file, $repository), $parser->files), $rawDiff); + } + + /** + * @internal + */ + public static function fromData(DiffData $data, Repository $repository): self + { + return new self(array_map(static fn (DiffFileData $file): File => File::fromData($file, $repository), $data->files), $data->rawDiff); } + /** + * @deprecated since gitonomy/gitlib 2.0, the diffs gitlib returns have their repository already + */ public function setRepository(Repository $repository): void { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); + foreach ($this->files as $file) { - $file->setRepository($repository); + // Without one more deprecation for each file. + \Closure::bind(static fn () => $file->repository = $repository, null, File::class)(); } } @@ -68,33 +92,24 @@ public function getRawDiff(): string /** * Export a diff as array. + * + * @return array{rawDiff: string, files: list} */ public function toArray(): array { return [ 'rawDiff' => $this->rawDiff, - 'files' => array_map( - static function (File $file) { - return $file->toArray(); - }, - $this->files - ), + 'files' => array_map(static fn (File $file): array => $file->toArray(), array_values($this->files)), ]; } /** * Create a new instance of Diff from an array. + * + * @param array{rawDiff: string, files: list} $array */ - public static function fromArray(array $array): self + public static function fromArray(array $array, ?Repository $repository = null): self { - return new self( - array_map( - static function ($array) { - return File::fromArray($array); - }, - $array['files'] - ), - $array['rawDiff'] - ); + return new self(array_map(static fn (array $file): File => File::fromArray($file, $repository), $array['files']), $array['rawDiff']); } } diff --git a/src/Gitonomy/Git/Diff/DiffLine.php b/src/Gitonomy/Git/Diff/DiffLine.php new file mode 100644 index 00000000..303d8bc9 --- /dev/null +++ b/src/Gitonomy/Git/Diff/DiffLine.php @@ -0,0 +1,77 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Diff; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; + +/** + * A line of a hunk. + * + * Reading it as an array, [$type, $content] with the type as one of the + * FileChange::LINE_* constants, is deprecated. + * + * @author Grégoire Pineau + * + * @implements \ArrayAccess + */ +final readonly class DiffLine implements \ArrayAccess +{ + public function __construct( + private DiffLineType $type, + private string $content, + ) { + } + + public function getType(): DiffLineType + { + return $this->type; + } + + /** + * @return string The line, without its "+", "-" or " " prefix, nor its line break + */ + public function getContent(): string + { + return $this->content; + } + + public function offsetExists(mixed $offset): bool + { + return 0 === $offset || 1 === $offset; + } + + /** + * @deprecated since gitonomy/gitlib 2.0, use getType() and getContent() instead + */ + public function offsetGet(mixed $offset): int|string + { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Reading a "%s" as an array is deprecated, use its "getType()" and "getContent()" methods instead.', self::class); + + return match ($offset) { + 0 => $this->type->value, + 1 => $this->content, + default => throw new InvalidArgumentException(\sprintf('A diff line has no offset %s.', var_export($offset, true))), + }; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new LogicException('A diff line is immutable.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new LogicException('A diff line is immutable.'); + } +} diff --git a/src/Gitonomy/Git/Diff/DiffLineType.php b/src/Gitonomy/Git/Diff/DiffLineType.php new file mode 100644 index 00000000..b7a88a70 --- /dev/null +++ b/src/Gitonomy/Git/Diff/DiffLineType.php @@ -0,0 +1,26 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Diff; + +/** + * The kind of a line of a hunk, its values those of the deprecated + * FileChange::LINE_* constants. + * + * @author Grégoire Pineau + */ +enum DiffLineType: int +{ + case Addition = 1; + case Context = 0; + case Deletion = -1; +} diff --git a/src/Gitonomy/Git/Diff/File.php b/src/Gitonomy/Git/Diff/File.php index 8a73748c..516195b9 100644 --- a/src/Gitonomy/Git/Diff/File.php +++ b/src/Gitonomy/Git/Diff/File.php @@ -13,22 +13,32 @@ namespace Gitonomy\Git\Diff; use Gitonomy\Git\Blob; +use Gitonomy\Git\Driver\Data\DiffFileData; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; /** * @author Alexandre Salomé + * + * @phpstan-import-type FileChangeArray from FileChange + * + * @phpstan-type FileArray array{old_name: string|null, new_name: string|null, old_mode: string|null, new_mode: string|null, old_index: string|null, new_index: string|null, is_binary: bool, is_copy: bool, changes: list} */ final class File { - /** - * @var FileChange[] - */ - private array $changes = []; - - private ?Repository $repository = null; + private readonly string $name; /** - * Instanciates a new File object. + * @param string|null $oldName Null for a creation + * @param string|null $newName Null for a deletion + * @param string|null $oldIndex The hash of the old blob, null when there is none (a creation...) + * @param string|null $newIndex The hash of the new blob, null when there is none (a deletion, a modified submodule...); the hash of a file of the working tree isn't in the repository + * @param FileChange[] $changes + * @param Repository|null $repository The repository to read the blobs from + * + * @throws InvalidArgumentException Both names are null */ public function __construct( private readonly ?string $oldName, @@ -39,11 +49,19 @@ public function __construct( private readonly ?string $newIndex, private readonly bool $isBinary, private readonly bool $isCopy = false, + private array $changes = [], + private ?Repository $repository = null, ) { + $this->name = $newName ?? $oldName ?? throw new InvalidArgumentException('A file of a diff has an old name, a new name, or both.'); } + /** + * @deprecated since gitonomy/gitlib 2.0, give the changes to the constructor instead + */ public function addChange(FileChange $change): void { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, give the changes to the constructor instead.', __METHOD__); + $this->changes[] = $change; } @@ -86,11 +104,21 @@ public function isCopy(): bool /** * Indicates if the file mode has changed. */ - public function isChangeMode(): bool + public function isModeChange(): bool { return $this->isModification() && $this->oldMode !== $this->newMode; } + /** + * @deprecated since gitonomy/gitlib 2.0, use isModeChange() instead + */ + public function isChangeMode(): bool + { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "isModeChange()" instead.', __METHOD__); + + return $this->isModeChange(); + } + /** * Indicates if this diff file is a deletion. */ @@ -100,11 +128,13 @@ public function isDeletion(): bool } /** - * Indicates if this diff file is a deletion. + * @deprecated since gitonomy/gitlib 2.0, use isDeletion() instead */ public function isDelete(): bool { - return null === $this->newName; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "isDeletion()" instead.', __METHOD__); + + return $this->isDeletion(); } /** @@ -112,12 +142,7 @@ public function isDelete(): bool */ public function getAdditions(): int { - $result = 0; - foreach ($this->changes as $change) { - $result += $change->getCount(FileChange::LINE_ADD); - } - - return $result; + return array_sum(array_map(static fn (FileChange $change): int => $change->getAdditions(), $this->changes)); } /** @@ -125,12 +150,7 @@ public function getAdditions(): int */ public function getDeletions(): int { - $result = 0; - foreach ($this->changes as $change) { - $result += $change->getCount(FileChange::LINE_REMOVE); - } - - return $result; + return array_sum(array_map(static fn (FileChange $change): int => $change->getDeletions(), $this->changes)); } public function getOldName(): ?string @@ -143,13 +163,12 @@ public function getNewName(): ?string return $this->newName; } - public function getName(): ?string + /** + * @return string The new name, or the old one for a deletion + */ + public function getName(): string { - if (null === $this->newName) { - return $this->oldName; - } - - return $this->newName; + return $this->name; } public function getOldMode(): ?string @@ -185,6 +204,9 @@ public function getChanges(): array return $this->changes; } + /** + * @return FileArray + */ public function toArray(): array { return [ @@ -196,25 +218,45 @@ public function toArray(): array 'new_index' => $this->newIndex, 'is_binary' => $this->isBinary, 'is_copy' => $this->isCopy, - 'changes' => array_map(static function (FileChange $change) { - return $change->toArray(); - }, $this->changes), + 'changes' => array_map(static fn (FileChange $change): array => $change->toArray(), array_values($this->changes)), ]; } - public static function fromArray(array $array): self + /** + * @internal + */ + public static function fromData(DiffFileData $data, ?Repository $repository): self { - $file = new self($array['old_name'], $array['new_name'], $array['old_mode'], $array['new_mode'], $array['old_index'], $array['new_index'], $array['is_binary'], $array['is_copy'] ?? false); - - foreach ($array['changes'] as $change) { - $file->addChange(FileChange::fromArray($change)); - } + return new self($data->oldName, $data->newName, $data->oldMode, $data->newMode, $data->oldIndex, $data->newIndex, $data->isBinary, $data->isCopy, $data->changes, $repository); + } - return $file; + /** + * @param array{old_name: string|null, new_name: string|null, old_mode: string|null, new_mode: string|null, old_index: string|null, new_index: string|null, is_binary: bool, is_copy?: bool, changes: list} $array Without "is_copy" from 1.x + */ + public static function fromArray(array $array, ?Repository $repository = null): self + { + return new self( + $array['old_name'], + $array['new_name'], + $array['old_mode'], + $array['new_mode'], + // 1.x used an empty string for a missing hash. + '' === $array['old_index'] ? null : $array['old_index'], + '' === $array['new_index'] ? null : $array['new_index'], + $array['is_binary'], + $array['is_copy'] ?? false, + array_map(FileChange::fromArray(...), $array['changes']), + $repository, + ); } + /** + * @deprecated since gitonomy/gitlib 2.0, a presentation concern: use substr($file->getNewIndex(), 0, 12) instead + */ public function getAnchor(): string { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "substr($file->getNewIndex(), 0, 12)" instead.', __METHOD__); + return substr($this->newIndex ?? '', 0, 12); } @@ -223,42 +265,52 @@ public function getRepository(): ?Repository return $this->repository; } + /** + * @deprecated since gitonomy/gitlib 2.0, the diffs gitlib returns have their repository already + */ public function setRepository(Repository $repository): void { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, the diffs gitlib returns have their repository already.', __METHOD__); + $this->repository = $repository; } + /** + * @throws LogicException The file has no repository, or is a creation + * @throws RuntimeException The diff doesn't know the hash of the old blob + */ public function getOldBlob(): Blob { - if (null === $this->repository) { - throw new \RuntimeException('Repository is missing to return Blob object.'); - } - if ($this->isCreation()) { - throw new \LogicException('Can\'t return old Blob on a creation'); + throw new LogicException(\sprintf('"%s" is a creation: it has no old blob.', $this->name)); } - if ('' === $this->oldIndex) { - throw new \RuntimeException('Index is missing to return Blob object.'); - } - - return $this->repository->getBlob($this->oldIndex); + return $this->blob($this->oldIndex, 'old'); } + /** + * @throws LogicException The file has no repository, or is a deletion + * @throws RuntimeException The diff doesn't know the hash of the new blob, like for a modified submodule + */ public function getNewBlob(): Blob { - if (null === $this->repository) { - throw new \RuntimeException('Repository is missing to return Blob object.'); + if ($this->isDeletion()) { + throw new LogicException(\sprintf('"%s" is a deletion: it has no new blob.', $this->name)); } - if ($this->isDeletion()) { - throw new \LogicException('Can\'t return new Blob on a deletion'); + return $this->blob($this->newIndex, 'new'); + } + + private function blob(?string $hash, string $side): Blob + { + if (null === $this->repository) { + throw new LogicException('A diff without repository can\'t read blobs: get it from a repository.'); } - if ('' === $this->newIndex) { - throw new \RuntimeException('Index is missing to return Blob object.'); + if (null === $hash) { + throw new RuntimeException(\sprintf('The diff doesn\'t know the hash of the %s blob of "%s".', $side, $this->name)); } - return $this->repository->getBlob($this->newIndex); + return $this->repository->getBlob($hash); } } diff --git a/src/Gitonomy/Git/Diff/FileChange.php b/src/Gitonomy/Git/Diff/FileChange.php index 1e7bc914..08f28236 100644 --- a/src/Gitonomy/Git/Diff/FileChange.php +++ b/src/Gitonomy/Git/Diff/FileChange.php @@ -12,14 +12,32 @@ namespace Gitonomy\Git\Diff; +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * A hunk of a file of a diff. + * + * @phpstan-type FileChangeArray array{range_old_start: int, range_old_count: int, range_new_start: int, range_new_count: int, lines: list} + */ final readonly class FileChange { + /** + * @deprecated since gitonomy/gitlib 2.0, use DiffLineType::Context instead + */ public const int LINE_CONTEXT = 0; + + /** + * @deprecated since gitonomy/gitlib 2.0, use DiffLineType::Deletion instead + */ public const int LINE_REMOVE = -1; + + /** + * @deprecated since gitonomy/gitlib 2.0, use DiffLineType::Addition instead + */ public const int LINE_ADD = 1; /** - * @param array $lines + * @param DiffLine[] $lines */ public function __construct( private int $rangeOldStart, @@ -30,16 +48,24 @@ public function __construct( ) { } + /** + * @deprecated since gitonomy/gitlib 2.0, use getAdditions() or getDeletions() instead + */ public function getCount(int $type): int { - $result = 0; - foreach ($this->lines as $line) { - if ($line[0] === $type) { - ++$result; - } - } - - return $result; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getAdditions()" or "getDeletions()" instead.', __METHOD__); + + return $this->count(self::lineType($type)); + } + + public function getAdditions(): int + { + return $this->count(DiffLineType::Addition); + } + + public function getDeletions(): int + { + return $this->count(DiffLineType::Deletion); } public function getRangeOldStart(): int @@ -63,13 +89,16 @@ public function getRangeNewCount(): int } /** - * @return array + * @return DiffLine[] */ public function getLines(): array { return $this->lines; } + /** + * @return FileChangeArray + */ public function toArray(): array { return [ @@ -77,12 +106,34 @@ public function toArray(): array 'range_old_count' => $this->rangeOldCount, 'range_new_start' => $this->rangeNewStart, 'range_new_count' => $this->rangeNewCount, - 'lines' => $this->lines, + 'lines' => array_map(static fn (DiffLine $line): array => [$line->getType()->value, $line->getContent()], array_values($this->lines)), ]; } + /** + * @param FileChangeArray $array + */ public static function fromArray(array $array): self { - return new self($array['range_old_start'], $array['range_old_count'], $array['range_new_start'], $array['range_new_count'], $array['lines']); + return new self( + $array['range_old_start'], + $array['range_old_count'], + $array['range_new_start'], + $array['range_new_count'], + array_map(static fn (array $line): DiffLine => new DiffLine(self::lineType($line[0]), $line[1]), $array['lines']), + ); + } + + /** + * @throws InvalidArgumentException $type is none of the LINE_* constants + */ + private static function lineType(int $type): DiffLineType + { + return DiffLineType::tryFrom($type) ?? throw new InvalidArgumentException(\sprintf('Unknown type of line %d: the types are FileChange::LINE_ADD, LINE_CONTEXT and LINE_REMOVE.', $type)); + } + + private function count(DiffLineType $type): int + { + return \count(array_filter($this->lines, static fn (DiffLine $line): bool => $line->getType() === $type)); } } diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php new file mode 100644 index 00000000..773555f9 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -0,0 +1,164 @@ + + * (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 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 + { + $this->mustRun('init', ['-q', $path]); + } + + public function initBare(string $path): void + { + $this->mustRun('init', ['-q', '--bare', $path]); + } + + public function cloneFrom(string $url, string $path, ?string $branch): void + { + $this->cloneRepository($path, $url, null === $branch ? [] : ['--branch', $branch]); + } + + public function cloneBareFrom(string $url, string $path, ?string $branch): void + { + $this->cloneRepository($path, $url, null === $branch ? ['--bare'] : ['--branch', $branch, '--bare']); + } + + public function mirrorFrom(string $url, string $path): void + { + $this->cloneRepository($path, $url, ['--mirror']); + } + + public function isValidRepository(string $url): bool + { + return $this->runner()->run('ls-remote', [self::absoluteUrl($url)])->isSuccessful(); + } + + public function isValidRepositoryAndBranch(string $url, string $branch): bool + { + // git lists the branches whose name ends with the pattern: "release" matches "feature/release". + $process = $this->runner()->run('ls-remote', ['--heads', self::absoluteUrl($url), 'refs/heads/'.$branch]); + if (!$process->isSuccessful()) { + return false; + } + + foreach (explode("\n", $process->getOutput()) as $line) { + if (str_ends_with($line, "\trefs/heads/".$branch)) { + return true; + } + } + + return false; + } + + /** + * @internal For the deprecated Admin::cloneRepository(), to be removed in 3.0 + * + * @param string[] $args Arguments of ``git clone`` + * + * @throws ProcessException + */ + public function cloneRepository(string $path, string $url, array $args): void + { + $this->mustRun('clone', ['-q', ...$args, $url, $path]); + } + + /** + * @internal For the deprecated Repository::getLogger(), to be removed in 3.0 + */ + public function getLogger(): ?LoggerInterface + { + return $this->logger; + } + + /** + * @internal For the deprecated Repository::setLogger(), to be removed in 3.0 + */ + public function withLogger(LoggerInterface $logger): self + { + return new self($this->command, $this->environmentVariables, $this->processTimeout, $logger); + } + + private function runner(): GitRunner + { + return new GitRunner($this->command, $this->environmentVariables, $this->processTimeout, $this->logger); + } + + /** + * @param string[] $args + */ + private function mustRun(string $command, array $args): void + { + $process = $this->runner()->run($command, $args); + + if (!$process->isSuccessful()) { + throw new ProcessException($process); + } + } + + /** + * Run in a repository, ls-remote reads a relative path as the name of a + * remote first: "origin" would be the URL of the remote of PHP's working + * directory. + */ + private static function absoluteUrl(string $url): string + { + if (str_starts_with($url, '/') || 1 === preg_match('#^[a-zA-Z]:[\\\/]#', $url)) { + return $url; + } + + // Like git: a URL, or an address like "host:path", has a colon before any slash. + $colon = strpos($url, ':'); + $slash = strpos($url, '/'); + if (false !== $colon && (false === $slash || $colon < $slash)) { + return $url; + } + + $cwd = getcwd(); + + return false === $cwd ? $url : $cwd.'/'.$url; + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php new file mode 100644 index 00000000..2d0a98e7 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -0,0 +1,434 @@ + + * (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\ProcessException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\FastForward; +use Gitonomy\Git\Parser\DiffParser; +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 +{ + // -z: the raw information gives the names as they are, without quoting. + // --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". + private const array DIFF_ARGS = ['-r', '-p', '--raw', '-z', '-M', '--full-index', '--no-abbrev']; + + /** + * 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); + } + + return trim($process->getOutput()); + } + + public function resolveRevision(string $revision): string + { + $process = $this->execute('rev-parse', ['--verify', '--quiet', $revision]); + + // --quiet: git exits with 1, without output, when nothing has this name. + if (1 === $process->getExitCode() && '' === $process->getOutput()) { + throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist.', $revision)); + } + + if (!$process->isSuccessful()) { + throw new ProcessException($process); + } + + return trim($process->getOutput()); + } + + public function getShortHash(string $hash): string + { + return trim($this->runOnObject($hash, null, 'log', ['--abbrev-commit', '--format=%h', '-n', '1', $hash])); + } + + public function getCommitData(string $hash): CommitData + { + $parser = new LogParser(); + $parser->parse($this->runOnObject($hash, ObjectType::Commit, 'log', ['--encoding=UTF-8', '--format=raw', '-n', '1', $hash])); + + $commit = $parser->commits[0] ?? null; + // git log peels a tag to its commit. + if (null === $commit || !str_starts_with($commit->hash, $hash)) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a commit.', $hash)); + } + + return $commit; + } + + public function getCommitLog(?array $includes, array $excludes, array $paths, int $offset, ?int $limit): array + { + if ([] === $includes) { + return []; + } + + // Re-encodes the commits whose "encoding" header isn't UTF-8. + $args = ['--encoding=UTF-8', '--format=raw', '--skip='.$offset]; + + if (null !== $limit) { + $args[] = '-n'; + $args[] = (string) $limit; + } + + $parser = new LogParser(); + $parser->parse($this->run('log', [...$args, ...self::revisions($includes, $excludes), '--', ...$paths])); + + return $parser->commits; + } + + public function countCommits(?array $includes, array $excludes, array $paths): int + { + if ([] === $includes) { + return 0; + } + + return (int) $this->run('rev-list', ['--count', ...self::revisions($includes, $excludes), '--', ...$paths]); + } + + public function getMergeBases(string $hash1, string $hash2): array + { + $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); + } + + 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); + } + + $parser = new ReferenceParser(); + $parser->parse($process->getOutput()); + + return $parser->references; + } + + public function createReference(string $fullname, string $hash): void + { + // Zeros as the old value: git refuses to update a reference that exists. + $this->run('update-ref', [$fullname, $hash, str_repeat('0', 40)]); + } + + public function updateReference(string $fullname, string $hash): void + { + $this->run('update-ref', [$fullname, $hash]); + } + + public function deleteReference(string $fullname): void + { + $this->run('update-ref', ['-d', $fullname]); + } + + public function getIncludingBranches(string $hash): array + { + // for-each-ref only accepts --contains since git 2.7. + return [ + ...$this->listBranches(['--contains', $hash], 'refs/heads/'), + ...$this->listBranches(['--remotes', '--contains', $hash], 'refs/remotes/'), + ]; + } + + public function getDiff(?string $from, string $to): DiffData + { + return $this->diff('diff-tree', [$from ?? self::EMPTY_TREE, $to]); + } + + public function getWorkingTreeDiff(string $hash): DiffData + { + 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 + { + $args = ['-p']; + 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 + { + $this->run('checkout', [$revision]); + } + + public function checkoutNewBranch(string $branch, string $startHash): void + { + $this->run('checkout', ['-b', $branch, $startHash]); + } + + public function merge(string $revision, FastForward $fastForward): void + { + $option = match ($fastForward) { + FastForward::Allow => '--ff', + FastForward::Never => '--no-ff', + FastForward::Only => '--ff-only', + }; + + $this->run('merge', [$option, $revision]); + } + + /** + * Runs any git command, for what no driver method covers. + * + * @param array $args + * @param string|null $input The standard input of git + * + * @throws ProcessException The command failed + */ + public function runProcess(string $command, array $args = [], ?string $input = null): Process + { + $process = $this->execute($command, array_map(strval(...), $args), $input); + + if (!$process->isSuccessful()) { + throw new ProcessException($process); + } + + return $process; + } + + /** + * @param string[] $args + * + * @throws ProcessException + */ + private function run(string $command, array $args): string + { + return $this->runProcess($command, $args)->getOutput(); + } + + /** + * @param string[] $args + */ + private function diff(string $command, array $args): DiffData + { + $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; + } + } + + /** + * @param list|null $includes + * @param list $excludes + * + * @return list + */ + private static function revisions(?array $includes, array $excludes): array + { + return [...$includes ?? ['--all'], ...array_map(static fn (string $hash): string => '^'.$hash, $excludes)]; + } + + /** + * @param string[] $args Arguments of ``git branch`` + * + * @return string[] The full names of the branches git lists + */ + private function listBranches(array $args, string $prefix): array + { + $branches = []; + foreach (explode("\n", rtrim($this->run('branch', ['--no-color', ...$args]), "\n")) as $line) { + // "* " flags the current branch, "(HEAD detached at ...)" a detached + // HEAD, and "origin/HEAD -> origin/main" a symbolic reference. + $name = substr($line, 2); + if ('' === $name || str_starts_with($name, '(') || str_contains($name, ' -> ')) { + continue; + } + + $branches[] = $prefix.$name; + } + + return $branches; + } + + /** + * @param string[] $args + */ + private function execute(string $command, array $args, ?string $input = null): Process + { + $globalOptions = ['--git-dir', $this->gitDir]; + if (null !== $this->workingDir) { + $globalOptions = [...$globalOptions, '--work-tree', $this->workingDir]; + } + + // Relative paths, in arguments as in pathspecs, resolve against the repository. + return $this->runner->run($command, $args, $globalOptions, $this->workingDir ?? $this->gitDir, $input); + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/GitRunner.php b/src/Gitonomy/Git/Driver/Cli/GitRunner.php new file mode 100644 index 00000000..9637221f --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/GitRunner.php @@ -0,0 +1,94 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli; + +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ProcessTimedOutException; +use Gitonomy\Git\Util\NulByte; +use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException; +use Symfony\Component\Process\Exception\RuntimeException as SymfonyRuntimeException; +use Symfony\Component\Process\Process; + +/** + * Runs the git processes, as configured on CliDriver, and logs them. + * + * @author Grégoire Pineau + * + * @internal + */ +final readonly class GitRunner +{ + /** + * @param array $environmentVariables + */ + public function __construct( + private string $command, + private array $environmentVariables, + private ?float $timeout, + private ?LoggerInterface $logger, + ) { + } + + /** + * Runs a git command, successful or not. + * + * @param string[] $args Arguments of $command + * @param string[] $globalOptions Options of git itself, before $command (--git-dir...) + * @param string|null $input The standard input of git + * + * @throws InvalidArgumentException An argument contains a NUL byte + * @throws ProcessException The git binary can't be run + */ + public function run(string $command, array $args, array $globalOptions = [], ?string $workingDirectory = null, ?string $input = null): Process + { + NulByte::assertNone(...$globalOptions, ...$args); + + $process = new Process([$this->command, ...$globalOptions, $command, ...$args], $workingDirectory); + $process->setEnv($this->environmentVariables); + $process->setInput($input); + + $process->setTimeout($this->timeout); + $process->setIdleTimeout($this->timeout); + + $this->logger?->info(\sprintf('run command: %s "%s" ', $command, implode(' ', $args))); + $before = microtime(true); + + try { + $process->run(); + } catch (SymfonyProcessTimedOutException $e) { + throw new ProcessTimedOutException(\sprintf('The git command "%s" ran longer than %s seconds.', $command, $this->timeout), 0, $e); + } catch (SymfonyRuntimeException $e) { + // A working directory removed since the repository was opened... + throw new DriverException(\sprintf('Unable to run the git command "%s": %s', $command, $e->getMessage()), 0, $e); + } + + // Symfony Process runs git through a shell, which exits with them when it can't find or run git. + if (\in_array($process->getExitCode(), [126, 127], true)) { + throw new ProcessException($process); + } + + if (null !== $this->logger) { + $this->logger->debug(\sprintf('last command (%s) duration: %.2fms', $command, (microtime(true) - $before) * 1000)); + $this->logger->debug(\sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); + $this->logger->debug(\sprintf('last command (%s) output: %s', $command, $process->getOutput())); + if (!$process->isSuccessful()) { + $this->logger->debug(\sprintf('last command (%s) error output: %s', $command, $process->getErrorOutput())); + } + } + + return $process; + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php b/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php new file mode 100644 index 00000000..5c9bb75d --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/LegacyOptions.php @@ -0,0 +1,111 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Cli; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Psr\Log\LoggerInterface; + +/** + * The options of Repository and Admin in 1.x, deprecated in favor of the + * "workingDir" argument of Repository, and of the arguments of CliDriver. + * + * @author Grégoire Pineau + * + * @internal To be removed in 3.0, with these options + * + * @phpstan-type Options array{command?: string, debug?: bool, environment_variables?: array, inherit_environment_variables?: bool, logger?: LoggerInterface|null, process_timeout?: float|null, working_dir?: string|null} + */ +final readonly class LegacyOptions +{ + /** + * The options configuring CliDriver. + */ + private const array DRIVER_OPTIONS = ['command', 'environment_variables', 'inherit_environment_variables', 'logger', 'process_timeout']; + + public ?string $workingDir; + + /** + * @var Options + */ + private array $options; + + /** + * @param Options $options + * + * @throws InvalidArgumentException An unknown option, or the "debug" one set to false + */ + public function __construct(array $options) + { + if (\array_key_exists('debug', $options) && !$options['debug']) { + throw new InvalidArgumentException('The "debug" option set to false is not supported anymore: a failing git operation always throws, catch its exception instead.'); + } + + $unknownOptions = array_diff(array_keys($options), [...self::DRIVER_OPTIONS, 'debug', 'working_dir']); + if ([] !== $unknownOptions) { + throw new InvalidArgumentException(\sprintf('Unknown options "%s".', implode('", "', $unknownOptions))); + } + + // PHP doesn't check the types of the options, unlike PHPStan. + if (null !== ($options['logger'] ?? null) && !$options['logger'] instanceof LoggerInterface) { // @phpstan-ignore instanceof.alwaysTrue, booleanAnd.alwaysFalse + throw new InvalidArgumentException(\sprintf('The "logger" option must be a Psr\Log\LoggerInterface, "%s" given.', get_debug_type($options['logger']))); + } + + $this->workingDir = $options['working_dir'] ?? null; + $this->options = $options; + } + + /** + * The CliDriver running the git processes of a repository, null when no option configures one. + * + * Like in 1.x, "inherit_environment_variables" also gives them the entries of $_SERVER. + */ + public function repositoryDriver(): ?CliDriver + { + $environmentVariables = $this->options['environment_variables'] ?? []; + if ($this->options['inherit_environment_variables'] ?? false) { + // $_SERVER also holds entries that aren't environment variables (argv, REQUEST_TIME...). + $environmentVariables = array_replace(array_filter($_SERVER, \is_string(...)), $environmentVariables); + } + + return $this->driver($environmentVariables); + } + + /** + * The CliDriver running the commands of Admin (init, clone, ls-remote), null when no option configures one. + * + * Like in 1.x, it ignores "inherit_environment_variables": these commands + * reach remotes, and $_SERVER holds the credentials of an HTTP request. + */ + public function adminDriver(): ?CliDriver + { + return $this->driver($this->options['environment_variables'] ?? []); + } + + /** + * @param array $environmentVariables + */ + private function driver(array $environmentVariables): ?CliDriver + { + if ([] === array_intersect(array_keys($this->options), self::DRIVER_OPTIONS)) { + return null; + } + + return new CliDriver( + $this->options['command'] ?? 'git', + $environmentVariables, + // null disables the timeout. + \array_key_exists('process_timeout', $this->options) ? $this->options['process_timeout'] : 3600, + $this->options['logger'] ?? null, + ); + } +} diff --git a/src/Gitonomy/Git/Parser/BlameParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php similarity index 66% rename from src/Gitonomy/Git/Parser/BlameParser.php rename to src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php index 1dd35d83..20281f45 100644 --- a/src/Gitonomy/Git/Parser/BlameParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php @@ -10,41 +10,40 @@ * with this source code in the file LICENSE. */ -namespace Gitonomy\Git\Parser; +namespace Gitonomy\Git\Driver\Cli\Parser; -use Gitonomy\Git\Blame\Line; -use Gitonomy\Git\Repository; +use Gitonomy\Git\Driver\Data\BlameLineData; +use Gitonomy\Git\Parser\ParserBase; +/** + * Parses the output of ``git blame -p``. + */ final class BlameParser extends ParserBase { /** - * @var Line[] + * @var BlameLineData[] */ public array $lines = []; - public function __construct( - protected readonly Repository $repository, - ) { - } + /** + * @var array + */ + private array $seenHashes = []; protected function doParse(): void { $this->lines = []; + $this->seenHashes = []; - $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 = $vars[4] ?? null; + // The number of lines of the group follows on its first line. + $vars = $this->consumeRegexp('/(\d+) (\d+)/A'); $this->consumeTo("\n"); $this->consumeNewLine(); - if (!isset($memory[$hash])) { + if (!isset($this->seenHashes[$hash])) { foreach (['author', 'author-mail', 'author-time', 'author-tz', 'committer', 'committer-mail', 'committer-time', 'committer-tz', 'summary', ] as $key) { @@ -64,13 +63,13 @@ protected function doParse(): void $this->consume('filename'); $this->consumeTo("\n"); // filename $this->consumeNewLine(); - $memory[$hash] = $this->repository->getCommit($hash); + $this->seenHashes[$hash] = true; } - $content = $this->consumeTo("\n"); // content of line + $this->consume("\t"); + $content = $this->consumeTo("\n"); $this->consumeNewLine(); - $this->lines[$line] = new Line($memory[$hash], $sourceLine, $targetLine, $blockLine, $content); - ++$line; + $this->lines[] = new BlameLineData($hash, (int) $vars[1], (int) $vars[2], $content); } } } diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php new file mode 100644 index 00000000..ac729fd0 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php @@ -0,0 +1,93 @@ + + * (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``. + */ +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::parseNameEmailDate($value), + 'committer' => $committer = self::parseNameEmailDate($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); + } + } + + /** + * @return array{string, string, \DateTimeImmutable} + */ + private static function parseNameEmailDate(string $value): array + { + if (!preg_match('/^(.*) <(.*)> (\d+) [+-]\d{4}$/', $value, $vars)) { + throw new RuntimeException(\sprintf('Unable to parse the name, email and date of "%s".', $value)); + } + + return [$vars[1], $vars[2], self::timestamp($vars[3])]; + } +} 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..16e5a0a7 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/Parser/ReferenceParser.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 Gitonomy\Git\Driver\Cli\Parser; + +use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Parser\ParserBase; + +/** + * Parses `` `` lines. + */ +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..bd14f349 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TagParser.php @@ -0,0 +1,53 @@ + + * (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\TagData; +use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Parser\ParserBase; + +/** + * Parses a tag object, as ``git cat-file --batch`` gives it. + */ +final class TagParser extends ParserBase +{ + public TagData $data; + + protected function doParse(): void + { + $this->consume('object '); + $object = $this->consumeHash(); + $this->consumeNewLine(); + + $this->consume('type '); + $typeName = $this->consumeTo("\n"); + $type = ObjectType::tryFrom($typeName) ?? throw new RuntimeException(\sprintf('The tag points to an object of the unknown type "%s".', $typeName)); + $this->consumeNewLine(); + + $this->consume('tag '); + $tag = $this->consumeTo("\n"); + $this->consumeNewLine(); + + // The 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($object, $type, $tag, $taggerName, $taggerEmail, $taggerDate, $this->consumeAll()); + } +} diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php new file mode 100644 index 00000000..54c769f9 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Cli/Parser/TreeParser.php @@ -0,0 +1,59 @@ + + * (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. + */ +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..31ed9ce7 --- /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 $originalLine The number of the line in the file of $commitHash + * @param int $finalLine The number of the line in the blamed file + */ + public function __construct( + public string $commitHash, + public int $originalLine, + public int $finalLine, + public string $content, + ) { + } +} diff --git a/src/Gitonomy/Git/Driver/Data/CommitData.php b/src/Gitonomy/Git/Driver/Data/CommitData.php new file mode 100644 index 00000000..f71aef3f --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/CommitData.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\Driver\Data; + +/** + * Raw commit data, as read by a Driver. Repository-agnostic: does not resolve + * parents/tree into objects, that's Commit's job. + * + * @author Grégoire Pineau + */ +final readonly class CommitData +{ + /** + * @param string[] $parentHashes + */ + public function __construct( + public string $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..4ce29b66 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/DiffFileData.php @@ -0,0 +1,55 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Data; + +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * A file of a diff, as computed by a Driver. + * + * Its changes are FileChange objects already: unlike a file, they need no + * repository. + * + * @author Grégoire Pineau + */ +final readonly class DiffFileData +{ + /** + * The new name, or the old one for a deletion. + */ + public string $name; + + /** + * @param string|null $oldName Null for a creation + * @param string|null $newName Null for a deletion + * @param string|null $oldIndex The hash of the old blob, null when there is none + * @param string|null $newIndex The hash of the new blob, null when there is none, or for a file of the working tree + * @param list $changes + * + * @throws InvalidArgumentException Both names are null + */ + public function __construct( + public ?string $oldName, + public ?string $newName, + public ?string $oldMode, + public ?string $newMode, + public ?string $oldIndex, + public ?string $newIndex, + public bool $isBinary, + public bool $isCopy, + public array $changes, + ) { + $this->name = $newName ?? $oldName ?? throw new InvalidArgumentException('A file of a diff has an old name, a new name, or both.'); + } +} diff --git a/src/Gitonomy/Git/Driver/Data/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..03b5983f --- /dev/null +++ b/src/Gitonomy/Git/Driver/Data/TagData.php @@ -0,0 +1,53 @@ + + * (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 + */ + public function __construct( + public string $objectHash, + public ObjectType $objectType, + public string $tagName, + 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 $objectHash, ObjectType $objectType, string $tagName, ?string $taggerName, ?string $taggerEmail, ?\DateTimeImmutable $taggerDate, string $signedMessage): self + { + if (!preg_match('/^(.*?)'.self::SIGNATURE.'(.*?)-----END \2-----/s', $signedMessage, $vars)) { + return new self($objectHash, $objectType, $tagName, $taggerName, $taggerEmail, $taggerDate, $signedMessage, null); + } + + return new self($objectHash, $objectType, $tagName, $taggerName, $taggerEmail, $taggerDate, $vars[1], $vars[3]); + } +} diff --git a/src/Gitonomy/Git/Driver/Data/TreeEntryData.php b/src/Gitonomy/Git/Driver/Data/TreeEntryData.php 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..ea5c76cb --- /dev/null +++ b/src/Gitonomy/Git/Driver/DriverInterface.php @@ -0,0 +1,95 @@ + + * (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 an {@see InvalidArgumentException} when an argument + * contains a NUL byte, which git doesn't accept. + * + * @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 + */ + 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 DriverException + */ + 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 DriverException + */ + public function cloneBareFrom(string $url, string $path, ?string $branch): void; + + /** + * Clones every reference of $url into a bare repository in $path, set up + * to mirror $url when fetching. + * + * @throws DriverException + */ + public function mirrorFrom(string $url, string $path): void; + + /** + * @param string $url A URL, or the path of a repository: never the name of a remote + * + * @return bool Whether $url can be reached + */ + public function isValidRepository(string $url): bool; + + /** + * @param string $url Same as {@see isValidRepository()} + * @param string $branch The name of the branch, exactly: "release" doesn't name "feature/release" + */ + public function isValidRepositoryAndBranch(string $url, string $branch): bool; +} diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php new file mode 100644 index 00000000..ad8a5704 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php @@ -0,0 +1,246 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver\Ffi; + +use FFI\CData; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\UnsupportedOperationException; + +/** + * Loads libgit2, and helps calling it. + * + * @author Grégoire Pineau + * + * @internal + */ +final class LibGit2 +{ + public const int GIT_ENOTFOUND = -3; + + private const int GIT_FEATURE_SHA256 = 1 << 11; + + /** + * The only libgit2 version libgit2.h is written for: libgit2 changes its + * struct layouts on every minor release, so loading any other version + * 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, string $context): void + { + if ($rc < 0) { + throw new DriverException(\sprintf('%s: %s', $context, self::lastErrorMessage($ffi))); + } + } + + /** + * A PHP string can't be stored in a `char *` field: copy it into a C + * buffer, to keep alive as long as the field is in use. + */ + public static function cString(\FFI $ffi, string $value): CData + { + $buffer = $ffi->new('char['.(\strlen($value) + 1).']'); + \FFI::memcpy($buffer, $value, \strlen($value)); + + return $buffer; + } + + public static function oidToHex(\FFI $ffi, CData $oid): string + { + $buffer = $ffi->new('char[41]'); + $ffi->git_oid_tostr($buffer, 41, $oid); + + return \FFI::string($buffer); + } + + private static function doLoad(?string $libraryPath): \FFI + { + self::assertFfiIsAvailable(); + + $ffi = self::preloaded($libraryPath) ?? self::define($libraryPath ?? self::locate()); + // Only counts one more user once libgit2 is initialized. + self::initialize($ffi); + + return $ffi; + } + + /** + * @return \FFI|null What preload() loaded for $libraryPath, if it was called + */ + private static function preloaded(?string $libraryPath): ?\FFI + { + try { + return \FFI::scope(self::scope($libraryPath)); + } catch (\FFI\Exception) { + return null; + } + } + + private static function define(string $libraryPath): \FFI + { + self::assertSupportedVersion($libraryPath); + + try { + return \FFI::cdef(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))); + } + } + + 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..ef69d86e --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -0,0 +1,447 @@ + + * (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\NulByte; + +/** + * 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(string $url, string $path, ?string $branch): void + { + NulByte::assertNone($url, $path, $branch ?? ''); + + // git_clone() only checks a branch out, where git also checks a tag out, HEAD detached. + $tag = null !== $branch && $this->isTagOnly($url, $branch) ? $branch : null; + + $options = $this->ffi->new('git_clone_options'); + LibGit2::check($this->ffi, $this->ffi->git_clone_options_init(\FFI::addr($options), 1), 'Unable to initialize clone options'); + // Like git, through the proxy that http.proxy or the environment configures. + $options->fetch_opts->proxy_opts->type = self::GIT_PROXY_AUTO; + + // The buffer must stay alive until git_clone() returns. + $branchBuffer = null; + if (null !== $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(string $url, string $path, ?string $branch): void + { + NulByte::assertNone($url, $path, $branch ?? ''); + + // Like git, only the branches (and the tags). + $this->fetchInto($path, $url, '+refs/heads/*:refs/heads/*', $branch, ['remote.origin.url' => $url]); + } + + public function mirrorFrom(string $url, string $path): void + { + NulByte::assertNone($url, $path); + + $this->fetchInto($path, $url, '+refs/*:refs/*', null, [ + 'remote.origin.url' => $url, + 'remote.origin.fetch' => '+refs/*:refs/*', + 'remote.origin.mirror' => 'true', + 'remote.origin.tagopt' => '--no-tags', + ]); + } + + public function isValidRepository(string $url): bool + { + NulByte::assertNone($url); + + return null !== $this->listRemoteReferences($url); + } + + public function isValidRepositoryAndBranch(string $url, string $branch): bool + { + NulByte::assertNone($url, $branch); + + return \in_array('refs/heads/'.$branch, $this->listRemoteReferences($url) ?? [], true); + } + + 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, string $url, string $refspec, ?string $branch, array $config): void + { + // Like git, which refuses to clone into a directory with files. + $existing = is_dir($path); + if ($existing && [] !== array_diff(scandir($path) ?: [], ['.', '..'])) { + throw new DriverException(\sprintf('Unable to clone "%s": "%s" exists and is not an empty directory.', $url, $path)); + } + + $repository = $this->initRepository($path, true); + + try { + try { + $this->fetch($repository, $url, $refspec, $branch); + $this->setConfig($repository, $config); + } finally { + $this->ffi->git_repository_free($repository); + } + } catch (DriverException $e) { + // Like git, which removes what it created when a clone fails. + self::remove($path, $existing); + + throw $e; + } + + self::removeFetchHead($path); + } + + /** + * libgit2 writes an empty FETCH_HEAD, even when told not to, where a clone with git writes none. + */ + private static function removeFetchHead(string $gitDir): void + { + if (is_file($gitDir.'/FETCH_HEAD')) { + unlink($gitDir.'/FETCH_HEAD'); + } + } + + /** + * @return bool Whether $url has a tag named $name, and no branch of this name + */ + private function isTagOnly(string $url, string $name): bool + { + $references = $this->listRemoteReferences($url) ?? []; + + return !\in_array('refs/heads/'.$name, $references, true) && \in_array('refs/tags/'.$name, $references, true); + } + + /** + * Checks the commit of the tag $tag out, HEAD detached, and removes the + * local branch git_clone() created: git creates none. + */ + private function checkOutTag(CData $repository, string $tag): void + { + $head = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_lookup(\FFI::addr($head), $repository, 'HEAD'), 'Unable to read HEAD'); + + try { + $branch = $this->ffi->git_reference_type($head) === $this->ffi->GIT_REFERENCE_SYMBOLIC ? $this->ffi->git_reference_symbolic_target($head) : null; + } finally { + $this->ffi->git_reference_free($head); + } + + $commit = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_revparse_single(\FFI::addr($commit), $repository, 'refs/tags/'.$tag.'^{commit}'), \sprintf('Unable to resolve the tag "%s"', $tag)); + + try { + $options = $this->ffi->new('git_checkout_options'); + LibGit2::check($this->ffi, $this->ffi->git_checkout_options_init(\FFI::addr($options), 1), 'Unable to initialize checkout options'); + // The clone wrote no file. + $options->checkout_strategy = self::GIT_CHECKOUT_FORCE; + LibGit2::check($this->ffi, $this->ffi->git_checkout_tree($repository, $commit, \FFI::addr($options)), \sprintf('Unable to checkout the tag "%s"', $tag)); + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($repository, $this->ffi->git_object_id($commit)), 'Unable to detach HEAD'); + } finally { + $this->ffi->git_object_free($commit); + } + + if (null === $branch) { + return; + } + + $reference = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($reference), $repository, $branch); + // An empty remote has no default branch. + if (LibGit2::GIT_ENOTFOUND === $rc) { + return; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up "%s"', $branch)); + + try { + LibGit2::check($this->ffi, $this->ffi->git_branch_delete($reference), \sprintf('Unable to delete "%s"', $branch)); + } finally { + $this->ffi->git_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, 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, string $url): void + { + $commit = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_revparse_single(\FFI::addr($commit), $repository, 'refs/tags/'.$tag.'^{commit}'); + if (LibGit2::GIT_ENOTFOUND === $rc) { + throw new DriverException(\sprintf('Remote branch "%s" not found in "%s".', $tag, $url)); + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve the tag "%s"', $tag)); + + try { + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($repository, $this->ffi->git_object_id($commit)), 'Unable to detach HEAD'); + } finally { + $this->ffi->git_object_free($commit); + } + } + + /** + * @return string|null The full name of the branch HEAD points to on the remote, or null for an empty repository + */ + 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, array $config): void + { + $handle = $this->ffi->new('git_config*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_config(\FFI::addr($handle), $repository), 'Unable to open the repository configuration'); + + try { + foreach ($config as $name => $value) { + LibGit2::check($this->ffi, $this->ffi->git_config_set_string($handle, $name, $value), \sprintf('Unable to set "%s"', $name)); + } + } finally { + $this->ffi->git_config_free($handle); + } + } + + /** + * @return string[]|null The full names of the references of $url, or null when it can't be reached + */ + private function listRemoteReferences(string $url): ?array + { + $remote = $this->ffi->new('git_remote*'); + if ($this->ffi->git_remote_create_detached(\FFI::addr($remote), $url) < 0) { + return null; + } + + try { + $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..f6dfcea7 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -0,0 +1,1616 @@ + + * (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\FastForward; +use Gitonomy\Git\Util\NulByte; +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_STATUS_OPT_INCLUDE_UNTRACKED = 1 << 0; + private const int GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS = 1 << 4; + private const int GIT_STATUS_WT_NEW = 1 << 7; + + private CData $repo; + + /** + * @param string $gitDir The git directory + * @param string|null $workingDir The working tree, null for a bare repository + */ + public function __construct( + private \FFI $ffi, + private string $gitDir, + private ?string $workingDir, + ) { + $out = $this->ffi->new('git_repository*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_open(\FFI::addr($out), $gitDir), \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); + + $object = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_revparse_single(\FFI::addr($object), $this->repo, $revision); + if (\in_array($rc, [LibGit2::GIT_ENOTFOUND, self::GIT_EAMBIGUOUS, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { + throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist.', $revision)); + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve "%s"', $revision)); + + try { + return $this->oidToHex($this->ffi->git_object_id($object)); + } finally { + $this->ffi->git_object_free($object); + } + } + + public function getCommitData(string $hash): CommitData + { + NulByte::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); + + $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); + + return iterator_count($this->walk($includes, $excludes, $paths)); + } + + public function getMergeBases(string $hash1, string $hash2): array + { + NulByte::assertNone($hash1, $hash2); + + $oneOid = $this->hexToOid($hash1); + $twoOid = $this->hexToOid($hash2); + $bases = $this->ffi->new('git_oidarray'); + $rc = $this->ffi->git_merge_bases(\FFI::addr($bases), $this->repo, \FFI::addr($oneOid), \FFI::addr($twoOid)); + if (LibGit2::GIT_ENOTFOUND === $rc) { + return []; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to compute the merge bases of "%s" and "%s"', $hash1, $hash2)); + + try { + $hashes = []; + for ($i = 0; $i < $bases->count; ++$i) { + $hashes[] = $this->oidToHex(\FFI::addr($bases->ids[$i])); + } + + return $hashes; + } finally { + $this->ffi->git_oidarray_dispose(\FFI::addr($bases)); + } + } + + public function getShortHash(string $hash): string + { + NulByte::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( + $this->oidToHex($this->ffi->git_tag_target_id($tag)), + $this->objectType($this->ffi->git_tag_target_type($tag)), + $this->ffi->git_tag_name($tag), + null === $tagger ? null : \FFI::string($tagger->name), + null === $tagger ? null : \FFI::string($tagger->email), + null === $tagger ? null : $this->signatureDate($tagger), + $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); + // Only a tag may point to an annotated tag. + $commitHash = str_starts_with($fullname, 'refs/tags/') ? $this->peeledHex($ref) : $hash; + $result[$fullname] = new ReferenceData($fullname, $commitHash, $commitHash === $hash ? null : $hash); + } + + // Like git, sorted by name: libgit2 lists the loose references first. + ksort($result, \SORT_STRING); + + return array_values($result); + } + + public function createReference(string $fullname, string $hash): void + { + NulByte::assertNone($fullname, $hash); + + $oid = $this->hexToOid($hash); + $ref = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_create(\FFI::addr($ref), $this->repo, $fullname, \FFI::addr($oid), 0, null), \sprintf('Unable to create reference "%s"', $fullname)); + $this->ffi->git_reference_free($ref); + } + + public function updateReference(string $fullname, string $hash): void + { + NulByte::assertNone($fullname, $hash); + + $oid = $this->hexToOid($hash); + $ref = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_reference_create(\FFI::addr($ref), $this->repo, $fullname, \FFI::addr($oid), 1, null), \sprintf('Unable to update reference "%s"', $fullname)); + $this->ffi->git_reference_free($ref); + } + + public function deleteReference(string $fullname): void + { + NulByte::assertNone($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); + + $tips = []; + foreach ($this->iterateBranches() as [$fullname, $ref]) { + $tips[$fullname] = $this->oidToHex($this->ffi->git_reference_target($ref)); + } + // Like git: the local branches, then the remote ones, sorted by name. + ksort($tips, \SORT_STRING); + + // One walk from every branch down to $hash, instead of a merge base + // per branch: parents first, a commit reaches $hash when one of its + // 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); + + $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); + + $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); + $files = $this->buildDiff($diff)->files; + + if ([] !== $unmerged) { + // Like git diff , which compares the unmerged files, conflict markers included, to the commit. + $unmergedDiff = $this->ffi->new('git_diff*'); + $opts = $this->diffOptions(); + // Keeps the C strings of the pathspec alive until the diff is computed. + $pathspec = $this->pathspec($opts, $unmerged); + $opts->flags |= self::GIT_DIFF_DISABLE_PATHSPEC_MATCH; + LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_workdir(\FFI::addr($unmergedDiff), $this->repo, $tree, \FFI::addr($opts)), 'Unable to compute the diff of the unmerged files'); + + $files = [...$files, ...$this->buildDiff($unmergedDiff)->files]; + // Sorted by name, like git. + usort($files, static fn (DiffFileData $a, DiffFileData $b): int => strcmp($a->name, $b->name)); + } + } finally { + $this->ffi->git_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'); + + return $this->buildDiff($diff); + } + + 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); + } + } + + return $this->buildDiff($diff); + } + + public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array + { + NulByte::assertNone($hash, $path); + + $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); + + if (null === $hunk || null === $line) { + // git_blame_linecount() can overcount by one for files not + // ending in a newline; stop rather than dereference NULL. + // (A NULL `const git_blame_line *` return is auto-converted + // to PHP null by FFI, unlike a NULL struct-field pointer.) + break; + } + + $content = rtrim(\FFI::string($line->ptr, $line->len), "\n"); + $originalLine = $hunk->orig_start_line_number + ($i - $hunk->final_start_line_number); + + $result[] = new BlameLineData($this->oidToHex(\FFI::addr($hunk->final_commit_id)), $originalLine, $i, $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); + + // `git checkout ` attaches HEAD when names a + // local branch, before a tag of the same name, and detaches it for + // anything else (a tag, a hash, a remote branch, a full reference name...). + $branch = $this->referenceExists('refs/heads/'.$revision) ? 'refs/heads/'.$revision : null; + // Resolved before changing anything. + $hash = $this->resolveCommit($branch ?? $revision); + + $this->checkoutRevision($hash); + + if (null !== $branch) { + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, $branch), 'Unable to update HEAD'); + } else { + $oid = $this->hexToOid($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 $startHash): void + { + NulByte::assertNone($branch, $startHash); + + 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)); + } + $commit = $this->lookupCommit($this->resolveCommit($startHash)); + + try { + $this->checkoutRevision($startHash); + + $reference = $this->ffi->new('git_reference*'); + LibGit2::check($this->ffi, $this->ffi->git_branch_create(\FFI::addr($reference), $this->repo, $branch, $commit, 0), \sprintf('Unable to create branch "%s"', $branch)); + $this->ffi->git_reference_free($reference); + } finally { + $this->ffi->git_commit_free($commit); + } + + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, 'refs/heads/'.$branch), 'Unable to update HEAD'); + } + + public function merge(string $revision, FastForward $fastForward): void + { + NulByte::assertNone($revision); + + $branch = $this->getHeadReference(); + $headHash = $this->resolveCommit('HEAD'); + $theirHash = $this->resolveCommit($revision); + $head = $this->hexToOid($headHash); + $their = $this->hexToOid($theirHash); + + // Already up to date, even with --no-ff. + if ($headHash === $theirHash || $this->isDescendantOf($head, $their)) { + return; + } + + $canFastForward = $this->isDescendantOf($their, $head); + if (FastForward::Only === $fastForward && !$canFastForward) { + throw new DriverException(\sprintf('Unable to fast-forward to "%s".', $revision)); + } + + if (FastForward::Never !== $fastForward && $canFastForward) { + $target = $this->revparse($theirHash); + + try { + $this->checkoutObject($target, 'Unable to fast-forward'); + } finally { + $this->ffi->git_object_free($target); + } + $this->moveHead($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 + * @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 the references point to; like `git log --all`, ignores the references to other objects and the dangling ones + */ + private function referencedCommits(): array + { + $hashes = []; + foreach ($this->iterateReferences() as $ref) { + $commit = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_reference_peel(\FFI::addr($commit), $ref, $this->ffi->GIT_OBJECT_COMMIT); + if (\in_array($rc, [LibGit2::GIT_ENOTFOUND, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { + continue; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to peel "%s"', $this->ffi->git_reference_name($ref))); + + try { + $hashes[] = $this->oidToHex($this->ffi->git_object_id($commit)); + } finally { + $this->ffi->git_object_free($commit); + } + } + + return $hashes; + } + + /** + * @param string[] $paths + */ + private function pathFilter(array $paths): ?PathFilter + { + if ([] === $paths) { + return null; + } + + // Like git, "." and "./x" are relative to the root, which "" names. + $paths = array_map(fn (string $path): string => trim((string) preg_replace('#^\.(/|$)#', '', trim($this->toRepoRelativePath($path), '/')), '/'), $paths); + + return new PathFilter($this->ffi, $this->repo, array_values($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 also accepts the absolute paths of files in the working tree, where + * libgit2 only deals with paths relative to the repository. + * + * @throws DriverException $path is an absolute path outside the working tree + */ + private function toRepoRelativePath(string $path): string + { + if (!str_starts_with($path, '/')) { + return $path; + } + + if (null !== $this->workingDir && ($path === $this->workingDir || str_starts_with($path, $this->workingDir.'/'))) { + return substr($path, \strlen($this->workingDir) + 1); + } + + // Like git. + throw new DriverException(\sprintf('The path "%s" is outside the repository.', $path)); + } + + // -- 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; + } + + /** + * 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'); + + // libgit2 documents no patch for a binary or unchanged file, though + // 1.9 creates one: computing it flagged the delta accordingly. + if (\FFI::isNull($patch)) { + return $this->deltaToFile($this->ffi->git_diff_get_delta($diff, $index), []); + } + + try { + $changes = []; + $hunkCount = $this->ffi->git_patch_num_hunks($patch); + 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); + } + + /** + * 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 fmt-merge-msg). + */ + private function mergeMessage(string $revision, ?string $branch): string + { + $message = null; + // Same lookup order as git's revision parsing. + foreach ([ + 'refs/tags/' => "Merge tag '%s'", + 'refs/heads/' => "Merge branch '%s'", + 'refs/remotes/' => "Merge remote-tracking branch '%s'", + ] as $prefix => $format) { + $name = str_starts_with($revision, $prefix) ? substr($revision, \strlen($prefix)) : $revision; + if ($this->referenceExists($prefix.$name)) { + $message = \sprintf($format, $name); + + break; + } + } + $message ??= \sprintf("Merge commit '%s'", $revision); + + // git leaves out the destination when merging into main or master. + if (null === $branch) { + $message .= ' into HEAD'; + } elseif (!\in_array($branch, ['refs/heads/main', 'refs/heads/master'], true)) { + $message .= ' into '.substr($branch, \strlen('refs/heads/')); + } + + return $message."\n"; + } + + // -- references ---------------------------------------------------------- + + 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); + } + } + + private function isDescendantOf(CData $commit, CData $ancestor): bool + { + $rc = $this->ffi->git_graph_descendant_of($this->repo, \FFI::addr($commit), \FFI::addr($ancestor)); + LibGit2::check($this->ffi, $rc, 'Unable to compare commits'); + + return 1 === $rc; + } + + // -- objects ------------------------------------------------------------- + + private function revparse(string $spec): CData + { + $out = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_revparse_single(\FFI::addr($out), $this->repo, $spec), \sprintf('Unable to resolve "%s"', $spec)); + + return $out; + } + + /** + * Resolves a revision to a commit hash, peeling annotated tags. + */ + private function resolveCommit(string $revision): string + { + $object = $this->revparse($revision.'^{commit}'); + + try { + return $this->oidToHex($this->ffi->git_object_id($object)); + } finally { + $this->ffi->git_object_free($object); + } + } + + private function lookupCommit(string $hash): CData + { + $oid = $this->hexToOid($hash); + $out = $this->ffi->new('git_commit*'); + $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; + } + } + + private function signatureDate(CData $signature): \DateTimeImmutable + { + // Same timezone as CliDriver's parsers. + return new \DateTimeImmutable('@'.$signature->when->time)->setTimezone(new \DateTimeZone('UTC')); + } + + 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..309cc566 --- /dev/null +++ b/src/Gitonomy/Git/Driver/Ffi/libgit2.h @@ -0,0 +1,491 @@ +/* + * This file is part of Gitonomy. + * + * (c) Alexandre Salomé + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + * + * Trimmed C declarations for libgit2 1.9, hand-maintained for use with + * PHP's FFI::cdef(). Struct layouts are transcribed from the real libgit2 + * 1.9 headers (git2/*.h) with GIT_EXPERIMENTAL_SHA256 unset (the default, + * plain SHA1 git_oid). libgit2 breaks its ABI on every minor release (struct + * layouts change while the *_OPTIONS_VERSION constants stay at 1), so + * LibGit2Driver refuses to load any other minor version. Function-pointer fields that this driver never + * populates (left zeroed) are declared as plain `void *` to avoid pulling + * in every callback typedef: this is safe for layout purposes since a + * function pointer and a data pointer have the same size/representation + * on every platform libgit2 supports. + */ + +typedef struct git_oid { unsigned char id[20]; } git_oid; + +typedef struct { char *ptr; size_t reserved; size_t size; } git_buf; + +typedef struct git_strarray { char **strings; size_t count; } git_strarray; + +typedef struct git_oidarray { git_oid *ids; size_t count; } git_oidarray; + +typedef struct git_time { int64_t time; int offset; char sign; } git_time; + +typedef struct git_signature { + char *name; + char *email; + git_time when; +} git_signature; + +typedef struct git_error { char *message; int klass; } git_error; + +typedef enum { + GIT_OBJECT_ANY = -2, GIT_OBJECT_INVALID = -1, + GIT_OBJECT_COMMIT = 1, GIT_OBJECT_TREE = 2, GIT_OBJECT_BLOB = 3, GIT_OBJECT_TAG = 4, + GIT_OBJECT_OFS_DELTA = 6, GIT_OBJECT_REF_DELTA = 7 +} git_object_t; + +typedef enum { + GIT_REFERENCE_INVALID = 0, GIT_REFERENCE_DIRECT = 1, GIT_REFERENCE_SYMBOLIC = 2, GIT_REFERENCE_ALL = 3 +} git_reference_t; + +typedef enum { + GIT_BRANCH_LOCAL = 1, GIT_BRANCH_REMOTE = 2, GIT_BRANCH_ALL = 3 +} git_branch_t; + +typedef enum { + GIT_FILEMODE_UNREADABLE = 0000000, GIT_FILEMODE_TREE = 0040000, + GIT_FILEMODE_BLOB = 0100644, GIT_FILEMODE_BLOB_EXECUTABLE = 0100755, + GIT_FILEMODE_LINK = 0120000, GIT_FILEMODE_COMMIT = 0160000 +} git_filemode_t; + +typedef struct git_repository git_repository; +typedef struct git_object git_object; +typedef struct git_commit git_commit; +typedef struct git_tree git_tree; +typedef struct git_tree_entry git_tree_entry; +typedef struct git_blob git_blob; +typedef struct git_tag git_tag; +typedef struct git_reference git_reference; +typedef struct git_reference_iterator git_reference_iterator; +typedef struct git_branch_iterator git_branch_iterator; +typedef struct git_revwalk git_revwalk; +typedef struct git_index git_index; +typedef struct git_diff git_diff; +typedef struct git_blame git_blame; +typedef struct git_status_list git_status_list; +typedef struct git_patch git_patch; +typedef struct git_remote git_remote; +typedef struct git_config git_config; + +/* -- global -- */ + +int git_libgit2_init(void); +int git_libgit2_version(int *major, int *minor, int *rev); +int git_libgit2_features(void); + +/* -- 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); + +/* -- 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); + +/* -- 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_oid *git_tag_target_id(const git_tag *tag); +git_object_t git_tag_target_type(const git_tag *tag); +const char *git_tag_name(const git_tag *tag); +const git_signature *git_tag_tagger(const git_tag *tag); +const char *git_tag_message(const git_tag *tag); + +/* -- revparse / revwalk -- */ + +int git_revparse_single(git_object **out, git_repository *repo, const char *spec); + +int git_revwalk_new(git_revwalk **out, git_repository *repo); +int git_revwalk_push(git_revwalk *walk, const git_oid *id); +int git_revwalk_hide(git_revwalk *walk, const git_oid *commit_id); +int git_revwalk_next(git_oid *out, git_revwalk *walk); +int git_revwalk_sorting(git_revwalk *walk, unsigned int sort_mode); +void git_revwalk_free(git_revwalk *walk); + +/* -- refs / branch -- */ + +int git_reference_lookup(git_reference **out, git_repository *repo, const char *name); +int git_reference_resolve(git_reference **out, const git_reference *ref); +const git_oid *git_reference_target(const git_reference *ref); +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_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 -- */ + +void git_index_free(git_index *index); +int git_index_write_tree_to(git_oid *out, git_index *index, git_repository *repo); +int git_index_has_conflicts(const git_index *index); + +/* -- checkout -- */ + +typedef struct git_checkout_options { + unsigned int version; + unsigned int checkout_strategy; + int disable_filters; + unsigned int dir_mode; + unsigned int file_mode; + int file_open_flags; + unsigned int notify_flags; + void *notify_cb; + void *notify_payload; + void *progress_cb; + void *progress_payload; + git_strarray paths; + git_tree *baseline; + git_index *baseline_index; + const char *target_directory; + const char *ancestor_label; + const char *our_label; + const char *their_label; + void *perfdata_cb; + void *perfdata_payload; +} git_checkout_options; + +int git_checkout_options_init(git_checkout_options *opts, unsigned int version); +int git_checkout_tree(git_repository *repo, const git_object *treeish, const git_checkout_options *opts); + +/* -- merge -- */ + +typedef struct { + unsigned int version; + uint32_t flags; + unsigned int rename_threshold; + unsigned int target_limit; + void *metric; + unsigned int recursion_limit; + const char *default_driver; + int file_favor; + uint32_t file_flags; +} git_merge_options; + +int git_merge_options_init(git_merge_options *opts, unsigned int version); +int git_merge_commits(git_index **out, git_repository *repo, const git_commit *our_commit, const git_commit *their_commit, const git_merge_options *opts); + +/* -- clone / remote (network operations) -- */ + +typedef struct git_proxy_options { + unsigned int version; + int type; + const char *url; + void *credentials; + void *certificate_check; + void *payload; +} git_proxy_options; + +typedef struct { + unsigned int version; + void *sideband_progress; + void *completion; + void *credentials; + void *certificate_check; + void *transfer_progress; + void *update_tips; + void *pack_progress; + void *push_transfer_progress; + void *push_update_reference; + void *push_negotiation; + void *transport; + void *remote_ready; + void *payload; + void *resolve_url; + void *update_refs; +} git_remote_callbacks; + +typedef struct { + int version; + git_remote_callbacks callbacks; + int prune; + unsigned int update_fetchhead; + int download_tags; + git_proxy_options proxy_opts; + int depth; + int follow_redirects; + git_strarray custom_headers; +} git_fetch_options; + +typedef struct { + unsigned int version; + git_checkout_options checkout_opts; + git_fetch_options fetch_opts; + int bare; + int local; + const char *checkout_branch; + void *repository_cb; + void *repository_cb_payload; + void *remote_cb; + void *remote_cb_payload; +} git_clone_options; + +int git_clone_options_init(git_clone_options *options, unsigned int version); +int git_clone(git_repository **out, const char *url, const char *local_path, const git_clone_options *options); + +typedef struct git_remote_head { + int local; + git_oid oid; + git_oid loid; + char *name; + char *symref_target; +} git_remote_head; + +int git_fetch_options_init(git_fetch_options *opts, unsigned int version); + +int git_remote_create_anonymous(git_remote **out, git_repository *repo, const char *url); +int git_remote_create_detached(git_remote **out, const char *url); +int git_remote_default_branch(git_buf *out, git_remote *remote); +int git_remote_fetch(git_remote *remote, const git_strarray *refspecs, const git_fetch_options *opts, const char *reflog_message); +int git_remote_connect(git_remote *remote, int direction, const git_remote_callbacks *callbacks, const git_proxy_options *proxy_opts, const git_strarray *custom_headers); +int git_remote_ls(const git_remote_head ***out, size_t *size, git_remote *remote); +int git_remote_disconnect(git_remote *remote); +void git_remote_free(git_remote *remote); diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php new file mode 100644 index 00000000..2d60586f --- /dev/null +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -0,0 +1,190 @@ + + * (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. + * + * Every method throws a {@see DriverException} when git reports a failure (an + * unknown revision, a conflict...), an {@see UnsupportedOperationException} + * when the driver can't perform the operation at all, and an + * {@see InvalidArgumentException} when an argument contains a NUL byte, which + * git doesn't accept. + * + * @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; + + /** + * @throws DriverException $fullname exists already + */ + public function createReference(string $fullname, string $hash): void; + + public function updateReference(string $fullname, string $hash): void; + + /** + * Like ``git update-ref -d``, deleting a missing reference does nothing. + */ + public function deleteReference(string $fullname): void; + + /** + * @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: HEAD points to the branch when $revision is the + * name of a local branch, and to the commit otherwise. + */ + public function checkout(string $revision): void; + + public function checkoutNewBranch(string $branch, string $startHash): void; + + /** + * Merges $revision, a name the message of the merge commit names, into the current branch. + */ + public function merge(string $revision, FastForward $fastForward): void; +} diff --git a/src/Gitonomy/Git/Driver/RepositoryPaths.php b/src/Gitonomy/Git/Driver/RepositoryPaths.php new file mode 100644 index 00000000..7cede834 --- /dev/null +++ b/src/Gitonomy/Git/Driver/RepositoryPaths.php @@ -0,0 +1,85 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Driver; + +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\NulByte; + +/** + * The git directory and the working tree of a repository, found the same way by every driver. + * + * @internal + */ +final readonly class RepositoryPaths +{ + private function __construct( + public string $gitDir, + public ?string $workingDir, + ) { + } + + /** + * @param string $dir A bare repository, a working tree, or a git directory when $workingDir is given + * @param string|null $workingDir The working tree of the git directory $dir + * + * @throws InvalidArgumentException $dir doesn't exist, or isn't a repository, or a path contains a NUL byte + * @throws DriverException The ".git" file of $dir doesn't point to a git directory + * @throws UnsupportedOperationException The repository uses SHA-256 hashes + */ + public static function resolve(string $dir, ?string $workingDir): self + { + NulByte::assertNone($dir, $workingDir ?? ''); + + $realDir = realpath($dir); + if (false === $realDir || !is_dir($realDir)) { + throw new InvalidArgumentException(\sprintf('Directory "%s" does not exist or is not a directory.', $dir)); + } + + $paths = match (true) { + null !== $workingDir => new self($realDir, $workingDir), + is_dir($realDir.'/.git') => new self($realDir.'/.git', $realDir), + // In a linked worktree or a submodule, ".git" is a file pointing to the git directory. + is_file($realDir.'/.git') => new self(self::readGitFile($realDir), $realDir), + default => new self($realDir, null), + }; + + // Like git, which requires it in any git directory. + if (!is_file($paths->gitDir.'/HEAD')) { + throw new InvalidArgumentException(\sprintf('Directory "%s" is not a git repository.', $dir)); + } + + $config = is_file($paths->gitDir.'/config') ? file_get_contents($paths->gitDir.'/config') : false; + if (false !== $config && preg_match('/^\s*objectformat\s*=\s*sha256\s*$/mi', $config)) { + throw new UnsupportedOperationException(\sprintf('The repository "%s" uses SHA-256 hashes, which gitlib doesn\'t support yet.', $dir)); + } + + return $paths; + } + + /** + * @return string The git directory the ".git" file of $dir points to + */ + private static function readGitFile(string $dir): string + { + $content = file_get_contents($dir.'/.git'); + if (false === $content || !preg_match('/^gitdir: (.+)$/m', $content, $match)) { + throw new DriverException(\sprintf('The "%s/.git" file doesn\'t point to a git directory.', $dir)); + } + + $gitDir = str_starts_with($match[1], '/') || preg_match('/^[a-zA-Z]:[\\\\\/]/', $match[1]) ? $match[1] : $dir.'/'.$match[1]; + + return realpath($gitDir) ?: throw new DriverException(\sprintf('The git directory "%s" of "%s" does not exist.', $match[1], $dir)); + } +} diff --git a/src/Gitonomy/Git/Exception/DriverException.php b/src/Gitonomy/Git/Exception/DriverException.php new file mode 100644 index 00000000..4b8a3081 --- /dev/null +++ b/src/Gitonomy/Git/Exception/DriverException.php @@ -0,0 +1,22 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Exception; + +/** + * Thrown when git reports a failure, whatever the driver running it. + * + * @author Grégoire Pineau + */ +class DriverException extends RuntimeException +{ +} diff --git a/src/Gitonomy/Git/Exception/InvalidArgumentException.php b/src/Gitonomy/Git/Exception/InvalidArgumentException.php index 119aad2b..ed1dcc39 100644 --- a/src/Gitonomy/Git/Exception/InvalidArgumentException.php +++ b/src/Gitonomy/Git/Exception/InvalidArgumentException.php @@ -12,6 +12,6 @@ namespace Gitonomy\Git\Exception; -final class InvalidArgumentException extends \InvalidArgumentException implements GitExceptionInterface +class InvalidArgumentException extends \InvalidArgumentException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index 9d76688f..0a008c92 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -14,7 +14,7 @@ use Symfony\Component\Process\Process; -final class ProcessException extends RuntimeException implements GitExceptionInterface +final class ProcessException extends DriverException implements GitExceptionInterface { public function __construct(private readonly Process $process) { diff --git a/src/Gitonomy/Git/Exception/ProcessTimedOutException.php b/src/Gitonomy/Git/Exception/ProcessTimedOutException.php new file mode 100644 index 00000000..1af2780f --- /dev/null +++ b/src/Gitonomy/Git/Exception/ProcessTimedOutException.php @@ -0,0 +1,22 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Exception; + +/** + * A git process ran longer than the timeout of CliDriver. + * + * @author Grégoire Pineau + */ +final class ProcessTimedOutException extends DriverException +{ +} diff --git a/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php b/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php index 134c17e2..fcfe134e 100644 --- a/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php +++ b/src/Gitonomy/Git/Exception/ReferenceNotFoundException.php @@ -12,10 +12,9 @@ namespace Gitonomy\Git\Exception; -final class ReferenceNotFoundException extends \InvalidArgumentException implements GitExceptionInterface +/** + * A revision, a reference or an object doesn't exist. + */ +final class ReferenceNotFoundException extends InvalidArgumentException { - public function __construct(string $reference, int $code = 0, ?\Throwable $previous = null) - { - parent::__construct(\sprintf('Reference not found: "%s"', $reference), $code, $previous); - } } diff --git a/src/Gitonomy/Git/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 6b6835c3..c673f919 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -13,7 +13,6 @@ namespace Gitonomy\Git; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\LogicException; use Gitonomy\Git\Exception\RuntimeException; /** @@ -45,15 +44,21 @@ public function has(string $name): bool * * @return string Content of the hook * - * @throws InvalidArgumentException Hook does not exist + * @throws InvalidArgumentException The hook does not exist + * @throws RuntimeException The hook can't be read */ public function get(string $name): string { if (!$this->has($name)) { - throw new InvalidArgumentException(\sprintf('Hook named "%s" is not present', $name)); + throw new InvalidArgumentException(\sprintf('The hook "%s" does not exist.', $name)); } - return file_get_contents($this->getPath($name)); + $content = file_get_contents($this->getPath($name)); + if (false === $content) { + throw new RuntimeException(\sprintf('Unable to read the hook "%s".', $name)); + } + + return $content; } /** @@ -62,18 +67,14 @@ public function get(string $name): string * @param string $name Name of the hook to insert * @param string $file Target of symlink * - * @throws LogicException Hook is already present - * @throws RuntimeException Error on symlink creation + * @throws RuntimeException The hook exists already, or can't be created */ public function setSymlink(string $name, string $file): void { - if ($this->has($name)) { - throw new LogicException(\sprintf('A hook "%s" is already defined', $name)); - } + $path = $this->createPath($name); - $path = $this->getPath($name); - if (false === symlink($file, $path)) { - throw new RuntimeException(\sprintf('Unable to create hook "%s" (%s)', $name, $path)); + if (false === @symlink($file, $path)) { + throw new RuntimeException(\sprintf('Unable to create the hook "%s" (%s).', $name, $path)); } } @@ -83,17 +84,15 @@ public function setSymlink(string $name, string $file): void * @param string $name Name of the hook * @param string $content Content of the hook * - * @throws LogicException The hook is already defined + * @throws RuntimeException The hook exists already, or can't be created */ public function set(string $name, string $content): void { - if ($this->has($name)) { - throw new LogicException(\sprintf('A hook "%s" is already defined', $name)); - } + $path = $this->createPath($name); - $path = $this->getPath($name); - file_put_contents($path, $content); - chmod($path, 0o700); + if (false === @file_put_contents($path, $content) || false === @chmod($path, 0o700)) { + throw new RuntimeException(\sprintf('Unable to create the hook "%s" (%s).', $name, $path)); + } } /** @@ -101,19 +100,60 @@ public function set(string $name, string $content): void * * @param string $name Name of the hook * - * @throws LogicException The hook is not present + * @throws RuntimeException The hook doesn't exist, or can't be removed */ public function remove(string $name): void { if (!$this->has($name)) { - throw new LogicException(\sprintf('The hook "%s" was not found', $name)); + throw new RuntimeException(\sprintf('The hook "%s" does not exist.', $name)); + } + + if (false === @unlink($this->getPath($name))) { + throw new RuntimeException(\sprintf('Unable to remove the hook "%s".', $name)); + } + } + + /** + * @throws RuntimeException The hook exists already, or the repository has no hooks directory + */ + private function createPath(string $name): string + { + if ($this->has($name)) { + throw new RuntimeException(\sprintf('The hook "%s" exists already.', $name)); + } + + // A repository created with an empty template has none. + $directory = $this->getDirectory(); + if (!is_dir($directory) && !@mkdir($directory) && !is_dir($directory)) { + throw new RuntimeException(\sprintf('Unable to create the hooks directory "%s".', $directory)); } - unlink($this->getPath($name)); + return $this->getPath($name); } private function getPath(string $name): string { - return $this->repository->getGitDir().'/hooks/'.$name; + return $this->getDirectory().'/'.$name; + } + + /** + * @throws RuntimeException The "commondir" file of a linked worktree can't be read + */ + private function getDirectory(): string + { + $gitDir = $this->repository->getGitDir(); + // Like git, a linked worktree runs the hooks of the repository it belongs to. + if (!is_file($gitDir.'/commondir')) { + return $gitDir.'/hooks'; + } + + $commonDir = file_get_contents($gitDir.'/commondir'); + if (false === $commonDir) { + throw new RuntimeException(\sprintf('Unable to read "%s/commondir".', $gitDir)); + } + + $commonDir = rtrim($commonDir, "\r\n"); + + return (preg_match('#^(/|[a-zA-Z]:[\\\/])#', $commonDir) ? $commonDir : $gitDir.'/'.$commonDir).'/hooks'; } } diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index 7d5137f3..d2065ead 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -13,16 +13,21 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; -use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; /** * @author Alexandre Salomé + * + * @implements \IteratorAggregate */ final class Log implements \Countable, \IteratorAggregate { private ?RevisionList $revisions; + /** + * @var string[] + */ private array $paths; private ?int $offset; @@ -32,11 +37,14 @@ final class Log implements \Countable, \IteratorAggregate /** * Instanciates a git log object. * - * @param Repository $repository the repository where log occurs - * @param RevisionList|Revision|string|array|null $revisions a list of revisions or null if you want all history - * @param array|string|null $paths paths to filter on - * @param int|null $offset start list from a given position - * @param int|null $limit limit number of fetched elements + * The revisions are names, ranges ("a..b", "a...b"), names to exclude + * ("^a"), or "--all". + * + * @param Repository $repository the repository where log occurs + * @param RevisionList|Revision|string|array|null $revisions a list of revisions or null if you want all history + * @param string[]|string|null $paths paths to filter on, with git's wildcards + * @param int|null $offset start list from a given position + * @param int|null $limit limit number of fetched elements */ public function __construct( private readonly Repository $repository, @@ -61,9 +69,14 @@ public function __construct( $this->limit = $limit; } + /** + * @deprecated since gitonomy/gitlib 2.0, use Repository::getDiff() instead: it ignored the paths of the log + */ public function getDiff(): Diff { - return $this->repository->getDiff($this->revisions); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::getDiff()" instead.', __METHOD__, Repository::class); + + return $this->repository->getLegacyDiff($this->revisions); } public function getRevisions(): ?RevisionList @@ -71,6 +84,9 @@ public function getRevisions(): ?RevisionList return $this->revisions; } + /** + * @return string[] + */ public function getPaths(): array { return $this->paths; @@ -81,8 +97,24 @@ public function getOffset(): ?int return $this->offset; } + /** + * @return self The log, starting from the commit $offset + */ + public function withOffset(?int $offset): self + { + $log = clone $this; + $log->offset = $offset; + + return $log; + } + + /** + * @deprecated since gitonomy/gitlib 2.0, use withOffset() instead + */ public function setOffset(?int $offset): static { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "withOffset()" instead.', __METHOD__); + $this->offset = $offset; return $this; @@ -93,25 +125,35 @@ public function getLimit(): ?int return $this->limit; } + /** + * @return self The log, of $limit commits at most + */ + public function withLimit(?int $limit): self + { + $log = clone $this; + $log->limit = $limit; + + return $log; + } + + /** + * @deprecated since gitonomy/gitlib 2.0, use withLimit() instead + */ public function setLimit(?int $limit): static { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "withLimit()" instead.', __METHOD__); + $this->limit = $limit; return $this; } + /** + * @throws ReferenceNotFoundException The log is empty + */ public function getSingleCommit(): Commit { - $limit = $this->limit; - $this->limit = 1; - $commits = $this->getCommits(); - $this->setLimit($limit); - - if (0 === \count($commits)) { - throw new ReferenceNotFoundException('The log is empty'); - } - - return array_pop($commits); + return $this->withLimit(1)->getCommits()[0] ?? throw new ReferenceNotFoundException('The log has no commit.'); } /** @@ -119,44 +161,12 @@ public function getSingleCommit(): Commit */ public function getCommits(): array { - $args = ['--encoding=UTF-8', '--format=raw']; - - if (null !== $this->offset) { - $args[] = '--skip='.$this->offset; - } - - if (null !== $this->limit) { - $args[] = '-n'; - $args[] = $this->limit; - } - - if (null !== $this->revisions) { - $args = array_merge($args, $this->revisions->getAsTextArray()); - } else { - $args[] = '--all'; - } - - $args[] = '--'; - - $args = array_merge($args, $this->paths); - - try { - $output = $this->repository->run('log', $args); - } catch (ProcessException $e) { - $revisionsText = null !== $this->revisions ? implode(' ', $this->revisions->getAsTextArray()) : '--all'; - - throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $revisionsText), 0, $e); - } - - $parser = new Parser\LogParser(); - $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; @@ -167,14 +177,20 @@ public function getCommits(): array /** * @see Countable + * + * @return int The number of commits of the log, after its offset and up to its limit: as many as getCommits() returns */ public function count(): int { - return $this->countCommits(); + $count = max(0, $this->countCommits() - ($this->offset ?? 0)); + + return null === $this->limit ? $count : min($count, $this->limit); } /** * @see IteratorAggregate + * + * @return \ArrayIterator */ public function getIterator(): \ArrayIterator { @@ -186,12 +202,64 @@ public function getIterator(): \ArrayIterator */ public function countCommits(): int { - if (null !== $this->revisions && \count($this->revisions)) { - $output = $this->repository->run('rev-list', array_merge(['--count'], $this->revisions->getAsTextArray(), ['--'], $this->paths)); - } else { - $output = $this->repository->run('rev-list', array_merge(['--count', '--all', '--'], $this->paths)); + [$includes, $excludes] = $this->range(); + + return $this->repository->getRepositoryDriver()->countCommits($includes, $excludes, $this->paths); + } + + /** + * Resolves the revisions to the commits whose history to list, and the + * ones whose history to leave out. + * + * @return array{list|null, list} Null for every reference and HEAD + * + * @throws ReferenceNotFoundException A revision names no commit + */ + private function range(): array + { + if (null === $this->revisions) { + return [null, []]; } - return (int) $output; + $all = false; + $includes = []; + $excludes = []; + foreach ($this->revisions->getAll() as $revision) { + if ($revision instanceof Commit || $revision instanceof Reference) { + $includes[] = $revision->getCommit()->getHash(); + + continue; + } + + $name = $revision->getRevision(); + if ('--all' === $name) { + $all = true; + } elseif (str_starts_with($name, '^')) { + $excludes[] = $this->commitHash(substr($name, 1)); + } elseif (str_contains($name, '...')) { + // Reachable from either end, but not from both. + [$one, $two] = array_map($this->commitHash(...), explode('...', $name, 2)); + array_push($includes, $one, $two); + array_push($excludes, ...$this->repository->getRepositoryDriver()->getMergeBases($one, $two)); + } elseif (str_contains($name, '..')) { + [$from, $to] = array_map($this->commitHash(...), explode('..', $name, 2)); + $includes[] = $to; + $excludes[] = $from; + } elseif (str_starts_with($name, '-')) { + throw new InvalidArgumentException(\sprintf('The log of "%s" is not supported: pass revisions, ranges, names to exclude or "--all".', $name)); + } else { + $includes[] = $this->commitHash($name); + } + } + + return [$all ? null : $includes, $excludes]; + } + + /** + * @param string $revision An end of a range, HEAD when empty + */ + private function commitHash(string $revision): string + { + return $this->repository->getRevision('' === $revision ? 'HEAD' : $revision)->getCommit()->getHash(); } } diff --git a/src/Gitonomy/Git/Parser/CommitParser.php b/src/Gitonomy/Git/Parser/CommitParser.php deleted file mode 100644 index c9300e1b..00000000 --- a/src/Gitonomy/Git/Parser/CommitParser.php +++ /dev/null @@ -1,90 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Parser; - -use Gitonomy\Git\Exception\RuntimeException; - -class CommitParser extends ParserBase -{ - public string $tree; - - /** - * @var string[] - */ - public array $parents; - - public string $authorName; - public string $authorEmail; - public \DateTime $authorDate; - public string $committerName; - public string $committerEmail; - public \DateTime $committerDate; - public string $message; - - protected function doParse(): void - { - $this->consume('tree '); - $this->tree = $this->consumeHash(); - $this->consumeNewLine(); - - $this->parents = []; - while ($this->expects('parent ')) { - $this->parents[] = $this->consumeHash(); - $this->consumeNewLine(); - } - - $this->consume('author '); - [$this->authorName, $this->authorEmail, $authorDate] = $this->consumeNameEmailDate(); - $this->authorDate = $this->parseDate($authorDate); - $this->consumeNewLine(); - - $this->consume('committer '); - [$this->committerName, $this->committerEmail, $committerDate] = $this->consumeNameEmailDate(); - $this->committerDate = $this->parseDate($committerDate); - - $this->consumeMergeTag(); - - // will consume an GPG signed commit if there is one - $this->consumeGPGSignature(); - - $this->consumeNewLine(); - - $this->consumeNewLine(); - $this->message = $this->consumeAll(); - } - - /** - * @return string[] A tuple of [name, email, date] - */ - protected function consumeNameEmailDate(): array - { - if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+ [+-]\d{4}))/A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('Unable to parse name, email and date'); - } - - $this->cursor += \strlen($vars[1]); - - return [$vars[2], $vars[3], $vars[4]]; - } - - protected function parseDate(string $text): \DateTime - { - $date = \DateTime::createFromFormat('U e O', $text.' UTC'); - - if (!$date instanceof \DateTime) { - throw new RuntimeException(\sprintf('Unable to convert "%s" to datetime', $text)); - } - - return $date; - } -} diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index 9a672e74..c5540658 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -12,160 +12,242 @@ namespace Gitonomy\Git\Parser; -use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\DiffLineType; use Gitonomy\Git\Diff\FileChange; - +use Gitonomy\Git\Driver\Data\DiffFileData; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\RuntimeException; + +/** + * Parses the output of ``git diff --raw --patch``, with ``-z`` or not: the + * names, modes, hashes and status of the files come from the raw + * information, and the hunks from the patches. + * + * Unmerged paths, during a conflict, are left out. + */ final class DiffParser extends ParserBase { + private const string HASH = '([0-9a-f]{7,64})(?:\.\.\.)?'; + /** - * @var File[] + * 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 ']; + + /** + * @var list */ public array $files = []; protected function doParse(): void { $this->files = []; - $indexes = []; - // Diff contains raw information - if (str_starts_with($this->content, ':')) { - while ($this->expects(':')) { - $this->consumeRegexp('/\d{6} \d{6} /'); - $oldIndex = $this->consumeShortHash(); - $this->consume(' '); - $newIndex = $this->consumeShortHash(); - $this->consumeTo("\n"); - $this->consumeNewLine(); - $indexes[] = [$oldIndex, $newIndex]; + + 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; } - $this->consumeNewLine(); - } elseif (!$this->isFinished()) { - @trigger_error('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.', \E_USER_DEPRECATED); + + $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'], + ); } + } - $fileIndex = 0; - while (!$this->isFinished()) { - // 1. title - // The prefix is normally "a" and "b", but git's diff.mnemonicPrefix option - // (used by tools such as GrumPHP) can produce other single-letter prefixes - // ("c"ommit, "i"ndex, "o"bject, "w"ork tree), or "1"/"2" with --no-index. - $vars = $this->consumeRegexp("/diff --git \"?([^\\/\\s]\\/.*?)\"? \"?([^\\/\\s]\\/.*?)\"?\n/"); - $oldName = $vars[1]; - $newName = $vars[2]; - // Get indexes from raw if it exists - $oldIndex = isset($indexes[$fileIndex]) ? $indexes[$fileIndex][0] : null; - $newIndex = isset($indexes[$fileIndex]) ? $indexes[$fileIndex][1] : null; - ++$fileIndex; - $oldMode = null; - $newMode = null; - - // 2. mode - if ($this->expects('new file mode ')) { - $newMode = $this->consumeTo("\n"); - $this->consumeNewLine(); - $oldMode = null; - $oldName = '/dev/null'; + /** + * @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('old mode ')) { - $oldMode = $this->consumeTo("\n"); - $this->consumeNewLine(); - $this->consume('new mode '); - $newMode = $this->consumeTo("\n"); - $this->consumeNewLine(); + + $modes = []; + for ($i = 0; $i <= $parents; ++$i) { + $modes[] = $this->consumeRegexp('/(\d{6}) /')[1]; } - if ($this->expects('deleted file mode ')) { - $oldMode = $this->consumeTo("\n"); - $newMode = null; - $newName = '/dev/null'; - $this->consumeNewLine(); + $hashes = []; + for ($i = 0; $i <= $parents; ++$i) { + $hashes[] = $this->consumeRegexp('/'.self::HASH.' /')[1]; } - - $isCopy = false; - if ($this->expects('similarity index ')) { - $this->consumeRegexp('/\d{1,3}%\n/'); - $isCopy = $this->expects('copy from '); - if (!$isCopy) { - $this->consume('rename from '); + $status = $this->consumeRegexp('/([A-Z]+)\d*/')[1]; + + // -z ends the fields with NUL characters, and doesn't quote names. + $nul ??= "\0" === ($this->content[$this->cursor] ?? ''); + $this->consume($nul ? "\0" : "\t"); + $oldName = $this->consumeName($nul); + $newName = $oldName; + if (\in_array($status, ['C', 'R'], true)) { + if (!$nul) { + $this->consume("\t"); } - $this->consumeTo("\n"); - $this->consumeNewLine(); - $this->consume($isCopy ? 'copy to ' : 'rename to '); - $this->consumeTo("\n"); + $newName = $this->consumeName($nul); + } + if (!$nul) { $this->consumeNewLine(); } - // 4. File informations - $isBinary = false; - if ($this->expects('index ')) { - $oldIndex = $this->consumeShortHash(); - $this->consume('..'); - $newIndex = $this->consumeShortHash(); - if ($this->expects(' ')) { - $vars = $this->consumeRegexp('/\d{6}/'); - $newMode = $oldMode = $vars[0]; - } - $this->consumeNewLine(); + if (1 === $parents && 'U' !== $status) { + $entries[] = [ + 'status' => $status, + 'oldName' => $oldName, + 'newName' => $newName, + 'oldMode' => self::nullIfZeros($modes[0]), + 'newMode' => self::nullIfZeros($modes[1]), + 'oldHash' => self::nullIfZeros($hashes[0]), + 'newHash' => self::nullIfZeros($hashes[1]), + ]; } + } - // verifying if the file was deleted or created - // Note: a dirty submodule diff has no "index " line, so this must - // not be nested inside the block above. - if ($this->expects('--- ')) { - $oldName = '/dev/null' === $this->consumeTo("\n") ? '/dev/null' : $oldName; - $this->consumeNewLine(); - $this->consume('+++ '); - $newName = '/dev/null' === $this->consumeTo("\n") ? '/dev/null' : $newName; - $this->consumeNewLine(); - } elseif ($this->expects('Binary files ')) { - $vars = $this->consumeRegexp('/"?(.*?)"? and "?(.*?)"? differ\n/'); - $isBinary = true; - $oldName = $vars[1]; - $newName = $vars[2]; + // The raw information ends with an empty field, or line, when the patches follow. + if (!$this->isFinished()) { + $this->consume($nul ? "\0" : "\n"); + } + + return $entries; + } + + private function consumeName(bool $nul): string + { + if ($nul) { + $name = $this->consumeTo("\0"); + $this->consume("\0"); + + return $name; + } + + return self::unquote($this->consumeRegexp('/("(?:[^"\\\]|\\\.)*"|[^\t\n]*)/')[1]); + } + + private function consumePatch(?string $oldName, ?string $newName, ?string $oldMode, ?string $newMode, ?string $oldHash, ?string $newHash, bool $isCopy): DiffFileData + { + while ($this->expects('* Unmerged path ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + + // Without --patch. + if (!$this->expects('diff --git ')) { + return new DiffFileData($oldName, $newName, $oldMode, $newMode, $oldHash, $newHash, false, $isCopy, []); + } + // The names of the header are ambiguous with spaces: the raw information gives them. + $this->consumeTo("\n"); + $this->consumeNewLine(); + + while (true) { + // The hashes of the files of the working tree, which the raw information leaves out. + if ($this->expects('index ')) { + $vars = $this->consumeRegexp('/'.self::HASH.'\.\.'.self::HASH.'/'); + $oldHash = self::nullIfZeros($vars[1]); + $newHash = self::nullIfZeros($vars[2]); + } elseif (!$this->expectsOneOf(self::HEADERS)) { + break; } - $oldName = '/dev/null' === $oldName ? null : substr($oldName, 2); - $newName = '/dev/null' === $newName ? null : substr($newName, 2); - - $oldIndex = null === $oldIndex ? '' : $oldIndex; - $newIndex = null === $newIndex ? '' : $newIndex; - $oldIndex = preg_match('/^0+$/', $oldIndex) ? null : $oldIndex; - $newIndex = preg_match('/^0+$/', $newIndex) ? null : $newIndex; - $file = new File($oldName, $newName, $oldMode, $newMode, $oldIndex, $newIndex, $isBinary, $isCopy); - - // 5. Diff - while ($this->expects('@@ ')) { - $vars = $this->consumeRegexp('/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?/'); - $rangeOldStart = (int) $vars[1]; - $rangeOldCount = (int) ($vars[2] ?? 1); - $rangeNewStart = (int) $vars[3]; - $rangeNewCount = (int) ($vars[4] ?? 1); - $this->consume(' @@'); - $this->consumeTo("\n"); - $this->consumeNewLine(); + $this->consumeTo("\n"); + $this->consumeNewLine(); + } - // 6. Lines - $lines = []; - while (true) { - if ($this->expects(' ')) { - $lines[] = [FileChange::LINE_CONTEXT, $this->consumeTo("\n")]; - } elseif ($this->expects('+')) { - $lines[] = [FileChange::LINE_ADD, $this->consumeTo("\n")]; - } elseif ($this->expects('-')) { - $lines[] = [FileChange::LINE_REMOVE, $this->consumeTo("\n")]; - } elseif ($this->expects('\ No newline at end of file')) { - // Ignore this case... - } else { - break; - } - - $this->consumeNewLine(); - } + $isBinary = false; + if ($this->expects('--- ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + $this->consume('+++ '); + $this->consumeTo("\n"); + $this->consumeNewLine(); + } elseif ($this->expects('Binary files ')) { + $isBinary = true; + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + + $changes = []; + while ($this->expects('@@ ')) { + $changes[] = $this->consumeHunk(); + } - $change = new FileChange($rangeOldStart, $rangeOldCount, $rangeNewStart, $rangeNewCount, $lines); + return new DiffFileData($oldName, $newName, $oldMode, $newMode, $oldHash, $newHash, $isBinary, $isCopy, $changes); + } - $file->addChange($change); + private function consumeHunk(): FileChange + { + if (!preg_match('/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[^\n]*\n/A', $this->content, $vars, \PREG_UNMATCHED_AS_NULL, $this->cursor)) { + throw new RuntimeException(\sprintf('Unable to parse the hunk header "%s".', substr($this->content, $this->cursor - 3, 50))); + } + $this->cursor += \strlen($vars[0]); + + $lines = []; + while (true) { + if ($this->expects(' ')) { + $lines[] = new DiffLine(DiffLineType::Context, $this->consumeTo("\n")); + } elseif ($this->expects('+')) { + $lines[] = new DiffLine(DiffLineType::Addition, $this->consumeTo("\n")); + } elseif ($this->expects('-')) { + $lines[] = new DiffLine(DiffLineType::Deletion, $this->consumeTo("\n")); + } elseif ($this->expects('\ No newline at end of file')) { + // The previous line has no line break. + } else { + break; } - $this->files[] = $file; + $this->consumeNewLine(); } + + // A count is left out when it is 1. + return new FileChange((int) $vars[1], (int) ($vars[2] ?? 1), (int) $vars[3], (int) ($vars[4] ?? 1), $lines); + } + + /** + * @param string[] $prefixes + */ + private function expectsOneOf(array $prefixes): bool + { + foreach ($prefixes as $prefix) { + if ($this->expects($prefix)) { + return true; + } + } + + return false; + } + + private static function nullIfZeros(string $value): ?string + { + return '' === trim($value, '0') ? null : $value; + } + + private static function unquote(string $name): string + { + 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 693abfc4..00000000 --- a/src/Gitonomy/Git/Parser/LogParser.php +++ /dev/null @@ -1,94 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Parser; - -final class LogParser extends CommitParser -{ - /** - * @var array[] - */ - public array $log = []; - - protected function doParse(): void - { - $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 '); - [$commit['authorName'], $commit['authorEmail'], $authorDate] = $this->consumeNameEmailDate(); - $commit['authorDate'] = $this->parseDate($authorDate); - $this->consumeNewLine(); - - $this->consume('committer '); - [$commit['committerName'], $commit['committerEmail'], $committerDate] = $this->consumeNameEmailDate(); - $commit['committerDate'] = $this->parseDate($committerDate); - - $this->consumeMergeTag(); - - // will consume an GPG signed commit if there is one - $this->consumeGPGSignature(); - - $this->consumeNewLine(); - $this->consumeUnsupportedLinesToNewLine(); - if ($this->cursor < \strlen($this->content)) { - $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; - } - } - - protected function consumeUnsupportedLinesToNewLine(): void - { - // Consume any unsupported lines that may appear in the log output. For - // example, gitbutler headers or other custom metadata but this should - // work regardless of the content. - while (!$this->isFinished() && "\n" !== substr($this->content, $this->cursor, 1)) { - $this->consumeTo("\n"); - $this->consumeNewLine(); - } - } -} diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index 70a7a81e..fd61640c 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -20,10 +20,10 @@ abstract class ParserBase protected string $content; protected int $length; - public function parse(?string $content): void + public function parse(string $content): void { $this->cursor = 0; - $this->content = $content ?? ''; + $this->content = $content; $this->length = \strlen($this->content); $this->doParse(); @@ -44,6 +44,11 @@ protected function consumeAll(): string return $rest; } + /** + * Consumes $expected if the content continues with it. + * + * @phpstan-impure + */ protected function expects(string $expected): bool { $length = \strlen($expected); @@ -60,7 +65,7 @@ protected function expects(string $expected): bool protected function consumeShortHash(): string { if (!preg_match('/([A-Za-z0-9]{7,40})/A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('No short hash found: '.substr($this->content, $this->cursor, 7)); + throw new RuntimeException(\sprintf('Expected a hash, got "%s".', substr($this->content, $this->cursor, 7))); } $this->cursor += \strlen($vars[1]); @@ -71,7 +76,7 @@ protected function consumeShortHash(): string protected function consumeHash(): string { if (!preg_match('/([A-Za-z0-9]{40})/A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('No hash found: '.substr($this->content, $this->cursor, 40)); + throw new RuntimeException(\sprintf('Expected a hash, got "%s".', substr($this->content, $this->cursor, 40))); } $this->cursor += 40; @@ -82,7 +87,7 @@ protected function consumeHash(): string protected function consumeRegexp(string $regexp): array { if (!preg_match($regexp.'A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('No match for regexp '.$regexp.' Upcoming: '.substr($this->content, $this->cursor, 500)); + throw new RuntimeException(\sprintf('Expected %s, got "%s".', $regexp, substr($this->content, $this->cursor, 500))); } $this->cursor += \strlen($vars[0]); @@ -95,7 +100,7 @@ protected function consumeTo(string $text): string $pos = strpos($this->content, $text, $this->cursor); if (false === $pos) { - throw new RuntimeException(\sprintf('Unable to find "%s"', $text)); + throw new RuntimeException(\sprintf('Expected "%s" after "%s".', $text, substr($this->content, $this->cursor, 100))); } $result = substr($this->content, $this->cursor, $pos - $this->cursor); @@ -109,7 +114,7 @@ protected function consume(string $expected): string $length = \strlen($expected); $actual = substr($this->content, $this->cursor, $length); if ($actual !== $expected) { - throw new RuntimeException(\sprintf('Expected "%s", but got "%s" (%s)', $expected, $actual, substr($this->content, $this->cursor, 10))); + throw new RuntimeException(\sprintf('Expected "%s", got "%s".', $expected, substr($this->content, $this->cursor, 10))); } $this->cursor += $length; @@ -121,30 +126,31 @@ protected function consumeNewLine(): string return $this->consume("\n"); } - protected function consumeGPGSignature(): string + /** + * @return array{string, string, \DateTimeImmutable} The name, email and date of an author, committer or tagger + */ + protected function consumeNameEmailDate(): array { - $expected = "\ngpgsig "; - $length = \strlen($expected); - $actual = substr($this->content, $this->cursor, $length); - if ($actual != $expected) { - return ''; + 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 += $length; - return $this->consumeTo("\n\n"); + $this->cursor += \strlen($vars[1]); + + return [$vars[2], $vars[3], self::timestamp($vars[4])]; } - protected function consumeMergeTag(): void + /** + * @param string $timestamp Seconds since the epoch, as git writes them + * + * @throws RuntimeException The date is out of range, in a corrupt object + */ + protected static function timestamp(string $timestamp): \DateTimeImmutable { - $expected = "\nmergetag "; - $length = \strlen($expected); - $actual = substr($this->content, $this->cursor, $length); - if ($actual != $expected) { - return; + try { + return new \DateTimeImmutable('@'.$timestamp)->setTimezone(new \DateTimeZone('UTC')); + } catch (\DateMalformedStringException $e) { + throw new RuntimeException(\sprintf('The date "%s" is out of range.', $timestamp), 0, $e); } - $this->cursor += $length; - - $this->consumeTo('-----END PGP SIGNATURE-----'); - $this->consume('-----END PGP SIGNATURE-----'); } } diff --git a/src/Gitonomy/Git/Parser/ReferenceParser.php b/src/Gitonomy/Git/Parser/ReferenceParser.php deleted file mode 100644 index aae23af7..00000000 --- a/src/Gitonomy/Git/Parser/ReferenceParser.php +++ /dev/null @@ -1,34 +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; - -final class ReferenceParser extends ParserBase -{ - /** - * @var array - */ - public array $references = []; - - protected function doParse(): void - { - $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/TagParser.php b/src/Gitonomy/Git/Parser/TagParser.php deleted file mode 100644 index b517f4e0..00000000 --- a/src/Gitonomy/Git/Parser/TagParser.php +++ /dev/null @@ -1,94 +0,0 @@ - - * (c) Julien DIDIER - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Gitonomy\Git\Parser; - -use Gitonomy\Git\Exception\RuntimeException; - -final class TagParser extends ParserBase -{ - public string $object; - public string $type; - public string $tag; - public string $taggerName; - public string $taggerEmail; - public \DateTime $taggerDate; - public ?string $gpgSignature = null; - public string $message; - - protected function doParse(): void - { - $this->consume('object '); - $this->object = $this->consumeHash(); - $this->consumeNewLine(); - - $this->consume('type '); - $this->type = $this->consumeTo("\n"); - $this->consumeNewLine(); - - $this->consume('tag '); - $this->tag = $this->consumeTo("\n"); - $this->consumeNewLine(); - - $this->consume('tagger '); - [$this->taggerName, $this->taggerEmail, $taggerDate] = $this->consumeNameEmailDate(); - $this->taggerDate = $this->parseDate($taggerDate); - - $this->consumeNewLine(); - $this->consumeNewLine(); - - try { - $this->message = $this->consumeTo('-----BEGIN PGP SIGNATURE-----'); - $this->gpgSignature = $this->consumeGPGSignature(); - } catch (RuntimeException $e) { - $this->message = $this->consumeAll(); - } - } - - protected function consumeGPGSignature(): string - { - $expected = '-----BEGIN PGP SIGNATURE-----'; - $length = \strlen($expected); - $actual = substr($this->content, $this->cursor, $length); - if ($actual != $expected) { - return ''; - } - $this->cursor += $length; - - return $this->consumeTo('-----END PGP SIGNATURE-----'); - } - - /** - * @return string[] A tuple of [name, email, date] - */ - protected function consumeNameEmailDate(): array - { - if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+ [+-]\d{4}))/A', $this->content, $vars, 0, $this->cursor)) { - throw new RuntimeException('Unable to parse name, email and date'); - } - - $this->cursor += \strlen($vars[1]); - - return [$vars[2], $vars[3], $vars[4]]; - } - - protected function parseDate(string $text): \DateTime - { - $date = \DateTime::createFromFormat('U e O', $text.' UTC'); - - if (!$date instanceof \DateTime) { - throw new RuntimeException(\sprintf('Unable to convert "%s" to datetime', $text)); - } - - return $date; - } -} diff --git a/src/Gitonomy/Git/Parser/TreeParser.php b/src/Gitonomy/Git/Parser/TreeParser.php deleted file mode 100644 index 0dbb7db6..00000000 --- a/src/Gitonomy/Git/Parser/TreeParser.php +++ /dev/null @@ -1,42 +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; - -final class TreeParser extends ParserBase -{ - /** - * @var array - */ - public array $entries = []; - - protected function doParse(): void - { - 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 e7ca21ff..3cc87c94 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -20,19 +20,23 @@ * * @author Julien DIDIER */ -final readonly class PushReference +final class PushReference { public const string ZERO = '0000000000000000000000000000000000000000'; - private bool $isForce; + private ?bool $isForce = null; + /** + * @param string $reference The full name of the pushed reference + * @param string $before Its commit before the push, ZERO for a creation + * @param string $after Its commit after the push, ZERO for a deletion + */ public function __construct( - private Repository $repository, - private string $reference, - private string $before, - private string $after, + private readonly Repository $repository, + private readonly string $reference, + private readonly string $before, + private readonly string $after, ) { - $this->isForce = $this->getForce(); } public function getRepository(): Repository @@ -40,8 +44,21 @@ public function getRepository(): Repository return $this->repository; } + /** + * @return string The full name of the pushed reference + */ + public function getFullname(): string + { + return $this->reference; + } + + /** + * @deprecated since gitonomy/gitlib 2.0, use getFullname() instead + */ public function getReference(): string { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getFullname()" instead.', __METHOD__); + return $this->reference; } @@ -60,18 +77,28 @@ public function getAfter(): string */ public function getLog(array $excludes = []): Log { - return $this->repository->getLog(array_merge( - [$this->getRevision()], - array_map(static function ($e) { - return '^'.$e; - }, $excludes) - )); + return $this->repository->getLog([$this->getRange(), ...array_map(static fn (string $exclude): string => '^'.$exclude, $excludes)]); } + /** + * @deprecated since gitonomy/gitlib 2.0, use getRange() instead + */ public function getRevision(): string + { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getRange()" instead.', __METHOD__); + + return $this->getRange(); + } + + /** + * @return string The pushed commits: "before..after", or "after" for a creation + * + * @throws LogicException The push deletes the reference + */ + public function getRange(): string { if ($this->isDelete()) { - throw new LogicException('No revision for deletion'); + throw new LogicException('A deletion pushes no commit.'); } if ($this->isCreate()) { @@ -93,7 +120,7 @@ public function isDelete(): bool public function isForce(): bool { - return $this->isForce; + return $this->isForce ??= $this->getForce(); } public function isFastForward(): bool @@ -112,16 +139,7 @@ private function getForce(): bool return false; } - $result = $this->repository->run('merge-base', [ - $this->before, - $this->after, - ]); - - if (null === $result) { - // No common ancestor could be established: treat it as a force update. - return true; - } - - return $this->before !== trim($result); + // 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 869e5b97..751fd199 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -12,9 +12,6 @@ namespace Gitonomy\Git; -use Gitonomy\Git\Exception\ProcessException; -use Gitonomy\Git\Exception\ReferenceNotFoundException; - /** * Reference in a Git repository. * @@ -23,13 +20,15 @@ */ abstract class Reference extends Revision { - protected ?string $commitHash; - - public function __construct(Repository $repository, string $revision, ?string $commitHash = null) - { - parent::__construct($repository, $revision); - - $this->commitHash = $commitHash; + /** + * @param string $commitHash What the reference points to, through an annotated tag + */ + public function __construct( + Repository $repository, + string $fullname, + protected readonly string $commitHash, + ) { + parent::__construct($repository, $fullname); } public function getFullname(): string @@ -42,23 +41,12 @@ public function delete(): void $this->repository->getReferences()->delete($this->getFullname()); } + /** + * @return string The commit the reference points to, when it was read: the commit an annotated tag points to, for one + */ public function getCommitHash(): string { - if (null !== $this->commitHash) { - return $this->commitHash; - } - - try { - $result = $this->repository->run('rev-parse', ['--verify', $this->revision]); - } catch (ProcessException $e) { - throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision)); - } - - if (null === $result) { - throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision)); - } - - return $this->commitHash = trim($result); + return $this->commitHash; } /** @@ -66,7 +54,7 @@ public function getCommitHash(): string */ public function getCommit(): Commit { - return $this->repository->getCommit($this->getCommitHash()); + return $this->repository->getCommit($this->commitHash); } public function getLastModification(?string $path = null): Commit diff --git a/src/Gitonomy/Git/Reference/Branch.php b/src/Gitonomy/Git/Reference/Branch.php index fdf4e431..1ac791da 100644 --- a/src/Gitonomy/Git/Reference/Branch.php +++ b/src/Gitonomy/Git/Reference/Branch.php @@ -12,10 +12,9 @@ namespace Gitonomy\Git\Reference; -use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference; -use Symfony\Component\String\CodePointString; +use Gitonomy\Git\Revision; /** * Representation of a branch reference. @@ -24,8 +23,6 @@ */ final class Branch extends Reference { - private ?bool $local = null; - public function getName(): string { $fullname = $this->getFullname(); @@ -38,61 +35,59 @@ public function getName(): string return $vars['remote'].'/'.$vars['name']; } - throw new RuntimeException(\sprintf('Cannot extract branch name from "%s"', $fullname)); + throw new RuntimeException(\sprintf('Cannot extract branch name from "%s".', $fullname)); } public function isRemote(): bool { - $this->detectBranchType(); - - return !$this->local; + return str_starts_with($this->getFullname(), 'refs/remotes/'); } public function isLocal(): bool { - $this->detectBranchType(); + return !$this->isRemote(); + } - return $this->local; + /** + * Checks if the history of $revision contains this branch. + */ + public function isMergedInto(Revision|string $revision): bool + { + $destination = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); + + return \in_array($this->commitHash, $this->repository->getRepositoryDriver()->getMergeBases($this->commitHash, $destination), true); } /** * Check if this branch is merged to a destination branch * Optionally, check only with remote branches. + * + * @deprecated since gitonomy/gitlib 2.0, use isMergedInto() instead */ public function isMergedTo(string $destinationBranchName = 'master', bool $compareOnlyWithRemote = false): bool { - $arguments = ['-a']; - - if ($compareOnlyWithRemote) { - $arguments = ['-r']; - } + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isMergedInto()" instead.', __METHOD__, self::class); - $arguments[] = '--merged'; - $arguments[] = $destinationBranchName; - - try { - $result = $this->repository->run('branch', $arguments); - } catch (ProcessException $e) { - throw new RuntimeException(\sprintf('Cannot determine if merged to the branch "%s"', $destinationBranchName), $e->getCode(), $e); - } - - if (!$result) { + if ($compareOnlyWithRemote && $this->isLocal()) { return false; } - $output = explode("\n", trim(str_replace(['*', 'remotes/'], '', $result))); - $filtered_output = array_filter($output, static function ($v) { - return null === new CodePointString($v)->indexOf('->'); - }); - $trimmed_output = array_map('trim', $filtered_output); - - return \in_array($this->getName(), $trimmed_output, true); + return $this->isMergedInto($destinationBranchName); } - private function detectBranchType(): void + /** + * Points the branch to $revision. + * + * @return self The branch, pointing to $revision + */ + public function moveTo(Revision|string $revision): self { - if (null === $this->local) { - $this->local = !preg_match('#^refs/remotes/(?[^/]*)/(?.*)$#', $this->getFullname()); - } + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); + $this->repository->getRepositoryDriver()->updateReference($this->getFullname(), $hash); + + $references = $this->repository->getReferences(); + $references->invalidate(); + + return $this->isLocal() ? $references->getBranch($this->getName()) : $references->getRemoteBranch($this->getName()); } } diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index b07076de..17066c33 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -12,12 +12,11 @@ namespace Gitonomy\Git\Reference; -use Gitonomy\Git\Commit; -use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Driver\Data\TagData; use Gitonomy\Git\Exception\RuntimeException; -use Gitonomy\Git\Parser\ReferenceParser; -use Gitonomy\Git\Parser\TagParser; use Gitonomy\Git\Reference; +use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\Message; /** * Representation of a tag reference. @@ -27,22 +26,28 @@ */ final class Tag extends Reference { - private bool $loaded = false; - - private ?string $taggerName = null; - private ?string $taggerEmail = null; - private ?\DateTime $taggerDate = null; - private ?string $message = null; - private ?string $gpgSignature = null; + /** + * The annotated tag object, read through the driver when needed. + */ + private ?TagData $data = null; - // Values derived from the fields above, computed and cached independently. - private ?string $subjectMessage = null; - private ?string $bodyMessage = null; + /** + * @param string $commitHash What the tag points to, through its annotated tag object + * @param string|null $tagHash The annotated tag object, null for a lightweight tag + */ + public function __construct( + Repository $repository, + string $fullname, + string $commitHash, + private readonly ?string $tagHash = null, + ) { + parent::__construct($repository, $fullname, $commitHash); + } public function getName(): string { if (!preg_match('#^refs/tags/(.*)$#', $this->revision, $vars)) { - throw new RuntimeException(\sprintf('Cannot extract tag name from "%s"', $this->revision)); + throw new RuntimeException(\sprintf('Cannot extract tag name from "%s".', $this->revision)); } return $vars[1]; @@ -53,195 +58,80 @@ public function getName(): string */ public function isAnnotated(): bool { - try { - $result = $this->repository->run('cat-file', ['tag', $this->revision]); - } catch (ProcessException $e) { - return false; // Is not an annotated tag - } - - // In non-debug mode, a failed command returns null instead of throwing. - return null !== $result; + return null !== $this->tagHash; } /** - * Returns the actual commit associated with the tag, and not the hash of the tag if annotated. + * @return string|null The name of the tagger, null for a lightweight tag or a tag without tagger */ - public function getCommit(): Commit + public function getTaggerName(): ?string { - if ($this->isAnnotated()) { - try { - $output = $this->repository->run('show-ref', ['-d', '--tag', $this->revision]); - $parser = new ReferenceParser(); - $parser->parse($output); - - $commitHash = null; - foreach ($parser->references as [$row]) { - $commitHash = $row; - } - - if (null !== $commitHash) { - return $this->repository->getCommit($commitHash); - } - } catch (ProcessException $e) { - // ignore the exception - } - } - - return parent::getCommit(); + return $this->data()?->taggerName; } /** - * Returns the tagger name. + * @return string|null The email of the tagger, null for a lightweight tag or a tag without tagger */ - public function getTaggerName(): string|false + public function getTaggerEmail(): ?string { - if (!$this->isAnnotated()) { - return false; - } - $this->ensureLoaded(); - - return $this->taggerName ?? throw new \InvalidArgumentException('No data named "taggerName" in Tag.'); + return $this->data()?->taggerEmail; } /** - * Returns the comitter email. + * @return \DateTimeImmutable|null The tagging date, null for a lightweight tag or a tag without tagger */ - public function getTaggerEmail(): string|false + public function getTaggerDate(): ?\DateTimeImmutable { - if (!$this->isAnnotated()) { - return false; - } - $this->ensureLoaded(); - - return $this->taggerEmail ?? throw new \InvalidArgumentException('No data named "taggerEmail" in Tag.'); + return $this->data()?->taggerDate; } /** - * Returns the authoring date. + * @return string|null The message of the annotated tag, without its signature; null for a lightweight tag */ - public function getTaggerDate(): \DateTime|false + public function getMessage(): ?string { - if (!$this->isAnnotated()) { - return false; - } - $this->ensureLoaded(); - - return $this->taggerDate ?? throw new \InvalidArgumentException('No data named "taggerDate" in Tag.'); - } - - /** - * Returns the message of the commit. - */ - public function getMessage(): string|false - { - if (!$this->isAnnotated()) { - return false; - } - $this->ensureLoaded(); - - return $this->message ?? throw new \InvalidArgumentException('No data named "message" in Tag.'); + return $this->data()?->message; } /** - * Returns the subject message (the first line). + * @return string|null The first paragraph of the message, on one line, null for a lightweight tag */ - public function getSubjectMessage(): string|false + public function getSubjectMessage(): ?string { - if (!$this->isAnnotated()) { - return false; - } - - if (null === $this->subjectMessage) { - $lines = explode("\n", $this->getMessageOrThrow()); - $this->subjectMessage = reset($lines); - } + $message = $this->getMessage(); - return $this->subjectMessage; + return null === $message ? null : Message::subject($message); } /** - * Return the body message. + * @return string|null What follows the subject and its empty lines, null for a lightweight tag */ - public function getBodyMessage(): string|false + public function getBodyMessage(): ?string { - if (!$this->isAnnotated()) { - return false; - } - - if (null === $this->bodyMessage) { - $lines = explode("\n", $this->getMessageOrThrow()); - - // Drop the subject line, then the blank separator line if the - // message follows the "subject\n\nbody" convention. - array_shift($lines); - if (isset($lines[0]) && '' === $lines[0]) { - array_shift($lines); - } - if ([] !== $lines && '' === end($lines)) { - array_pop($lines); - } + $message = $this->getMessage(); - $this->bodyMessage = implode("\n", $lines); - } - - return $this->bodyMessage; + return null === $message ? null : Message::body($message); } /** - * Return the GPG signature. + * @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|false + public function getGpgSignature(): ?string { - if (!$this->isAnnotated()) { - return false; - } - $this->ensureLoaded(); - - return $this->gpgSignature ?? throw new \InvalidArgumentException('No data named "gpgSignature" in Tag.'); + return $this->data()?->gpgSignature; } - /** - * Check whether tag is signed. - */ public function isSigned(): bool { - try { - $this->getGPGSignature(); - - return true; - } catch (\InvalidArgumentException $e) { - return false; - } - } - - private function getMessageOrThrow(): string - { - $this->ensureLoaded(); - - return $this->message ?? throw new \InvalidArgumentException('No data named "message" in Tag.'); + return null !== $this->getGpgSignature(); } - private function ensureLoaded(): void + private function data(): ?TagData { - if ($this->loaded) { - return; + if (null === $this->tagHash) { + return null; } - $parser = new TagParser(); - $result = $this->repository->run('cat-file', ['tag', $this->revision]); - - if (null === $result) { - throw new \InvalidArgumentException('Unable to read tag data.'); - } - - $parser->parse($result); - - $this->taggerName = $parser->taggerName; - $this->taggerEmail = $parser->taggerEmail; - $this->taggerDate = $parser->taggerDate; - $this->message = $parser->message; - $this->gpgSignature = $parser->gpgSignature; - - $this->loaded = true; + return $this->data ??= $this->repository->getRepositoryDriver()->getTagData($this->tagHash); } } diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index e5408ad6..9cfa972b 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -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,34 +23,17 @@ * * @author Alexandre Salomé * @author Julien DIDIER + * + * @implements \IteratorAggregate */ final class ReferenceBag implements \Countable, \IteratorAggregate { /** - * Associative array of fullname references. - * - * @var array - */ - private array $references = []; - - /** - * List with all tags. + * The references, by full name: null until read, and after every write. * - * @var Tag[] - */ - private array $tags = []; - - /** - * List with all branches. - * - * @var Branch[] - */ - private array $branches = []; - - /** - * A boolean indicating if the bag is already initialized. + * @var array|null */ - private bool $initialized = false; + private ?array $references = null; public function __construct( private readonly Repository $repository, @@ -64,20 +47,12 @@ public function __construct( */ public function get(string $fullname): Reference { - $this->initialize(); - - if (!isset($this->references[$fullname])) { - throw new ReferenceNotFoundException($fullname); - } - - return $this->references[$fullname]; + return $this->references()[$fullname] ?? throw new ReferenceNotFoundException(\sprintf('The reference "%s" does not exist.', $fullname)); } public function has(string $fullname): bool { - $this->initialize(); - - return isset($this->references[$fullname]); + return isset($this->references()[$fullname]); } /** @@ -86,45 +61,50 @@ public function has(string $fullname): bool * @param T $reference * * @return T + * + * @deprecated since gitonomy/gitlib 2.0, use Branch::moveTo() instead */ public function update(Reference $reference): Reference { - $fullname = $reference->getFullname(); - - $this->initialize(); - $this->repository->run('update-ref', [$fullname, $reference->getCommitHash()]); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::moveTo()" instead.', __METHOD__, Branch::class); - $this->references[$fullname] = $reference; + $this->repository->getRepositoryDriver()->updateReference($reference->getFullname(), $reference->getCommitHash()); + $this->invalidate(); return $reference; } - public function createBranch(string $name, string $commitHash): Branch + /** + * @throws InvalidArgumentException The branch exists already + */ + public function createBranch(string $name, Revision|string $revision): Branch { - $branch = new Branch($this->repository, 'refs/heads/'.$name, $commitHash); + $this->create('refs/heads/'.$name, $revision); - return $this->update($branch); + return $this->getBranch($name); } - public function createTag(string $name, string $commitHash): Tag + /** + * Creates a lightweight tag. + * + * @throws InvalidArgumentException The tag exists already + */ + public function createTag(string $name, Revision|string $revision): Tag { - $tag = new Tag($this->repository, 'refs/tags/'.$name, $commitHash); + $this->create('refs/tags/'.$name, $revision); - return $this->update($tag); + return $this->getTag($name); } public function delete(string $fullname): void { - $this->repository->run('update-ref', ['-d', $fullname]); - - unset($this->references[$fullname]); + $this->repository->getRepositoryDriver()->deleteReference($fullname); + $this->invalidate(); } public function hasBranches(): bool { - $this->initialize(); - - return \count($this->branches) > 0; + return [] !== $this->getBranches(); } public function hasBranch(string $name): bool @@ -142,74 +122,39 @@ public function hasTag(string $name): bool return $this->has('refs/tags/'.$name); } - public function getFirstBranch(): Reference|false + public function getFirstBranch(): ?Branch { - $this->initialize(); - - return reset($this->branches); + return $this->getBranches()[0] ?? null; } /** - * @return Tag[] An array of Tag objects + * @return Tag[] The tags pointing to $commit, annotated or not */ - public function resolveTags(Commit|string $hash): array + public function resolveTags(Commit|string $commit): array { - $this->initialize(); - - if ($hash instanceof Commit) { - $hash = $hash->getHash(); - } - - $tags = []; - foreach ($this->references as $reference) { - if ($reference instanceof Tag && $reference->getCommitHash() === $hash) { - $tags[] = $reference; - } - } - - return $tags; + return array_values(array_filter($this->resolve($commit), static fn (Reference $reference): bool => $reference instanceof Tag)); } /** - * @return Branch[] An array of Branch objects + * @return Branch[] The branches pointing to $commit */ - public function resolveBranches(Commit|string $hash): array + public function resolveBranches(Commit|string $commit): array { - $this->initialize(); - - if ($hash instanceof Commit) { - $hash = $hash->getHash(); - } - - $branches = []; - foreach ($this->references as $reference) { - if ($reference instanceof Branch && $reference->getCommitHash() === $hash) { - $branches[] = $reference; - } - } - - return $branches; + return array_values(array_filter($this->resolve($commit), static fn (Reference $reference): bool => $reference instanceof Branch)); } /** - * @return Reference[] An array of references + * @return Reference[] The references pointing to $commit, through annotated tags */ - public function resolve(Commit|string $hash): array + public function resolve(Commit|string $commit): array { - $this->initialize(); + $hash = match (true) { + $commit instanceof Commit => $commit->getHash(), + 1 === preg_match('/^[0-9a-f]{40}$/', $commit) => $commit, + default => $this->repository->getRevision($commit)->getCommit()->getHash(), + }; - if ($hash instanceof Commit) { - $hash = $hash->getHash(); - } - - $result = []; - foreach ($this->references as $k => $reference) { - if ($reference->getCommitHash() === $hash) { - $result[] = $reference; - } - } - - return $result; + return array_values(array_filter($this->references(), static fn (Reference $reference): bool => $reference->getCommitHash() === $hash)); } /** @@ -217,9 +162,7 @@ public function resolve(Commit|string $hash): array */ public function getTags(): array { - $this->initialize(); - - return $this->tags; + return array_values(array_filter($this->references(), static fn (Reference $reference): bool => $reference instanceof Tag)); } /** @@ -227,16 +170,7 @@ public function getTags(): array */ public function getBranches(): array { - $this->initialize(); - - $result = []; - foreach ($this->references as $reference) { - if ($reference instanceof Branch) { - $result[] = $reference; - } - } - - return $result; + return array_values(array_filter($this->references(), static fn (Reference $reference): bool => $reference instanceof Branch)); } /** @@ -244,14 +178,7 @@ public function getBranches(): array */ public function getLocalBranches(): array { - $result = []; - foreach ($this->getBranches() as $branch) { - if ($branch->isLocal()) { - $result[] = $branch; - } - } - - return $result; + return array_values(array_filter($this->getBranches(), static fn (Branch $branch): bool => $branch->isLocal())); } /** @@ -259,14 +186,7 @@ public function getLocalBranches(): array */ public function getRemoteBranches(): array { - $result = []; - foreach ($this->getBranches() as $branch) { - if ($branch->isRemote()) { - $result[] = $branch; - } - } - - return $result; + return array_values(array_filter($this->getBranches(), static fn (Branch $branch): bool => $branch->isRemote())); } /** @@ -274,29 +194,21 @@ public function getRemoteBranches(): array */ public function getAll(): array { - $this->initialize(); - - return $this->references; + return $this->references(); } public function getTag(string $name): Tag { - $this->initialize(); - return $this->getAs('refs/tags/'.$name, Tag::class); } public function getBranch(string $name): Branch { - $this->initialize(); - return $this->getAs('refs/heads/'.$name, Branch::class); } public function getRemoteBranch(string $name): Branch { - $this->initialize(); - return $this->getAs('refs/remotes/'.$name, Branch::class); } @@ -305,19 +217,42 @@ public function getRemoteBranch(string $name): Branch */ public function count(): int { - $this->initialize(); - - return \count($this->references); + return \count($this->references()); } /** * @see IteratorAggregate + * + * @return \ArrayIterator */ public function getIterator(): \ArrayIterator { - $this->initialize(); + return new \ArrayIterator($this->references()); + } - return new \ArrayIterator($this->references); + /** + * Reads the references again when next needed: gitlib calls it after + * every write, through this bag or not (checkout, merge, run()...). + * + * @internal + */ + public function invalidate(): void + { + $this->references = null; + } + + /** + * @throws InvalidArgumentException $fullname exists already + */ + private function create(string $fullname, Revision|string $revision): void + { + if ($this->has($fullname)) { + throw new InvalidArgumentException(\sprintf('The reference "%s" exists already.', $fullname)); + } + + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); + $this->repository->getRepositoryDriver()->createReference($fullname, $hash); + $this->invalidate(); } /** @@ -332,45 +267,38 @@ private function getAs(string $fullname, string $class): Reference $reference = $this->get($fullname); if (!$reference instanceof $class) { - throw new ReferenceNotFoundException($fullname); + throw new ReferenceNotFoundException(\sprintf('The reference "%s" does not exist.', $fullname)); } return $reference; } - private function initialize(): void + /** + * @return array + */ + private function references(): array { - if (true === $this->initialized) { - return; - } - $this->initialized = true; - - try { - $parser = new Parser\ReferenceParser(); - $output = $this->repository->run('show-ref'); - } catch (RuntimeException $e) { - return; + if (null !== $this->references) { + return $this->references; } - $parser->parse($output); - foreach ($parser->references as $row) { - [$commitHash, $fullname] = $row; + $references = []; + foreach ($this->repository->getRepositoryDriver()->getReferences() as $data) { + $fullname = $data->fullname; - if (preg_match('#^refs/(heads|remotes)/(.*)$#', $fullname)) { - if (preg_match('#.*HEAD$#', $fullname)) { + if (str_starts_with($fullname, 'refs/heads/') || str_starts_with($fullname, 'refs/remotes/')) { + // The default branch of a remote, a symbolic reference to one of its branches; a remote may be named "foo/bar". + if (str_ends_with($fullname, '/HEAD') && str_starts_with($fullname, 'refs/remotes/')) { continue; } - $reference = new Branch($this->repository, $fullname, $commitHash); - $this->references[$fullname] = $reference; - $this->branches[] = $reference; - } elseif (preg_match('#^refs/tags/(.*)$#', $fullname)) { - $reference = new Tag($this->repository, $fullname, $commitHash); - $this->references[$fullname] = $reference; - $this->tags[] = $reference; + $references[$fullname] = new Branch($this->repository, $fullname, $data->commitHash); + } elseif (str_starts_with($fullname, 'refs/tags/')) { + $references[$fullname] = new Tag($this->repository, $fullname, $data->commitHash, $data->tagHash); } elseif ('refs/stash' === $fullname) { - $reference = new Stash($this->repository, $fullname, $commitHash); - $this->references[$fullname] = $reference; + $references[$fullname] = new Stash($this->repository, $fullname, $data->commitHash); } } + + return $this->references = $references; } } diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index d31a1885..cc599e7e 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -13,9 +13,19 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\Cli\CliRepositoryDriver; +use Gitonomy\Git\Driver\Cli\LegacyOptions; +use Gitonomy\Git\Driver\Data\ObjectType; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\WeakCache; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; @@ -25,6 +35,8 @@ * Main entry point for browsing a Git repository. * * @author Alexandre Salomé + * + * @phpstan-import-type Options from LegacyOptions */ final class Repository { @@ -41,25 +53,25 @@ final class Repository private readonly ?string $workingDir; /** - * Cache of commit objects, indexed by hash. + * The commits, by hash, as long as the application uses them. * - * @var array + * @var WeakCache */ - private array $commits = []; + private readonly WeakCache $commits; /** - * Cache of tree objects, indexed by hash. + * The trees, by hash, as long as the application uses them. * - * @var array + * @var WeakCache */ - private array $trees = []; + private readonly WeakCache $trees; /** - * Cache of blob objects, indexed by hash. + * The blobs, by hash, as long as the application uses them. * - * @var array + * @var WeakCache */ - private array $blobs = []; + private readonly WeakCache $blobs; /** * Reference bag associated to this repository. @@ -67,83 +79,150 @@ final class Repository private ?ReferenceBag $referenceBag = null; /** - * Logger (can be null). + * Backend used to run operations. */ - private ?LoggerInterface $logger; + private DriverInterface $driver; /** - * Path to git command. + * Operations on this repository, opened by the driver. */ - private readonly string $command; + private RepositoryDriverInterface $repositoryDriver; /** - * Debug flag, indicating if errors should be thrown. + * Opens the repository in $dir: a bare repository, or a working tree with + * its ".git" directory or file (a linked worktree, a submodule...). + * + * An array of options as second argument is deprecated: its "working_dir" + * option is the $workingDir argument, and its "logger", "command", + * "environment_variables", "inherit_environment_variables" and + * "process_timeout" options configure a CliDriver. + * + * @param Options|string|null $workingDir The working tree, when $dir is a git directory kept apart from it + * @param DriverInterface $driver Backend running the operations + * + * @throws InvalidArgumentException $dir doesn't exist or isn't a repository, or an option is unknown + * @throws DriverException The driver can't open the repository + * @throws UnsupportedOperationException The repository uses SHA-256 hashes */ - private readonly bool $debug; + public function __construct(string $dir, array|string|null $workingDir = null, DriverInterface $driver = new CliDriver()) + { + if (\is_array($workingDir)) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing an array of options to "%s()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.', __METHOD__); + + $options = new LegacyOptions($workingDir); + $optionsDriver = $options->repositoryDriver(); + if (null !== $optionsDriver) { + // The driver is the third argument. + if (\func_num_args() > 2) { + throw new InvalidArgumentException('The driver argument can\'t be combined with the "logger" or git options: configure the driver instead.'); + } + + $driver = $optionsDriver; + } + + $workingDir = $options->workingDir; + } + + $this->commits = new WeakCache(); + $this->trees = new WeakCache(); + $this->blobs = new WeakCache(); + $this->driver = $driver; + $this->repositoryDriver = $driver->open($dir, $workingDir); + $this->gitDir = $this->repositoryDriver->getGitDir(); + $this->workingDir = $this->repositoryDriver->getWorkingDir(); + } /** - * Environment variables that should be set for every running process. + * Initializes a repository with a working directory in $path. + * + * @throws DriverException The directory can't be written, or is already a file */ - private readonly array $environmentVariables; + public static function init(string $path, DriverInterface $driver = new CliDriver()): self + { + $driver->init($path); - private readonly bool $inheritEnvironmentVariables; + return new self($path, null, $driver); + } /** - * Timeout that should be set for every running process. + * Initializes a bare repository in $path. + * + * @throws DriverException The directory can't be written, or is already a file */ - private readonly int $processTimeout; + public static function initBare(string $path, DriverInterface $driver = new CliDriver()): self + { + $driver->initBare($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) + * Clones $url with a working directory in $path. * - * * debug : (default: true) enable/disable minimize errors and reduce log level + * @param string|null $branch The branch to check out, the default branch of $url when null * - * * logger : a logger to use for logging actions (Psr\Log\LoggerInterface) - * - * * environment_variables : define environment variables for every ran process + * @throws DriverException + */ + public static function cloneFrom(string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self + { + $driver->cloneFrom($url, $path, $branch); + + return new self($path, 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 */ - public function __construct(string $dir, array $options = []) + public static function cloneBareFrom(string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self { - $options = array_merge([ - 'working_dir' => null, - 'debug' => true, - 'logger' => null, - 'command' => 'git', - 'environment_variables' => [], - 'inherit_environment_variables' => false, - 'process_timeout' => 3600, - ], $options); + $driver->cloneBareFrom($url, $path, $branch); - 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']))); - } + return new self($path, null, $driver); + } - $this->logger = $options['logger']; - [$this->gitDir, $this->workingDir] = self::resolveDir($dir, $options['working_dir']); + /** + * Clones every reference of $url into a bare repository in $path, set up + * to mirror $url when fetching. + * + * @throws DriverException + */ + public static function mirrorFrom(string $url, string $path, DriverInterface $driver = new CliDriver()): self + { + $driver->mirrorFrom($url, $path); - $this->command = $options['command']; - $this->debug = (bool) $options['debug']; - $this->processTimeout = $options['process_timeout']; + return new self($path, null, $driver); + } - if (\defined('PHP_WINDOWS_VERSION_BUILD') && isset($_SERVER['PATH']) && !isset($options['environment_variables']['PATH'])) { - $options['environment_variables']['PATH'] = $_SERVER['PATH']; - } + /** + * Checks, without cloning it, that $url is a repository the driver can + * read: false when it doesn't exist, can't be reached, or refuses access. + */ + public static function isValidRepository(string $url, DriverInterface $driver = new CliDriver()): bool + { + return $driver->isValidRepository($url); + } - $this->environmentVariables = $options['environment_variables']; - $this->inheritEnvironmentVariables = $options['inherit_environment_variables']; + /** + * Checks, without cloning it, that $url is a repository the driver can + * read, and that it has the $branch branch. + */ + public static function isValidRepositoryAndBranch(string $url, string $branch, DriverInterface $driver = new CliDriver()): bool + { + return $driver->isValidRepositoryAndBranch($url, $branch); + } - if (true === $this->debug && null !== $this->logger) { - $this->logger->debug(\sprintf('Repository created (git dir: "%s", working dir: "%s")', $this->gitDir, $this->workingDir ?: 'none')); - } + /** + * Returns the operations on this repository, opened by the driver. + * + * @internal + */ + public function getRepositoryDriver(): RepositoryDriverInterface + { + return $this->repositoryDriver; } /** @@ -156,10 +235,8 @@ public function isBare(): bool /** * Returns the HEAD resolved as a commit. - * - * @return Commit|null returns a Commit or ``null`` if repository is empty */ - public function getHeadCommit(): ?Commit + public function getHeadCommit(): Commit { $head = $this->getHead(); @@ -171,50 +248,19 @@ public function getHeadCommit(): ?Commit } /** - * @return Reference|Commit|null current HEAD object or null if error occurs + * @return Reference|Commit The branch HEAD points to, or the commit when HEAD is detached * - * @throws RuntimeException Unable to find file HEAD (debug-mode only) + * @throws ReferenceNotFoundException HEAD points to a branch without commits yet */ - public function getHead(): Reference|Commit|null + public function getHead(): Reference|Commit { - $file = $this->gitDir.'/HEAD'; - - if (!file_exists($file)) { - $message = \sprintf('Unable to find HEAD file ("%s")', $file); - - if (null !== $this->logger) { - $this->logger->error($message); - } - - if (true === $this->debug) { - throw new RuntimeException($message); - } - } - - $content = trim(file_get_contents($file)); - - if (null !== $this->logger) { - $this->logger->debug('HEAD file read: '.$content); - } + $reference = $this->repositoryDriver->getHeadReference(); - if (preg_match('/^ref: (.+)$/', $content, $vars)) { - return $this->getReferences()->get($vars[1]); - } - if (preg_match('/^[0-9a-f]{40}$/', $content)) { - return $this->getCommit($content); - } - - $message = \sprintf('Unexpected HEAD file content (file: %s). Content of file: %s', $file, $content); - - if (null !== $this->logger) { - $this->logger->error($message); - } - - if (true === $this->debug) { - throw new RuntimeException($message); + if (null === $reference) { + return $this->getCommit($this->repositoryDriver->resolveRevision('HEAD')); } - return null; + return $this->getReferences()->get($reference); } public function isHeadDetached(): bool @@ -224,7 +270,7 @@ public function isHeadDetached(): bool public function isHeadAttached(): bool { - return $this->getHead() instanceof Reference; + return null !== $this->repositoryDriver->getHeadReference(); } /** @@ -268,68 +314,122 @@ public function getWorkingCopy(): WorkingCopy } /** - * Returns the reference list associated to the repository. + * Returns the references of the repository, read again after every write + * gitlib makes. Open a new Repository to see the writes of other + * processes. * - * @param bool $reload Reload references from the filesystem + * Passing true, to read them again, is deprecated. */ public function getReferences(bool $reload = false): ReferenceBag { - if (null === $this->referenceBag || $reload) { - $this->referenceBag = new ReferenceBag($this); + $this->referenceBag ??= new ReferenceBag($this); + + if (\func_num_args() > 0) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing an argument to "%s()" is deprecated, gitlib reads the references again after its writes: open a new Repository to see the writes of other processes.', __METHOD__); + } + + if ($reload) { + $this->referenceBag->invalidate(); } return $this->referenceBag; } /** - * Instanciates a commit object or fetches one from the cache. + * Returns a commit, the same object while the application uses it. + * + * @param string $hash The full hash of the commit: getRevision()->getCommit() resolves a name * - * @param string $hash A commit hash, with a length of 40 + * @throws ReferenceNotFoundException $hash isn't a full hash */ public function getCommit(string $hash): Commit { - if (!isset($this->commits[$hash])) { - $this->commits[$hash] = new Commit($this, $hash); + $commit = $this->commits->get($hash); + if (null === $commit) { + $commit = new Commit($this, $hash); + $this->commits->set($hash, $commit); } - return $this->commits[$hash]; + return $commit; } /** - * Instanciates a tree object or fetches one from the cache. + * Returns a tree, the same object while the application uses it. * - * @param string $hash A tree hash, with a length of 40 + * @param string $hash The hash of the tree, abbreviated or not + * + * @throws ReferenceNotFoundException $hash isn't a hash, or names nothing */ public function getTree(string $hash): Tree { - if (!isset($this->trees[$hash])) { - $this->trees[$hash] = new Tree($this, $hash); + $hash = $this->fullHash($hash, ObjectType::Tree); + $tree = $this->trees->get($hash); + if (null === $tree) { + $tree = new Tree($this, $hash); + $this->trees->set($hash, $tree); } - return $this->trees[$hash]; + return $tree; } /** - * Instanciates a blob object or fetches one from the cache. + * Returns a blob, the same object while the application uses it. + * + * @param string $hash The hash of the blob, abbreviated or not * - * @param string $hash A blob hash, with a length of 40 + * @throws ReferenceNotFoundException $hash isn't a hash, or names nothing */ public function getBlob(string $hash): Blob { - if (!isset($this->blobs[$hash])) { - $this->blobs[$hash] = new Blob($this, $hash); + $hash = $this->fullHash($hash, ObjectType::Blob); + $blob = $this->blobs->get($hash); + if (null === $blob) { + $blob = new Blob($this, $hash); + $this->blobs->set($hash, $blob); } - return $this->blobs[$hash]; + return $blob; } - public function getBlame(string|Revision $revision, string $file, ?string $lineRange = null): Blame + /** + * Returns the best common ancestor of two commits, the one "git diff a...b" compares to. + * + * @return Commit|null Null when they have no common history + */ + public function getMergeBase(Revision|string $one, Revision|string $two): ?Commit { - if (\is_string($revision)) { - $revision = $this->getRevision($revision); + $hash = $this->repositoryDriver->getMergeBases($this->commitHash($one), $this->commitHash($two))[0] ?? null; + + return null === $hash ? null : $this->getCommit($hash); + } + + /** + * Returns the commits that last changed the lines of $file. + * + * A string as third argument, "," or ",+" like + * "git blame -L", is deprecated. + * + * @param int|string|null $firstLine The first line to blame, from 1, the first one of the file when null + * @param int|null $lastLine The last line to blame, the last one of the file when null + */ + public function getBlame(Revision|string $revision, string $file, int|string|null $firstLine = null, ?int $lastLine = null): Blame + { + if (\is_string($firstLine)) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing a range of lines as a string to "%s()" is deprecated, pass the first and the last lines instead.', __METHOD__); + + if (!preg_match('/^(\d+),(\+?)(\d*)$/', $firstLine, $vars)) { + throw new InvalidArgumentException(\sprintf('Unsupported range of lines "%s": pass the first and the last lines instead.', $firstLine)); + } + + $firstLine = (int) $vars[1]; + $lastLine = match (true) { + '' === $vars[3] => null, + '+' === $vars[2] => $firstLine + (int) $vars[3] - 1, + default => (int) $vars[3], + }; } - return new Blame($this, $revision, $file, $lineRange); + return new Blame($this, \is_string($revision) ? $this->getRevision($revision) : $revision, $file, $firstLine, $lastLine); } /** @@ -349,7 +449,38 @@ public function getLog(RevisionList|Revision|string|array|null $revisions = null return new Log($this, $revisions, $paths, $offset, $limit); } - public function getDiff(RevisionList|Revision|string|array|null $revisions): Diff + /** + * Returns the changes from the commit $from to the commit $to. + * + * Passing one argument is deprecated: an "a..b" or "a...b" range, two + * revisions in an array or a RevisionList, or one revision to compare + * with the working tree, like WorkingCopy::getDiff() does. + * + * @param RevisionList|Revision|string|array|null $from + */ + public function getDiff(RevisionList|Revision|string|array|null $from, Revision|string|null $to = null): Diff + { + if (null === $to) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Calling "%s()" with one argument is deprecated, pass the revisions to compare as "from" and "to" arguments, or use "%s::getDiff()" to compare a revision with the working tree.', __METHOD__, WorkingCopy::class); + + return $this->getLegacyDiff($from); + } + + if (\is_array($from) || $from instanceof RevisionList || null === $from) { + throw new InvalidArgumentException('The "from" argument of "Repository::getDiff()" must be a revision, when "to" is given.'); + } + + return Diff::fromData($this->repositoryDriver->getDiff($this->commitHash($from), $this->commitHash($to)), $this); + } + + /** + * For the deprecated forms of getDiff() and Log::getDiff(), to be removed in 3.0. + * + * @param RevisionList|Revision|string|array|null $revisions + * + * @internal + */ + public function getLegacyDiff(RevisionList|Revision|string|array|null $revisions): Diff { if (null === $revisions) { throw new InvalidArgumentException('Cannot compute a diff without revisions.'); @@ -358,18 +489,31 @@ public function getDiff(RevisionList|Revision|string|array|null $revisions): Dif if (!$revisions instanceof RevisionList) { $revisions = new RevisionList($this, $revisions); } + $revisions = $revisions->getAsTextArray(); + + if (2 === \count($revisions)) { + return $this->getDiff($revisions[0], $revisions[1]); + } + + if (1 === \count($revisions) && str_contains($revisions[0], '..')) { + $symmetric = str_contains($revisions[0], '...'); + [$from, $to] = explode($symmetric ? '...' : '..', $revisions[0], 2); + $from = '' === $from ? 'HEAD' : $from; + $to = '' === $to ? 'HEAD' : $to; - $args = array_merge(['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index'], $revisions->getAsTextArray()); + if ($symmetric) { + // Like git diff: from the best common ancestor. + $from = $this->getMergeBase($from, $to) ?? throw new InvalidArgumentException(\sprintf('The revisions of "%s" have no common ancestor.', $revisions[0])); + } - $result = $this->run('diff', $args); - if (null === $result) { - throw new RuntimeException('Unable to compute diff.'); + return $this->getDiff($from, $to); } - $diff = Diff::parse($result); - $diff->setRepository($this); + if (1 === \count($revisions)) { + return $this->getWorkingCopy()->getDiff($revisions[0]); + } - return $diff; + throw new InvalidArgumentException(\sprintf('Cannot compute a diff of "%s".', implode('", "', $revisions))); } /** @@ -380,24 +524,25 @@ public function getDiff(RevisionList|Revision|string|array|null $revisions): Dif public function getSize(): int { $totalBytes = 0; - $path = realpath($this->gitDir); - if ($path && file_exists($path)) { - /** @var \SplFileInfo $object */ - foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)) as $object) { - $totalBytes += $object->getSize(); - } + /** @var \SplFileInfo $file */ + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->gitDir, \FilesystemIterator::SKIP_DOTS)) as $file) { + $totalBytes += $file->getSize(); } - return (int) ($totalBytes / 1000 + 0.5); + return (int) round($totalBytes / 1000); } /** * Executes a shell command on the repository, using PHP pipes. * * @param string $command The command to execute + * + * @deprecated since gitonomy/gitlib 2.0, run "git shell" with Symfony Process instead */ public function shell(string $command, array $env = []): void { + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, run "git shell" with Symfony Process instead: it ignores the driver.', __METHOD__); + $argument = \sprintf('%s %s', $command, escapeshellarg($this->gitDir)); $prefix = ''; @@ -424,21 +569,17 @@ public function getHooks(): Hooks 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 self::DEFAULT_DESCRIPTION; + $description = file_get_contents($file); + if (false === $description) { + throw new RuntimeException(\sprintf('Unable to read "%s".', $file)); } - return file_get_contents($this->gitDir.'/description'); + return $description; } /** @@ -456,12 +597,9 @@ public function hasDescription(): bool */ 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; } @@ -478,15 +616,14 @@ public function setDescription(string $description): static * @param string $command Git command to run (checkout, branch, tag) * @param array $args Arguments of git command * - * @return string|null output of a successful process or null if execution failed and debug-mode is disabled + * @return string output of the process * - * @throws RuntimeException Error while executing git command (debug-mode only) + * @throws ProcessException Error while executing git command + * @throws UnsupportedOperationException The driver isn't a CliDriver */ - public function run(string $command, array $args = []): ?string + public function run(string $command, array $args = []): string { - $process = $this->runProcess($command, $args); - - return $process->isSuccessful() ? $process->getOutput() : null; + return $this->runProcess($command, $args)->getOutput(); } /** @@ -500,132 +637,114 @@ public function run(string $command, array $args = []): ?string * @param string $command Git command to run (checkout, branch, tag) * @param array $args Arguments of git command * - * @throws RuntimeException Error while executing git command (debug-mode only) + * @throws ProcessException Error while executing git command + * @throws UnsupportedOperationException The driver isn't a CliDriver */ public function runProcess(string $command, array $args = []): Process { - $process = $this->getProcess($command, $args); - - if ($this->logger) { - $this->logger->info(\sprintf('run command: %s "%s" ', $command, implode(' ', $args))); - $before = microtime(true); + if (!$this->repositoryDriver instanceof CliRepositoryDriver) { + throw new UnsupportedOperationException('Only CliDriver can run git commands.'); } - $process->run(); - - if ($this->logger && $this->debug) { - $duration = microtime(true) - $before; - $this->logger->debug(\sprintf('last command (%s) duration: %sms', $command, \sprintf('%.2f', $duration * 1000))); - $this->logger->debug(\sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); - $this->logger->debug(\sprintf('last command (%s) output: %s', $command, $process->getOutput())); - } - - if (!$process->isSuccessful()) { - $error = \sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput()); - - if ($this->logger) { - $this->logger->error($error); - } - - if ($this->debug) { - throw new ProcessException($process); - } + try { + return $this->repositoryDriver->runProcess($command, $args); + } finally { + // Any command may write references, even a failing one. + $this->referenceBag?->invalidate(); } - - return $process; } /** * Set repository logger. * - * @param LoggerInterface $logger A logger - * * @return $this + * + * @throws UnsupportedOperationException The driver isn't a CliDriver + * + * @deprecated since gitonomy/gitlib 2.0, pass a CliDriver configured with the logger as driver argument instead */ public function setLogger(LoggerInterface $logger): static { - $this->logger = $logger; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, pass a "%s" configured with the logger as driver argument instead.', __METHOD__, CliDriver::class); + + if (!$this->driver instanceof CliDriver) { + throw new UnsupportedOperationException('Only CliDriver logs the git commands it runs.'); + } + + $this->driver = $this->driver->withLogger($logger); + $this->repositoryDriver = $this->driver->open($this->gitDir, $this->workingDir); return $this; } /** - * Returns repository logger. + * Returns the logger of the CliDriver. + * + * @deprecated since gitonomy/gitlib 2.0, the logger is an argument of CliDriver */ public function getLogger(): ?LoggerInterface { - return $this->logger; + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, the logger is an argument of "%s".', __METHOD__, CliDriver::class); + + return $this->driver instanceof CliDriver ? $this->driver->getLogger() : null; } /** * 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 + * The clone uses the driver of the current repository. * - * @return Repository the newly created repository - */ - public function cloneTo(string $path, bool $bare = true, array $options = []): self - { - return Admin::cloneTo($path, $this->gitDir, $bare, $options); - } - - /** - * Resolves directory attributes for the repository. + * @param string $path path to the new repository in which current repository will be cloned + * @param bool $bare flag indicating if repository is bare or has a working-copy + * @param Options $options options of the new repository * - * @param string $gitDir directory of a working copy with files checked out - * @param string|null $workingDir directory containing git files (objects, config...) + * @return Repository the newly created repository * - * @return array{string, string|null} + * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() with getGitDir() as URL instead */ - private static function resolveDir(string $gitDir, ?string $workingDir = null): array + public function cloneTo(string $path, bool $bare = true, array $options = []): self { - $realGitDir = realpath($gitDir); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" with "getGitDir()" as URL instead.', __METHOD__, self::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); - if (false === $realGitDir) { - throw new InvalidArgumentException(\sprintf('Directory "%s" does not exist or is not a directory', $gitDir)); - } - if (!is_dir($realGitDir)) { - throw new InvalidArgumentException(\sprintf('Directory "%s" does not exist or is not a directory', $realGitDir)); - } - if (null === $workingDir && is_dir($realGitDir.'/.git')) { - $workingDir = $realGitDir; - $realGitDir .= '/.git'; + // Like in 1.x, the options configuring git apply to the clone. + $options = new LegacyOptions($options); + $driver = $options->adminDriver() ?? $this->driver; + if ($bare) { + $driver->cloneBareFrom($this->gitDir, $path, null); + } else { + $driver->cloneFrom($this->gitDir, $path, null); } - return [$realGitDir, $workingDir]; + return new self($path, $options->workingDir, $options->repositoryDriver() ?? $driver); + } + + private function commitHash(Revision|string $revision): string + { + return ($revision instanceof Revision ? $revision : $this->getRevision($revision))->getCommit()->getHash(); } /** - * This internal method is used to create a process object. + * @param ObjectType $type The type of the object, which an abbreviated hash must name * - * Made private to be sure that process creation is handled through the run method. - * run method ensures logging and debug. - * - * @see self::run + * @throws ReferenceNotFoundException $hash isn't a hash, or names no object of this type */ - private function getProcess(string $command, array $args = []): Process + private function fullHash(string $hash, ObjectType $type): string { - $base = [$this->command, '--git-dir', $this->gitDir]; - - if ($this->workingDir) { - $base = array_merge($base, ['--work-tree', $this->workingDir]); + if (!preg_match('/^[0-9a-f]{4,40}$/', $hash)) { + throw new ReferenceNotFoundException(\sprintf('"%s" is not a hash.', $hash)); } - $base[] = $command; - - $process = new Process(array_merge($base, $args)); - $process->setWorkingDirectory($this->getPath()); - - if ($this->inheritEnvironmentVariables) { - $process->setEnv(array_replace($_SERVER, $this->environmentVariables)); - } else { - $process->setEnv($this->environmentVariables); + // The type of an object of a full hash is checked when it is read. + if (40 === \strlen($hash)) { + return $hash; } - $process->setTimeout($this->processTimeout); - $process->setIdleTimeout($this->processTimeout); + $fullHash = $this->repositoryDriver->resolveRevision($hash.'^{'.$type->value.'}'); + // "^{tree}" also peels a commit to its tree. + if (!str_starts_with($fullHash, $hash)) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value)); + } - return $process; + return $fullHash; } } diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index b6c831b6..15430584 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -12,6 +12,8 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\ReferenceNotFoundException; + /** * @author Alexandre Salomé */ @@ -29,11 +31,19 @@ public function getLog(array|string|null $paths = null, ?int $offset = null, ?in } /** - * Returns the last modification date of the reference. + * Returns the commit the revision names. + * + * @throws ReferenceNotFoundException The revision names no commit */ public function getCommit(): Commit { - return $this->getLog()->getSingleCommit(); + try { + $hash = $this->repository->getRepositoryDriver()->resolveRevision($this->revision.'^{commit}'); + } catch (ReferenceNotFoundException $e) { + throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist, or names no commit.', $this->revision), 0, $e); + } + + return $this->repository->getCommit($hash); } public function getRevision(): string diff --git a/src/Gitonomy/Git/RevisionList.php b/src/Gitonomy/Git/RevisionList.php index 609d354d..c31c0972 100644 --- a/src/Gitonomy/Git/RevisionList.php +++ b/src/Gitonomy/Git/RevisionList.php @@ -12,6 +12,8 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\InvalidArgumentException; + /** * @author Alexandre Salomé */ @@ -35,8 +37,8 @@ public function __construct(Repository $repository, string|Revision|array $revis $revisions = [$revisions]; } - if (0 == \count($revisions)) { - throw new \InvalidArgumentException('Empty revision list not allowed'); + if ([] === $revisions) { + throw new InvalidArgumentException('A revision list needs one revision at least.'); } $normalizedRevisions = []; @@ -44,7 +46,7 @@ public function __construct(Repository $repository, string|Revision|array $revis if (\is_string($revision)) { $revision = new Revision($repository, $revision); } elseif (!$revision instanceof Revision) { - throw new \InvalidArgumentException(\sprintf('Expected a "Revision", got a "%s".', \is_object($revision) ? $revision::class : \gettype($revision))); + throw new InvalidArgumentException(\sprintf('Expected a "Revision", got a "%s".', get_debug_type($revision))); } $normalizedRevisions[] = $revision; diff --git a/src/Gitonomy/Git/Submodule.php b/src/Gitonomy/Git/Submodule.php new file mode 100644 index 00000000..92990d2a --- /dev/null +++ b/src/Gitonomy/Git/Submodule.php @@ -0,0 +1,38 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git; + +/** + * A submodule in a tree: the commit of another repository it points to. + * + * @author Grégoire Pineau + */ +final readonly class Submodule +{ + public function __construct(private string $hash) + { + } + + /** + * @return string The commit of the submodule, in its own repository + */ + public function getHash(): string + { + return $this->hash; + } +} + +// For the deprecated name, like Symfony renames classes. +if (!class_exists(CommitReference::class, false)) { + class_alias(Submodule::class, CommitReference::class); +} diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index 43f9f706..2eb8e17b 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -12,25 +12,18 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\UnexpectedValueException; /** * @author Alexandre Salomé */ final class Tree { - private bool $isInitialized = false; - /** - * @var array + * @var array|null By name, null until read */ - private array $entries; - - /** - * @var array{blob: array, tree: array, commit: array} - */ - private array $entriesByType; + private ?array $entries = null; public function __construct( private readonly Repository $repository, @@ -44,111 +37,90 @@ public function getHash(): string } /** - * @return array An associative array name => $object + * @return array By name */ public function getEntries(): array { - $this->initialize(); + if (null !== $this->entries) { + return $this->entries; + } + + $this->entries = []; + foreach ($this->repository->getRepositoryDriver()->getTreeEntries($this->hash) as $data) { + $object = match ($data->type) { + ObjectType::Blob => $this->repository->getBlob($data->hash), + ObjectType::Tree => $this->repository->getTree($data->hash), + // A commit in a tree is a submodule. + default => new Submodule($data->hash), + }; + $this->entries[$data->name] = new TreeEntry($data->name, $data->mode, $object); + } return $this->entries; } /** - * @return array An associative array of name => [mode, commit reference] + * @return array The submodules, by name + */ + public function getSubmoduleEntries(): array + { + return array_filter($this->getEntries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof Submodule); + } + + /** + * @return array The submodules, by name + * + * @deprecated since gitonomy/gitlib 2.0, use getSubmoduleEntries() instead */ public function getCommitReferenceEntries(): array { - $this->initialize(); + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getSubmoduleEntries()" instead.', __METHOD__); - return $this->entriesByType['commit']; + return $this->getSubmoduleEntries(); } /** - * @return array An associative array of name => [mode, tree] + * @return array The trees, by name */ public function getTreeEntries(): array { - $this->initialize(); - - return $this->entriesByType['tree']; + return array_filter($this->getEntries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof self); } /** - * @return array An associative array of name => [mode, blob] + * @return array The blobs, by name */ public function getBlobEntries(): array { - $this->initialize(); - - return $this->entriesByType['blob']; + return array_filter($this->getEntries(), static fn (TreeEntry $entry): bool => $entry->getObject() instanceof Blob); } - public function getEntry(string $name): CommitReference|self|Blob + /** + * @throws InvalidArgumentException The tree has no entry $name + */ + public function getEntry(string $name): Submodule|self|Blob { - $this->initialize(); - - if (!isset($this->entries[$name])) { - throw new InvalidArgumentException('No entry '.$name); - } - - return $this->entries[$name][1]; + return ($this->getEntries()[$name] ?? throw new InvalidArgumentException(\sprintf('The tree "%s" has no entry "%s".', $this->hash, $name)))->getObject(); } - public function resolvePath(string $path): CommitReference|self|Blob + /** + * @param string $path Relative to the tree, "" or "/" for the tree itself + * + * @throws InvalidArgumentException $path doesn't exist in the tree + */ + public function resolvePath(string $path): Submodule|self|Blob { - if ('' == $path) { - return $this; - } - - $path = preg_replace('#^/#', '', $path); - - $segments = explode('/', $path); $element = $this; - foreach ($segments as $segment) { - if ($element instanceof self) { - $element = $element->getEntry($segment); - } elseif ($element instanceof Blob) { - throw new InvalidArgumentException('Unresolvable path'); - } else { - throw new UnexpectedValueException('Unknow type of element'); + $resolved = []; + foreach (array_filter(explode('/', $path), static fn (string $segment): bool => '' !== $segment) as $segment) { + if (!$element instanceof self) { + throw new InvalidArgumentException(\sprintf('Unable to resolve "%s": "%s" is a %s, not a tree.', $path, implode('/', $resolved), $element instanceof Blob ? 'file' : 'submodule')); } - } - - return $element; - } - private function initialize(): void - { - if (true === $this->isInitialized) { - return; + $resolved[] = $segment; + $element = ($element->getEntries()[$segment] ?? throw new InvalidArgumentException(\sprintf('Unable to resolve "%s": "%s" doesn\'t exist.', $path, implode('/', $resolved))))->getObject(); } - $output = $this->repository->run('cat-file', ['-p', $this->hash]); - $parser = new Parser\TreeParser(); - $parser->parse($output); - - $this->entries = []; - $this->entriesByType = [ - 'blob' => [], - 'tree' => [], - 'commit' => [], - ]; - - foreach ($parser->entries as $entry) { - [$mode, $type, $hash, $name] = $entry; - if ('blob' == $type) { - $treeEntry = [$mode, $this->repository->getBlob($hash)]; - $this->entriesByType['blob'][$name] = $treeEntry; - } elseif ('tree' == $type) { - $treeEntry = [$mode, $this->repository->getTree($hash)]; - $this->entriesByType['tree'][$name] = $treeEntry; - } else { - $treeEntry = [$mode, new CommitReference($hash)]; - $this->entriesByType['commit'][$name] = $treeEntry; - } - $this->entries[$name] = $treeEntry; - } - - $this->isInitialized = true; + return $element; } } diff --git a/src/Gitonomy/Git/TreeEntry.php b/src/Gitonomy/Git/TreeEntry.php new file mode 100644 index 00000000..d4dd8daa --- /dev/null +++ b/src/Gitonomy/Git/TreeEntry.php @@ -0,0 +1,82 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; + +/** + * An entry of a tree: a blob, a tree or a submodule, with its name and mode. + * + * Reading it as an array, [$mode, $object], is deprecated. + * + * @author Grégoire Pineau + * + * @implements \ArrayAccess + */ +final readonly class TreeEntry implements \ArrayAccess +{ + public function __construct( + private string $name, + private string $mode, + private Blob|Tree|Submodule $object, + ) { + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return string The mode git stores, like "100644" for a file or "040000" for a tree + */ + public function getMode(): string + { + return $this->mode; + } + + public function getObject(): Blob|Tree|Submodule + { + return $this->object; + } + + public function offsetExists(mixed $offset): bool + { + return 0 === $offset || 1 === $offset; + } + + /** + * @deprecated since gitonomy/gitlib 2.0, use getMode() and getObject() instead + */ + public function offsetGet(mixed $offset): string|Blob|Tree|Submodule + { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Reading a "%s" as an array is deprecated, use its "getMode()" and "getObject()" methods instead.', self::class); + + return match ($offset) { + 0 => $this->mode, + 1 => $this->object, + default => throw new InvalidArgumentException(\sprintf('A tree entry has no offset %s.', var_export($offset, true))), + }; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new LogicException('A tree entry is immutable.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new LogicException('A tree entry is immutable.'); + } +} diff --git a/src/Gitonomy/Git/Util/Message.php b/src/Gitonomy/Git/Util/Message.php new file mode 100644 index 00000000..7e671b1d --- /dev/null +++ b/src/Gitonomy/Git/Util/Message.php @@ -0,0 +1,39 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * Splits the message of a commit or a tag like git does. + * + * @author Grégoire Pineau + * + * @internal + */ +final class Message +{ + /** + * @return string The first paragraph of $message, on one line, like "git log --format=%s" + */ + public static function subject(string $message): string + { + return implode(' ', array_map(trim(...), explode("\n", trim(explode("\n\n", $message, 2)[0])))); + } + + /** + * @return string What follows the first paragraph of $message and its empty lines, like "git log --format=%b" + */ + public static function body(string $message): string + { + return ltrim(explode("\n\n", trim($message, "\n")."\n", 2)[1] ?? '', "\n"); + } +} diff --git a/src/Gitonomy/Git/Util/NulByte.php b/src/Gitonomy/Git/Util/NulByte.php new file mode 100644 index 00000000..c6b7a8c7 --- /dev/null +++ b/src/Gitonomy/Git/Util/NulByte.php @@ -0,0 +1,38 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * git and libgit2 read their arguments as C strings, which end at the first + * NUL byte: libgit2 would read "a.txt\0.md" as "a.txt". + * + * @author Grégoire Pineau + * + * @internal + */ +final class NulByte +{ + /** + * @throws InvalidArgumentException One of the values contains a NUL byte + */ + public static function assertNone(string ...$values): void + { + foreach ($values as $value) { + if (str_contains($value, "\0")) { + throw new InvalidArgumentException(\sprintf('"%s" contains a NUL byte, which git doesn\'t accept.', addcslashes($value, "\0"))); + } + } + } +} diff --git a/src/Gitonomy/Git/Util/WeakCache.php b/src/Gitonomy/Git/Util/WeakCache.php new file mode 100644 index 00000000..edaf2c20 --- /dev/null +++ b/src/Gitonomy/Git/Util/WeakCache.php @@ -0,0 +1,62 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * Objects by key, as long as the application uses them. + * + * The keys of the objects freed since are removed once the cache has grown + * twice as big: a long-running process reading millions of commits keeps + * only the ones it uses. + * + * @author Grégoire Pineau + * + * @template T of object + * + * @internal + */ +final class WeakCache +{ + private const int MIN_SWEEP_SIZE = 64; + + /** + * @var array> + */ + private array $references = []; + + private int $sweepSize = self::MIN_SWEEP_SIZE; + + /** + * @return T|null + */ + public function get(string $key): ?object + { + return ($this->references[$key] ?? null)?->get(); + } + + /** + * @param T $object + */ + public function set(string $key, object $object): void + { + $this->references[$key] = \WeakReference::create($object); + + if (\count($this->references) < $this->sweepSize) { + return; + } + + $this->references = array_filter($this->references, static fn (\WeakReference $reference): bool => null !== $reference->get()); + // Each sweep at least halves the cache, or waits for it to double: constant time on average. + $this->sweepSize = max(self::MIN_SWEEP_SIZE, 2 * \count($this->references)); + } +} diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index 3d3612dd..988004ba 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -15,7 +15,7 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\LogicException; -use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Reference\Branch; /** * @author Alexandre Salomé @@ -26,7 +26,7 @@ public function __construct( private Repository $repository, ) { if ($this->repository->isBare()) { - throw new LogicException('Can\'t create a working copy on a bare repository'); + throw new LogicException('A bare repository has no working copy.'); } } @@ -35,97 +35,113 @@ public function __construct( */ public function getUntrackedFiles(): array { - $output = $this->run('status', ['--porcelain', '--untracked-files=all', '-z']); - if (null === $output) { - return []; - } + return $this->repository->getRepositoryDriver()->getUntrackedFiles(); + } - $lines = explode("\0", $output); - $lines = array_filter($lines, static function ($l) { - return '?? ' === substr($l, 0, 3); - }); + /** + * Returns the changes of the working tree, staged or not, since $revision. + */ + public function getDiff(Revision|string $revision = 'HEAD'): Diff + { + $hash = ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit()->getHash(); - return array_map(static function ($l) { - return substr($l, 3); - }, $lines); + return Diff::fromData($this->repository->getRepositoryDriver()->getWorkingTreeDiff($hash), $this->repository); } + /** + * Returns the changes of the working tree not staged yet. + */ public function getDiffPending(): Diff { - $result = $this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index']); - if (null === $result) { - throw new RuntimeException('Unable to compute pending diff.'); - } - - $diff = Diff::parse($result); - $diff->setRepository($this->repository); - - return $diff; + return Diff::fromData($this->repository->getRepositoryDriver()->getDiffPending(), $this->repository); } + /** + * Returns the changes staged in the index. + */ public function getDiffStaged(): Diff { - $result = $this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--staged']); - if (null === $result) { - throw new RuntimeException('Unable to compute staged diff.'); + return Diff::fromData($this->repository->getRepositoryDriver()->getDiffStaged(), $this->repository); + } + + /** + * Checks $revision out: HEAD points to it when it is a local branch, to + * its commit otherwise. + * + * Passing a branch to create as second argument is deprecated. + */ + public function checkout(Revision|string $revision, ?string $branch = null): static + { + if (null !== $branch) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing a branch to create to "%s()" is deprecated, use "checkoutNewBranch()" instead.', __METHOD__); + + return $this->checkoutNewBranch($branch, $revision); } - $diff = Diff::parse($result); - $diff->setRepository($this->repository); + try { + $this->repository->getRepositoryDriver()->checkout(self::name($revision)); + } finally { + $this->repository->getReferences()->invalidate(); + } - return $diff; + return $this; } /** - * @param Commit|Reference|string $revision + * Creates the branch $branch at $startPoint, and checks it out. */ - public function checkout($revision, ?string $branch = null): static + public function checkoutNewBranch(string $branch, Revision|string $startPoint = 'HEAD'): static { - $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))); - } + $startHash = ($startPoint instanceof Revision ? $startPoint : $this->repository->getRevision($startPoint))->getCommit()->getHash(); - if (null !== $branch) { - $args = array_merge($args, ['-b', $branch]); + try { + $this->repository->getRepositoryDriver()->checkoutNewBranch($branch, $startHash); + } finally { + $this->repository->getReferences()->invalidate(); } - $this->run('checkout', $args); - return $this; } /** * Merges a revision into the current branch. * - * @param Commit|Reference|string $revision - * @param string[] $args extra arguments for the merge command (e.g. ``['--no-ff']``) + * An array of arguments of "git merge" ([], ["--no-ff"] or ["--ff-only"]) is deprecated. + * + * @param FastForward|string[] $fastForward */ - public function merge($revision, array $args = []): static + public function merge(Revision|string $revision, FastForward|array $fastForward = FastForward::Allow): static { - 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))); + if (\is_array($fastForward)) { + trigger_deprecation('gitonomy/gitlib', '2.0', 'Passing arguments of "git merge" to "%s()" is deprecated, pass a "%s" instead.', __METHOD__, FastForward::class); + + $fastForward = match ($fastForward) { + [] => FastForward::Allow, + ['--no-ff'] => FastForward::Never, + ['--ff-only'] => FastForward::Only, + default => throw new InvalidArgumentException(\sprintf('Unsupported arguments "%s": pass a "%s" instead.', implode(' ', $fastForward), FastForward::class)), + }; } - $this->run('merge', $args); + try { + $this->repository->getRepositoryDriver()->merge(self::name($revision), $fastForward); + } finally { + $this->repository->getReferences()->invalidate(); + } return $this; } - private function run(string $command, array $args = []): ?string + /** + * @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 index 11365762..82cdd48b 100755 --- a/test-git-versions.php +++ b/test-git-versions.php @@ -25,11 +25,11 @@ const GIT_URL = 'https://github.com/git/git.git'; const BUILDS_PATH = __DIR__.'/git-builds'; -// One v1.x and several v2.y releases, picked for how long and how widely each -// shipped as the default git on a major distro, rather than for a spread across -// git's history: v1.7.1 (RHEL/CentOS 6), v1.8.3.1 (RHEL/CentOS 7), v2.17.1 (Ubuntu -// 18.04), v2.25.1 (Ubuntu 20.04), v2.34.1 (Ubuntu 22.04), v2.43.0 (Ubuntu 24.04). -const DEFAULT_VERSIONS = ['v1.7.1', 'v1.8.3.1', 'v2.17.1', 'v2.25.1', 'v2.34.1', 'v2.43.0']; +// v1.8.4, the oldest version gitlib supports, and v2.y releases picked for how +// long and how widely each shipped as the default git on a major distro, rather +// than for a spread across git's history: v2.17.1 (Ubuntu 18.04), v2.25.1 (Ubuntu +// 20.04), v2.34.1 (Ubuntu 22.04), v2.43.0 (Ubuntu 24.04). +const DEFAULT_VERSIONS = ['v1.8.4', 'v2.17.1', 'v2.25.1', 'v2.34.1', 'v2.43.0']; function sh(array $args, ?string $cwd = null, ?string $logfile = null): Process { diff --git a/tests/Gitonomy/Git/Tests/AbstractTestCase.php b/tests/Gitonomy/Git/Tests/AbstractTestCase.php index 80cd16e9..d68dc773 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTestCase.php +++ b/tests/Gitonomy/Git/Tests/AbstractTestCase.php @@ -12,9 +12,16 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Admin; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +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 { @@ -34,41 +41,54 @@ abstract class AbstractTestCase extends TestCase public const SIGNED_COMMIT = 'e1a83f16ed61ae3807e5652c7ef894692c813513'; /** - * Local clone of remote URL. Avoids network call on each test. + * Bare clone of REPOSITORY_URL, the fixture repositories are cloned from: + * for the tests of a class, as each class deletes it. */ private static ?Repository $localRepository = null; /** - * Creates an empty git repository and returns instance. + * 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. */ - public static function createEmptyRepository(bool $bare = true): Repository - { - $dir = self::createTempDir(); - $repository = Admin::init($dir, $bare, self::getOptions()); - self::registerDeletion($repository); + private static ?string $materializedRepositoryUrl = null; - return $repository; - } + /** + * Directories created by createTempDir() for the current test. + * + * @var string[] + */ + private static array $tempDirs = []; /** - * Can be used as data provider to get bare/not-bare repositories. + * Directories kept for the tests of a class. + * + * @var string[] */ - public static function provideFoobar(): array + private static array $classTempDirs = []; + + /** + * Creates an empty git repository and returns instance. + */ + public static function createEmptyRepository(bool $bare = true): Repository { - return [ - [self::createFoobarRepository()], - [self::createFoobarRepository(false)], - ]; + $dir = self::createTempDir(); + + return $bare ? Repository::initBare($dir, driver: self::getDriver()) : Repository::init($dir, driver: self::getDriver()); } /** - * Can be used as data provider to get bare/not-bare repositories. + * Data provider of the "bare" argument of createFoobarRepository() and + * createEmptyRepository(). + * + * @return array */ - public static function provideEmpty(): array + public static function provideBare(): array { return [ - [self::createEmptyRepository()], - [self::createEmptyRepository(false)], + 'bare' => [true], + 'working copy' => [false], ]; } @@ -78,37 +98,62 @@ public static function provideEmpty(): array public static function createFoobarRepository(bool $bare = true): Repository { if (null === self::$localRepository) { - self::$localRepository = Admin::cloneTo(self::createTempDir(), self::REPOSITORY_URL, $bare, self::getOptions()); + self::$localRepository = Repository::cloneBareFrom(self::repositoryUrl(), self::createClassTempDir(), driver: self::getDriver()); } - $repository = self::$localRepository->cloneTo(self::createTempDir(), $bare, self::getOptions()); - self::registerDeletion($repository); + $dir = self::createTempDir(); + $url = self::$localRepository->getGitDir(); + + return $bare ? Repository::cloneBareFrom($url, $dir, driver: self::getDriver()) : Repository::cloneFrom($url, $dir, driver: self::getDriver()); + } - return $repository; + /** + * Creates an empty directory, deleted after the current test. + */ + public static function createTempDir(): string + { + return self::$tempDirs[] = self::createDir(); } - public static function registerDeletion(Repository $repository): void + #[After] + public function deleteTempDirs(): void { - register_shutdown_function(static function () use ($repository): void { - if ($repository->getWorkingDir()) { - $dir = $repository->getWorkingDir(); - } else { - $dir = $repository->getGitDir(); - } + 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; } /** - * Created an empty directory and return path to it. + * 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. */ - public static function createTempDir(): string + protected static function repositoryUrl(): string { - $tmpDir = tempnam(sys_get_temp_dir(), 'gitlib_'); - unlink($tmpDir); - mkdir($tmpDir); + 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 $tmpDir; + return self::$materializedRepositoryUrl; } /** @@ -116,15 +161,18 @@ public static function createTempDir(): string */ protected static function deleteDir(string $dir): void { - $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS); + // Without FOLLOW_SYMLINKS: a link to a directory is deleted, never what it points to. + $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::CHILD_FIRST); + /** @var \SplFileInfo $file */ foreach ($iterator as $file) { - if (!is_link($file)) { + if (is_link($file)) { + unlink($file); + } elseif (is_dir($file)) { chmod($file, 0o777); - } - if (is_dir($file)) { rmdir($file); } else { + chmod($file, 0o777); unlink($file); } } @@ -133,15 +181,136 @@ protected static function deleteDir(string $dir): void rmdir($dir); } - protected static function getOptions(): array + /** + * 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 + { + // git 2.29 and later run their maintenance after a commit, in the + // background since 2.46: it would write in the repositories the tests delete. + $envs = [...self::gitEnvs(), 'GIT_CONFIG_COUNT' => '1', 'GIT_CONFIG_KEY_0' => 'maintenance.auto', 'GIT_CONFIG_VALUE_0' => 'false']; + + return new CliDriver(self::gitCommand(), [...$envs, ...$environmentVariables], processTimeout: $processTimeout, logger: $logger); + } + + /** + * @return string The git binary of the tests: the GIT_COMMAND environment variable, or "git" + */ + protected static function gitCommand(): string { $command = $_SERVER['GIT_COMMAND'] ?? 'git'; - $envs = isset($_SERVER['GIT_ENVS']) ? (array) $_SERVER['GIT_ENVS'] : []; + if (!\is_string($command)) { + throw new \UnexpectedValueException('The GIT_COMMAND environment variable must be a string.'); + } - return [ - 'command' => $command, - 'environment_variables' => $envs, - 'process_timeout' => 60, - ]; + return $command; + } + + /** + * @return array The environment variables of git: GIT_ENVS, which the bootstrap sets on Windows + */ + protected static function gitEnvs(): array + { + $envs = []; + $variables = $_SERVER['GIT_ENVS'] ?? []; + if (!\is_array($variables)) { + throw new \UnexpectedValueException('GIT_ENVS must be an array.'); + } + foreach ($variables as $name => $value) { + if (!\is_string($name) || !\is_string($value)) { + throw new \UnexpectedValueException('GIT_ENVS must map names to values.'); + } + $envs[$name] = $value; + } + + return $envs; + } + + /** + * Creates an empty directory, deleted after the last test of the class. + */ + private static function createClassTempDir(): string + { + return self::$classTempDirs[] = self::createDir(); + } + + private static function createDir(): string + { + $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 index f47702d8..b383fc75 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -13,179 +13,206 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Admin; -use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Reference\Branch; -use Gitonomy\Git\Repository; -use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use Psr\Log\LoggerInterface; +/** + * Admin is deprecated: its methods must keep behaving like in 1.x, always + * with CliDriver, whatever the driver under test. + */ +#[Group('legacy')] +#[IgnoreDeprecations] class AdminTest extends AbstractTestCase { - private string $tmpDir; - - #[Before] - public function setUpTmpDir(): void + public function testInit(): void { - $this->tmpDir = self::createTempDir(); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::initBare()" instead.'); + + $repository = Admin::init(self::createTempDir(), options: self::getOptions()); + + $this->assertTrue($repository->isBare()); } - #[After] - public function tearDownTmpDir(): void + public function testInitNotBare(): void { - self::deleteDir(self::createTempDir()); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::init()" method is deprecated, use "Gitonomy\Git\Repository::init()" instead.'); + + $repository = Admin::init(self::createTempDir(), false, self::getOptions()); + + $this->assertFalse($repository->isBare()); } - public function testBare(): void + public function testIsValidRepository(): void { - $repository = Admin::init($this->tmpDir, true, self::getOptions()); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::isValidRepository()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepository()" instead.'); + + $this->assertTrue(Admin::isValidRepository(self::repositoryUrl(), self::getOptions())); + } - $objectDir = $this->tmpDir.'/objects'; + public function testIsValidRepositoryAndBranch(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::isValidRepositoryAndBranch()" method is deprecated, use "Gitonomy\Git\Repository::isValidRepositoryAndBranch()" instead.'); - $this->assertTrue($repository->isBare(), 'Repository is bare'); - $this->assertDirectoryExists($objectDir, 'objects/ folder is present'); - $this->assertInstanceOf(Repository::class, $repository, 'Admin::init returns a repository'); - $this->assertEquals($this->tmpDir, $repository->getGitDir(), 'The folder passed as argument is git dir'); - $this->assertNull($repository->getWorkingDir(), 'No working dir in bare repository'); + $this->assertTrue(Admin::isValidRepositoryAndBranch(self::repositoryUrl(), 'new-feature', self::getOptions())); + $this->assertFalse(Admin::isValidRepositoryAndBranch(self::repositoryUrl(), 'unknown', self::getOptions())); } - public function testNotBare(): void + public function testCloneTo(): void { - $repository = Admin::init($this->tmpDir, false, self::getOptions()); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" instead, which takes the URL before the path.'); - $objectDir = $this->tmpDir.'/.git/objects'; + $repository = Admin::cloneTo(self::createTempDir(), self::repositoryUrl(), options: self::getOptions()); - $this->assertFalse($repository->isBare(), 'Repository is not bare'); - $this->assertDirectoryExists($objectDir, 'objects/ folder is present'); - $this->assertInstanceOf(Repository::class, $repository, 'Admin::init returns a repository'); - $this->assertEquals($this->tmpDir.'/.git', $repository->getGitDir(), 'git dir as subfolder of argument'); - $this->assertEquals($this->tmpDir, $repository->getWorkingDir(), 'working dir present in bare repository'); + $this->assertTrue($repository->isBare()); + $this->assertContains('refs/heads/new-feature', array_keys($repository->getReferences()->getAll())); } - #[DataProvider('provideFoobar')] - public function testClone(Repository $repository): void + public function testCloneToNotBare(): void { - $newDir = self::createTempDir(); - $new = $repository->cloneTo($newDir, $repository->isBare(), self::getOptions()); - self::registerDeletion($new); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" instead, which takes the URL before the path.'); - $newRefs = array_keys($new->getReferences()->getAll()); + $repository = Admin::cloneTo(self::createTempDir(), self::repositoryUrl(), false, self::getOptions()); - $this->assertContains('refs/heads/master', $newRefs); - $this->assertContains('refs/tags/0.1', $newRefs); - - if ($repository->isBare()) { - $this->assertEquals($newDir, $new->getGitDir()); - $this->assertContains('refs/heads/new-feature', $newRefs); - } else { - $this->assertEquals($newDir.'/.git', $new->getGitDir()); - $this->assertEquals($newDir, $new->getWorkingDir()); - } + $this->assertFalse($repository->isBare()); } - public function testCloneBranchBare(): void + public function testCloneBranchTo(): void { - // we can't use AbstractTestCase::createFoobarRepository() - // because it does not clone other branches than "master" - // so we test it directly against the remote repository + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with its "branch" argument instead, which takes the URL before the path.'); - $newDir = self::createTempDir(); - $new = Admin::cloneBranchTo($newDir, self::REPOSITORY_URL, 'new-feature'); - self::registerDeletion($new); + $repository = Admin::cloneBranchTo(self::createTempDir(), self::repositoryUrl(), 'new-feature', options: self::getOptions()); - $head = $new->getHead(); - $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); - $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + $this->assertTrue($repository->isBare()); + $head = $repository->getHead(); + $this->assertInstanceOf(Branch::class, $head); + $this->assertSame('new-feature', $head->getName()); } - public function testCloneBranchNotBare(): void + public function testCloneBranchToNotBare(): void { - // we can't use AbstractTestCase::createFoobarRepository() - // because it does not clone other branches than "master" - // so we test it directly against remote repository + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneBranchTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with its "branch" argument instead, which takes the URL before the path.'); - $newDir = self::createTempDir(); - $new = Admin::cloneBranchTo($newDir, self::REPOSITORY_URL, 'new-feature', false); - self::registerDeletion($new); + $repository = Admin::cloneBranchTo(self::createTempDir(), self::repositoryUrl(), 'new-feature', false, self::getOptions()); - $head = $new->getHead(); - $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); - $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + $this->assertFalse($repository->isBare()); + $head = $repository->getHead(); + $this->assertInstanceOf(Branch::class, $head); + $this->assertSame('new-feature', $head->getName()); } - #[DataProvider('provideFoobar')] - public function testMirror(Repository $repository): void + public function testMirrorTo(): void { - $newDir = self::createTempDir(); - $new = Admin::mirrorTo($newDir, $repository->getGitDir(), self::getOptions()); - self::registerDeletion($new); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::mirrorTo()" method is deprecated, use "Gitonomy\Git\Repository::mirrorFrom()" instead, which takes the URL before the path.'); - $newRefs = array_keys($new->getReferences()->getAll()); + $repository = Admin::mirrorTo(self::createTempDir(), self::repositoryUrl(), self::getOptions()); - $this->assertContains('refs/heads/master', $newRefs); - $this->assertContains('refs/tags/0.1', $newRefs); - $this->assertEquals($newDir, $new->getGitDir()); + $this->assertSame('true', trim($repository->run('config', ['remote.origin.mirror']))); + } - if ($repository->isBare()) { - $this->assertContains('refs/heads/new-feature', $newRefs); - } else { - $this->assertContains('refs/remotes/origin/new-feature', $newRefs); - } + public function testCloneRepositoryPassesItsArgumentsToGit(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Admin::cloneRepository()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()", "Gitonomy\Git\Repository::cloneBareFrom()" or "Gitonomy\Git\Repository::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".'); + + // --config is the only argument --bare accepts on every git version. + $repository = Admin::cloneRepository(self::createTempDir(), self::repositoryUrl(), ['--bare', '--config', 'gitlib.test=yes'], self::getOptions()); + + $this->assertTrue($repository->isBare()); + $this->assertSame('yes', trim($repository->run('config', ['gitlib.test']))); } - #[DataProvider('provideFoobar')] - public function testCheckValidRepository(Repository $repository): void + public function testOptionsConfigureGitAndTheRepository(): void { - $url = $repository->getGitDir(); - $this->assertTrue(Admin::isValidRepository($url)); + $logger = $this->createStub(LoggerInterface::class); + + $repository = Admin::init(self::createTempDir(), options: [...self::getOptions(), 'logger' => $logger, 'process_timeout' => 42]); + + $this->assertSame($logger, $repository->getLogger()); + $this->assertSame(42.0, $repository->runProcess('config', ['core.bare'])->getTimeout()); } - public function testCheckInvalidRepository(): void + public function testCommandOptionRunsAnotherGitBinary(): void { - $url = $this->tmpDir.'/invalid.git'; - mkdir($url); + $this->expectException(ProcessException::class); - $this->assertFalse(Admin::isValidRepository($url)); + Admin::init(self::createTempDir(), options: ['command' => 'not-git']); } - #[DataProvider('provideFoobar')] - public function testCheckValidRepositoryAndBranch(Repository $repository): void + public function testInheritEnvironmentVariablesOnlyAppliesToTheRepository(): void { - $url = $repository->getGitDir(); - $this->assertTrue(Admin::isValidRepositoryAndBranch($url, 'master')); + if (\defined('PHP_WINDOWS_VERSION_MAJOR')) { + $this->markTestSkipped('The fake git binary is a shell script.'); + } + + // A git binary logging its first argument, and whether it gets SECRET. + $dir = self::createTempDir(); + file_put_contents($dir.'/git', \sprintf("#!/bin/sh\necho \"\$1 \${GITLIB_TEST_SECRET:-none}\" >> %s\nexec %s \"\$@\"\n", escapeshellarg($dir.'/log'), escapeshellarg(self::gitCommand()))); + chmod($dir.'/git', 0o755); + // Like the credentials of an HTTP request, in $_SERVER only. + $_SERVER['GITLIB_TEST_SECRET'] = 'secret'; + + try { + $repository = Admin::init($dir.'/repository', options: [...self::getOptions(), 'command' => $dir.'/git', 'inherit_environment_variables' => true]); + $repository->run('config', ['core.bare']); + } finally { + unset($_SERVER['GITLIB_TEST_SECRET']); + } + + // Like in 1.x, git init doesn't get $_SERVER, the git processes of the repository do. + $this->assertSame("init none\n--git-dir secret\n", file_get_contents($dir.'/log')); } - #[DataProvider('provideFoobar')] - public function testCheckInvalidRepositoryAndBranch(Repository $repository): void + public function testRemovedOptionsAreRefused(): void { - $url = $repository->getGitDir(); - $this->assertFalse(Admin::isValidRepositoryAndBranch($url, 'invalid-branch-name')); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "debug" option set to false is not supported anymore: a failing git operation always throws, catch its exception instead.'); + + Admin::init(self::createTempDir(), options: ['debug' => false]); } - public function testExistingFile(): void + public function testRepositoryCloneTo(): void { - $this->expectException(RuntimeException::class); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneBareFrom()" with "getGitDir()" as URL instead.'); - $file = $this->tmpDir.'/test'; - touch($file); + $repository = self::createFoobarRepository(); + $new = $repository->cloneTo(self::createTempDir()); - Admin::init($file, true, self::getOptions()); + $this->assertTrue($new->isBare()); + $this->assertSame($repository->getHeadCommit()->getHash(), $new->getHeadCommit()->getHash()); } - public function testCloneRepository(): void + public function testRepositoryCloneToNotBare(): void { - $newDir = self::createTempDir(); - $args = []; + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::cloneTo()" method is deprecated, use "Gitonomy\Git\Repository::cloneFrom()" with "getGitDir()" as URL instead.'); - $new = Admin::cloneRepository($newDir, self::REPOSITORY_URL, $args, self::getOptions()); - self::registerDeletion($new); + $repository = self::createFoobarRepository(); + $new = $repository->cloneTo(self::createTempDir(), false); - $newRefs = array_keys($new->getReferences()->getAll()); + $this->assertFalse($new->isBare()); + $this->assertSame($repository->getHeadCommit()->getHash(), $new->getHeadCommit()->getHash()); + } - $this->assertContains('refs/heads/master', $newRefs); - $this->assertContains('refs/tags/0.1', $newRefs); + public function testRepositoryCloneToWithGitOptions(): void + { + $repository = self::createFoobarRepository(); + $new = $repository->cloneTo(self::createTempDir(), options: [...self::getOptions(), 'process_timeout' => 42]); - $this->assertEquals($newDir.'/.git', $new->getGitDir()); - $this->assertEquals($newDir, $new->getWorkingDir()); + $this->assertSame(42.0, $new->runProcess('config', ['core.bare'])->getTimeout()); + } + + /** + * The options of 1.x running the git binary of the tests. + * + * @return array{command: string, environment_variables: array} + */ + private static function getOptions(): array + { + return [ + 'command' => self::gitCommand(), + 'environment_variables' => self::gitEnvs(), + ]; } } diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index 3fe66c3f..96db58cc 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -12,32 +12,104 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Repository; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class BlameTest extends AbstractTestCase { - #[DataProvider('provideFoobar')] - public function testBlame(Repository $repository): void + #[DataProvider('provideBare')] + public function testBlame(bool $bare): void { + $repository = self::createFoobarRepository($bare); $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md'); $this->assertCount(7, $blame); - $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->assertEquals('alice', $blame->getLine(5)->getCommit()->getAuthorName()); - $this->assertNotEquals(self::INITIAL_COMMIT, $blame->getLine(5)->getCommit()->getHash()); + $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('provideFoobar')] - public function testGroupedBlame(Repository $repository): void + #[DataProvider('provideBare')] + public function testHunks(bool $bare): void { - $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getGroupedLines(); + $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.'); + + $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 0, 3); + } + + public function testBlameOfAnInvertedRangeIsTheRangeInOrder(): void + { + $repository = self::createFoobarRepository(); + + // Like git blame -L 5,3. + $this->assertSame([3, 4, 5], array_keys($repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 5, 3)->getLines())); + } + + public function testBlameOfARangeAfterTheEndThrows(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(DriverException::class); + + $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 100)->getLines(); + } + + #[DataProvider('provideBare')] + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyBlameLineRange(bool $bare): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing a range of lines as a string to "Gitonomy\Git\Repository::getBlame()" is deprecated, pass the first and the last lines instead.'); + + $repository = self::createFoobarRepository($bare); + + foreach (['3,+3', '3,5'] as $lineRange) { + $blame = $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', $lineRange); - $this->assertEquals(self::INITIAL_COMMIT, $blame[0][0]->getHash()); + $this->assertSame([3, 4, 5], array_keys($blame->getLines()), $lineRange); + $this->assertSame('3', $blame->getLine(3)->getLine(), $lineRange); + } + $this->assertSame(self::INITIAL_COMMIT, $repository->getBlame(self::LONGFILE_COMMIT, 'README.md')->getGroupedLines()[0][0]->getHash()); } } diff --git a/tests/Gitonomy/Git/Tests/BlobTest.php b/tests/Gitonomy/Git/Tests/BlobTest.php index 5673e782..8f5c214b 100644 --- a/tests/Gitonomy/Git/Tests/BlobTest.php +++ b/tests/Gitonomy/Git/Tests/BlobTest.php @@ -13,7 +13,7 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Blob; -use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; @@ -21,71 +21,105 @@ class BlobTest extends AbstractTestCase { public const README_FRAGMENT = 'Foo Bar project'; - public function getReadmeBlob(Repository $repository): Blob + #[DataProvider('provideBare')] + public function testGetContent(bool $bare): void { - return $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('README.md'); - } - - public function getImageBlob(Repository $repository): Blob - { - return $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('image.jpg'); - } - - #[DataProvider('provideFoobar')] - public function testGetContent(Repository $repository): void - { - $blob = $this->getReadmeBlob($repository); + $repository = self::createFoobarRepository($bare); + $blob = self::getReadmeBlob($repository); $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); } - #[DataProvider('provideFoobar')] - public function testGetContentSiblingToSubmodule(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetContentSiblingToSubmodule(bool $bare): void { + $repository = self::createFoobarRepository($bare); // Regression test for https://github.com/gitonomy/gitlib/issues/12: // a submodule entry in a tree used to break parsing of that tree, // making sibling blobs unreadable. $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $this->assertNotEmpty($tree->getCommitReferenceEntries(), 'Tree contains a submodule entry'); + $this->assertNotEmpty($tree->getSubmoduleEntries(), 'Tree contains a submodule entry'); $blob = $tree->resolvePath('README.md'); + $this->assertInstanceOf(Blob::class, $blob); $this->assertStringContainsString(self::README_FRAGMENT, $blob->getContent()); } - #[DataProvider('provideFoobar')] - public function testNotExisting(Repository $repository): void + #[DataProvider('provideBare')] + public function testNotExisting(bool $bare): void { - $this->expectException(RuntimeException::class); + $repository = self::createFoobarRepository($bare); + $blob = $repository->getBlob(str_repeat('1', 40)); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The object "1111111111111111111111111111111111111111" does not exist.'); - $blob = $repository->getBlob('foobar'); $blob->getContent(); } - #[DataProvider('provideFoobar')] - public function testGetMimetype(Repository $repository): void + public function testNotAHash(): void { - $blob = $this->getReadmeBlob($repository); + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('"master" is not a hash.'); - $this->assertMatchesRegularExpression('#text/plain#', $blob->getMimetype()); + self::createFoobarRepository()->getBlob('master'); } - #[DataProvider('provideFoobar')] - public function testIsText(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetMimetype(bool $bare): void { - $readmeBlob = $this->getReadmeBlob($repository); + $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 = $this->getImageBlob($repository); + $imageBlob = self::getImageBlob($repository); $this->assertFalse($imageBlob->isText()); } - #[DataProvider('provideFoobar')] - public function testIsBinary(Repository $repository): void + #[DataProvider('provideBare')] + public function testIsBinary(bool $bare): void { - $readmeBlob = $this->getReadmeBlob($repository); + $repository = self::createFoobarRepository($bare); + $readmeBlob = self::getReadmeBlob($repository); $this->assertFalse($readmeBlob->isBinary()); - $imageBlob = $this->getImageBlob($repository); + $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/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 4fdf02dc..67c813ec 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -13,214 +13,264 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Commit; -use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Diff\File; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; -use Gitonomy\Git\Tree; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class CommitTest extends AbstractTestCase { - #[DataProvider('provideFoobar')] - public function testGetDiff(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetDiff(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $diff = $commit->getDiff(); - $this->assertInstanceOf(Diff::class, $diff, 'getDiff() returns a Diff object'); + $this->assertSame(['long.php'], array_map(static fn (File $file): string => $file->getName(), $diff->getFiles())); } - #[DataProvider('provideFoobar')] - public function testGetHash(Repository $repository): void + public function testADateOutOfRangeThrows(): void { + $repository = self::createFoobarRepository(); + $hash = self::writeObject($repository, 'commit', "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A 99999999999999999999 +0000\ncommitter A 99999999999999999999 +0000\n\nCorrupt\n"); + + // A RuntimeException of gitlib, not of PHP. + $this->expectException(RuntimeException::class); + + $repository->getCommit($hash)->getAuthorDate(); + } + + #[DataProvider('provideBare')] + public function testGetHash(bool $bare): void + { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals(self::LONGFILE_COMMIT, $commit->getHash()); + $this->assertSame(self::LONGFILE_COMMIT, $commit->getHash()); } - #[DataProvider('provideFoobar')] - public function testInvalideHashThrowException(Repository $repository): void + #[DataProvider('provideBare')] + public function testInvalideHashThrowException(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(ReferenceNotFoundException::class); - $this->expectExceptionMessage('Reference not found: "that-hash-doest-not-exists"'); + $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('provideFoobar')] - public function testGetShortHash(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetShortHash(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('4f17752', $commit->getShortHash(), 'Short hash'); + $this->assertSame('4f17752', $commit->getShortHash(), 'Short hash'); } - #[DataProvider('provideFoobar')] - public function testGetParentHashesWithNoParent(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetParentHashesWithNoParent(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::INITIAL_COMMIT); $this->assertCount(0, $commit->getParentHashes(), 'No parent on initial commit'); } - #[DataProvider('provideFoobar')] - public function testGetParentHashesWithOneParent(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetParentHashesWithOneParent(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $parents = $commit->getParentHashes(); $this->assertCount(1, $parents, 'One parent found'); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $parents[0], 'Parent hash is correct'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $parents[0], 'Parent hash is correct'); } - #[DataProvider('provideFoobar')] - public function testGetParentsWithOneParent(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetParentsWithOneParent(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $parents = $commit->getParents(); $this->assertCount(1, $parents, 'One parent found'); - $this->assertInstanceOf(Commit::class, $parents[0], 'First parent is a Commit object'); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $parents[0]->getHash(), "First parents's hash is correct"); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $parents[0]->getHash(), "First parents's hash is correct"); } - #[DataProvider('provideFoobar')] - public function testGetTreeHash(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetTreeHash(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTreeHash(), 'Tree hash is correct'); + $this->assertSame('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTreeHash(), 'Tree hash is correct'); } - #[DataProvider('provideFoobar')] - public function testGetTree(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetTree(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertInstanceOf(Tree::class, $commit->getTree(), 'Tree is a tree'); - $this->assertEquals('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTree()->getHash(), 'Tree hash is correct'); + $this->assertSame('b06890c7b10904979d2f69613c2ccda30aafe262', $commit->getTree()->getHash(), 'Tree hash is correct'); } - #[DataProvider('provideFoobar')] - public function testGetAuthorName(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetAuthorName(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('alice', $commit->getAuthorName(), 'Author name'); + $this->assertSame('alice', $commit->getAuthorName(), 'Author name'); } - #[DataProvider('provideFoobar')] - public function testGetAuthorEmail(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetAuthorEmail(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('alice@example.org', $commit->getAuthorEmail(), 'Author email'); + $this->assertSame('alice@example.org', $commit->getAuthorEmail(), 'Author email'); } - #[DataProvider('provideFoobar')] - public function testGetAuthorDate(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetAuthorDate(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('2012-12-31 14:21:03', $commit->getAuthorDate()->format('Y-m-d H:i:s'), 'Author date'); + $this->assertSame('2012-12-31 14:21:03', $commit->getAuthorDate()->format('Y-m-d H:i:s'), 'Author date'); } - #[DataProvider('provideFoobar')] - public function testGetCommitterName(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetCommitterName(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('alice', $commit->getCommitterName(), 'Committer name'); + $this->assertSame('alice', $commit->getCommitterName(), 'Committer name'); } - #[DataProvider('provideFoobar')] - public function testGetCommitterEmail(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetCommitterEmail(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('alice@example.org', $commit->getCommitterEmail(), 'Committer email'); + $this->assertSame('alice@example.org', $commit->getCommitterEmail(), 'Committer email'); } - #[DataProvider('provideFoobar')] - public function testGetCommitterDate(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetCommitterDate(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('2012-12-31 14:21:03', $commit->getCommitterDate()->format('Y-m-d H:i:s'), 'Committer date'); + $this->assertSame('2012-12-31 14:21:03', $commit->getCommitterDate()->format('Y-m-d H:i:s'), 'Committer date'); } - #[DataProvider('provideFoobar')] - public function testGetMessage(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetMessage(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertEquals('add a long file'."\n", $commit->getMessage()); + $this->assertSame('add a long file'."\n", $commit->getMessage()); } - #[DataProvider('provideFoobar')] - public function testGetEmptyMessage(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetEmptyMessage(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::NO_MESSAGE_COMMIT); - $this->assertEquals('', $commit->getMessage()); + $this->assertSame('', $commit->getMessage()); } - #[DataProvider('provideFoobar')] - public function testGetEmptyMessageFromLog(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetEmptyMessageFromLog(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::NO_MESSAGE_COMMIT); $commitMessageFromLog = $commit->getLog()->getCommits()[0]->getMessage(); - $this->assertEquals('', $commitMessageFromLog); + $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 $repository): void + #[DataProvider('provideBare')] + public function testGetSignedMessage(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::SIGNED_COMMIT); - $this->assertEquals('signed commit'."\n", $commit->getMessage()); + $this->assertSame('signed commit'."\n", $commit->getMessage()); } - #[DataProvider('provideFoobar')] - public function testGetShortMessage(Repository $repository): void + #[DataProvider('provideBare')] + #[Group('legacy')] + #[IgnoreDeprecations] + public function testGetShortMessage(bool $bare): void { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Commit::getShortMessage()" method is deprecated, use symfony/string instead: "u($commit->getSubjectMessage())->truncate($length, $separator, !$preserve)".'); + + $repository = self::createFoobarRepository($bare); // tests with a multi-line message $commit = $repository->getCommit(self::LONGMESSAGE_COMMIT); - $this->assertEquals('Fixed perm...', $commit->getShortMessage(10)); - $this->assertEquals('Fixed perm!!!', $commit->getShortMessage(10, false, '!!!')); - $this->assertEquals('Fixed permissions!!!', $commit->getShortMessage(10, true, '!!!')); + $this->assertSame('Fixed perm...', $commit->getShortMessage(10)); + $this->assertSame('Fixed perm!!!', $commit->getShortMessage(10, false, '!!!')); + $this->assertSame('Fixed permissions!!!', $commit->getShortMessage(10, true, '!!!')); // tests with a single-line message $commit = $repository->getCommit(self::INITIAL_COMMIT); - $this->assertEquals('Add README', $commit->getShortMessage(20)); - $this->assertEquals('A', $commit->getShortMessage(1, false, '')); - $this->assertEquals('Add!!!', $commit->getShortMessage(1, true, '!!!')); + $this->assertSame('Add README', $commit->getShortMessage(20)); + $this->assertSame('A', $commit->getShortMessage(1, false, '')); + $this->assertSame('Add!!!', $commit->getShortMessage(1, true, '!!!')); } - #[DataProvider('provideFoobar')] - public function testGetBodyMessage(Repository $repository): void + public function testSubjectIsTheFirstParagraph(): void { + $repository = self::createFoobarRepository(); + $tree = $repository->getCommit(self::INITIAL_COMMIT)->getTreeHash(); + $hash = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\n\nSubject\nsecond line\n\nBody\n"); + $commit = $repository->getCommit($hash); + + $this->assertSame('Subject second line', $commit->getSubjectMessage()); + $this->assertSame("Body\n", $commit->getBodyMessage()); + } + + #[DataProvider('provideBare')] + 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->assertEquals($message, $commit->getBodyMessage()); + $this->assertSame($message, $commit->getBodyMessage()); $commit = $repository->getCommit(self::INITIAL_COMMIT); - $this->assertEquals('', $commit->getBodyMessage()); + $this->assertSame('', $commit->getBodyMessage()); } - #[DataProvider('provideFoobar')] - public function testGetIncludingBranchesException(Repository $repository): void + #[DataProvider('provideBare')] + #[Group('legacy')] + #[IgnoreDeprecations] + public function testGetIncludingBranchesException(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(InvalidArgumentException::class); $commit = $repository->getCommit(self::INITIAL_COMMIT); @@ -228,45 +278,96 @@ public function testGetIncludingBranchesException(Repository $repository): void $commit->getIncludingBranches(false, false); } - #[DataProvider('provideFoobar')] - public function testGetIncludingBranches(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetIncludingBranches(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $commit = $repository->getCommit(self::INITIAL_COMMIT); + + $this->assertSame($repository->getReferences()->getBranches(), $commit->getIncludingBranches()); + } + + #[DataProvider('provideBare')] + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetIncludingLocalOrRemoteBranches(bool $bare): void { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing arguments to "Gitonomy\Git\Commit::getIncludingBranches()" is deprecated, filter the branches it returns with "Gitonomy\Git\Reference\Branch::isLocal()" or "isRemote()" instead.'); + + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::INITIAL_COMMIT); - $branches = $commit->getIncludingBranches(true, false); - $this->assertCount(\count($repository->getReferences()->getLocalBranches()), $branches); + $this->assertSame($repository->getReferences()->getLocalBranches(), $commit->getIncludingBranches(true, false)); + $this->assertSame($repository->getReferences()->getRemoteBranches(), $commit->getIncludingBranches(false, true)); + } - $branches = $commit->getIncludingBranches(true, true); - $this->assertCount(\count($repository->getReferences()->getBranches()), $branches); + public function testGetIncludingBranchesNamedLikeRemoteOnes(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'branch', ['remotes/origin/fake', self::INITIAL_COMMIT]); + + $branches = $repository->getCommit(self::INITIAL_COMMIT)->getIncludingBranches(); - $branches = $commit->getIncludingBranches(false, true); - $this->assertCount(\count($repository->getReferences()->getRemoteBranches()), $branches); + $this->assertContains($repository->getReferences()->getBranch('remotes/origin/fake'), $branches); } - #[DataProvider('provideFoobar')] - public function testGetLastModification(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetLastModification(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); $lastModification = $commit->getLastModification('image.jpg'); - $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 $repository): void + #[DataProvider('provideBare')] + public function testMergeCommit(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::MERGE_COMMIT); - $this->assertEquals("Merge branch 'authors'", $commit->getSubjectMessage()); + $this->assertSame("Merge branch 'authors'", $commit->getSubjectMessage()); } - #[DataProvider('provideFoobar')] - public function testEncoding(Repository $repository): void + #[DataProvider('provideBare')] + public function testEncoding(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::ENCODING_COMMIT); - $this->assertEquals('contribute to AUTHORS file', $commit->getSubjectMessage()); + $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()); } } diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 6d605d10..7c5e9afe 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -13,9 +13,19 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\DiffLineType; use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class DiffTest extends AbstractTestCase { @@ -25,56 +35,61 @@ class DiffTest extends AbstractTestCase public const CHANGEMODE_COMMIT = '93da965f58170f13017477b9a608657e87e23230'; public const FILE_WITH_UMLAUTS_COMMIT = '8defb9217692dc1f4c18e05e343ca91cf5047702'; - #[DataProvider('provideFoobar')] - public function testSerialization(Repository $repository): void + #[DataProvider('provideBare')] + public function testSerialization(bool $bare): void { + $repository = self::createFoobarRepository($bare); $data = $repository->getCommit(self::CREATE_COMMIT)->getDiff()->toArray(); $diff = Diff::fromArray($data); $this->verifyCreateCommitDiff($diff); } - #[DataProvider('provideFoobar')] - public function testGetFilesAddition(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFilesAddition(bool $bare): void { + $repository = self::createFoobarRepository($bare); $diff = $repository->getCommit(self::CREATE_COMMIT)->getDiff(); $this->verifyCreateCommitDiff($diff); } - #[DataProvider('provideFoobar')] - public function testGetFilesModification(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFilesModification(bool $bare): void { + $repository = self::createFoobarRepository($bare); $files = $repository->getCommit(self::BEFORE_LONGFILE_COMMIT)->getDiff()->getFiles(); $this->assertCount(1, $files, '1 files in diff'); $this->assertTrue($files[0]->isModification(), 'image.jpg modified'); - $this->assertEquals('image.jpg', $files[0]->getOldName(), 'Second file name is image.jpg'); - $this->assertEquals('image.jpg', $files[0]->getNewName(), 'Second file name is image.jpg'); - $this->assertEquals('100644', $files[0]->getOldMode(), 'Second file mode is a new file'); - $this->assertEquals('100644', $files[0]->getNewMode(), 'Second file mode is correct'); + $this->assertSame('image.jpg', $files[0]->getOldName(), 'Second file name is image.jpg'); + $this->assertSame('image.jpg', $files[0]->getNewName(), 'Second file name is image.jpg'); + $this->assertSame('100644', $files[0]->getOldMode(), 'Second file mode is a new file'); + $this->assertSame('100644', $files[0]->getNewMode(), 'Second file mode is correct'); $this->assertTrue($files[0]->isBinary(), 'binary file'); - $this->assertEquals(0, $files[0]->getAdditions(), '0 lines added'); - $this->assertEquals(0, $files[0]->getDeletions(), '0 lines deleted'); + $this->assertSame(0, $files[0]->getAdditions(), '0 lines added'); + $this->assertSame(0, $files[0]->getDeletions(), '0 lines deleted'); } - #[DataProvider('provideFoobar')] - public function testGetFilesDeletion(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFilesDeletion(bool $bare): void { + $repository = self::createFoobarRepository($bare); $files = $repository->getCommit(self::DELETE_COMMIT)->getDiff()->getFiles(); $this->assertCount(1, $files, '1 files modified'); $this->assertTrue($files[0]->isDeletion(), 'File deletion'); - $this->assertEquals('script_B.php', $files[0]->getOldName(), 'verify old filename'); - $this->assertEquals(1, $files[0]->getDeletions(), '1 line deleted'); + $this->assertSame('script_B.php', $files[0]->getOldName(), 'verify old filename'); + $this->assertSame(1, $files[0]->getDeletions(), '1 line deleted'); } - #[DataProvider('provideFoobar')] - public function testGetFilesRename(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFilesRename(bool $bare): void { + $repository = self::createFoobarRepository($bare); $files = $repository->getCommit(self::RENAME_COMMIT)->getDiff()->getFiles(); $this->assertCount(1, $files, '1 files modified'); @@ -83,26 +98,28 @@ public function testGetFilesRename(Repository $repository): void $this->assertTrue($files[0]->isRename()); $this->assertFalse($files[0]->isDeletion()); $this->assertFalse($files[0]->isCreation()); - $this->assertFalse($files[0]->isChangeMode()); + $this->assertFalse($files[0]->isModeChange()); } - #[DataProvider('provideFoobar')] - public function testGetFilesChangemode(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFilesChangemode(bool $bare): void { + $repository = self::createFoobarRepository($bare); $files = $repository->getCommit(self::CHANGEMODE_COMMIT)->getDiff()->getFiles(); $this->assertCount(1, $files, '1 files modified'); $this->assertTrue($files[0]->isModification()); - $this->assertTrue($files[0]->isChangeMode()); + $this->assertTrue($files[0]->isModeChange()); $this->assertFalse($files[0]->isDeletion()); $this->assertFalse($files[0]->isCreation()); $this->assertFalse($files[0]->isRename()); } - #[DataProvider('provideFoobar')] - public function testDiffRangeParse(Repository $repository): void + #[DataProvider('provideBare')] + public function testDiffRangeParse(bool $bare): void { + $repository = self::createFoobarRepository($bare); $files = $repository->getCommit(self::CREATE_COMMIT)->getDiff()->getFiles(); $changes = $files[0]->getChanges(); @@ -114,61 +131,70 @@ public function testDiffRangeParse(Repository $repository): void $this->assertSame(1, $changes[0]->getRangeNewCount()); } - #[DataProvider('provideFoobar')] - public function testWorksWithUmlauts(Repository $repository): void + #[DataProvider('provideBare')] + public function testRootCommitDiffListsTheFilesItAdds(bool $bare): void { - $files = $repository->getCommit(self::FILE_WITH_UMLAUTS_COMMIT)->getDiff()->getFiles(); - $this->assertSame('file_with_umlauts_\303\244\303\266\303\274', $files[0]->getNewName()); + $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()); } - public function testDeleteFileWithoutRaw(): void + #[DataProvider('provideBare')] + public function testWorksWithUmlauts(bool $bare): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); + $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' - diff --git a/test b/test - deleted file mode 100644 - index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 + :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); - $firstFile = $diff->getFiles()[0]; + $files = $diff->getFiles(); - $this->assertFalse($firstFile->isCreation()); - $this->assertTrue($firstFile->isDeletion()); - $this->assertFalse($firstFile->isChangeMode()); - $this->assertSame('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', $firstFile->getOldIndex()); - $this->assertNull($firstFile->getNewIndex()); + $this->assertSame("tab\there", $files[0]->getNewName()); + $this->assertSame('café "au lait"', $files[1]->getOldName()); + $this->assertSame('café "au lait"', $files[1]->getNewName()); } - public function testModeChangeFileWithoutRaw(): void + public function testDeleteFile(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - $diff = Diff::parse(<<<'DIFF' - diff --git a/a.out b/a.out - old mode 100755 - new mode 100644 + :100644 000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 D test + + diff --git a/test b/test + deleted file mode 100644 + index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 DIFF); $firstFile = $diff->getFiles()[0]; $this->assertFalse($firstFile->isCreation()); - $this->assertFalse($firstFile->isDeletion()); - $this->assertTrue($firstFile->isChangeMode()); - $this->assertSame('', $firstFile->getOldIndex()); - $this->assertSame('', $firstFile->getNewIndex()); + $this->assertTrue($firstFile->isDeletion()); + $this->assertFalse($firstFile->isModeChange()); + $this->assertSame('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', $firstFile->getOldIndex()); + $this->assertNull($firstFile->getNewIndex()); } - public function testModeChangeFileWithRaw(): void + public function testModeChangeFile(): void { - $deprecationCalled = false; - set_error_handler(static function (int $errno, string $errstr) use (&$deprecationCalled): void { - $deprecationCalled = true; - }, \E_USER_DEPRECATED); - $diff = Diff::parse(<<<'DIFF' - :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M testfile - :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 M testfile2 + :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M testfile + :100644 100755 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82 M testfile2 diff --git a/testfile b/testfile old mode 100644 @@ -182,28 +208,24 @@ public function testModeChangeFileWithRaw(): void $firstFile = $files[0]; $secondFile = $files[1]; - restore_error_handler(); - - $this->assertFalse($deprecationCalled); - $this->assertFalse($firstFile->isCreation()); $this->assertFalse($firstFile->isDeletion()); - $this->assertTrue($firstFile->isChangeMode()); + $this->assertTrue($firstFile->isModeChange()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getOldIndex()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81', $firstFile->getNewIndex()); $this->assertFalse($secondFile->isCreation()); $this->assertFalse($secondFile->isDeletion()); - $this->assertTrue($secondFile->isChangeMode()); + $this->assertTrue($secondFile->isModeChange()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getOldIndex()); $this->assertSame('d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa82', $secondFile->getNewIndex()); } - public function testCopyFileWithoutRaw(): void + public function testCopyFile(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - $diff = Diff::parse(<<<'DIFF' + :100644 100644 1234567890123456789012345678901234567890 89abcdef0123456789abcdef0123456789abcdef C097 style.css common.css + diff --git a/style.css b/common.css similarity index 97% copy from style.css @@ -229,11 +251,11 @@ public function testCopyFileWithoutRaw(): void $this->assertSame(1, $firstFile->getAdditions()); } - public function testPureCopyFileWithoutRaw(): void + public function testPureCopyFile(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - $diff = Diff::parse(<<<'DIFF' + :100644 100644 1234567890123456789012345678901234567890 1234567890123456789012345678901234567890 C100 style.css common.css + diff --git a/style.css b/common.css similarity index 100% copy from style.css @@ -248,39 +270,224 @@ public function testPureCopyFileWithoutRaw(): void $this->assertSame('common.css', $firstFile->getNewName()); } - public function testThrowErrorOnBlobGetWithoutIndex(): void + public function testParseRequiresTheRawInformation(): void { - $repository = self::createEmptyRepository(); - $file = new File('oldName', 'newName', '100755', '100644', '', '', false); - $file->setRepository($repository); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The diff has no raw information: Diff::parse() parses the output of "git diff --raw --patch".'); + + Diff::parse("diff --git a/a.out b/a.out\nold mode 100755\nnew mode 100644\n"); + } + + public function testParseAnEmptyDiff(): void + { + $this->assertSame([], Diff::parse('')->getFiles()); + } + + public function 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 { - $file->getOldBlob(); - } catch (\RuntimeException $exception) { - $this->assertSame('Index is missing to return Blob object.', $exception->getMessage()); + self::git($repository, 'merge', ['-q', 'side']); + } catch (ProcessException) { + // The conflict. } + file_put_contents($repository->getWorkingDir().'/other', "other\n"); + self::git($repository, 'add', ['other']); - try { - $file->getNewBlob(); - } catch (\RuntimeException $exception) { - $this->assertSame('Index is missing to return Blob object.', $exception->getMessage()); + $this->assertSame(['other'], array_map(static fn (File $file): string => $file->getName(), $repository->getWorkingCopy()->getDiffStaged()->getFiles())); + $this->assertSame([], $repository->getWorkingCopy()->getDiffPending()->getFiles()); + + // Like git diff HEAD, which compares the unmerged file, conflict markers included, to HEAD. + $files = $repository->getWorkingCopy()->getDiff()->getFiles(); + $this->assertSame(['conflict', 'other'], array_map(static fn (File $file): string => $file->getName(), $files)); + $this->assertContains('<<<<<<< HEAD', array_map(static fn (DiffLine $line): string => $line->getContent(), $files[0]->getChanges()[0]->getLines())); + } + + public function testWorkingCopyDiff(): void + { + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'file', "a\n"); + file_put_contents($repository->getWorkingDir().'/file', "a\nb\n"); + + $files = $repository->getWorkingCopy()->getDiff()->getFiles(); + + $this->assertCount(1, $files); + $this->assertSame(1, $files[0]->getAdditions()); + $this->assertSame([DiffLineType::Context, DiffLineType::Addition], array_map(static fn (DiffLine $line): DiffLineType => $line->getType(), $files[0]->getChanges()[0]->getLines())); + $this->assertSame('b', $files[0]->getChanges()[0]->getLines()[1]->getContent()); + } + + public function testHunkWithOneLine(): void + { + $diff = Diff::parse(":100644 100644 7898192261 422c2b7ab3 M\tfile\n\ndiff --git a/file b/file\nindex 7898192261..422c2b7ab3 100644\n--- a/file\n+++ b/file\n@@ -1 +1,2 @@\n a\n+b\n"); + $change = $diff->getFiles()[0]->getChanges()[0]; + + $this->assertSame([1, 1, 1, 2], [$change->getRangeOldStart(), $change->getRangeOldCount(), $change->getRangeNewStart(), $change->getRangeNewCount()]); + } + + public function testParseNulSeparatedRawInformation(): void + { + $raw = ":100644 100644 7898192261 422c2b7ab3 R087\0old name\0new name\0:000000 100644 0000000000 e69de29bb2 A\0new\0"; + $files = Diff::parse($raw)->getFiles(); + + $this->assertSame(['old name', 'new name'], [$files[0]->getOldName(), $files[0]->getNewName()]); + $this->assertTrue($files[0]->isRename()); + $this->assertTrue($files[1]->isCreation()); + $this->assertSame('e69de29bb2', $files[1]->getNewIndex()); + } + + public function testParseQuotedNames(): void + { + $files = Diff::parse(":100644 100644 7898192261 422c2b7ab3 M\t\"caf\\303\\251\\t.txt\"\n")->getFiles(); + + $this->assertSame("caf\u{e9}\t.txt", $files[0]->getName()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetDiffOfARange(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Calling "Gitonomy\Git\Repository::getDiff()" with one argument is deprecated, pass the revisions to compare as "from" and "to" arguments, or use "Gitonomy\Git\WorkingCopy::getDiff()" to compare a revision with the working tree.'); + + $repository = self::createFoobarRepository(); + + $this->assertSame($repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(), $repository->getDiff(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT)->toArray()); + $this->assertSame($repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(), $repository->getDiff([self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT])->toArray()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyLinesAsArrays(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Reading a "Gitonomy\Git\Diff\DiffLine" as an array is deprecated, use its "getType()" and "getContent()" methods instead.'); + + $line = new DiffLine(DiffLineType::Addition, 'added'); + [$type, $content] = $line; + + $this->assertSame([FileChange::LINE_ADD, 'added'], [$type, $content]); + } + + public function testFromArrayRefusesAnUnknownTypeOfLine(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown type of line 2: the types are FileChange::LINE_ADD, LINE_CONTEXT and LINE_REMOVE.'); + + FileChange::fromArray(['range_old_start' => 1, 'range_old_count' => 1, 'range_new_start' => 1, 'range_new_count' => 1, 'lines' => [[2, 'line']]]); + } + + public function testRawDiffIsTheOutputOfGit(): void + { + $diff = self::createFoobarRepository()->getCommit(self::LONGFILE_COMMIT)->getDiff(); + + if (self::getDriver() instanceof LibGit2Driver) { + // No git command computed it. + $this->assertSame('', $diff->getRawDiff()); + } else { + $this->assertStringContainsString("\0diff --git a/long.php b/long.php\n", $diff->getRawDiff()); } + } + + public function testGetBlobWithoutHashThrows(): void + { + $file = new File('sub', 'sub', '160000', '160000', null, null, false, repository: self::createEmptyRepository()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The diff doesn\'t know the hash of the new blob of "sub".'); - $this->assertFalse($file->isCreation()); - $this->assertFalse($file->isDeletion()); - $this->assertTrue($file->isChangeMode()); - $this->assertSame('', $file->getOldIndex()); - $this->assertSame('', $file->getNewIndex()); + $file->getNewBlob(); } - public function testMnemonicPrefix(): void + public function testGetBlobWithoutRepositoryThrows(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); + $file = Diff::parse(":100644 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 d1af4b23d0cc9313e5b2d3ef2fb9696c94afaa81 M\tfile\n")->getFiles()[0]; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('A diff without repository can\'t read blobs: get it from a repository.'); + + $file->getOldBlob(); + } + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacySetRepository(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Diff\File::setRepository()" method is deprecated, the diffs gitlib returns have their repository already.'); + + $repository = self::createFoobarRepository(); + $file = new File('README.md', 'README.md', '100644', '100644', null, null, false); + $file->setRepository($repository); + + $this->assertSame($repository, $file->getRepository()); + } + + public function testMnemonicPrefix(): void + { // 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 @@ -295,9 +502,7 @@ public function testMnemonicPrefix(): void public function testEmptyNewFile(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - - $diff = Diff::parse("diff --git a/test b/test\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n"); + $diff = Diff::parse(":000000 100644 0000000000000000000000000000000000000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 A\ttest\n\ndiff --git a/test b/test\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n"); $firstFile = $diff->getFiles()[0]; $this->assertTrue($firstFile->isCreation()); @@ -308,9 +513,7 @@ public function testEmptyNewFile(): void public function testEmptyOldFile(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - - $diff = Diff::parse("diff --git a/test b/test\ndeleted file mode 100644\nindex e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000\n"); + $diff = Diff::parse(":100644 000000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 D\ttest\n\ndiff --git a/test b/test\ndeleted file mode 100644\nindex e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000\n"); $firstFile = $diff->getFiles()[0]; $this->assertFalse($firstFile->isCreation()); @@ -319,11 +522,11 @@ public function testEmptyOldFile(): void $this->assertSame('test', $firstFile->getOldName()); } - public function testDirtySubmoduleWithoutRaw(): void + public function testDirtySubmodule(): void { - $this->expectUserDeprecationMessage('Using Diff::parse without raw information is deprecated. See https://github.com/gitonomy/gitlib/issues/227.'); - $diff = Diff::parse(<<<'DIFF' + :160000 160000 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 M sub + diff --git a/sub b/sub --- a/sub +++ b/sub @@ -349,11 +552,33 @@ protected function verifyCreateCommitDiff(Diff $diff): void $this->assertTrue($files[0]->isCreation(), 'script_A.php created'); $this->assertNull($files[0]->getOldName(), 'First file name is a new file'); - $this->assertEquals('script_A.php', $files[0]->getNewName(), 'First file name is script_A.php'); + $this->assertSame('script_A.php', $files[0]->getNewName(), 'First file name is script_A.php'); $this->assertNull($files[0]->getOldMode(), 'First file mode is a new file'); - $this->assertEquals('100644', $files[0]->getNewMode(), 'First file mode is correct'); + $this->assertSame('100644', $files[0]->getNewMode(), 'First file mode is correct'); + + $this->assertSame(1, $files[0]->getAdditions(), '1 line added'); + $this->assertSame(0, $files[0]->getDeletions(), '0 lines deleted'); + } + + private static function createWorkingCopy(): Repository + { + $repository = self::createEmptyRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + // Whatever the default of the git running the tests. + self::git($repository, 'symbolic-ref', ['HEAD', 'refs/heads/master']); - $this->assertEquals(1, $files[0]->getAdditions(), '1 line added'); - $this->assertEquals(0, $files[0]->getDeletions(), '0 lines deleted'); + return $repository; + } + + private static function commitFile(Repository $repository, string $name, string $content): void + { + $path = $repository->getWorkingDir().'/'.$name; + if (!is_dir(\dirname($path))) { + mkdir(\dirname($path), 0o777, true); + } + file_put_contents($path, $content); + self::git($repository, 'add', [$name]); + self::git($repository, 'commit', ['-q', '-m', $name]); } } diff --git a/tests/Gitonomy/Git/Tests/DriverParityTest.php b/tests/Gitonomy/Git/Tests/DriverParityTest.php new file mode 100644 index 00000000..da19c7c1 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/DriverParityTest.php @@ -0,0 +1,150 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Diff\DiffLine; +use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Driver\Data\ObjectType; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\Before; + +/** + * Both drivers read the same data from the same repository, but where + * doc/repository.md documents a difference. + */ +class DriverParityTest extends AbstractTestCase +{ + private RepositoryDriverInterface $cli; + + private RepositoryDriverInterface $libgit2; + + /** + * The objects compared already: most commits share them. + * + * @var array + */ + private array $compared = []; + + #[Before] + public function openWithBothDrivers(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not available.'); + } + + $dir = self::createFoobarRepository(false)->getPath(); + $this->cli = new Repository($dir, driver: self::getCliDriver())->getRepositoryDriver(); + $this->libgit2 = new Repository($dir, driver: new LibGit2Driver())->getRepositoryDriver(); + } + + public function testReferences(): void + { + $this->assertSameData($this->cli->getReferences(), $this->libgit2->getReferences()); + $this->assertSame($this->cli->getHeadReference(), $this->libgit2->getHeadReference()); + } + + public function testHistory(): void + { + $this->assertSameData($this->cli->getCommitLog(null, [], [], 0, null), $this->libgit2->getCommitLog(null, [], [], 0, null)); + $this->assertSame($this->cli->countCommits(null, [], []), $this->libgit2->countCommits(null, [], [])); + + $master = $this->cli->resolveRevision('master'); + $this->assertSameData($this->cli->getCommitLog([$master], [], ['README.md'], 0, null), $this->libgit2->getCommitLog([$master], [], ['README.md'], 0, null)); + + foreach ($this->cli->getCommitLog(null, [], [], 0, null) as $commit) { + $this->assertSame($this->cli->getIncludingBranches($commit->hash), $this->libgit2->getIncludingBranches($commit->hash), $commit->hash); + } + } + + public function testObjects(): void + { + foreach ($this->cli->getCommitLog(null, [], [], 0, null) as $commit) { + $this->assertSameTree($commit->treeHash); + } + + foreach ($this->cli->getReferences() as $reference) { + if (null !== $reference->tagHash) { + $this->assertSameData($this->cli->getTagData($reference->tagHash), $this->libgit2->getTagData($reference->tagHash)); + } + } + } + + public function testDiffs(): void + { + foreach ($this->cli->getCommitLog(null, [], [], 0, null) as $commit) { + $from = $commit->parentHashes[0] ?? null; + + // The raw diff is the output of git, which LibGit2Driver doesn't run. + $this->assertSameData($this->cli->getDiff($from, $commit->hash)->files, $this->libgit2->getDiff($from, $commit->hash)->files, $commit->hash); + } + } + + public function testBlame(): void + { + $master = $this->cli->resolveRevision('master'); + + $this->assertSameData($this->cli->getBlameLines($master, 'README.md', null, null), $this->libgit2->getBlameLines($master, 'README.md', null, null)); + $this->assertSameData($this->cli->getBlameLines($master, 'README.md', 2, 3), $this->libgit2->getBlameLines($master, 'README.md', 2, 3)); + } + + private function assertSameTree(string $hash): void + { + if (isset($this->compared[$hash])) { + return; + } + $this->compared[$hash] = true; + + $entries = $this->cli->getTreeEntries($hash); + $this->assertSameData($entries, $this->libgit2->getTreeEntries($hash), $hash); + + foreach ($entries as $entry) { + match ($entry->type) { + ObjectType::Tree => $this->assertSameTree($entry->hash), + ObjectType::Blob => $this->assertSameBlob($entry->hash), + // A submodule: a commit of another repository. + default => null, + }; + } + } + + private function assertSameBlob(string $hash): void + { + if (!isset($this->compared[$hash])) { + $this->compared[$hash] = true; + $this->assertSame($this->cli->getBlobContent($hash), $this->libgit2->getBlobContent($hash), $hash); + } + } + + private function assertSameData(mixed $expected, mixed $actual, string $message = ''): void + { + $this->assertSame(self::normalize($expected), self::normalize($actual), $message); + } + + /** + * Turns the data objects into arrays, which assertSame() compares by value. + */ + private static function normalize(mixed $data): mixed + { + return match (true) { + \is_array($data) => array_map(self::normalize(...), $data), + $data instanceof \DateTimeImmutable => $data->format(\DATE_ATOM), + $data instanceof \UnitEnum => $data->name, + $data instanceof FileChange => self::normalize($data->toArray()), + $data instanceof DiffLine => [$data->getType()->name, $data->getContent()], + \is_object($data) => self::normalize(get_object_vars($data)), + default => $data, + }; + } +} diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index b1ab8bee..75a5f68a 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -13,7 +13,8 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\BeforeClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -27,9 +28,9 @@ public static function setUpWindows(): void { if (\defined('PHP_WINDOWS_VERSION_MAJOR')) { self::$symlinkOnWindows = true; - $originDir = tempnam(sys_get_temp_dir(), 'sl'); - $targetDir = tempnam(sys_get_temp_dir(), 'sl'); - if (true !== @symlink($originDir, $targetDir)) { + $dir = self::createTempDir(); + touch($dir.'/origin'); + if (true !== @symlink($dir.'/origin', $dir.'/target')) { $report = error_get_last(); if (\is_array($report) && str_contains($report['message'], 'error code(1314)')) { self::$symlinkOnWindows = false; @@ -68,33 +69,37 @@ public function assertNoHook(Repository $repository, string $hook): void $this->assertFileDoesNotExist($file, "Hook {$hook} is not present"); } - #[DataProvider('provideFoobar')] - public function testHas(Repository $repository): void + #[DataProvider('provideBare')] + public function testHas(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertNoHook($repository, 'foo'); $this->touchHook($repository, 'foo'); $this->assertHasHook($repository, 'foo'); } - #[DataProvider('provideFoobar')] - public function testGetInvalidNameThrowsException(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetInvalidNameThrowsException(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(InvalidArgumentException::class); $repository->getHooks()->get('foo'); } - #[DataProvider('provideFoobar')] - public function testGet(Repository $repository): void + #[DataProvider('provideBare')] + public function testGet(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->touchHook($repository, 'foo', 'foobar'); - $this->assertEquals('foobar', $repository->getHooks()->get('foo')); + $this->assertSame('foobar', $repository->getHooks()->get('foo')); } - #[DataProvider('provideFoobar')] - public function testSymlink(Repository $repository): void + #[DataProvider('provideBare')] + public function testSymlink(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->markAsSkippedIfSymlinkIsMissing(); $file = $this->touchHook($repository, 'bar', 'barbarbar'); @@ -102,17 +107,18 @@ public function testSymlink(Repository $repository): void $this->assertTrue(is_link($this->hookPath($repository, 'foo')), 'foo hook is a symlink'); - $this->assertEquals( + $this->assertSame( str_replace('\\', '/', $file), - str_replace('\\', '/', readlink($this->hookPath($repository, 'foo'))), + str_replace('\\', '/', (string) readlink($this->hookPath($repository, 'foo'))), 'target of symlink is correct' ); } - #[DataProvider('provideFoobar')] - public function testSymlinkWithExistingThrowsLogicException(Repository $repository): void + #[DataProvider('provideBare')] + public function testSymlinkWithExistingThrows(bool $bare): void { - $this->expectException(LogicException::class); + $repository = self::createFoobarRepository($bare); + $this->expectException(RuntimeException::class); $this->markAsSkippedIfSymlinkIsMissing(); @@ -125,31 +131,34 @@ public function testSymlinkWithExistingThrowsLogicException(Repository $reposito $repository->getHooks()->setSymlink('foo', $file); } - #[DataProvider('provideFoobar')] - public function testSet(Repository $repository): void + #[DataProvider('provideBare')] + public function testSet(bool $bare): void { + $repository = self::createFoobarRepository($bare); $file = $this->hookPath($repository, 'foo'); $repository->getHooks()->set('foo', 'bar'); - $this->assertEquals('bar', file_get_contents($file), 'Hook content is correct'); + $this->assertSame('bar', file_get_contents($file), 'Hook content is correct'); $perms = fileperms($file); - $this->assertEquals(\defined('PHP_WINDOWS_VERSION_BUILD') ? 0o666 : 0o700, $perms & 0o777, 'Hook permissions are correct'); + $this->assertSame(\defined('PHP_WINDOWS_VERSION_BUILD') ? 0o666 : 0o700, $perms & 0o777, 'Hook permissions are correct'); } - #[DataProvider('provideFoobar')] - public function testSetExistingThrowsLogicException(Repository $repository): void + #[DataProvider('provideBare')] + public function testSetExistingThrows(bool $bare): void { + $repository = self::createFoobarRepository($bare); $repository->getHooks()->set('foo', 'bar'); - $this->expectException(LogicException::class); + $this->expectException(RuntimeException::class); $repository->getHooks()->set('foo', 'bar'); } - #[DataProvider('provideFoobar')] - public function testRemove(Repository $repository): void + #[DataProvider('provideBare')] + public function testRemove(bool $bare): void { + $repository = self::createFoobarRepository($bare); $file = $this->hookPath($repository, 'foo'); touch($file); @@ -158,14 +167,43 @@ public function testRemove(Repository $repository): void $this->assertFileDoesNotExist($file); } - #[DataProvider('provideFoobar')] - public function testRemoveNotExistingThrowsLogicException(Repository $repository): void + #[DataProvider('provideBare')] + public function testRemoveNotExistingThrows(bool $bare): void { - $this->expectException(LogicException::class); + $repository = self::createFoobarRepository($bare); + $this->expectException(RuntimeException::class); $repository->getHooks()->remove('foo'); } + public function testSetCreatesTheHooksDirectory(): void + { + $repository = self::createEmptyRepository(); + self::deleteDir($repository->getGitDir().'/hooks'); + + $repository->getHooks()->set('pre-receive', "#!/bin/sh\nexit 1\n"); + + $this->assertSame("#!/bin/sh\nexit 1\n", $repository->getHooks()->get('pre-receive')); + } + + public function testALinkedWorktreeSharesTheHooksOfItsRepository(): void + { + $repository = self::createFoobarRepository(false); + $dir = self::createTempDir().'/worktree'; + + try { + self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); + } catch (ProcessException) { + $this->markTestSkipped('git worktree requires git 2.5.'); + } + + $worktree = new Repository($dir, driver: self::getDriver()); + $worktree->getHooks()->set('pre-commit', "#!/bin/sh\nexit 1\n"); + + $this->assertHasHook($repository, 'pre-commit'); + $this->assertTrue($worktree->getHooks()->has('pre-commit')); + } + private function markAsSkippedIfSymlinkIsMissing(): void { if (!\function_exists('symlink')) { diff --git a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php new file mode 100644 index 00000000..01a65e4c --- /dev/null +++ b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php @@ -0,0 +1,69 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use 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()); + } +} 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 74b94eaf..358bad7d 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -12,15 +12,24 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Parser\LogParser; +use Gitonomy\Git\Commit; +use Gitonomy\Git\Driver\Cli\Parser\LogParser; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Log; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\Attributes\TestWith; class LogTest extends AbstractTestCase { - #[DataProvider('provideFoobar')] - public function testRevisionAndPath(Repository $repository): void + #[DataProvider('provideBare')] + public function testRevisionAndPath(bool $bare): void { + $repository = self::createFoobarRepository($bare); $logReadme = $repository->getLog(self::LONGFILE_COMMIT, 'README'); $logImage = $repository->getLog(self::LONGFILE_COMMIT, 'image.jpg'); @@ -28,43 +37,47 @@ public function testRevisionAndPath(Repository $repository): void $this->assertCount(2, $logImage); } - #[DataProvider('provideFoobar')] - public function testGetCommits(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetCommits(bool $bare): void { + $repository = self::createFoobarRepository($bare); $log = $repository->getLog(self::LONGFILE_COMMIT, null, null, 3); $commits = $log->getCommits(); $this->assertCount(3, $commits, '3 commits in log'); - $this->assertEquals(self::LONGFILE_COMMIT, $commits[0]->getHash(), 'First is requested one'); - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commits[1]->getHash(), "Second is longfile parent\\'s"); + $this->assertSame(self::LONGFILE_COMMIT, $commits[0]->getHash(), 'First is requested one'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $commits[1]->getHash(), "Second is longfile parent\\'s"); } - #[DataProvider('provideFoobar')] - public function testCountCommits(Repository $repository): void + #[DataProvider('provideBare')] + public function testCountCommits(bool $bare): void { + $repository = self::createFoobarRepository($bare); $log = $repository->getLog(self::LONGFILE_COMMIT, null, 2, 3); - $this->assertEquals(8, $log->countCommits(), '8 commits found in history'); + $this->assertSame(8, $log->countCommits(), '8 commits found in history'); } - #[DataProvider('provideFoobar')] - public function testCountAllCommits(Repository $repository): void + #[DataProvider('provideBare')] + public function testCountAllCommits(bool $bare): void { + $repository = self::createFoobarRepository($bare); $log = $repository->getLog(); $this->assertGreaterThan(100, $log->countCommits(), 'Returns all commits from all branches'); } - #[DataProvider('provideFoobar')] - public function testIterable(Repository $repository): void + #[DataProvider('provideBare')] + public function testIterable(bool $bare): void { + $repository = self::createFoobarRepository($bare); $log = $repository->getLog(self::LONGFILE_COMMIT); $expectedHashes = [self::LONGFILE_COMMIT, self::BEFORE_LONGFILE_COMMIT]; foreach ($log as $entry) { $hash = array_shift($expectedHashes); - $this->assertEquals($hash, $entry->getHash()); + $this->assertSame($hash, $entry->getHash()); if (0 == \count($expectedHashes)) { break; } @@ -73,17 +86,15 @@ public function testIterable(Repository $repository): void public function testFirstMessageEmpty(): void { - $repository = self::createEmptyRepository(false); - $repository->run('config', ['--local', 'user.name', '"Unit Test"']); - $repository->run('config', ['--local', 'user.email', '"unit_test@unit-test.com"']); - + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); // Edge case: first commit lacks a message. - file_put_contents($repository->getWorkingDir().'/file', 'foo'); - $repository->run('add', ['.']); - $repository->run('commit', ['--allow-empty-message', '--no-edit']); + $commit = self::writeObject($repository, 'commit', "tree {$tree}\nauthor Unit Test 1000000000 +0000\ncommitter Unit Test 1000000000 +0000\n\n"); + self::git($repository, 'update-ref', ['refs/heads/master', $commit]); $commits = $repository->getLog()->getCommits(); $this->assertCount(1, $commits); + $this->assertSame('', $commits[0]->getMessage()); } public function testParsesCommitsWithAndWithoutGitButlerHeaders(): void @@ -118,11 +129,267 @@ public function testParsesCommitsWithAndWithoutGitButlerHeaders(): void $parser = new LogParser(); $parser->parse($logContent); - $log = $parser->log; + $log = $parser->commits; $this->assertCount(3, $log); - $this->assertEquals("First commit message\n", $log[0]['message']); - $this->assertEquals("Commit with GitButler headers\n", $log[1]['message']); - $this->assertEquals("Another commit without GitButler headers\n", $log[2]['message']); + $this->assertSame("First commit message\n", $log[0]->message); + $this->assertSame("Commit with GitButler headers\n", $log[1]->message); + $this->assertSame("Another commit without GitButler headers\n", $log[2]->message); + } + + #[DataProvider('provideBare')] + 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 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)); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacySetLimit(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Log::setLimit()" method is deprecated, use "withLimit()" instead.'); + + $log = self::createFoobarRepository()->getLog(self::LONGFILE_COMMIT); + + $this->assertSame($log, $log->setLimit(1)); + $this->assertCount(1, $log->getCommits()); + } + + public function testLogOfExcludedAndSymmetricRevisions(): void + { + $repository = self::createRepositoryToCommitTo(); + $base = self::commit($repository, 'file', 'base'); + self::git($repository, 'checkout', ['-q', '-b', 'side']); + $side = self::commit($repository, 'file', 'side'); + self::git($repository, 'checkout', ['-q', 'main']); + $main = self::commit($repository, 'other', 'main'); + + $this->assertSame([$main], self::hashes($repository->getLog(['main', '^side']))); + $this->assertEqualsCanonicalizing([$main, $side], self::hashes($repository->getLog('main...side'))); + $this->assertSame([$side], self::hashes($repository->getLog('main..side'))); + $this->assertCount(3, $repository->getLog('--all')); + $this->assertSame([], self::hashes($repository->getLog('^main'))); + $this->assertSame([$main, $base], self::hashes($repository->getLog('main'))); + } + + public function testLogOfAnUnknownRevisionNamesIt(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('"unknown"'); + + $repository->getLog('master..unknown', 'README.md')->getCommits(); + } + + public function testLogRefusesOptions(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(InvalidArgumentException::class); + + $repository->getLog('--output=/dev/null')->getCommits(); + } + + public function testParsesCommitsMergingTagsAndSignedOnes(): void + { + $repository = self::createRepositoryToCommitTo(); + $tree = trim(self::git($repository, 'write-tree')); + $signed = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n signature\n -----END PGP SIGNATURE-----\n\nSigned\n"); + $other = self::writeObject($repository, 'commit', "tree {$tree}\nparent {$signed}\nauthor A 1000000001 +0000\ncommitter A 1000000001 +0000\n\nOther\n"); + // An unsigned tag: no "END PGP SIGNATURE" line in its "mergetag" header. + $merge = self::writeObject($repository, 'commit', "tree {$tree}\nparent {$other}\nparent {$signed}\nauthor A 1000000002 +0000\ncommitter A 1000000002 +0000\nmergetag object {$signed}\n type commit\n tag v1\n tagger A 1000000000 +0000\n \n Tag v1\n\nMerge tag v1\n"); + self::git($repository, 'update-ref', ['refs/heads/main', $merge]); + + $commits = $repository->getLog('main')->getCommits(); + + $this->assertSame([$merge, $other, $signed], array_map(static fn (Commit $commit): string => $commit->getHash(), $commits)); + $this->assertSame("Merge tag v1\n", $commits[0]->getMessage()); + $this->assertSame("Signed\n", $commits[2]->getMessage()); + } + + #[DataProvider('provideBare')] + public function testLogOfTheRoot(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + + $this->assertSame(self::hashes($repository->getLog(self::LONGFILE_COMMIT, ['README.md', 'long.php'])), self::hashes($repository->getLog(self::LONGFILE_COMMIT, ['./README.md', './long.php']))); + $this->assertCount(\count($repository->getLog(self::LONGFILE_COMMIT)), $repository->getLog(self::LONGFILE_COMMIT, '.')); + } + + private static function createRepositoryToCommitTo(): Repository + { + $repository = self::createEmptyRepository(false); + 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/PushReferenceTest.php b/tests/Gitonomy/Git/Tests/PushReferenceTest.php index dd115d0e..e3a0cf34 100644 --- a/tests/Gitonomy/Git/Tests/PushReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/PushReferenceTest.php @@ -12,8 +12,8 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\PushReference; -use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; class PushReferenceTest extends AbstractTestCase @@ -27,12 +27,15 @@ class PushReferenceTest extends AbstractTestCase public function testIsers(string $reference, string $before, string $after, int $mask): void { $reference = new PushReference(self::createFoobarRepository(), $reference, $before, $after); - $this->assertEquals($mask & self::CREATE, $reference->isCreate(), 'Create value is correct.'); - $this->assertEquals($mask & self::DELETE, $reference->isDelete(), 'Delete value is correct.'); - $this->assertEquals($mask & self::FORCE, $reference->isForce(), 'Force value is correct.'); - $this->assertEquals($mask & self::FAST_FORWARD, $reference->isFastForward(), 'FastForward value is correct.'); + $this->assertSame(0 !== ($mask & self::CREATE), $reference->isCreate(), 'Create value is correct.'); + $this->assertSame(0 !== ($mask & self::DELETE), $reference->isDelete(), 'Delete value is correct.'); + $this->assertSame(0 !== ($mask & self::FORCE), $reference->isForce(), 'Force value is correct.'); + $this->assertSame(0 !== ($mask & self::FAST_FORWARD), $reference->isFastForward(), 'FastForward value is correct.'); } + /** + * @return list + */ public static function provideIsers(): array { // mask: force fastforward create delete @@ -44,35 +47,47 @@ public static function provideIsers(): array ]; } - #[DataProvider('provideFoobar')] - public function testLog(Repository $repository): void + public function testUnknownBeforeIsAnErrorNotAForcePush(): void + { + $reference = new PushReference(self::createFoobarRepository(), 'foo', str_repeat('1', 40), self::LONGFILE_COMMIT); + + $this->expectException(DriverException::class); + + $reference->isForce(); + } + + #[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->assertCount(7, $log, '7 commits in log'); - $this->assertEquals('add a long file', $log[0]->getShortMessage(), 'First commit is correct'); + $this->assertSame('add a long file', $log[0]->getSubjectMessage(), 'First commit is correct'); } /** * This test ensures that GPG signed requests does not break the reading of commit logs. */ - #[DataProvider('provideFoobar')] - public function testSignedLog(Repository $repository): void + #[DataProvider('provideBare')] + public function testSignedLog(bool $bare): void { + $repository = self::createFoobarRepository($bare); $ref = new PushReference($repository, 'foo', self::INITIAL_COMMIT, self::SIGNED_COMMIT); $log = $ref->getLog()->getCommits(); $this->assertCount(16, $log, '16 commits in log'); - $this->assertEquals('signed commit', $log[0]->getShortMessage(), 'Last commit is correct'); + $this->assertSame('signed commit', $log[0]->getSubjectMessage(), 'Last commit is correct'); } - #[DataProvider('provideFoobar')] - public function testLogWithExclude(Repository $repository): void + #[DataProvider('provideBare')] + public function testLogWithExclude(bool $bare): void { + $repository = self::createFoobarRepository($bare); $ref = new PushReference($repository, 'foo', PushReference::ZERO, self::LONGFILE_COMMIT); $log = $ref->getLog([self::INITIAL_COMMIT])->getCommits(); $this->assertCount(7, $log, '7 commits in log'); - $this->assertEquals('add a long file', $log[0]->getShortMessage(), 'First commit is correct'); + $this->assertSame('add a long file', $log[0]->getSubjectMessage(), 'First commit is correct'); } } diff --git a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php index 7278b19b..fb50ec91 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php @@ -13,14 +13,14 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Reference\Branch; -use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; class ReferenceBagTest extends AbstractTestCase { - #[DataProvider('provideFoobar')] - public function testGetFirstBranch(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFirstBranch(bool $bare): void { + $repository = self::createFoobarRepository($bare); $branch = $repository->getReferences()->getFirstBranch(); $this->assertInstanceOf(Branch::class, $branch); @@ -36,23 +36,23 @@ public function testGetFirstBranchWithNoBranches(): void $branch->delete(); } - $refs = $repository->getReferences(true); + $refs = $repository->getReferences(); $this->assertFalse($refs->hasBranches()); - $this->assertFalse($refs->getFirstBranch(), 'getFirstBranch() must not return a non-branch reference'); + $this->assertNull($refs->getFirstBranch(), 'getFirstBranch() must not return a non-branch reference'); } - #[DataProvider('provideFoobar')] - public function testUnknownReference(Repository $repository): void + #[DataProvider('provideBare')] + public function testUnknownReference(bool $bare): void { + $repository = self::createFoobarRepository($bare); $hash = $repository->getLog()->getSingleCommit()->getHash(); - $repository->run('update-ref', ['refs/pipelines/1', $hash]); - $repository->run('update-ref', ['refs/merge-request/1/head', $hash]); - $repository->run('update-ref', ['refs/pull/1/head', $hash]); - $repository->run('update-ref', ['refs/notes/gtm-data', $hash]); + self::git($repository, 'update-ref', ['refs/pipelines/1', $hash]); + self::git($repository, 'update-ref', ['refs/merge-request/1/head', $hash]); + self::git($repository, 'update-ref', ['refs/pull/1/head', $hash]); + self::git($repository, 'update-ref', ['refs/notes/gtm-data', $hash]); $refs = $repository->getReferences()->getAll(); - $this->assertIsArray($refs); // Check that at least it has the master ref $this->assertArrayHasKey('refs/heads/master', $refs); @@ -64,10 +64,25 @@ public function testUnknownReference(Repository $repository): void $this->assertArrayNotHasKey('refs/notes/gtm-data', $refs); } - #[DataProvider('provideEmpty')] - public function testEmptyRepo(Repository $repository): void + #[DataProvider('provideBare')] + public function testEmptyRepo(bool $bare): void { + $repository = self::createEmptyRepository($bare); $refs = $repository->getReferences()->getAll(); $this->assertSame([], $refs); } + + 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 55da2a5d..4251fdd2 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -12,144 +12,142 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Tag; -use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class ReferenceTest extends AbstractTestCase { - #[DataProvider('provideEmpty')] - public function testEmptyRepository(Repository $repository): void + #[DataProvider('provideBare')] + public function testEmptyRepository(bool $bare): void { + $repository = self::createEmptyRepository($bare); $this->assertCount(0, $repository->getReferences()); - $this->assertEquals([], $repository->getReferences()->getAll()); + $this->assertSame([], $repository->getReferences()->getAll()); } - #[DataProvider('provideFoobar')] - public function testGetBranch(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetBranch(bool $bare): void { + $repository = self::createFoobarRepository($bare); $branch = $repository->getReferences()->getBranch('master'); - - $this->assertInstanceOf(Branch::class, $branch, 'Branch object is correct type'); - $this->assertEquals($branch->getCommitHash(), $branch->getCommit()->getHash(), 'Hash is correctly resolved'); + $this->assertSame($branch->getCommitHash(), $branch->getCommit()->getHash(), 'Hash is correctly resolved'); } - #[DataProvider('provideFoobar')] - public function testHasBranch(Repository $repository): void + #[DataProvider('provideBare')] + public function testHasBranch(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertTrue($repository->getReferences()->hasBranch('master'), 'Branch master exists'); $this->assertFalse($repository->getReferences()->hasBranch('foobar'), 'Branch foobar does not exists'); } - #[DataProvider('provideFoobar')] - public function testHasTag(Repository $repository): void + #[DataProvider('provideBare')] + public function testHasTag(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertTrue($repository->getReferences()->hasTag('0.1'), 'Tag 0.1 exists'); $this->assertFalse($repository->getReferences()->hasTag('foobar'), 'Tag foobar does not exists'); } - #[DataProvider('provideFoobar')] - public function testGetBranchNotExistingError(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetBranchNotExistingError(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(ReferenceNotFoundException::class); $repository->getReferences()->getBranch('notexisting'); } - #[DataProvider('provideFoobar')] - public function testGetTag(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetTag(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tag = $repository->getReferences()->getTag('0.1'); - - $this->assertInstanceOf(Tag::class, $tag, 'Tag object is correct type'); $this->assertFalse($tag->isAnnotated(), 'Tag is not annotated'); - $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommitHash(), 'Commit hash is correct'); - $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommit()->getHash(), 'Commit hash is correct'); + $this->assertSame(self::LONGFILE_COMMIT, $tag->getCommitHash(), 'Commit hash is correct'); + $this->assertSame(self::LONGFILE_COMMIT, $tag->getCommit()->getHash(), 'Commit hash is correct'); } - #[DataProvider('provideFoobar')] - public function testIsAnnotatedIsFalseForLightweightTagWhenDebugIsFalse(Repository $repository): void - { - $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); - - $tag = $repository->getReferences()->getTag('0.1'); - - $this->assertFalse($tag->isAnnotated(), 'Lightweight tag must not be reported as annotated'); - } - - #[DataProvider('provideFoobar')] - public function testAnnotatedTag(Repository $repository): void + #[DataProvider('provideBare')] + public function testAnnotatedTag(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tag = $repository->getReferences()->getTag('annotated'); - - $this->assertInstanceOf(Tag::class, $tag, 'Tag object is correct type'); $this->assertTrue($tag->isAnnotated(), 'Tag is annotated'); $this->assertFalse($tag->isSigned(), 'Tag is not signed'); - $this->assertEquals('Graham Campbell', $tag->getTaggerName(), 'Tagger name is correct'); - $this->assertEquals('graham@alt-three.com', $tag->getTaggerEmail(), 'Tagger email is correct'); - $this->assertEquals(1471428000, $tag->getTaggerDate()->getTimestamp(), 'Tag date is correct'); + $this->assertSame('Graham Campbell', $tag->getTaggerName(), 'Tagger name is correct'); + $this->assertSame('graham@alt-three.com', $tag->getTaggerEmail(), 'Tagger email is correct'); + $this->assertSame(1471428000, $tag->getTaggerDate()?->getTimestamp(), 'Tag date is correct'); - $this->assertEquals('heading', $tag->getSubjectMessage(), 'Message heading is correct'); - $this->assertEquals("body\nbody", $tag->getBodyMessage(), 'Message body is correct'); + // Without an empty line after its first one, the message is one paragraph: its subject, like "git log --format=%s". + $this->assertSame('heading body body', $tag->getSubjectMessage(), 'Message heading is correct'); + $this->assertSame('', $tag->getBodyMessage(), 'Message body is correct'); - $closure = function () { - return parent::getCommit(); - }; - $parentCommit = $closure->bindTo($tag, Tag::class); - $this->assertNotEquals($parentCommit()->getHash(), $tag->getCommit()->getHash(), 'Tag commit is not the same as main commit'); - $this->assertEquals('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommit()->getHash(), 'Tag commit is correct'); + // The commit the tag object points to, not the tag object. + $this->assertSame('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommitHash()); + $this->assertSame('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommit()->getHash()); + $this->assertSame([$tag], $repository->getReferences()->resolveTags($tag->getCommit())); + $this->assertContains($tag, $tag->getCommit()->resolveReferences()); } - #[DataProvider('provideFoobar')] - public function testAnnotatedTagBodyMessageWithBlankLineSeparator(Repository $repository): void + #[DataProvider('provideBare')] + public function testAnnotatedTagBodyMessageWithBlankLineSeparator(bool $bare): void { - $repository->run('config', ['user.email', 'test@example.com']); - $repository->run('config', ['user.name', 'Test']); + $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(); - $repository->run('tag', ['-a', 'blank-sep', '-m', 'Subject line', '-m', "Body line 1\nBody line 2", $hash]); + self::git($repository, 'tag', ['-a', 'blank-sep', '-m', 'Subject line', '-m', "Body line 1\nBody line 2", $hash]); $tag = $repository->getReferences()->getTag('blank-sep'); - $this->assertEquals('Subject line', $tag->getSubjectMessage(), 'Message subject is correct'); - $this->assertEquals("Body line 1\nBody line 2", $tag->getBodyMessage(), 'Message body has no leading blank line'); + $this->assertSame('Subject line', $tag->getSubjectMessage(), 'Message subject is correct'); + $this->assertSame("Body line 1\nBody line 2\n", $tag->getBodyMessage(), 'Message body has no leading blank line'); } - #[DataProvider('provideFoobar')] - public function testGetTagNotExistingError(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetTagNotExistingError(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(ReferenceNotFoundException::class); $repository->getReferences()->getTag('notexisting'); } - #[DataProvider('provideFoobar')] - public function testResolve(Repository $repository): void + #[DataProvider('provideBare')] + public function testResolve(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getReferences()->getTag('0.1')->getCommit(); $resolved = $repository->getReferences()->resolve($commit->getHash()); $this->assertCount(1, $resolved, '1 revision resolved'); - $this->assertInstanceOf(Tag::class, reset($resolved), 'Resolved object is a tag'); + $this->assertSame('refs/tags/0.1', $resolved[0]->getFullname()); } - #[DataProvider('provideFoobar')] - public function testResolveTags(Repository $repository): void + #[DataProvider('provideBare')] + public function testResolveTags(bool $bare): void { + $repository = self::createFoobarRepository($bare); $commit = $repository->getReferences()->getTag('0.1')->getCommit(); $resolved = $repository->getReferences()->resolveTags($commit->getHash()); $this->assertCount(1, $resolved, '1 revision resolved'); - $this->assertInstanceOf(Tag::class, reset($resolved), 'Resolved object is a tag'); + $this->assertSame('refs/tags/0.1', $resolved[0]->getFullname()); } - #[DataProvider('provideFoobar')] - public function testResolveBranches(Repository $repository): void + #[DataProvider('provideBare')] + public function testResolveBranches(bool $bare): void { + $repository = self::createFoobarRepository($bare); $master = $repository->getReferences()->getBranch('master'); $resolved = $repository->getReferences()->resolveBranches($master->getCommitHash()); @@ -160,18 +158,20 @@ public function testResolveBranches(Repository $repository): void $this->assertCount(2, $resolved, '2 revision resolved'); } - $this->assertInstanceOf(Branch::class, reset($resolved), 'Resolved object is a branch'); + $this->assertNotEmpty($resolved, 'A branch is resolved'); } - #[DataProvider('provideFoobar')] - public function testCountable(Repository $repository): void + #[DataProvider('provideBare')] + public function testCountable(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertGreaterThanOrEqual(2, \count($repository->getReferences()), 'At least two references in repository'); } - #[DataProvider('provideFoobar')] - public function testIterable(Repository $repository): void + #[DataProvider('provideBare')] + public function testIterable(bool $bare): void { + $repository = self::createFoobarRepository($bare); $i = 0; foreach ($repository->getReferences() as $ref) { ++$i; @@ -179,28 +179,30 @@ public function testIterable(Repository $repository): void $this->assertGreaterThanOrEqual(2, $i, 'At least two references in repository'); } - #[DataProvider('provideFoobar')] - public function testCreateAndDeleteTag(Repository $repository): void + #[DataProvider('provideBare')] + public function testCreateAndDeleteTag(bool $bare): void { + $repository = self::createFoobarRepository($bare); $references = $repository->getReferences(); $tag = $references->createTag('0.0', self::INITIAL_COMMIT); $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 $repository): void + #[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(); @@ -211,24 +213,193 @@ public function testIsBranchMergedToMaster(): void { $repository = self::createFoobarRepository(false); - $repository->run('config', ['--local', 'user.name', '"Unit Test"']); - $repository->run('config', ['--local', 'user.email', '"unit_test@unit-test.com"']); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); $master = $repository->getReferences()->getBranch('master'); $references = $repository->getReferences(); $branch = $references->createBranch('foobar-new', $master->getCommit()->getHash()); - $this->assertTrue($branch->isMergedTo('master')); + $this->assertTrue($branch->isMergedInto('master')); + $this->assertTrue($branch->isMergedInto($master)); $wc = $repository->getWorkingCopy(); $wc->checkout('foobar-new'); $file = $repository->getWorkingDir().'/foobar-test.txt'; file_put_contents($file, 'test'); - $repository->run('add', [$file]); - $repository->run('commit', ['-m', 'foobar-test.txt updated']); + self::git($repository, 'add', [$file]); + self::git($repository, 'commit', ['-m', 'foobar-test.txt updated']); + + // A branch is read once: read it again to see the new commit. + $this->assertFalse($references->getBranch('foobar-new')->isMergedInto('master')); + $this->assertTrue($master->isMergedInto('foobar-new')); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyIsMergedTo(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Reference\Branch::isMergedTo()" method is deprecated, use "Gitonomy\Git\Reference\Branch::isMergedInto()" instead.'); + + $repository = self::createFoobarRepository(false); + $references = $repository->getReferences(); + $branch = $references->createBranch('foobar-new', 'master'); + + $this->assertTrue($branch->isMergedTo()); + $this->assertFalse($branch->isMergedTo('master', true), 'A local branch is not remote'); + } + + #[DataProvider('provideBare')] + public function testCreateRefusesAnExistingReference(bool $bare): void + { + $references = self::createFoobarRepository($bare)->getReferences(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The reference "refs/heads/master" exists already.'); + + $references->createBranch('master', self::INITIAL_COMMIT); + } + + #[DataProvider('provideBare')] + public function testCreatedReferencesAreListed(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $references = $repository->getReferences(); + $names = static fn (array $tags): array => array_map(static fn (Tag $tag): string => $tag->getFullname(), $tags); + $before = $names($references->getTags()); + + $tag = $references->createTag('0.0', self::INITIAL_COMMIT); + + $this->assertSame(['refs/tags/0.0', ...$before], $names($references->getTags())); + $this->assertContains($tag, $repository->getReferences()->resolveTags(self::INITIAL_COMMIT)); + + $tag->delete(); + $this->assertSame($before, $names($references->getTags())); + } + + public function testResolveTagsOfARevision(): void + { + $references = self::createFoobarRepository()->getReferences(); + + $this->assertSame([$references->getTag('annotated')], $references->resolveTags('annotated')); + } + + public function testBranchMoveTo(): void + { + $repository = self::createFoobarRepository(); + $branch = $repository->getReferences()->createBranch('moving', self::INITIAL_COMMIT); + + $moved = $branch->moveTo($repository->getCommit(self::LONGFILE_COMMIT)); + + $this->assertSame(self::LONGFILE_COMMIT, $moved->getCommitHash()); + $this->assertSame($moved, $repository->getReferences()->getBranch('moving')); + $this->assertSame(self::INITIAL_COMMIT, $branch->getCommitHash(), 'A branch is read once'); + } - $this->assertFalse($branch->isMergedTo('master')); + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyUpdate(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\ReferenceBag::update()" method is deprecated, use "Gitonomy\Git\Reference\Branch::moveTo()" instead.'); + + $repository = self::createFoobarRepository(); + $references = $repository->getReferences(); + + $references->update(new Branch($repository, 'refs/heads/master', self::INITIAL_COMMIT)); + + $this->assertSame(self::INITIAL_COMMIT, $references->getBranch('master')->getCommitHash()); + } + + public function testBranchesEndingWithHead(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'branch', ['AHEAD', 'master']); + + $this->assertTrue($repository->getReferences()->hasBranch('AHEAD')); + } + + public function testDeletingADeletedReferenceDoesNothing(): void + { + $repository = self::createFoobarRepository(); + $branch = $repository->getReferences()->createBranch('deleted', 'master'); + self::git($repository, 'update-ref', ['-d', 'refs/heads/deleted']); + + // Like git update-ref -d. + $branch->delete(); + + $this->assertFalse($repository->getReferences()->hasBranch('deleted')); + } + + public function testTheHeadOfARemoteNamedWithASlashIsNoBranch(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['refs/remotes/foo/bar/main', self::LONGFILE_COMMIT]); + self::git($repository, 'symbolic-ref', ['refs/remotes/foo/bar/HEAD', 'refs/remotes/foo/bar/main']); + + $names = array_map(static fn (Branch $branch): string => $branch->getFullname(), $repository->getReferences()->getRemoteBranches()); + + $this->assertContains('refs/remotes/foo/bar/main', $names); + $this->assertNotContains('refs/remotes/foo/bar/HEAD', $names); + } + + /** + * 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/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index e8fcc8e8..40bad56b 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -14,47 +14,508 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\Commit; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\Data\ReferenceData; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ProcessTimedOutException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Process; class RepositoryTest extends AbstractTestCase { - public function testRunReturnsNullInsteadOfThrowingWhenDebugIsFalse(): void + 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(), 'working dir present in bare repository'); + } + + public function testOpensALinkedWorktree(): void + { + $repository = self::createFoobarRepository(false); + $dir = self::createTempDir().'/worktree'; + + try { + self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); + } catch (ProcessException) { + $this->markTestSkipped('git worktree requires git 2.5.'); + } + + // Its ".git" is a file pointing to a directory of the main repository. + $worktree = new Repository($dir, driver: self::getDriver()); + + $this->assertSame(realpath($dir), $worktree->getWorkingDir()); + $this->assertSame(realpath($repository->getGitDir().'/worktrees/worktree'), $worktree->getGitDir()); + $this->assertFalse($worktree->isBare()); + $head = $worktree->getHead(); + $this->assertInstanceOf(Branch::class, $head); + $this->assertSame('in-worktree', $head->getName()); + $this->assertSame($repository->getHeadCommit()->getHash(), $worktree->getHeadCommit()->getHash()); + } + + public function testOpeningADirectoryThatIsNotARepositoryThrows(): void + { + $dir = self::createTempDir(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Directory "%s" is not a git repository.', $dir)); + + new Repository($dir, driver: self::getDriver()); + } + + public function testReadsTheHeadOfAReftableRepository(): void + { + // libgit2 1.9 doesn't read reftable repositories. + $this->skipUnlessCliDriver(); + + $dir = self::createTempDir(); + $process = new Process([self::gitCommand(), 'init', '-q', '--ref-format=reftable', '--initial-branch=trunk', $dir]); + if (0 !== $process->run()) { + $this->markTestSkipped('Reftable repositories require git 2.45.'); + } + + $repository = new Repository($dir, driver: self::getDriver()); + $this->assertTrue($repository->isHeadAttached()); + + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + self::git($repository, 'commit', ['--allow-empty', '-m', 'First']); + + // The HEAD file of a reftable repository holds "ref: refs/heads/.invalid". + $this->assertSame('trunk', self::headBranch($repository)->getName()); + $this->assertSame('First', $repository->getHeadCommit()->getSubjectMessage()); + } + + public function testDetachedHead(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'checkout', ['-q', self::LONGFILE_COMMIT]); + + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); + } + + public function testObjectsAreTheSameWhileUsed(): void + { + $repository = self::createFoobarRepository(); + $commit = $repository->getCommit(self::INITIAL_COMMIT); + + $this->assertSame($commit, $repository->getCommit(self::INITIAL_COMMIT)); + $this->assertSame($repository->getBlob('412a0dad051163dcf9f9ce2fc04a141ffcb69479'), $repository->getBlob('412a0dad'), 'An abbreviated hash names the same blob'); + } + + public function testRefusesSha256Repositories(): void + { + $dir = self::createTempDir(); + $process = new Process([self::gitCommand(), 'init', '-q', '--object-format=sha256', $dir]); + if (0 !== $process->run()) { + $this->markTestSkipped('SHA-256 repositories require git 2.29.'); + } + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('uses SHA-256 hashes'); + + new Repository($dir, driver: self::getDriver()); + } + + public function testTimeout(): void + { + $repository = self::createFoobarRepository(false); + $repository->getHooks()->set('pre-commit', "#!/bin/sh\nsleep 10\n"); + $repository = new Repository($repository->getPath(), driver: self::getCliDriver(['GIT_AUTHOR_NAME' => 'A', 'GIT_AUTHOR_EMAIL' => 'a@example.com', 'GIT_COMMITTER_NAME' => 'A', 'GIT_COMMITTER_EMAIL' => 'a@example.com'], processTimeout: 0.5)); + + $this->expectException(ProcessTimedOutException::class); + $this->expectExceptionMessage('The git command "commit" ran longer than 0.5 seconds.'); + + $repository->run('commit', ['--allow-empty', '-m', 'Waits for its hook']); + } + + public function testIsValidRepositoryAndBranchMatchesTheNameExactly(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['refs/heads/feature/release', self::LONGFILE_COMMIT]); + $url = $repository->getGitDir(); + + $this->assertTrue(Repository::isValidRepositoryAndBranch($url, 'feature/release', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'release', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'rel*', self::getDriver())); + } + + public function testIsValidRepositoryReadsANameAsAPath(): void + { + // A clone, with an "origin" remote. + $repository = self::createFoobarRepository(false); + $cwd = getcwd(); + self::assertIsString($cwd); + + chdir($repository->getPath()); + + try { + $this->assertFalse(Repository::isValidRepository('origin', self::getDriver())); + $this->assertTrue(Repository::isValidRepository('.', self::getDriver())); + } finally { + chdir($cwd); + } + } + + public function testIsValidRepositoryThrowsWhenGitCantRun(): void + { + $this->skipUnlessCliDriver(); + + $this->expectException(ProcessException::class); + + Repository::isValidRepository(self::repositoryUrl(), new CliDriver('not-git')); + } + + public function testCloneATagAsBranchDetachesHead(): void + { + $repository = Repository::cloneFrom(self::repositoryUrl(), self::createTempDir().'/clone', 'annotated', self::getDriver()); + + // Like git, at the commit of the tag, without any local branch. + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame($repository->getReferences()->getTag('annotated')->getCommitHash(), $repository->getHeadCommit()->getHash()); + $this->assertSame([], $repository->getReferences()->getLocalBranches()); + $this->assertFileExists($repository->getWorkingDir().'/README.md'); + $this->assertSame([], $repository->getWorkingCopy()->getDiff()->getFiles(), 'The files of the tag are checked out'); + $this->assertFileDoesNotExist($repository->getGitDir().'/FETCH_HEAD'); + } + + public function testCloneBareATagAsBranchDetachesHead(): void + { + $repository = Repository::cloneBareFrom(self::repositoryUrl(), self::createTempDir(), '0.1', self::getDriver()); + + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); + $this->assertFileDoesNotExist($repository->getGitDir().'/FETCH_HEAD'); + } + + public function testCloneOfAMissingBranchThrows(): void + { + $this->expectException(DriverException::class); + + Repository::cloneFrom(self::repositoryUrl(), self::createTempDir().'/clone', 'missing', self::getDriver()); + } + + public function testClonesWriteNoFetchHead(): void + { + $dir = self::createTempDir(); + + $this->assertFileDoesNotExist(Repository::cloneFrom(self::repositoryUrl(), $dir.'/clone', driver: self::getDriver())->getGitDir().'/FETCH_HEAD'); + $this->assertFileDoesNotExist(Repository::cloneBareFrom(self::repositoryUrl(), $dir.'/bare', driver: self::getDriver())->getGitDir().'/FETCH_HEAD'); + $this->assertFileDoesNotExist(Repository::mirrorFrom(self::repositoryUrl(), $dir.'/mirror', self::getDriver())->getGitDir().'/FETCH_HEAD'); + } + + public function testReadingAMovedRepositoryThrows(): void + { + $dir = self::createTempDir(); + $repository = Repository::cloneBareFrom(self::repositoryUrl(), $dir.'/repository', driver: self::getDriver()); + rename($dir.'/repository', $dir.'/moved'); + + $this->expectException(DriverException::class); + + $repository->getReferences()->getBranches(); + } + + public function testAnObjectOfAnotherTypeThrows(): void + { + $repository = self::createFoobarRepository(); + $treeHash = $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->getHash(); + + foreach ([ + 'blob' => static fn () => $repository->getBlob(self::LONGFILE_COMMIT)->getContent(), + 'tree' => static fn () => $repository->getTree(self::LONGFILE_COMMIT)->getEntries(), + 'commit' => static fn () => $repository->getCommit($treeHash)->getMessage(), + ] as $type => $read) { + try { + $read(); + $this->fail(\sprintf('A %s of another type is refused.', $type)); + } catch (ReferenceNotFoundException $e) { + $this->assertMatchesRegularExpression(\sprintf('/^The object "[0-9a-f]{40}" is not a %s\.$/', $type), $e->getMessage()); + } + } + } + + public function testCloneRefusesADirectoryWithFiles(): void + { + $dir = self::createTempDir(); + touch($dir.'/file'); + + $this->expectException(DriverException::class); + + Repository::cloneBareFrom(self::repositoryUrl(), $dir, driver: self::getDriver()); + } + + public function testFailedCloneLeavesNothing(): void + { + $dir = self::createTempDir().'/clone'; + + try { + Repository::cloneBareFrom(self::repositoryUrl(), $dir, 'unknown', self::getDriver()); + $this->fail('The clone should have failed.'); + } catch (DriverException) { + } + + $this->assertDirectoryDoesNotExist($dir); + } + + public function testMirrorDoesNotFetchTagsTwice(): void + { + if (!self::getDriver() instanceof LibGit2Driver) { + $this->markTestSkipped('The configuration CliDriver writes depends on the version of git.'); + } + + $mirror = Repository::mirrorFrom(self::repositoryUrl(), self::createTempDir(), self::getDriver()); + + $this->assertSame('--no-tags', trim(self::git($mirror, 'config', ['remote.origin.tagopt']))); + } + + public function testGetMergeBase(): void + { + $repository = self::createFoobarRepository(); + + $this->assertSame(self::LONGFILE_COMMIT, $repository->getMergeBase(self::LONGFILE_COMMIT, 'master')?->getHash()); + } + + public function testAnAbbreviatedHashNamesAnObjectOfTheRightType(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(ReferenceNotFoundException::class); + + $repository->getBlob(substr(self::LONGFILE_COMMIT, 0, 8)); + } + + public function testAnAbbreviatedCommitHashNamesNoTree(): void + { + $repository = self::createFoobarRepository(); + $hash = substr(self::LONGFILE_COMMIT, 0, 8); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage(\sprintf('The object "%s" is not a tree.', $hash)); + + $repository->getTree($hash); + } + + public function testInitOnAnExistingFileFails(): void + { + $this->expectException(RuntimeException::class); + + $file = self::createTempDir().'/test'; + touch($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); - $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); - $this->assertNull($repository->run('not-a-command')); + $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 testRunProcessGivesAccessToStderrOnSuccess(): void + public function testCloneFrom(): void { $repository = self::createFoobarRepository(false); - // `git checkout` reports the switched branch on stderr, even on success. - $process = $repository->runProcess('checkout', ['master']); + $newDir = self::createTempDir(); + $new = Repository::cloneFrom($repository->getGitDir(), $newDir, driver: self::getDriver()); - $this->assertTrue($process->isSuccessful()); - $this->assertStringContainsString('master', $process->getErrorOutput()); + $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 testRunProcessReturnsFailedProcessInsteadOfThrowingWhenDebugIsFalse(): void + 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); - $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); - $process = $repository->runProcess('not-a-command'); + $this->expectException(ProcessException::class); - $this->assertFalse($process->isSuccessful()); + $repository->run('not-a-command'); } - public function testGetShortHashThrowsCleanExceptionWhenDebugIsFalse(): void + 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 { $repository = self::createFoobarRepository(true); - $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); $commit = new Commit($repository, str_repeat('a', 40)); @@ -63,18 +524,20 @@ public function testGetShortHashThrowsCleanExceptionWhenDebugIsFalse(): void $commit->getShortHash(); } - #[DataProvider('provideFoobar')] - public function testGetBlobWithExistingWorks(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetBlobWithExistingWorks(bool $bare): void { + $repository = self::createFoobarRepository($bare); $blob = $repository->getCommit(self::LONGFILE_COMMIT)->getTree()->resolvePath('README.md'); $this->assertInstanceOf(Blob::class, $blob, 'getBlob() returns a Blob object'); $this->assertStringContainsString('Foo Bar project', $blob->getContent(), 'file is correct'); } - #[DataProvider('provideFoobar')] - public function testGetSize(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetSize(bool $bare): void { + $repository = self::createFoobarRepository($bare); $size = $repository->getSize(); // The exact figure depends on git's packing/compression, which varies across versions. $this->assertGreaterThanOrEqual(40, $size, 'Repository is at least 40KB'); @@ -90,14 +553,15 @@ public function testIsBare(): void $this->assertFalse($notBare->isBare(), 'Working copy is not bare'); } - #[DataProvider('provideFoobar')] - public function testGetDescription(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetDescription(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->assertSame("Unnamed repository; edit this file 'description' to name the repository.\n", $repository->getDescription()); } - #[DataProvider('provideFoobar')] - public function testLoggerOk(Repository $repository): void + #[DataProvider('provideBare')] + public function testLoggerOk(bool $bare): void { $logger = $this->createMock(LoggerInterface::class); $logger->expects($this->once()) @@ -109,13 +573,13 @@ public function testLoggerOk(Repository $repository): void ->with($this->isString()) ; - $repository->setLogger($logger); + $repository = new Repository(self::createFoobarRepository($bare)->getPath(), driver: self::getCliDriver(logger: $logger)); $repository->run('remote'); } - #[DataProvider('provideFoobar')] - public function testLoggerNOk(Repository $repository): void + #[DataProvider('provideBare')] + public function testLoggerNOk(bool $bare): void { $this->expectException(RuntimeException::class); @@ -124,16 +588,16 @@ public function testLoggerNOk(Repository $repository): void ->method('info') ->with($this->isString()) ; - $logger->expects($this->exactly(3)) // duration, return code and output + $logger->expects($this->exactly(4)) // duration, return code, output and error output ->method('debug') ->with($this->isString()) ; - $logger->expects($this->once()) + // The exception reports the failure: the caller may well expect it. + $logger->expects($this->never()) ->method('error') - ->with($this->isString()) ; - $repository->setLogger($logger); + $repository = new Repository(self::createFoobarRepository($bare)->getPath(), driver: self::getCliDriver(logger: $logger)); $repository->run('not-work'); } @@ -143,6 +607,8 @@ public function testLoggerNOk(Repository $repository): void */ public function testRunResolvesRelativePathsAgainstTheRepositoryRegardlessOfCwd(): void { + $this->skipUnlessCliDriver(); + $repository = self::createFoobarRepository(false); $file = $repository->getWorkingDir().'/README.md'; @@ -171,4 +637,113 @@ public function testRunResolvesRelativePathsAgainstTheRepositoryRegardlessOfCwd( $this->assertSame($original."Applied line.\n", file_get_contents($file)); } + + public function testRunIsOnlySupportedByCliDriver(): void + { + $repository = new Repository(self::createTempDir(), driver: $this->createStub(DriverInterface::class)); + + $this->expectException(UnsupportedOperationException::class); + + $repository->run('status'); + } + + public function testDriverOpensTheRepository(): void + { + $dir = 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()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacySetLogger(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::setLogger()" method is deprecated, pass a "Gitonomy\Git\Driver\Cli\CliDriver" configured with the logger as driver argument instead.'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('info') + ->with('run command: remote "" ') + ; + + $repository = new Repository(self::createFoobarRepository()->getPath(), driver: self::getCliDriver()); + $repository->setLogger($logger); + $repository->run('remote'); + + $this->assertSame($logger, $repository->getLogger()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacySetLoggerRequiresCliDriver(): void + { + $this->expectException(UnsupportedOperationException::class); + + new Repository(self::createTempDir(), driver: $this->createStub(DriverInterface::class))->setLogger($this->createStub(LoggerInterface::class)); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testUnknownOptionsAreRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown options "foo", "bar".'); + + new Repository(self::createTempDir(), ['foo' => 1, 'bar' => 2]); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyOptionsConfigureACliDriver(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing an array of options to "Gitonomy\Git\Repository::__construct()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.'); + + $logger = $this->createStub(LoggerInterface::class); + $repository = new Repository(self::createFoobarRepository()->getGitDir(), [ + 'command' => self::gitCommand(), + 'logger' => $logger, + 'process_timeout' => 42, + ]); + + $this->assertSame($logger, $repository->getLogger()); + $this->assertSame(42.0, $repository->runProcess('rev-parse', ['HEAD'])->getTimeout()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyOptionsCantBeCombinedWithADriver(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The driver argument can\'t be combined with the "logger" or git options: configure the driver instead.'); + + new Repository(self::createTempDir(), ['process_timeout' => 60], self::getDriver()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyDebugOption(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing an array of options to "Gitonomy\Git\Repository::__construct()" is deprecated, pass the working directory as "workingDir" argument, and configure the logger and git through the "driver" argument.'); + + $repository = new Repository(self::createFoobarRepository()->getGitDir(), ['debug' => true], self::getDriver()); + + $this->assertSame(self::LONGFILE_COMMIT, $repository->getCommit(self::LONGFILE_COMMIT)->getHash()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testDebugOptionSetToFalseIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "debug" option set to false is not supported anymore: a failing git operation always throws, catch its exception instead.'); + + new Repository(self::createTempDir(), ['debug' => false]); + } } diff --git a/tests/Gitonomy/Git/Tests/RevisionTest.php b/tests/Gitonomy/Git/Tests/RevisionTest.php index 1dbbbfc5..41377da6 100644 --- a/tests/Gitonomy/Git/Tests/RevisionTest.php +++ b/tests/Gitonomy/Git/Tests/RevisionTest.php @@ -12,48 +12,41 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Commit; use Gitonomy\Git\Exception\ReferenceNotFoundException; -use Gitonomy\Git\Log; -use Gitonomy\Git\Repository; -use Gitonomy\Git\Revision; use PHPUnit\Framework\Attributes\DataProvider; class RevisionTest extends AbstractTestCase { - #[DataProvider('provideFoobar')] - public function testGetCommit(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetCommit(bool $bare): void { + $repository = self::createFoobarRepository($bare); $revision = $repository->getRevision(self::LONGFILE_COMMIT.'^'); - $this->assertInstanceOf(Revision::class, $revision, 'Revision object type'); - $commit = $revision->getCommit(); - $this->assertInstanceOf(Commit::class, $commit, 'getCommit returns a Commit'); - - $this->assertEquals(self::BEFORE_LONGFILE_COMMIT, $commit->getHash(), 'Resolution is correct'); + $this->assertSame(self::BEFORE_LONGFILE_COMMIT, $commit->getHash(), 'Resolution is correct'); } - #[DataProvider('provideFoobar')] - public function testGetFailingReference(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetFailingReference(bool $bare): void { + $repository = self::createFoobarRepository($bare); $this->expectException(ReferenceNotFoundException::class); - $this->expectExceptionMessage('Can not find revision "non-existent-commit"'); + $this->expectExceptionMessage('The revision "non-existent-commit" does not exist, or names no commit.'); $repository->getRevision('non-existent-commit')->getCommit(); } - #[DataProvider('provideFoobar')] - public function testGetLog(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetLog(bool $bare): void { + $repository = self::createFoobarRepository($bare); $revision = $repository->getRevision(self::LONGFILE_COMMIT); $log = $revision->getLog(null, 2, 3); - - $this->assertInstanceOf(Log::class, $log, 'Log type object'); - $this->assertEquals(2, $log->getOffset(), 'Log offset is passed'); - $this->assertEquals(3, $log->getLimit(), 'Log limit is passed'); - $this->assertEquals([$revision], $log->getRevisions()->getAll(), 'Revision is passed'); + $this->assertSame(2, $log->getOffset(), 'Log offset is passed'); + $this->assertSame(3, $log->getLimit(), 'Log limit is passed'); + $this->assertSame([$revision], $log->getRevisions()?->getAll(), 'Revision is passed'); } } diff --git a/tests/Gitonomy/Git/Tests/TreeTest.php b/tests/Gitonomy/Git/Tests/TreeTest.php index fb8a4d6c..ca21b858 100644 --- a/tests/Gitonomy/Git/Tests/TreeTest.php +++ b/tests/Gitonomy/Git/Tests/TreeTest.php @@ -14,41 +14,116 @@ use Gitonomy\Git\Blob; use Gitonomy\Git\CommitReference; -use Gitonomy\Git\Repository; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Submodule; +use Gitonomy\Git\Tree; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class TreeTest extends AbstractTestCase { public const PATH_RESOLVING_COMMIT = 'cc06ac171d884282202dff88c1ded499a1f89420'; - #[DataProvider('provideFoobar')] - public function testGetEntries(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetEntries(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::LONGFILE_COMMIT)->getTree(); $entries = $tree->getEntries(); - $this->assertNotEmpty($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 testGetEntriesWithNonAsciiNames(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $entries = $repository->getCommit(DiffTest::FILE_WITH_UMLAUTS_COMMIT)->getTree()->getEntries(); + + $this->assertArrayHasKey('file_with_umlauts_äöü', $entries); + } + + public function testGetEntriesWithSpecialCharactersInNames(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + file_put_contents($repository->getWorkingDir().'/back\slash'."\ttab\"quote", 'special'); + self::git($repository, 'add', ['--all']); + self::git($repository, 'commit', ['-m', 'Add a file with special characters in its name']); - $this->assertNotEmpty($entries['README.md'], 'README.md is present'); - $this->assertTrue($entries['README.md'][1] instanceof Blob, 'README.md is a Blob'); + $entries = $repository->getHeadCommit()->getTree()->getEntries(); + + $this->assertArrayHasKey('back\slash'."\ttab\"quote", $entries); } - #[DataProvider('provideFoobar')] - public function testGetCommitReferenceEntries(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetSubmoduleEntries(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $commits = $tree->getCommitReferenceEntries(); + $submodules = $tree->getSubmoduleEntries(); + + $this->assertSame(['barbaz'], array_keys($submodules)); + $this->assertInstanceOf(Submodule::class, $submodules['barbaz']->getObject()); + $this->assertSame('160000', $submodules['barbaz']->getMode()); + } + + #[DataProvider('provideBare')] + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetCommitReferenceEntries(bool $bare): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Tree::getCommitReferenceEntries()" method is deprecated, use "getSubmoduleEntries()" instead.'); + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Reading a "Gitonomy\Git\TreeEntry" as an array is deprecated, use its "getMode()" and "getObject()" methods instead.'); + + $tree = self::createFoobarRepository($bare)->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + [$mode, $object] = $tree->getCommitReferenceEntries()['barbaz']; + + $this->assertSame('160000', $mode); + // @phpstan-ignore method.impossibleType (PHPStan doesn't follow the alias of the deprecated class.) + $this->assertInstanceOf(CommitReference::class, $object); + } + + public function testResolvePathThroughASubmoduleThrows(): void + { + $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to resolve "barbaz/file": "barbaz" is a submodule, not a tree.'); + + $tree->resolvePath('barbaz/file'); + } + + public function testResolveAMissingPathThrows(): void + { + $tree = self::createFoobarRepository()->getCommit(self::PATH_RESOLVING_COMMIT)->getTree(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to resolve "test/a/missing/c": "test/a/missing" doesn\'t exist.'); + + $tree->resolvePath('test/a/missing/c'); + } + + public function testResolveTheRootPath(): void + { + $tree = self::createFoobarRepository()->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); - $this->assertNotEmpty($commits['barbaz'], 'barbaz is present'); - $this->assertTrue($commits['barbaz'][1] instanceof CommitReference, 'barbaz is a Commit'); + $this->assertSame($tree, $tree->resolvePath('/')); + $this->assertSame($tree, $tree->resolvePath('')); } - #[DataProvider('provideFoobar')] - public function testGetTreeEntries(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetTreeEntries(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); $trees = $tree->getTreeEntries(); @@ -56,26 +131,28 @@ public function testGetTreeEntries(Repository $repository): void $this->assertEmpty($trees); } - #[DataProvider('provideFoobar')] - public function testGetBlobEntries(Repository $repository): void + #[DataProvider('provideBare')] + public function testGetBlobEntries(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::NO_MESSAGE_COMMIT)->getTree(); $blobs = $tree->getBlobEntries(); - $this->assertNotEmpty($blobs['README.md'], 'README.md is present'); - $this->assertTrue($blobs['README.md'][1] instanceof Blob, 'README.md is a blob'); + $this->assertInstanceOf(Blob::class, $blobs['README.md']->getObject()); } - #[DataProvider('provideFoobar')] - public function testResolvePath(Repository $repository): void + #[DataProvider('provideBare')] + public function testResolvePath(bool $bare): void { + $repository = self::createFoobarRepository($bare); $tree = $repository->getCommit(self::PATH_RESOLVING_COMMIT)->getTree(); $path = 'test/a/b/c'; $resolved = $tree->resolvePath($path); + $this->assertInstanceOf(Tree::class, $resolved); $entries = $resolved->getEntries(); - $this->assertNotEmpty($entries['d'], 'Successfully resolved source folder'); + $this->assertArrayHasKey('d', $entries, 'Successfully resolved source folder'); } } diff --git a/tests/Gitonomy/Git/Tests/WeakCacheTest.php b/tests/Gitonomy/Git/Tests/WeakCacheTest.php new file mode 100644 index 00000000..0347ece7 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/WeakCacheTest.php @@ -0,0 +1,48 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Util\WeakCache; +use PHPUnit\Framework\TestCase; + +class WeakCacheTest extends TestCase +{ + public function testKeepsAnObjectWhileItIsUsed(): void + { + /** @var WeakCache<\stdClass> $cache */ + $cache = new WeakCache(); + $object = new \stdClass(); + $cache->set('used', $object); + $cache->set('freed', new \stdClass()); + + $this->assertSame($object, $cache->get('used')); + $this->assertNull($cache->get('freed')); + $this->assertNull($cache->get('unknown')); + } + + public function testForgetsTheKeysOfTheFreedObjects(): void + { + /** @var WeakCache<\stdClass> $cache */ + $cache = new WeakCache(); + $object = new \stdClass(); + $cache->set('used', $object); + for ($i = 0; $i < 10_000; ++$i) { + $cache->set('freed'.$i, new \stdClass()); + } + + $references = new \ReflectionProperty(WeakCache::class, 'references')->getValue($cache); + $this->assertIsArray($references); + $this->assertLessThan(64, \count($references), 'Only the keys of the objects used, and the last ones'); + $this->assertSame($object, $cache->get('used')); + } +} diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 1e532e72..4867cdff 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -12,10 +12,14 @@ namespace Gitonomy\Git\Tests; -use Gitonomy\Git\Admin; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\LogicException; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\FastForward; use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; class WorkingCopyTest extends AbstractTestCase { @@ -24,7 +28,7 @@ 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(); } @@ -33,11 +37,36 @@ public function testCheckout(): void { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); - $wc->checkout('origin/new-feature', 'new-feature'); + $wc->checkoutNewBranch('new-feature', 'origin/new-feature'); $head = $repository->getHead(); $this->assertInstanceOf(Branch::class, $head, 'HEAD is a branch'); - $this->assertEquals('new-feature', $head->getName(), 'HEAD is branch new-feature'); + $this->assertSame('new-feature', $head->getName(), 'HEAD is branch new-feature'); + } + + public function testCheckoutOfABranchObjectAttachesHead(): void + { + $repository = self::createFoobarRepository(false); + $branch = $repository->getReferences()->createBranch('twin', 'master'); + $wc = $repository->getWorkingCopy(); + + $wc->checkout($repository->getRevision(self::INITIAL_COMMIT)); + $this->assertTrue($repository->isHeadDetached()); + + $wc->checkout($branch); + $this->assertSame('refs/heads/twin', self::headBranch($repository)->getFullname()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyCheckoutOfANewBranch(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing a branch to create to "Gitonomy\Git\WorkingCopy::checkout()" is deprecated, use "checkoutNewBranch()" instead.'); + + $repository = self::createFoobarRepository(false); + $repository->getWorkingCopy()->checkout('origin/new-feature', 'new-feature'); + + $this->assertSame('new-feature', self::headBranch($repository)->getName()); } public function testDiffStaged(): void @@ -50,7 +79,7 @@ public function testDiffStaged(): void $file = $repository->getWorkingDir().'/foobar-test'; file_put_contents($file, 'test'); - $repository->run('add', [$file]); + self::git($repository, 'add', [$file]); $diffStaged = $wc->getDiffStaged(); $this->assertCount(1, $diffStaged->getFiles()); @@ -71,6 +100,21 @@ public function testDiffPending(): void $this->assertCount(1, $diffPending->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(RuntimeException::class); @@ -114,8 +158,8 @@ public function testGetUntracked(): void public function testMerge(): void { $repository = self::createFoobarRepository(false); - $repository->run('config', ['--local', 'user.name', '"Unit Test"']); - $repository->run('config', ['--local', 'user.email', '"unit_test@unit-test.com"']); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); $wc = $repository->getWorkingCopy(); $wc->checkout('master'); @@ -123,17 +167,176 @@ public function testMerge(): void $head = $repository->getHeadCommit(); $this->assertCount(2, $head->getParents(), 'Merge produced a merge commit'); + $this->assertSame("Merge remote-tracking branch 'origin/diff-features'", $head->getSubjectMessage()); $this->assertFileExists($repository->getWorkingDir().'/script_A.php'); } public function testMergeConflict(): void { - $this->expectException(RuntimeException::class); + $repository = self::createFoobarRepository(false); + $master = $repository->getHeadCommit()->getHash(); + $wc = $repository->getWorkingCopy(); + + try { + $wc->merge('origin/new-feature'); + $this->fail('The merge should have been refused.'); + } catch (DriverException) { + } + $this->assertSame($master, $repository->getHeadCommit()->getHash(), 'No merge commit is created'); + } + + public function testMergeOnlyFastForwards(): void + { $repository = self::createFoobarRepository(false); $wc = $repository->getWorkingCopy(); - $wc->checkout('master'); - $wc->merge('origin/new-feature'); + $this->expectException(DriverException::class); + + $wc->merge('origin/diff-features', FastForward::Only); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyMergeArguments(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: Passing arguments of "git merge" to "Gitonomy\Git\WorkingCopy::merge()" is deprecated, pass a "Gitonomy\Git\FastForward" instead.'); + + $repository = self::createFoobarRepository(false); + $head = $repository->getHeadCommit(); + + $repository->getWorkingCopy()->merge($head->getHash(), ['--no-ff']); + + $this->assertSame($head->getHash(), $repository->getHeadCommit()->getHash()); + } + + public function testMergeFastForwardsTheCurrentBranch(): void + { + $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 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'); + } + + 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()); + } + + public function testCheckoutNewBranchWithAnInvalidNameChangesNothing(): void + { + $repository = self::createFoobarRepository(false); + $head = $repository->getHeadCommit()->getHash(); + + try { + $repository->getWorkingCopy()->checkoutNewBranch('bad..name', self::INITIAL_COMMIT); + $this->fail('The checkout should have been refused.'); + } catch (DriverException) { + } + + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + $this->assertSame($head, $repository->getHeadCommit()->getHash()); + $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); + } + + public function testMergeRefusesUnrelatedHistories(): void + { + $repository = self::createFoobarRepository(false); + $tree = $repository->getHeadCommit()->getTreeHash(); + $orphan = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\n\nOrphan\n"); + + $this->expectException(DriverException::class); + + $repository->getWorkingCopy()->merge($orphan); + } + + public function testMergeRefusesStagedChanges(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + file_put_contents($repository->getWorkingDir().'/staged', 'staged'); + self::git($repository, 'add', ['staged']); + $head = $repository->getHeadCommit()->getHash(); + + try { + $repository->getWorkingCopy()->merge('origin/diff-features'); + $this->fail('The merge should have been refused.'); + } catch (DriverException) { + } + + $this->assertSame($head, $repository->getHeadCommit()->getHash()); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 589c0ffc..8987aca0 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -13,7 +13,7 @@ require __DIR__.'/../vendor/autoload.php'; if (defined('PHP_WINDOWS_VERSION_BUILD')) { - $server = array_change_key_case($_SERVER, true); + $server = array_change_key_case($_SERVER, \CASE_UPPER); $_SERVER['GIT_ENVS'] = []; foreach (['PATH', 'SYSTEMROOT'] as $key) { if (isset($server[$key])) { diff --git a/tests/fixtures/bundle-heads.txt b/tests/fixtures/bundle-heads.txt new file mode 100644 index 00000000..e7b18c46 --- /dev/null +++ b/tests/fixtures/bundle-heads.txt @@ -0,0 +1,8 @@ +7e9ba14939b0ce67ab95d83e03bd64710db91ef4 HEAD +7e9ba14939b0ce67ab95d83e03bd64710db91ef4 refs/heads/master +543dc7c3983b6a99b43e274d4823e4f8a9bda7a9 refs/heads/new-feature +519d5693c72c925cd59205d9f11e9fa1d550028b refs/heads/diff-features +10b5d7125a5da15eb350430b6f0504fccff26d71 refs/heads/pagination +cc06ac171d884282202dff88c1ded499a1f89420 refs/heads/path-resolving +4f17752acc9b7c54ba679291bf24cb7d354f0f4f refs/tags/0.1 +b61d8c2cac78a443729ca16cad12b96e3d98365d refs/tags/annotated diff --git a/tests/fixtures/verify-bundle.sh b/tests/fixtures/verify-bundle.sh index aaa52d63..a1b0d3ee 100755 --- a/tests/fixtures/verify-bundle.sh +++ b/tests/fixtures/verify-bundle.sh @@ -4,7 +4,8 @@ # request touching it should be reviewed by hand (see CONTRIBUTING.md), but # this catches the obvious cases automatically: # - a corrupted or incomplete bundle -# - refs that were not in the original fixture +# - refs that were not in the original fixture, or that moved: the tests +# rely on the commits they point to (bundle-heads.txt pins them) # - a file that has silently grown well past its expected size # # Run it locally with: tests/fixtures/verify-bundle.sh @@ -22,7 +23,7 @@ echo "== Verifying $BUNDLE ==" VERIFY_OUTPUT="$(git bundle verify "$BUNDLE")" echo "$VERIFY_OUTPUT" -SIZE_KB=$(( $(stat -c%s "$BUNDLE") / 1024 )) +SIZE_KB=$(( $(wc -c < "$BUNDLE") / 1024 )) echo "Size: ${SIZE_KB}KB (limit: ${MAX_SIZE_KB}KB)" if [ "$SIZE_KB" -gt "$MAX_SIZE_KB" ]; then echo "ERROR: $BUNDLE is ${SIZE_KB}KB, which exceeds the ${MAX_SIZE_KB}KB limit." >&2 @@ -40,4 +41,10 @@ if [ -n "$UNEXPECTED_REFS" ]; then exit 1 fi -echo "OK: bundle structure and size are within expected bounds." +if ! diff -u bundle-heads.txt <(git bundle list-heads "$BUNDLE"); then + echo "ERROR: the refs of $BUNDLE differ from tests/fixtures/bundle-heads.txt." >&2 + echo "Existing refs must not move: add commits on new branches or tags, then add their lines to bundle-heads.txt." >&2 + exit 1 +fi + +echo "OK: bundle structure, refs and size are within expected bounds." diff --git a/tools/phpstan/composer.json b/tools/phpstan/composer.json index 33cce923..e66b3802 100644 --- a/tools/phpstan/composer.json +++ b/tools/phpstan/composer.json @@ -1,6 +1,7 @@ { "require": { - "phpstan/phpstan": "^2.2.12" + "phpstan/phpstan": "^2.2.12", + "phpstan/phpstan-phpunit": "^2.0" }, "config": { "bump-after-update": true, diff --git a/tools/phpstan/composer.lock b/tools/phpstan/composer.lock index 9c925845..99a5babc 100644 --- a/tools/phpstan/composer.lock +++ b/tools/phpstan/composer.lock @@ -4,8 +4,59 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ab3e41a1c8f6553bf1007f6afad3d08e", + "content-hash": "c5d318150410709bc4ee6eeb07693fc4", "packages": [ + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, { "name": "phpstan/phpstan", "version": "2.2.12", @@ -69,6 +120,64 @@ } ], "time": "2026-08-31T19:09:43+00:00" + }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.0.21", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "8dbdc3a3e2d7c5dfea655d20ecca5bc9b1b1e441" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/8dbdc3a3e2d7c5dfea655d20ecca5bc9b1b1e441", + "reference": "8dbdc3a3e2d7c5dfea655d20ecca5bc9b1b1e441", + "shasum": "" + }, + "require": { + "phar-io/version": "^3.2", + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.2.6" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.21" + }, + "time": "2026-10-01T11:59:29+00:00" } ], "packages-dev": [],