diff --git a/.gitattributes b/.gitattributes index f8886cc3..cbf8b342 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,7 +8,6 @@ /.php-cs-fixer.php export-ignore /benchmark.php export-ignore /castor.php export-ignore -/phpstan-baseline.neon export-ignore /phpstan.neon export-ignore /phpunit.xml.dist export-ignore /test-git-versions.php export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 23a1fb82..c25cb5a1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@v7 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.4' @@ -55,7 +55,7 @@ jobs: uses: actions/checkout@v7 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.4' @@ -87,7 +87,7 @@ jobs: uses: actions/checkout@v7 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: ${{ matrix.php }} @@ -137,7 +137,7 @@ jobs: make -C "git-${{ matrix.git }}" prefix="$HOME/git" CFLAGS='-std=gnu17 -O2' NO_OPENSSL=1 NO_CURL=1 NO_EXPAT=1 NO_GETTEXT=1 NO_PERL=1 NO_PYTHON=1 NO_TCLTK=1 --jobs="$(nproc)" install - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.4' @@ -185,7 +185,7 @@ jobs: cmake --install libgit2-build - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: ${{ matrix.php }} extensions: ffi diff --git a/CHANGELOG.md b/CHANGELOG.md index 081545f9..a6b03ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,10 @@ deprecated until 3.0. lines and logs are typed objects; the subject of a message is its first paragraph. See [UPGRADE-2.0.md](UPGRADE-2.0.md) for every change. ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Require PHP 8.4 or later, Symfony 6.4/7.4/8.1+: most classes are `final` (some `readonly`), +- Return the dates of commits and tags at the UTC offset of their author, committer or tagger, + like `git log` shows them, instead of in UTC: the instant doesn't change, but `format()` + returns their local time. +- Require PHP 8.4 or later, Symfony 7.4/8.1+: most classes are `final` (some `readonly`), and every method has native types. ([#238](https://github.com/gitonomy/gitlib/pull/238)) - Remove `Gitonomy\Git\Util\StringHelper`, replaced by `symfony/string`, and its configurable encoding: gitlib reads everything in UTF-8. ([#252](https://github.com/gitonomy/gitlib/pull/252)) @@ -30,6 +33,21 @@ deprecated until 3.0. - `Diff::parse()` throws for a diff without its raw information, deprecated since 1.6.0 ([#229](https://github.com/gitonomy/gitlib/pull/229)): parse the output of `git diff --raw --patch`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- The constructors of `Commit`, `Tree`, `Blob`, `Revision`, the references and `ReferenceBag` + are internal: get these objects from `Repository`, which returns the same commit, tree or + blob while the application uses it, and reads the references again after its writes. + Extending `Revision` or `Reference` isn't supported anymore. +- `ProcessException` has a message of one line, with the error of git, the first line of its + error output starting with `fatal:` or `error:`, after the warnings: + `The git command "clone" failed (exit 128): fatal: ...`. The command line, the rest of the + error output and the output are no longer in the message: `getErrorOutput()` and + `getOutput()` return the outputs of git. +- When git can't start, a missing binary for instance, `CliDriver` throws a `DriverException` + instead of a `ProcessException`, with a message of one line, without the command line: + `The git command "clone" could not start: ...`. +- `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a name that no revision has, + with both drivers: the name of a remote branch alone, like `feature` for `origin/feature`, + created the local branch. Use `checkoutNewBranch()`. ### Features @@ -37,9 +55,9 @@ deprecated until 3.0. FFI extension, as an alternative to the default `Driver\Cli\CliDriver`: pass it as the driver of `Repository`. It avoids spawning a `git` process for every operation; see ["Choosing a driver"](doc/repository.md#choosing-a-driver) for its requirements, its - differences with `CliDriver`, and when it is faster. `benchmark.php` compares both drivers - on a repository. With php-fpm, `LibGit2Driver::preload()` keeps libgit2 loaded from - `opcache.preload`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) + differences with `CliDriver`, and when it is faster. `benchmark.php`, from a clone of gitlib, + compares both drivers on a repository. With php-fpm, `LibGit2Driver::preload()` keeps libgit2 + loaded from `opcache.preload`. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - `Repository` takes the working directory and the driver as arguments: a `Driver\Cli\CliDriver` by default, configured through its constructor (git binary, environment, timeout and logger, which gets every command, clones included), or any `Driver\DriverInterface`, to replace it in @@ -52,7 +70,8 @@ deprecated until 3.0. `isValidRepository()` and `isValidRepositoryAndBranch()`; see ["Create and access git repositories"](doc/admin.md). ([#253](https://github.com/gitonomy/gitlib/pull/253)) -- Add `Repository::getDiff($from, $to)`, `Repository::getMergeBase()` and `WorkingCopy::getDiff()`, +- Add `Repository::getDiff($from, $to)`, `Repository::getMergeBase()`, + `Repository::getSizeInBytes()` and `WorkingCopy::getDiff()`, `WorkingCopy::checkoutNewBranch()`, `Branch::moveTo()` and `Branch::isMergedInto()`, `Log::withOffset()` and `withLimit()`, `Blame::getHunks()` and the line numbers of `Blame\Line`, `TreeEntry` and `Submodule`, `Diff\DiffLine` and `Diff\DiffLineType`, @@ -99,9 +118,52 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). `WorkingCopy::merge()`. - `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, `PushReference::getReference()` and `getRevision()`. +- `Repository::getSize()`, in kilobytes: use `getSizeInBytes()`. +- `Exception\UnexpectedValueException`, which gitlib no longer throws. + +### Security + +- Refuse a revision, a reference, a hash or a URL starting with a dash, which git read as an + option: `getRevision('--output=/path')`, `WorkingCopy::checkout()` or `merge()` could write + a file, and `Repository::isValidRepository('--upload-pack=...')` run a command. A path may + start with a dash: `init()` and the clones end the options of git with `--`. +- Refuse an `ext::` URL, like `ext::sh -c ...`, whose command git runs, by default before git + 2.12. +- Refuse an ssh URL whose host or user starts with a dash, even encoded (`%2D`), like + `ssh://-oProxyCommand=.../repository`: git before 2.14.1 passes it to ssh, which reads it as an + option and runs the command (CVE-2017-1000117). A `git://` URL whose host starts with a dash + is refused too: git passes it to the command of `core.gitProxy`. +- Hide the credentials of URLs (`https://user:token@host`), a password holding a `#` or a `?` + included, in the messages of exceptions, in their traces (`#[\SensitiveParameter]`), and in + the logs. The exceptions of Symfony Process, which hold the command line, no longer leak, not + even as the previous exception. +- Refuse a hook name that isn't the one of a file of the hooks directory: + `Hooks::set('../config', ...)` and `remove('../config')` wrote and deleted the + configuration of the repository. +- Fix `Hooks` writing through a symbolic link to a missing file, out of the hooks directory: + `has()` returned `false` for it, and `set()` followed it. +- Ignore the replacements of objects (`git replace`, `refs/replace/`) with `CliDriver`, like + `LibGit2Driver`: whoever could push such a reference to a repository changed the message of a + commit, or the content of a blob, gitlib read for a hash. +- Fix `WorkingCopy::checkout()` of a path, like `checkout('README.md')`, discarding the changes + of the file: a path is no revision, and throws a `ReferenceNotFoundException`. +- Fix moving or deleting a symbolic reference, which changed the branch it points to instead: + `ReferenceBag::delete('refs/remotes/origin/HEAD')` deleted `origin/main`. +- Fix `ReferenceBag::delete('HEAD')` deleting `HEAD`, which left no repository: a name outside + `refs/` throws an `InvalidArgumentException`. ### Bug fixes +- Fix the configuration of the user changing what `CliDriver` reads: `color.ui=always`, + `log.showSignature`, `diff.noprefix`, `diff.external`, `diff.submodule`, textconv filters, + `column.ui`... made reads throw or return wrong data, and `diff.algorithm`, `diff.context`, + `diff.orderFile`, `log.follow` or `branch.sort` changed diffs, logs and the branches of a + commit. gitlib sets them back to git's defaults; see + ["Git configuration"](doc/repository.md#git-configuration). +- Fix `Commit::getShortHash()`, `Commit::getDiff()` and `Repository::getDiff()` failing when a + file of the working tree has the name of the revision they read, which git found ambiguous. +- Fix logs of thousands of revisions exceeding the limit of the command line: `CliDriver` passes + them to git on its standard input. - Fix `Commit::getDiff()` returning an empty diff for a root commit, and throwing for a merge commit (`CliDriver`). ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix `Repository::getDiff()`, `WorkingCopy::getDiffPending()` and `WorkingCopy::getDiffStaged()` @@ -132,6 +194,9 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - Fix blame lines numbered from 1 for a range of lines. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix `Hooks` failing silently, in a repository without hooks directory, and writing the hooks of a linked worktree where git doesn't run them. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- Fix `Repository::getSize()` throwing for a link to a missing file in the git directory, like + a hook not deployed yet: it no longer follows links. A file git deletes meanwhile, like a lock + during a `gc` or a push, no longer makes it throw either. - Fix blame lines starting with a TAB character (`Blame\Line::getContent()`): code removing it must stop doing so. ([#253](https://github.com/gitonomy/gitlib/pull/253)) - Fix reading a commit whose `encoding` header isn't UTF-8, which threw: its message, author and @@ -166,8 +231,13 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). ### Minor - CI tests git 1.8.4 and 2.25.1, analyses the tests and the libgit2 driver with PHPStan, - checks the archive Composer installs, and the suite deletes what it creates, in any order. + without baseline, checks the archive Composer installs, pins the third-party actions by + commit, and the suite deletes what it creates, in any order. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- The tests run with a git configuration of their own, whatever the developer's + (`tests/fixtures/home`), which sets what gitlib sets back to git's defaults, and without the + variables of git of their environment: run from a hook, they wrote the repository of the hook + (`GIT_DIR`, `GIT_INDEX_FILE`...). - Add a regression test for reading a blob's content in a tree that also contains a submodule. ([#243](https://github.com/gitonomy/gitlib/pull/243)) - Replace the network-dependent test fixture (cloned from GitHub on every run) with a local, diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 5a611d5f..ddd2c018 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -99,7 +99,7 @@ Creating repositories `release` no longer matches `feature/release`, nor `rel*` `release`. * `isValidRepository()` and `isValidRepositoryAndBranch()` read a relative path as a path, never as the name of a remote of PHP's working directory, - and throw a `ProcessException` when git can't run, instead of returning + and throw a `DriverException` when git can't start, instead of returning `false`. Before: @@ -173,6 +173,10 @@ References * `ReferenceBag::getFirstBranch()` returns `null` instead of `false` when there is no branch, and only branches. + * `Repository::getHead()` returns a `Reference\Branch` or a `Commit`. + `HEAD` pointing to another reference, like a tag, which git allows, + throws a `ReferenceNotFoundException`, where 1.x returned the reference: + `getHeadCommit()` still returns its commit. * The getters of `Reference\Tag` (`getTaggerName()`, `getTaggerEmail()`, `getTaggerDate()`, `getMessage()`, `getSubjectMessage()`, `getBodyMessage()`, `getGpgSignature()`) return `null` instead of `false` @@ -181,6 +185,11 @@ References unsigned tag instead of throwing, and finds X.509 and SSH signatures too. * A branch whose name ends with `HEAD` is listed: only the `HEAD` of a remote is left out. + * Moving or deleting a symbolic reference, like `refs/remotes/origin/HEAD`, + changes it, instead of the branch it points to. + * `ReferenceBag::delete()` throws an `InvalidArgumentException` for a name + outside `refs/`: `HEAD`, which `CliDriver` deleted, leaving no + repository, or a short name, like `master`, which did nothing. History ------- @@ -284,7 +293,8 @@ Trees * `CommitReference` is deprecated: it is `Submodule`. So is `Tree::getCommitReferenceEntries()`: use `getSubmoduleEntries()`. * `Tree::resolvePath()` throws an `InvalidArgumentException` explaining - why a path doesn't resolve, and resolves `/` to the tree. + why a path doesn't resolve, a path through a submodule included, which + threw an `UnexpectedValueException`, and resolves `/` to the tree. Working copy ------------ @@ -293,6 +303,23 @@ Working copy `Branch` object attaches `HEAD` to the branch, instead of detaching it. Its second argument, a branch to create, is deprecated: use `checkoutNewBranch($branch, $startPoint)`. + * `WorkingCopy::checkout()` throws a `ReferenceNotFoundException` for a + name that no revision has, with both drivers: the name of a remote + branch alone, like `feature` for `origin/feature`, created the local + branch, and a path, like `README.md`, discarded its changes. + + Before: + + ```php + $workingCopy->checkout('feature'); + ``` + + After: + + ```php + $workingCopy->checkoutNewBranch('feature', 'origin/feature'); + ``` + * `WorkingCopy::merge()` takes a `FastForward` instead of arguments of `git merge`, deprecated. @@ -372,27 +399,79 @@ Errors `\InvalidArgumentException`, and takes a whole message instead of a name. * A timeout throws a `Gitonomy\Git\Exception\ProcessTimedOutException`, and the other failures of Symfony Process a `DriverException`: no - exception of Symfony or of PHP leaks anymore. + exception of Symfony or of PHP leaks anymore, not even as the previous + exception, as they hold the command line. * `ProcessException` extends the new `Gitonomy\Git\Exception\DriverException`, itself a `RuntimeException`: catching `RuntimeException` or `GitExceptionInterface` still works. + * The message of a `ProcessException` is one line, with the error of git: + the first line of its error output starting with `fatal:` or `error:`, + after the warnings, or its first line: `The git command "clone" failed + (exit 128): fatal: ...`, or `The git command "diff" failed (exit 1).` + when git writes no error. The command line, the rest of the error output + and the output are no longer in the message: `getErrorOutput()` and + `getOutput()` return the outputs of git. Its constructor takes the git + command as second argument, optional. + * When git can't start, a missing binary for instance, `CliDriver` throws + a `DriverException` instead of a `ProcessException`, with a message of + one line, without the command line: `The git command "clone" could not + start: ...`. * These methods throw instead of returning a fallback value: * `Commit::getIncludingBranches()` threw nothing and returned `[]` when git failed; * `ReferenceBag` (`Repository::getReferences()`) only returns an empty bag for a repository without references, and throws for any other failure. + * `Gitonomy\Git\Exception\UnexpectedValueException` is deprecated: gitlib + no longer throws it. Catch `InvalidArgumentException` for + `Tree::resolvePath()`. * `Commit::getShortHash()` throws a `ReferenceNotFoundException` for an unknown commit, instead of a `ProcessException`. * `Hooks` throws a `RuntimeException` when a hook exists already, doesn't exist, or can't be written, instead of a `LogicException` or a silent failure, and creates the hooks directory. * The `Admin` methods throw a `ProcessException`, still a - `RuntimeException`, with git's error output as message. + `RuntimeException`, with the first line of git's error output in its + message, or a `DriverException` when git can't start. * An argument holding a NUL byte throws an `InvalidArgumentException`, where PHP threw a `ValueError`: git and libgit2 read their arguments as C strings, which end at the first NUL byte. + * A revision, a reference, a hash or a URL starting with a dash throws an + `InvalidArgumentException`, with both drivers: git read it as an option, + `getRevision('--output=/path')` writing a file, or + `isValidRepository('--upload-pack=...')` running a command. A path may + start with a dash. + * An `ext::` URL throws an `InvalidArgumentException`, with both drivers: + git runs its command, by default before git 2.12. + * An ssh URL whose host or user starts with a dash, even encoded (`%2D`), + like `ssh://-oProxyCommand=.../repository` or + `ssh://-oProxyCommand=...@host/path`, or a `git://` URL whose host does, + throws an `InvalidArgumentException`, with both drivers: git before 2.14.1 + passes it to ssh, or to the command of `core.gitProxy`, which reads it as + an option. + * `Hooks` throws an `InvalidArgumentException` for a name that isn't the one + of a file of the hooks directory, like `../config` or `.hidden`. + * The messages of exceptions, their traces (`#[\SensitiveParameter]`) and + the logs hide the credentials of URLs + (`https://***@example.com/repository.git`). `getErrorOutput()` and + `getOutput()` of `ProcessException` don't. * Every message is a sentence ending with a dot. +Git configuration +----------------- + + * `CliDriver` sets back to git's defaults the configuration of the user that + changes what git prints, what a read returns, or the upstream of a new + branch: colors, columns, `branch.autoSetupMerge`, + `branch.autoSetupRebase`, `branch.sort`, `log.follow`, + `log.showSignature`, and for diffs `diff.algorithm`, `diff.context`, + `diff.external`, `diff.orderFile`, `diff.submodule`, the prefixes, the + textconv filters... Before, they made reads throw or return other data: a + log of a path following its renames with `log.follow`, for instance. See + ["Git configuration"](doc/repository.md#git-configuration). + * `CliDriver` ignores the replacements of objects (`git replace`, + `refs/replace/`), like `LibGit2Driver`: `run()` and `runProcess()` too. + Run git yourself to read them. + Dates ----- @@ -400,6 +479,23 @@ Dates `Reference\Tag::getTaggerDate()` return a `\DateTimeImmutable` instead of a `\DateTime`. Code that modifies the returned date must use the returned value of `modify()`, `setTimezone()`... + * These dates are at the UTC offset of their author, committer or tagger, + like `git log` shows them (`2016-08-17T12:00:00+02:00`), instead of in + UTC. The instant doesn't change: comparisons and `getTimestamp()` return + the same results, but `format()` returns the local time of the author, + committer or tagger. To keep UTC, convert the date: + + Before: + + ```php + $date = $commit->getAuthorDate()->format('Y-m-d H:i'); + ``` + + After: + + ```php + $date = $commit->getAuthorDate()->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i'); + ``` Extending gitlib ---------------- @@ -413,9 +509,16 @@ Extending gitlib * These classes are `final readonly`: `Blame\Line`, `Diff\Diff`, `Diff\FileChange`, `Hooks`, `RevisionList`, `WorkingCopy`. * Every method has native parameter and return types. A class extending - `Revision`, `Reference` or `Parser\ParserBase`, which remain extendable, - must declare the same types, and must not redeclare their properties. - The constructor of `Reference` requires the commit hash. + `Parser\ParserBase`, which remains extendable, must declare the same + types, and must not redeclare its properties. + * The constructors of `Commit`, `Tree`, `Blob`, `Revision`, `Reference`, + `Reference\Tag` and `ReferenceBag` are internal: get these objects from + `Repository` (`getCommit()`, `getTree()`, `getBlob()`, `getRevision()`, + `getReferences()`), which returns the same commit, tree or blob while your + application uses it, and reads the references again after its writes. + The constructor of `Reference` requires the commit hash. Extending + `Revision` or `Reference` isn't supported anymore, and won't be possible + in 3.0: `Reference` declares `getName()`, abstract. * To change how gitlib runs git, for example in tests, implement `Gitonomy\Git\Driver\DriverInterface` and pass it as the driver of `Repository`, instead of extending `Repository`: see @@ -471,3 +574,11 @@ Other changes instead of `0777`): change their permissions if another user runs them. * `Repository::shell()` is deprecated: it ignored the driver. Run `git shell` with Symfony Process instead. + * `Hooks::has()` returns `true` for a symbolic link to a missing file: + `get()` throws a `RuntimeException`, as it can't read it, `set()` and + `setSymlink()` refuse it, as a hook that exists already, and `remove()` + removes the link. + * `Repository::getSize()` is deprecated: `getSizeInBytes()` returns bytes, + instead of kilobytes. Neither follows links: a link in the git directory, + to a hook not deployed yet for instance, counts for nothing, where + `getSize()` counted the file it points to, and threw for a missing one. diff --git a/doc/admin.md b/doc/admin.md index 3aff0a53..6e42f23b 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -98,8 +98,24 @@ When git fails (an unknown branch, a directory with files, a network error...), the methods creating repositories throw a `Gitonomy\Git\Exception\DriverException`: a `ProcessException`, with git's error output, when `CliDriver` runs git. With `CliDriver`, every method, -`isValidRepository()` included, throws a `ProcessException` when git can't -run, like a missing binary. See [Errors](repository.md#errors). +`isValidRepository()` included, throws a `DriverException` when git can't +start, like a missing binary. See [Errors](repository.md#errors). + +A URL that makes git run a command throws an `InvalidArgumentException`, +with both drivers: + +- a URL starting with a dash, which git would read as an option, like + `--upload-pack=...`; a path may start with one; +- an `ext::` URL, like `ext::sh -c ...`, whose command git runs, unless its + `protocol.allow` setting forbids it, as it does by default since git 2.12; +- an ssh URL whose host or user starts with a dash, even encoded (`%2D`), + like `ssh://-oProxyCommand=.../repository` or + `ssh://-oProxyCommand=...@host/repository`: before 2.14.1, git passes it + to ssh, which reads it as an option and runs the command. + +The message of an exception hides the credentials of the URL +(`https://***@example.com/repository.git`). gitlib doesn't check the URLs +any further: a path, for instance, names a repository of the server. Driver and logger ----------------- diff --git a/doc/commit.md b/doc/commit.md index f75b119f..5b06c423 100644 --- a/doc/commit.md +++ b/doc/commit.md @@ -97,6 +97,12 @@ $commit->getCommitterEmail(); $commit->getCommitterDate(); // A DateTimeImmutable ``` +Like `git log`, a date is at the UTC offset of its author or committer: +`2012-12-31T15:21:03+01:00` for a commit made in Paris in winter. Use +`setTimezone()` to show it in another timezone. Comparing dates doesn't +depend on their offsets. An offset of more than 14 hours or 59 minutes, +which no timezone has, gives UTC. + Message ------- diff --git a/doc/diff.md b/doc/diff.md index 415816a9..18660de6 100644 --- a/doc/diff.md +++ b/doc/diff.md @@ -174,14 +174,17 @@ Parsing a diff `Diff::parse()` reads the output of `git diff --raw --patch`, with `-z` or not. The raw information gives the names, modes, hashes and status of the -files: a patch without it throws an `InvalidArgumentException`. Pass the -repository to read the blobs of the files. Here, the output comes from -`Repository::run()`, which needs `CliDriver`: +files: a patch without it throws an `InvalidArgumentException`. What the +parser doesn't know throws a `RuntimeException`, like the log of a submodule +that `git diff` writes instead of its patch, when the configuration sets +`diff.submodule`: pass `--submodule=short`. Pass the repository to read the +blobs of the files. Here, the output comes from `Repository::run()`, which +needs `CliDriver`: ```php use Gitonomy\Git\Diff\Diff; -$output = $repository->run('diff', ['--raw', '--patch', '0.1', 'master']); +$output = $repository->run('diff', ['--raw', '--patch', '--submodule=short', '0.1', 'master']); $diff = Diff::parse($output, $repository); ``` diff --git a/doc/hooks.md b/doc/hooks.md index 8bc11895..21ca84df 100644 --- a/doc/hooks.md +++ b/doc/hooks.md @@ -11,6 +11,11 @@ Those files need to be executable: gitlib makes them executable by their owner only (*0700*). If another user runs them (a git daemon, a web server...), change their permissions yourself. +A hook is a file of the hooks directory: its name, like `pre-receive`, is +made of letters, digits, `-`, `_` and `.`, and doesn't start with a dot. Any +other name, like `../config`, throws a +`Gitonomy\Git\Exception\InvalidArgumentException`. + git runs them, when it receives a push for instance, or when `CliDriver` checks out or merges. `LibGit2Driver` doesn't run them (see ["Choosing a driver"](repository.md#choosing-a-driver)). @@ -44,6 +49,9 @@ You can test if a hook is present using the method *has*: $hooks->has('pre-receive'); // a boolean indicating presence ``` +A symbolic link is a hook, even when the file it points to doesn't exist +yet: *get* can't read it, and *set* doesn't write through it. + Inserting hooks --------------- diff --git a/doc/log.md b/doc/log.md index 29bdea25..ceb9df81 100644 --- a/doc/log.md +++ b/doc/log.md @@ -79,6 +79,10 @@ $log = $repository->getLog('master', '*.php'); $log = $repository->getLog('master', 'src'); ``` +With `CliDriver`, they support the magic of git's pathspecs too, like +`:(exclude)src`, which git knows since 1.9. `LibGit2Driver` doesn't: a path +starting with `:` throws an `UnsupportedOperationException`. + Iterating --------- diff --git a/doc/references.md b/doc/references.md index ec658d6e..9feb1de7 100644 --- a/doc/references.md +++ b/doc/references.md @@ -76,9 +76,12 @@ $hash = $master->getCommitHash(); $lastModification = $master->getLastModification('README.md'); ``` -`getCommitHash()` is always a commit: for an annotated tag, the commit the -tag object points to. A reference object keeps the commit it pointed to when -it was read: get it again from the bag after a change. +`getCommitHash()` is the commit the reference points to: for an annotated +tag, the commit the tag object points to. A tag may point to a tree or a blob +instead: `getCommitHash()` is then its hash, and reading the `Commit` of +`getCommit()` throws a `ReferenceNotFoundException`. A reference object keeps +the commit it pointed to when it was read: get it again from the bag after a +change. Create and delete references ---------------------------- @@ -99,6 +102,11 @@ $branch->delete(); $tag->delete(); ``` +`ReferenceBag::delete()` takes the full name of a reference under `refs/`, +like `refs/heads/foobar`, and does nothing when it doesn't exist, like +`git update-ref -d`. Another name, like `HEAD` or `foobar`, throws an +`InvalidArgumentException`. + `createBranch()` and `createTag()` throw an `InvalidArgumentException` when the reference exists: they never move it. To move a branch, use `Branch::moveTo()`: see [branch.md](branch.md). `ReferenceBag::update()` is @@ -121,8 +129,9 @@ if ($tag->isAnnotated()) { ``` - `getTaggerName()`, `getTaggerEmail()` and `getTaggerDate()` (a - `DateTimeImmutable`) return `null` for a lightweight tag, and for an - annotated tag without tagger; + `DateTimeImmutable`, at the UTC offset of the tagger, like the dates of + a [commit](commit.md#author-and-committer)) return `null` for a + lightweight tag, and for an annotated tag without tagger; - `getMessage()` returns the message, without the signature, `null` for a lightweight tag; - `getSubjectMessage()` and `getBodyMessage()` split the message like diff --git a/doc/repository.md b/doc/repository.md index d70f5231..998a6ad0 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -89,6 +89,8 @@ if ($repository->isHeadDetached()) { In a repository without commits yet, `HEAD` points to a branch that doesn't exist yet: `getHead()` and `getHeadCommit()` throw a `ReferenceNotFoundException`, and `isHeadAttached()` returns `true`. +`getHead()` throws one too when `HEAD` points to a reference that isn't a +branch, like a tag, which git allows. Commits, trees and blobs ------------------------ @@ -160,15 +162,19 @@ $repository->setDescription('My project'); Compute size of a repository ---------------------------- -`getSize()` returns the size of the files of the git directory, working tree -excluded, in kilobytes (1,000 bytes): +`getSizeInBytes()` returns the size of the files of the git directory, +working tree excluded, in bytes. It doesn't follow links, and leaves out a +file git deletes meanwhile, like a lock during a `gc` or a push: ```php -$size = $repository->getSize(); +$size = $repository->getSizeInBytes(); -echo 'Your repository size is '.$size.' KB'; +echo 'Your repository size is '.round($size / 1000).' KB'; ``` +`getSize()`, which returns kilobytes (1,000 bytes), is deprecated since 2.0: +use `getSizeInBytes()`. + Running git commands -------------------- @@ -189,10 +195,14 @@ $process = $repository->runProcess('push', ['origin', 'main']); echo $process->getErrorOutput(); ``` -Both throw a `ProcessException` when git fails, and an -`UnsupportedOperationException` with another driver than `CliDriver`. The -relative paths of the arguments are relative to the repository. gitlib reads -the references again after the command, which may have written them. +Both throw a `ProcessException` when git fails, a `DriverException` when it +can't start, a `ProcessTimedOutException` when it runs longer than +[the timeout](#configuring-clidriver), and an `UnsupportedOperationException` +with another driver than `CliDriver`. The relative paths of the arguments are +relative to the repository. The arguments go to git as they are: a value from +your users starting with a dash is an option for git, unless `--` comes +before it. gitlib reads the references again after the command, which may +have written them. `shell()` is deprecated since 2.0: run `git shell` with Symfony Process instead. @@ -209,16 +219,30 @@ or empty data instead. Every exception of gitlib implements a reference or an object doesn't exist (`getRevision('unknown')->getCommit()`, `getReferences()->getBranch('unknown')`...). - `DriverException`, a `RuntimeException`: git reports a failure, like a - merge conflict or a clone of an unknown repository. With `CliDriver`, it - is mostly a `ProcessException`, whose `getErrorOutput()` and - `getOutput()` return the output of git, or a `ProcessTimedOutException`, - when git runs longer than [the timeout](#configuring-clidriver). + merge conflict or a clone of an unknown repository, or can't start, like + a missing binary (`The git command "clone" could not start: ...`). With + `CliDriver`, it is mostly a `ProcessException`, whose message has the + error of git, the first `fatal:` or `error:` line of its error output + (`The git command "clone" failed (exit 128): fatal: ...`), and whose + `getErrorOutput()` and `getOutput()` return the output of git, or a + `ProcessTimedOutException`, when git runs longer than + [the timeout](#configuring-clidriver). - `UnsupportedOperationException`, a `RuntimeException`: the driver can't - perform the operation at all, where another driver may: `run()` with - `LibGit2Driver`, libgit2 that isn't installed... + perform the operation at all, where another driver may: `run()`, cloning + a bundle or opening a reftable repository with `LibGit2Driver`, libgit2 + that isn't installed... - `InvalidArgumentException`: an argument gitlib doesn't support, like an - option of `git log` in the revisions of a log, or a string holding a NUL - byte, which git doesn't accept. + option of `git log` in the revisions of a log, a string holding a NUL + byte, which git doesn't accept, a revision, a reference, a hash or a URL + starting with a dash, which git would read as an option + (`getRevision('--output=/path')`), an `ext::` URL, whose command git + would run, a URL whose ssh host or user starts with a dash, which ssh + would read as an option, or a reference to delete outside `refs/`, like + `HEAD`. +- `RuntimeException`: any other failure, like a hook that exists already + (`Hooks::set()`), a file of the git directory that can't be written, or + an output of git that gitlib can't read. `DriverException` and + `UnsupportedOperationException` extend it. - `LogicException`: a call that makes no sense, like the working copy of a bare repository. @@ -232,6 +256,10 @@ try { } ``` +The message of an exception hides the credentials of the URLs it holds +(`https://***@example.com/repository.git`): `getErrorOutput()` and +`getOutput()` of a `ProcessException` return the output of git as it is. + Choosing a driver ----------------- @@ -281,6 +309,19 @@ path to the driver: $driver = new LibGit2Driver('/opt/libgit2/lib/libgit2.so.1.9'); ``` +`isSupported()` only looks for the usual names: to check a path, construct +the driver and catch the exception instead: + +```php +use Gitonomy\Git\Exception\UnsupportedOperationException; + +try { + $driver = new LibGit2Driver('/opt/libgit2/lib/libgit2.so.1.9'); +} catch (UnsupportedOperationException) { + $driver = new CliDriver(); +} +``` + Differences with `CliDriver`: - `Repository::run()` and `Repository::runProcess()` throw an @@ -289,21 +330,26 @@ Differences with `CliDriver`: - SSH and git's credential helpers aren't supported: clone a private repository through HTTPS, with the credentials in the URL (`https://user:token@example.com/repo.git`). -- Bundles (`.bundle` files) can't be cloned. -- Repositories storing their references in the reftable format can't be - opened. +- Bundles (`.bundle` files) can't be cloned, and repositories storing + their references in the reftable format can't be opened: both throw an + `UnsupportedOperationException`. +- The paths of a log don't support the magic of git's pathspecs + (`:(exclude)src`, `:/src`...): a path starting with `:` throws an + `UnsupportedOperationException`. - Neither hooks nor external filters (Git LFS...) are run: a checkout or a merge writes the files without them. +- Like git, `LibGit2Driver` doesn't read `/etc/gitconfig` when the + `GIT_CONFIG_NOSYSTEM` environment variable is true, but it doesn't read + `/etc/gitattributes` either, which git only skips with + `GIT_ATTR_NOSYSTEM`. - `Diff::getRawDiff()` returns an empty string. - Short hashes (`Commit::getShortHash()`) have 7 characters, more when 7 are ambiguous: git adapts their length to the size of the repository. -- `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`. + whatever `merge.log`, nor has the output of gpg checking a signed tag. - Blame is much slower on a long history: see ["Performance"](#performance). - Rename detection in diffs may pair files differently. @@ -390,9 +436,14 @@ an AMD Ryzen AI 9 HX 370. ([libgit2#3027](https://github.com/libgit2/libgit2/issues/3027)). Keep the default `CliDriver`, unless your application runs many small reads -in a long-running process, and check with your own repository: +in a long-running process, and check with your own repository, from a clone +of gitlib (Composer doesn't install `benchmark.php`, nor `symfony/console`, +which it requires): ```bash +git clone https://github.com/gitonomy/gitlib.git +cd gitlib +composer install php benchmark.php /path/to/repository path/to/a/file ``` @@ -427,6 +478,41 @@ arguments. So is `inherit_environment_variables`, which passed the entries of `$_SERVER` to git, the headers and credentials of an HTTP request included: pass the variables git needs to `environmentVariables`. +Git configuration +----------------- + +git reads the configuration of the user running PHP (`~/.gitconfig`, +`/etc/gitconfig`...) and of the repository. Where it changes what git +prints, which gitlib reads, what a read returns, or the branches gitlib +creates, `CliDriver` sets it back to git's defaults: + +- colors and columns; +- `branch.autoSetupMerge` and `branch.autoSetupRebase`: like with + `LibGit2Driver`, a branch `WorkingCopy::checkoutNewBranch()` creates from + a remote branch tracks it, without rebasing; +- `branch.sort`, `log.follow` and `log.showSignature`; +- for diffs, `diff.algorithm`, `diff.context`, `diff.external`, + `diff.indentHeuristic`, `diff.interHunkContext`, `diff.orderFile`, + `diff.submodule`, `diff.suppressBlankEmpty`, the prefixes + (`diff.noprefix`, `diff.mnemonicPrefix`...), and the textconv filters, of + blames too. + +Like libgit2, `CliDriver` reads the objects as they are, ignoring their +replacements (`git replace`, `refs/replace/`): whoever can push such a +reference to a repository would change what your application shows for a +hash. + +The rest of the configuration applies, like with git: the identity, the +credentials and the rewrites of URLs, `safe.directory`, line endings, hooks, +`merge.log`, `blame.ignoreRevsFile`, or `core.abbrev`, the length of short +hashes, with `LibGit2Driver` too. + +`run()` and `runProcess()` get `branch.autoSetupMerge`, +`branch.autoSetupRebase`, `branch.sort`, `log.follow`, `log.showSignature`, +`diff.indentHeuristic` and `diff.suppressBlankEmpty` set back too, and ignore +the replacements, but not the rest, which gitlib sets with the options of the +commands it runs: pass `--no-color` or `--no-ext-diff` yourself. + Logger ------ @@ -452,7 +538,8 @@ debug last command (fetch) output: Fetching origin ``` A failing command also logs its error output, at the `debug` level: the -exception it throws reports the failure. `LibGit2Driver` logs nothing. +exception it throws reports the failure. The logs hide the credentials of +URLs, like exceptions. `LibGit2Driver` logs nothing. `Repository::setLogger()` and `Repository::getLogger()` are deprecated since 2.0: give the logger to `CliDriver`. diff --git a/doc/workingcopy.md b/doc/workingcopy.md index c2b34471..52431f15 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -19,8 +19,9 @@ Checkout a revision ------------------- You can checkout any revision using *checkout* method. Like `git checkout`, -it attaches `HEAD` to a local branch, and detaches it on anything else: a -tag, a commit, a remote branch... +it attaches `HEAD` to a local branch, or to `@{-1}`, the branch checked out +before (`@{-2}` the one before...), leaves it as it is for `HEAD`, and +detaches it on anything else: a tag, a commit, a remote branch... ```php // git checkout master @@ -28,6 +29,9 @@ $wc->checkout('master'); // git checkout v1.0 (HEAD is detached) $wc->checkout('v1.0'); + +// git checkout @{-1}: the branch checked out before +$wc->checkout('@{-1}'); ``` You can also pass a *Revision*: a *Commit*, a *Reference*... A local *Branch* @@ -37,9 +41,11 @@ object attaches `HEAD` to the branch. $wc->checkout($repo->getReferences()->getBranch('master')); ``` -A name that only exists as a remote branch, like `feature` for -`origin/feature`, creates the local tracking branch with `CliDriver`, like -`git checkout` does. `LibGit2Driver` throws instead. +A name that no revision has throws a `ReferenceNotFoundException`, with both +drivers, unlike `git checkout`: a path, like `README.md`, whose changes git +would discard, or the name of a remote branch alone, like `feature` for +`origin/feature`, whose local branch git would create. Create it with +*checkoutNewBranch*. To create a branch and check it out, use *checkoutNewBranch*. Its second argument is the revision to start the branch from, `HEAD` by default: @@ -52,9 +58,18 @@ $wc->checkoutNewBranch('feature'); $wc->checkoutNewBranch('develop', 'origin/develop'); ``` +Like with `git checkout -b`, a branch created from a remote branch, like +`origin/develop`, tracks it: `git pull` and `git push` know its upstream +branch. One created from a local branch, a tag or a commit tracks none. +gitlib does so whatever the configuration (`branch.autoSetupMerge`), with +both drivers. Like git, *checkoutNewBranch* refuses a start point several +references have the name of, like `origin/develop` when a local branch has +this name too, with a `DriverException`: use its full name, +`refs/remotes/origin/develop`. + Like `git checkout`, both methods keep the local changes, and refuse to overwrite the ones the checkout would change: they throw a -`DriverException`. +`DriverException`. *checkoutNewBranch* then creates no branch. Passing the branch to create as second argument of *checkout* is deprecated since 2.0: use *checkoutNewBranch*. @@ -83,9 +98,10 @@ $wc->merge('origin/develop', FastForward::Only); merge commit otherwise. `FastForward::Never` always creates a merge commit. `FastForward::Only` throws instead of creating one. -You can also pass a *Revision*. A `DriverException` is thrown if the merge -fails: a conflict, staged changes, a revision whose history is unrelated to -the branch... +You can also pass a *Revision*. Like with *checkout*, a name that no +revision has throws a `ReferenceNotFoundException`. A `DriverException` is +thrown if the merge fails: a conflict, staged changes, a revision whose +history is unrelated to the branch... With `CliDriver`, a conflict leaves the working copy merging, with conflict markers in the files, like `git merge`. To cancel the merge: @@ -141,6 +157,10 @@ calling method `getDiffPending()`: $diff = $wc->getDiffPending(); ``` +Like with git 2.19 and later, a file added with `git add -N` (intent to add) +is no staged change: `getDiffPending()` lists it as created. With +`CliDriver`, older versions of git list it as an empty staged file. + During a merge conflict, `getDiffStaged()` and `getDiffPending()` leave the unmerged files out, and `getDiff()` compares them, conflict markers included, to the revision, like `git diff HEAD`. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index 60e89c10..00000000 --- a/phpstan-baseline.neon +++ /dev/null @@ -1,62 +0,0 @@ -parameters: - ignoreErrors: - - - message: '#^Method Gitonomy\\Git\\Parser\\ParserBase\:\:consumeRegexp\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Parser/ParserBase.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:getLog\(\) has parameter \$paths with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:getLog\(\) has parameter \$revisions with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:run\(\) has parameter \$args with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:runProcess\(\) has parameter \$args with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Repository\:\:shell\(\) has parameter \$env with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Repository.php - - - - message: '#^Method Gitonomy\\Git\\Revision\:\:getLog\(\) has parameter \$paths with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Revision.php - - - - message: '#^Class Gitonomy\\Git\\RevisionList implements generic interface IteratorAggregate but does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/Gitonomy/Git/RevisionList.php - - - - message: '#^Method Gitonomy\\Git\\RevisionList\:\:__construct\(\) has parameter \$revisions with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/RevisionList.php - - - - message: '#^Method Gitonomy\\Git\\RevisionList\:\:getIterator\(\) return type with generic class ArrayIterator does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/Gitonomy/Git/RevisionList.php - diff --git a/phpstan.neon b/phpstan.neon index 7a65f6c4..781747b6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,4 @@ includes: - - phpstan-baseline.neon - tools/phpstan/vendor/phpstan/phpstan-phpunit/extension.neon - tools/phpstan/vendor/phpstan/phpstan-phpunit/rules.neon diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 3799838c..83e2d7b5 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -71,7 +71,7 @@ public static function init(string $path, bool $bare = true, array $options = [] * * @deprecated since gitonomy/gitlib 2.0, use Repository::isValidRepository() instead */ - public static function isValidRepository(string $url, array $options = []): bool + public static function isValidRepository(#[\SensitiveParameter] string $url, array $options = []): bool { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class); @@ -94,7 +94,7 @@ public static function isValidRepository(string $url, array $options = []): bool * * @deprecated since gitonomy/gitlib 2.0, use Repository::isValidRepositoryAndBranch() instead */ - public static function isValidRepositoryAndBranch(string $url, string $branchName, array $options = []): bool + public static function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branchName, array $options = []): bool { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class); @@ -111,7 +111,7 @@ public static function isValidRepositoryAndBranch(string $url, string $branchNam * * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() instead */ - public static function cloneTo(string $path, string $url, bool $bare = true, array $options = []): Repository + public static function cloneTo(string $path, #[\SensitiveParameter] string $url, bool $bare = true, array $options = []): Repository { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); @@ -137,7 +137,7 @@ public static function cloneTo(string $path, string $url, bool $bare = true, arr * * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneBareFrom() or Repository::cloneFrom() with their "branch" argument instead */ - public static function cloneBranchTo(string $path, string $url, string $branch, bool $bare = true, array $options = []): Repository + public static function cloneBranchTo(string $path, #[\SensitiveParameter] string $url, string $branch, bool $bare = true, array $options = []): Repository { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); @@ -161,7 +161,7 @@ public static function cloneBranchTo(string $path, string $url, string $branch, * * @deprecated since gitonomy/gitlib 2.0, use Repository::mirrorFrom() instead */ - public static function mirrorTo(string $path, string $url, array $options = []): Repository + public static function mirrorTo(string $path, #[\SensitiveParameter] string $url, array $options = []): Repository { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class); @@ -182,7 +182,7 @@ public static function mirrorTo(string $path, string $url, array $options = []): * * @deprecated since gitonomy/gitlib 2.0, use Repository::cloneFrom(), Repository::cloneBareFrom() or Repository::mirrorFrom() instead */ - public static function cloneRepository(string $path, string $url, array $args = [], array $options = []): Repository + public static function cloneRepository(string $path, #[\SensitiveParameter] string $url, array $args = [], array $options = []): Repository { trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class); diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index 7901ed38..009ce6ee 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -113,7 +113,7 @@ public function getLines(): array $hash = $this->revision->getCommit()->getHash(); $this->lines = []; foreach ($this->repository->getRepositoryDriver()->getBlameLines($hash, $this->file, $this->firstLine, $this->lastLine) as $data) { - $this->lines[$data->finalLine] = new Line($this->repository->getCommit($data->commitHash), $data->finalLine, $data->originalLine, $data->content); + $this->lines[$data->number] = new Line($this->repository->getCommit($data->commitHash), $data->number, $data->originalNumber, $data->content); } return $this->lines; diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index c857a2ae..4d4d4f8e 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -12,10 +12,12 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; /** - * Representation of a Blob commit. + * Representation of a blob, the content of a file. * * @author Alexandre Salomé */ @@ -35,6 +37,8 @@ final class Blob /** * @param Repository $repository Repository where the blob is located * @param string $hash Hash of the blob + * + * @internal Get it from Repository::getBlob(), which returns the same object while the application uses it */ public function __construct( private readonly Repository $repository, @@ -50,7 +54,8 @@ public function getHash(): string /** * @return string content of the blob * - * @throws Exception\DriverException Error occurred while getting content of blob + * @throws ReferenceNotFoundException The blob doesn't exist, or the object of its hash isn't a blob + * @throws DriverException git fails to read it */ public function getContent(): string { diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 4a1af38f..150f935e 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -39,6 +39,8 @@ final class Commit extends Revision /** * @param Repository $repository Repository of the commit * @param string $hash Hash of the commit + * + * @internal Get it from Repository::getCommit(), which returns the same object while the application uses it */ public function __construct(Repository $repository, string $hash, ?CommitData $data = null) { @@ -226,7 +228,8 @@ public function getAuthorEmail(): string } /** - * Returns the authoring date. + * Returns the authoring date, at the UTC offset of the author, like git + * log shows it. */ public function getAuthorDate(): \DateTimeImmutable { @@ -242,7 +245,7 @@ public function getCommitterName(): string } /** - * Returns the comitter email. + * Returns the committer email. */ public function getCommitterEmail(): string { @@ -250,7 +253,8 @@ public function getCommitterEmail(): string } /** - * Returns the committing date. + * Returns the committing date, at the UTC offset of the committer, like + * git log shows it. */ public function getCommitterDate(): \DateTimeImmutable { diff --git a/src/Gitonomy/Git/Diff/Diff.php b/src/Gitonomy/Git/Diff/Diff.php index 43e13920..bb26cc61 100644 --- a/src/Gitonomy/Git/Diff/Diff.php +++ b/src/Gitonomy/Git/Diff/Diff.php @@ -15,6 +15,7 @@ use Gitonomy\Git\Driver\Data\DiffData; use Gitonomy\Git\Driver\Data\DiffFileData; use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Parser\DiffParser; use Gitonomy\Git\Repository; @@ -42,6 +43,7 @@ public function __construct( * @param Repository|null $repository The repository to read the blobs of the files from * * @throws InvalidArgumentException The diff has no raw information + * @throws RuntimeException The diff holds what the parser doesn't know, like the log of a submodule */ public static function parse(string $rawDiff, ?Repository $repository = null): self { diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index 773555f9..2f73b713 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -15,6 +15,7 @@ use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Util\RemoteUrl; use Psr\Log\LoggerInterface; /** @@ -49,36 +50,42 @@ public function open(string $dir, ?string $workingDir): CliRepositoryDriver public function init(string $path): void { - $this->mustRun('init', ['-q', $path]); + // --: a path may start with a dash. + $this->mustRun('init', ['-q', '--', $path]); } public function initBare(string $path): void { - $this->mustRun('init', ['-q', '--bare', $path]); + $this->mustRun('init', ['-q', '--bare', '--', $path]); } - public function cloneFrom(string $url, string $path, ?string $branch): void + public function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void { $this->cloneRepository($path, $url, null === $branch ? [] : ['--branch', $branch]); } - public function cloneBareFrom(string $url, string $path, ?string $branch): void + public function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void { $this->cloneRepository($path, $url, null === $branch ? ['--bare'] : ['--branch', $branch, '--bare']); } - public function mirrorFrom(string $url, string $path): void + public function mirrorFrom(#[\SensitiveParameter] string $url, string $path): void { $this->cloneRepository($path, $url, ['--mirror']); } - public function isValidRepository(string $url): bool + public function isValidRepository(#[\SensitiveParameter] string $url): bool { + // ls-remote only takes "--" since git 2.8. + RemoteUrl::assertSafe($url); + return $this->runner()->run('ls-remote', [self::absoluteUrl($url)])->isSuccessful(); } - public function isValidRepositoryAndBranch(string $url, string $branch): bool + public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch): bool { + RemoteUrl::assertSafe($url); + // 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()) { @@ -101,9 +108,13 @@ public function isValidRepositoryAndBranch(string $url, string $branch): bool * * @throws ProcessException */ - public function cloneRepository(string $path, string $url, array $args): void + public function cloneRepository(string $path, #[\SensitiveParameter] string $url, array $args): void { - $this->mustRun('clone', ['-q', ...$args, $url, $path]); + // Like isValidRepository(), whose ls-remote can't take "--". + RemoteUrl::assertSafe($url); + + // --: a path may start with a dash. + $this->mustRun('clone', ['-q', ...$args, '--', $url, $path]); } /** @@ -130,12 +141,12 @@ private function runner(): GitRunner /** * @param string[] $args */ - private function mustRun(string $command, array $args): void + private function mustRun(string $command, #[\SensitiveParameter] array $args): void { $process = $this->runner()->run($command, $args); if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, $command); } } @@ -144,7 +155,7 @@ private function mustRun(string $command, array $args): void * remote first: "origin" would be the URL of the remote of PHP's working * directory. */ - private static function absoluteUrl(string $url): string + private static function absoluteUrl(#[\SensitiveParameter] string $url): string { if (str_starts_with($url, '/') || 1 === preg_match('#^[a-zA-Z]:[\\\/]#', $url)) { return $url; diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index 2d0a98e7..025699ea 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -22,10 +22,13 @@ use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Driver\Data\TagData; use Gitonomy\Git\Driver\RepositoryDriverInterface; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\FastForward; use Gitonomy\Git\Parser\DiffParser; +use Gitonomy\Git\Util\LeadingDash; +use Gitonomy\Git\Util\ReferenceName; use Symfony\Component\Process\Process; /** @@ -37,9 +40,36 @@ */ final readonly class CliRepositoryDriver implements RepositoryDriverInterface { - // -z: the raw information gives the names as they are, without quoting. - // --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". - private const array DIFF_ARGS = ['-r', '-p', '--raw', '-z', '-M', '--full-index', '--no-abbrev']; + /** + * Sets back to git's defaults the configuration of the user that changes + * what git prints, reads or writes, where git 1.8.4 has no option for it. + * A git version that doesn't know a key ignores it. + * + * branch.autoSetupMerge and branch.autoSetupRebase: a branch created from + * a remote branch tracks it, without rebasing, like with LibGit2Driver. + * diff.indentHeuristic is the default since git 2.14, and LibGit2Driver's. + */ + private const array CONFIG = [ + '-c', 'branch.autoSetupMerge=true', + '-c', 'branch.autoSetupRebase=never', + '-c', 'branch.sort=refname', + '-c', 'diff.indentHeuristic=true', + '-c', 'diff.suppressBlankEmpty=false', + '-c', 'log.follow=false', + '-c', 'log.showSignature=false', + ]; + + /** + * -z: the raw information gives the names as they are, without quoting. + * --no-abbrev: before git 2.16, --raw abbreviates hashes with a trailing "...". + * The others set back to git's defaults the configuration "git diff" reads, + * unlike diff-tree: diff.algorithm, diff.context, diff.external, diff.noprefix, + * diff.submodule... + */ + private const array DIFF_ARGS = [ + '-r', '-p', '--raw', '-z', '-M', '--full-index', '--no-abbrev', + '--diff-algorithm=myers', '--dst-prefix=b/', '--inter-hunk-context=0', '--no-color', '--no-ext-diff', '--no-textconv', '-O/dev/null', '--src-prefix=a/', '--submodule=short', '-U3', + ]; /** * The tree without any file: git knows it, even when no commit has it. @@ -73,7 +103,7 @@ public function getHeadReference(): ?string } if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, 'symbolic-ref'); } return trim($process->getOutput()); @@ -81,15 +111,18 @@ public function getHeadReference(): ?string public function resolveRevision(string $revision): string { + LeadingDash::assertNone($revision); + $process = $this->execute('rev-parse', ['--verify', '--quiet', $revision]); - // --quiet: git exits with 1, without output, when nothing has this name. - if (1 === $process->getExitCode() && '' === $process->getOutput()) { + // --quiet: git exits with 1 when nothing has this name, or when it + // names several objects, like the range "a...b", which it prints. + if (1 === $process->getExitCode()) { throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist.', $revision)); } if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, 'rev-parse'); } return trim($process->getOutput()); @@ -97,13 +130,18 @@ public function resolveRevision(string $revision): string public function getShortHash(string $hash): string { - return trim($this->runOnObject($hash, null, 'log', ['--abbrev-commit', '--format=%h', '-n', '1', $hash])); + LeadingDash::assertNone($hash); + + // --: a file of the working tree may have the name of the hash. + return trim($this->runOnObject($hash, null, 'log', ['--abbrev-commit', '--format=%h', '-n', '1', $hash, '--'])); } public function getCommitData(string $hash): CommitData { + LeadingDash::assertNone($hash); + $parser = new LogParser(); - $parser->parse($this->runOnObject($hash, ObjectType::Commit, 'log', ['--encoding=UTF-8', '--format=raw', '-n', '1', $hash])); + $parser->parse($this->runOnObject($hash, ObjectType::Commit, 'log', ['--encoding=UTF-8', '--format=raw', '--no-color', '-n', '1', $hash, '--'])); $commit = $parser->commits[0] ?? null; // git log peels a tag to its commit. @@ -116,12 +154,14 @@ public function getCommitData(string $hash): CommitData public function getCommitLog(?array $includes, array $excludes, array $paths, int $offset, ?int $limit): array { + LeadingDash::assertNone(...$includes ?? [], ...$excludes); + if ([] === $includes) { return []; } // Re-encodes the commits whose "encoding" header isn't UTF-8. - $args = ['--encoding=UTF-8', '--format=raw', '--skip='.$offset]; + $args = ['--encoding=UTF-8', '--format=raw', '--no-color', '--skip='.$offset]; if (null !== $limit) { $args[] = '-n'; @@ -129,22 +169,26 @@ public function getCommitLog(?array $includes, array $excludes, array $paths, in } $parser = new LogParser(); - $parser->parse($this->run('log', [...$args, ...self::revisions($includes, $excludes), '--', ...$paths])); + $parser->parse($this->runOnRevisions('log', $args, $includes, $excludes, $paths)); return $parser->commits; } public function countCommits(?array $includes, array $excludes, array $paths): int { + LeadingDash::assertNone(...$includes ?? [], ...$excludes); + if ([] === $includes) { return 0; } - return (int) $this->run('rev-list', ['--count', ...self::revisions($includes, $excludes), '--', ...$paths]); + return (int) $this->runOnRevisions('rev-list', ['--count'], $includes, $excludes, $paths); } public function getMergeBases(string $hash1, string $hash2): array { + LeadingDash::assertNone($hash1, $hash2); + $process = $this->execute('merge-base', ['--all', $hash1, $hash2]); // git exits with 1 and no output when there is no common ancestor; any @@ -154,7 +198,7 @@ public function getMergeBases(string $hash1, string $hash2): array } if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, 'merge-base'); } return explode("\n", trim($process->getOutput())); @@ -192,7 +236,7 @@ public function getReferences(): array } if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, 'show-ref'); } $parser = new ReferenceParser(); @@ -203,22 +247,35 @@ public function getReferences(): array public function createReference(string $fullname, string $hash): void { + LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); + // Zeros as the old value: git refuses to update a reference that exists. - $this->run('update-ref', [$fullname, $hash, str_repeat('0', 40)]); + $this->run('update-ref', ['--no-deref', $fullname, $hash, str_repeat('0', 40)]); } public function updateReference(string $fullname, string $hash): void { - $this->run('update-ref', [$fullname, $hash]); + LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); + + // --no-deref: a symbolic reference, like refs/remotes/origin/HEAD, is + // written itself, instead of the reference it points to. + $this->run('update-ref', ['--no-deref', $fullname, $hash]); } public function deleteReference(string $fullname): void { - $this->run('update-ref', ['-d', $fullname]); + LeadingDash::assertNone($fullname); + ReferenceName::assertUnderRefs($fullname); + + $this->run('update-ref', ['--no-deref', '-d', $fullname]); } public function getIncludingBranches(string $hash): array { + LeadingDash::assertNone($hash); + // for-each-ref only accepts --contains since git 2.7. return [ ...$this->listBranches(['--contains', $hash], 'refs/heads/'), @@ -228,11 +285,15 @@ public function getIncludingBranches(string $hash): array public function getDiff(?string $from, string $to): DiffData { + LeadingDash::assertNone($from ?? '', $to); + return $this->diff('diff-tree', [$from ?? self::EMPTY_TREE, $to]); } public function getWorkingTreeDiff(string $hash): DiffData { + LeadingDash::assertNone($hash); + return $this->diff('diff', [$hash]); } @@ -248,7 +309,10 @@ public function getDiffStaged(): DiffData public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array { - $args = ['-p']; + LeadingDash::assertNone($hash); + + // --no-textconv: blame reads the files through their textconv filter by default. + $args = ['-p', '--no-textconv']; if (null !== $firstLine || null !== $lastLine) { $args[] = '-L'; $args[] = ($firstLine ?? 1).','.$lastLine; @@ -274,16 +338,38 @@ public function getUntrackedFiles(): array public function checkout(string $revision): void { - $this->run('checkout', [$revision]); + LeadingDash::assertNone($revision); + + // --: without it, git restores the file $revision names, when no + // revision has this name, discarding its changes. + $this->run('checkout', [$revision, '--']); } - public function checkoutNewBranch(string $branch, string $startHash): void + public function checkoutNewBranch(string $branch, string $startPoint): void { - $this->run('checkout', ['-b', $branch, $startHash]); + LeadingDash::assertNone($branch, $startPoint); + + // The branch, then its files: "checkout -b" checks the files of an + // ambiguous $startPoint out before refusing it, and leaves them. + // CONFIG sets the upstream branch, like git's default: --track or + // --no-track would need to know whether $startPoint is a remote branch. + $this->run('branch', [$branch, $startPoint]); + + try { + $this->checkout($branch); + } catch (DriverException $e) { + // Like "checkout -b", which creates nothing when the checkout + // fails. The configuration of the branch goes with it. + $this->run('branch', ['-D', $branch]); + + throw $e; + } } public function merge(string $revision, FastForward $fastForward): void { + LeadingDash::assertNone($revision); + $option = match ($fastForward) { FastForward::Allow => '--ff', FastForward::Never => '--no-ff', @@ -301,12 +387,12 @@ public function merge(string $revision, FastForward $fastForward): void * * @throws ProcessException The command failed */ - public function runProcess(string $command, array $args = [], ?string $input = null): Process + public function runProcess(string $command, #[\SensitiveParameter] array $args = [], ?string $input = null): Process { $process = $this->execute($command, array_map(strval(...), $args), $input); if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, $command); } return $process; @@ -327,7 +413,8 @@ private function run(string $command, array $args): string */ private function diff(string $command, array $args): DiffData { - $output = $this->run($command, [...self::DIFF_ARGS, ...$args]); + // --: a file of the working tree may have the name of a revision. + $output = $this->run($command, [...self::DIFF_ARGS, ...$args, '--']); $parser = new DiffParser(); $parser->parse($output); @@ -386,14 +473,26 @@ private function runOnObject(string $hash, ?ObjectType $type, string $command, a } /** - * @param list|null $includes - * @param list $excludes + * Runs $command on the commits reachable from $includes, but not from + * $excludes. * - * @return list + * The hashes go through the standard input: thousands of them would + * exceed the limit of the command line. + * + * @param string[] $args Arguments of $command + * @param list|null $includes Null for every reference and HEAD + * @param list $excludes + * @param string[] $paths */ - private static function revisions(?array $includes, array $excludes): array + private function runOnRevisions(string $command, array $args, ?array $includes, array $excludes, array $paths): string { - return [...$includes ?? ['--all'], ...array_map(static fn (string $hash): string => '^'.$hash, $excludes)]; + // A hash per line, "^" excluding it. The standard input takes no option, like "--all". + $input = ''; + foreach ([...$includes ?? [], ...array_map(static fn (string $hash): string => '^'.$hash, $excludes)] as $revision) { + $input .= $revision."\n"; + } + + return $this->runProcess($command, [...$args, ...null === $includes ? ['--all'] : [], '--stdin', '--', ...$paths], $input)->getOutput(); } /** @@ -404,7 +503,7 @@ private static function revisions(?array $includes, array $excludes): array private function listBranches(array $args, string $prefix): array { $branches = []; - foreach (explode("\n", rtrim($this->run('branch', ['--no-color', ...$args]), "\n")) as $line) { + foreach (explode("\n", rtrim($this->run('branch', ['--no-color', '--no-column', ...$args]), "\n")) as $line) { // "* " flags the current branch, "(HEAD detached at ...)" a detached // HEAD, and "origin/HEAD -> origin/main" a symbolic reference. $name = substr($line, 2); @@ -421,9 +520,11 @@ private function listBranches(array $args, string $prefix): array /** * @param string[] $args */ - private function execute(string $command, array $args, ?string $input = null): Process + private function execute(string $command, #[\SensitiveParameter] array $args, ?string $input = null): Process { - $globalOptions = ['--git-dir', $this->gitDir]; + // --no-replace-objects: like libgit2, gitlib reads the objects as they + // are, whatever "git replace" made of them in refs/replace/. + $globalOptions = [...self::CONFIG, '--git-dir', $this->gitDir, '--no-replace-objects']; if (null !== $this->workingDir) { $globalOptions = [...$globalOptions, '--work-tree', $this->workingDir]; } diff --git a/src/Gitonomy/Git/Driver/Cli/GitRunner.php b/src/Gitonomy/Git/Driver/Cli/GitRunner.php index 9637221f..268343e6 100644 --- a/src/Gitonomy/Git/Driver/Cli/GitRunner.php +++ b/src/Gitonomy/Git/Driver/Cli/GitRunner.php @@ -14,10 +14,11 @@ use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ProcessTimedOutException; +use Gitonomy\Git\Util\Credentials; use Gitonomy\Git\Util\NulByte; use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Exception\ProcessStartFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException; use Symfony\Component\Process\Exception\RuntimeException as SymfonyRuntimeException; use Symfony\Component\Process\Process; @@ -50,9 +51,9 @@ public function __construct( * @param string|null $input The standard input of git * * @throws InvalidArgumentException An argument contains a NUL byte - * @throws ProcessException The git binary can't be run + * @throws DriverException git can't start, or is killed */ - public function run(string $command, array $args, array $globalOptions = [], ?string $workingDirectory = null, ?string $input = null): Process + public function run(string $command, #[\SensitiveParameter] array $args, array $globalOptions = [], ?string $workingDirectory = null, ?string $input = null): Process { NulByte::assertNone(...$globalOptions, ...$args); @@ -63,32 +64,65 @@ public function run(string $command, array $args, array $globalOptions = [], ?st $process->setTimeout($this->timeout); $process->setIdleTimeout($this->timeout); - $this->logger?->info(\sprintf('run command: %s "%s" ', $command, implode(' ', $args))); + // The logs hide the credentials of the URLs, like the exceptions. + $this->logger?->info(Credentials::redact(\sprintf('run command: %s "%s" ', $command, implode(' ', $args)))); $before = microtime(true); + // The exceptions of Symfony aren't chained: their messages may hold the + // command line, with the credentials of its URLs. try { - $process->run(); - } catch (SymfonyProcessTimedOutException $e) { - throw new ProcessTimedOutException(\sprintf('The git command "%s" ran longer than %s seconds.', $command, $this->timeout), 0, $e); + $process->start(); } 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); + // Too many arguments (E2BIG), a working directory removed since the + // repository was opened... + $error = $e->getMessage(); + if ($e instanceof ProcessStartFailedException) { + // Its message ends with the error, after the command line. + $lines = explode("\n", $error); + $error = substr(end($lines), \strlen('Error: ')); + } + + throw self::notStarted($command, $error); + } + + try { + $process->wait(); + } catch (SymfonyProcessTimedOutException) { + throw new ProcessTimedOutException(\sprintf('The git command "%s" ran longer than %s seconds.', $command, $this->timeout)); + } catch (SymfonyRuntimeException $e) { + // Killed by a signal... + throw new DriverException(Credentials::redact(\sprintf('Unable to run the git command "%s": %s', $command, $e->getMessage()))); } // Symfony Process runs git through a shell, which exits with them when it can't find or run git. if (\in_array($process->getExitCode(), [126, 127], true)) { - throw new ProcessException($process); + throw self::notStarted($command, $process->getErrorOutput()); } if (null !== $this->logger) { $this->logger->debug(\sprintf('last command (%s) duration: %.2fms', $command, (microtime(true) - $before) * 1000)); $this->logger->debug(\sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); - $this->logger->debug(\sprintf('last command (%s) output: %s', $command, $process->getOutput())); + $this->logger->debug(Credentials::redact(\sprintf('last command (%s) output: %s', $command, $process->getOutput()))); if (!$process->isSuccessful()) { - $this->logger->debug(\sprintf('last command (%s) error output: %s', $command, $process->getErrorOutput())); + $this->logger->debug(Credentials::redact(\sprintf('last command (%s) error output: %s', $command, $process->getErrorOutput()))); } } return $process; } + + /** + * Like the one of ProcessException, the message is a sentence, with the + * first line of $error. + */ + private static function notStarted(string $command, string $error): DriverException + { + $message = \sprintf('The git command "%s" could not start', $command); + $error = trim(explode("\n", trim($error), 2)[0]); + if ('' !== $error) { + $message .= ': '.$error; + } + + return new DriverException(Credentials::redact(1 === preg_match('/[.!?]$/', $message) ? $message : $message.'.')); + } } diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php index 20281f45..844805a5 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php @@ -69,7 +69,7 @@ protected function doParse(): void $content = $this->consumeTo("\n"); $this->consumeNewLine(); - $this->lines[] = new BlameLineData($hash, (int) $vars[1], (int) $vars[2], $content); + $this->lines[] = new BlameLineData($hash, (int) $vars[2], (int) $vars[1], $content); } } } diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php index ac729fd0..1fe8decd 100644 --- a/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php +++ b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php @@ -84,10 +84,10 @@ protected function doParse(): void */ private static function parseNameEmailDate(string $value): array { - if (!preg_match('/^(.*) <(.*)> (\d+) [+-]\d{4}$/', $value, $vars)) { + if (!preg_match('/^(.*) <(.*)> (\d+) ([+-]\d{4})$/', $value, $vars)) { throw new RuntimeException(\sprintf('Unable to parse the name, email and date of "%s".', $value)); } - return [$vars[1], $vars[2], self::timestamp($vars[3])]; + return [$vars[1], $vars[2], self::signatureDate($vars[3], $vars[4])]; } } diff --git a/src/Gitonomy/Git/Driver/Data/BlameLineData.php b/src/Gitonomy/Git/Driver/Data/BlameLineData.php index 31ed9ce7..fc651c91 100644 --- a/src/Gitonomy/Git/Driver/Data/BlameLineData.php +++ b/src/Gitonomy/Git/Driver/Data/BlameLineData.php @@ -22,13 +22,13 @@ final readonly class BlameLineData { /** - * @param int $originalLine The number of the line in the file of $commitHash - * @param int $finalLine The number of the line in the blamed file + * @param int $number The number of the line in the blamed file + * @param int $originalNumber The number of the line in the file of $commitHash */ public function __construct( public string $commitHash, - public int $originalLine, - public int $finalLine, + public int $number, + public int $originalNumber, public string $content, ) { } diff --git a/src/Gitonomy/Git/Driver/Data/CommitData.php b/src/Gitonomy/Git/Driver/Data/CommitData.php index f71aef3f..e01dca73 100644 --- a/src/Gitonomy/Git/Driver/Data/CommitData.php +++ b/src/Gitonomy/Git/Driver/Data/CommitData.php @@ -21,7 +21,9 @@ final readonly class CommitData { /** - * @param string[] $parentHashes + * @param string[] $parentHashes + * @param \DateTimeImmutable $authorDate At the UTC offset of the author, like git log shows it + * @param \DateTimeImmutable $committerDate At the UTC offset of the committer, like git log shows it */ public function __construct( public string $hash, diff --git a/src/Gitonomy/Git/Driver/Data/DiffFileData.php b/src/Gitonomy/Git/Driver/Data/DiffFileData.php index 4ce29b66..aecb8682 100644 --- a/src/Gitonomy/Git/Driver/Data/DiffFileData.php +++ b/src/Gitonomy/Git/Driver/Data/DiffFileData.php @@ -34,7 +34,7 @@ * @param string|null $oldName Null for a creation * @param string|null $newName Null for a deletion * @param string|null $oldIndex The hash of the old blob, null when there is none - * @param string|null $newIndex The hash of the new blob, null when there is none, or for a file of the working tree + * @param string|null $newIndex The hash of the new blob, null when there is none; for a file of the working tree, the hash of its content, which the repository may not store * @param list $changes * * @throws InvalidArgumentException Both names are null diff --git a/src/Gitonomy/Git/Driver/Data/TagData.php b/src/Gitonomy/Git/Driver/Data/TagData.php index 03b5983f..87b8044c 100644 --- a/src/Gitonomy/Git/Driver/Data/TagData.php +++ b/src/Gitonomy/Git/Driver/Data/TagData.php @@ -25,7 +25,8 @@ private const string SIGNATURE = '-----BEGIN (PGP SIGNATURE|PGP MESSAGE|SIGNED MESSAGE|SSH SIGNATURE)-----'; /** - * @param string|null $taggerName Null for a tag without a tagger, like the oldest tags of git itself + * @param string|null $taggerName Null for a tag without a tagger, like the oldest tags of git itself + * @param \DateTimeImmutable|null $taggerDate At the UTC offset of the tagger, like git log shows it */ public function __construct( public string $objectHash, diff --git a/src/Gitonomy/Git/Driver/DriverInterface.php b/src/Gitonomy/Git/Driver/DriverInterface.php index ea5c76cb..9323c764 100644 --- a/src/Gitonomy/Git/Driver/DriverInterface.php +++ b/src/Gitonomy/Git/Driver/DriverInterface.php @@ -24,8 +24,17 @@ * Pass one to Repository, which uses {@see CliDriver} by default, to * configure the backend, or to replace it in tests. * - * Every method throws an {@see InvalidArgumentException} when an argument - * contains a NUL byte, which git doesn't accept. + * Every method throws a {@see DriverException} when git reports a failure, + * an {@see UnsupportedOperationException} when the driver can't perform the + * operation at all, like LibGit2Driver cloning a bundle, and an + * {@see InvalidArgumentException} when an argument contains a NUL byte, which + * git doesn't accept. + * + * An application may get the URL of a repository from its users: every method + * taking one refuses, with an {@see InvalidArgumentException}, a URL git would + * misread, before running git. A URL starting with a dash is an option for + * git, an "ext::" URL runs its command, and an ssh host or user starting with + * a dash is an option for ssh. * * @author Grégoire Pineau */ @@ -39,7 +48,7 @@ interface DriverInterface * * @throws InvalidArgumentException $dir doesn't exist, or isn't a repository * @throws DriverException The driver can't open the repository - * @throws UnsupportedOperationException The repository uses SHA-256 hashes + * @throws UnsupportedOperationException The repository uses SHA-256 hashes, or a format the driver can't read, like reftable for LibGit2Driver */ public function open(string $dir, ?string $workingDir): RepositoryDriverInterface; @@ -58,7 +67,9 @@ public function initBare(string $path): void; * or the default branch of $url when null; like git, a tag named $branch * is checked out with HEAD detached, when $url has no such branch. * - * @throws DriverException + * @throws InvalidArgumentException $url is a URL git would misread + * @throws DriverException git fails to clone $url + * @throws UnsupportedOperationException The driver can't clone $url, like LibGit2Driver a bundle */ public function cloneFrom(string $url, string $path, ?string $branch): void; @@ -68,7 +79,9 @@ public function cloneFrom(string $url, string $path, ?string $branch): void; * when null; like git, HEAD is detached at a tag named $branch, when $url * has no such branch. * - * @throws DriverException + * @throws InvalidArgumentException $url is a URL git would misread + * @throws DriverException git fails to clone $url + * @throws UnsupportedOperationException The driver can't clone $url, like LibGit2Driver a bundle */ public function cloneBareFrom(string $url, string $path, ?string $branch): void; @@ -76,7 +89,9 @@ public function cloneBareFrom(string $url, string $path, ?string $branch): void; * Clones every reference of $url into a bare repository in $path, set up * to mirror $url when fetching. * - * @throws DriverException + * @throws InvalidArgumentException $url is a URL git would misread + * @throws DriverException git fails to clone $url + * @throws UnsupportedOperationException The driver can't clone $url, like LibGit2Driver a bundle */ public function mirrorFrom(string $url, string $path): void; @@ -84,12 +99,16 @@ public function mirrorFrom(string $url, string $path): void; * @param string $url A URL, or the path of a repository: never the name of a remote * * @return bool Whether $url can be reached + * + * @throws InvalidArgumentException $url is a URL git would misread */ public function isValidRepository(string $url): bool; /** * @param string $url Same as {@see isValidRepository()} * @param string $branch The name of the branch, exactly: "release" doesn't name "feature/release" + * + * @throws InvalidArgumentException $url is a URL git would misread */ public function isValidRepositoryAndBranch(string $url, string $branch): bool; } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php index ad8a5704..05feed0a 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php @@ -15,6 +15,7 @@ use FFI\CData; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\Credentials; /** * Loads libgit2, and helps calling it. @@ -27,7 +28,9 @@ final class LibGit2 { public const int GIT_ENOTFOUND = -3; + private const int GIT_CONFIG_LEVEL_SYSTEM = 2; private const int GIT_FEATURE_SHA256 = 1 << 11; + private const int GIT_OPT_SET_SEARCH_PATH = 5; /** * The only libgit2 version libgit2.h is written for: libgit2 changes its @@ -97,10 +100,11 @@ public static function preload(?string $libraryPath): void /** * @throws DriverException When $rc is a libgit2 error code */ - public static function check(\FFI $ffi, int $rc, string $context): void + public static function check(\FFI $ffi, int $rc, #[\SensitiveParameter] string $context): void { if ($rc < 0) { - throw new DriverException(\sprintf('%s: %s', $context, self::lastErrorMessage($ffi))); + // $context and libgit2 may name a URL holding credentials. + throw new DriverException(Credentials::redact(\sprintf('%s: %s', $context, self::lastErrorMessage($ffi)))); } } @@ -217,6 +221,16 @@ private static function initialize(\FFI $ffi): void if ($ffi->git_libgit2_init() < 0) { throw new UnsupportedOperationException(\sprintf('Unable to initialize libgit2: %s', self::lastErrorMessage($ffi))); } + + // Like git, no system configuration (/etc/gitconfig) when + // GIT_CONFIG_NOSYSTEM is true: libgit2 only reads this variable when + // it reads GIT_DIR, GIT_INDEX_FILE... too. An empty search path also + // leaves the system gitattributes out. FFI passes the level, a + // variadic argument, as a 64-bit integer, which reads as the int + // libgit2 expects. + if (filter_var(getenv('GIT_CONFIG_NOSYSTEM'), \FILTER_VALIDATE_BOOL) && $ffi->git_libgit2_opts(self::GIT_OPT_SET_SEARCH_PATH, self::GIT_CONFIG_LEVEL_SYSTEM, '') < 0) { + throw new UnsupportedOperationException(\sprintf('Unable to ignore the system configuration: %s', self::lastErrorMessage($ffi))); + } } private static function locate(): string diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index ef69d86e..dbb3e541 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -17,7 +17,9 @@ use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\Credentials; use Gitonomy\Git\Util\NulByte; +use Gitonomy\Git\Util\RemoteUrl; /** * Runs libgit2 1.9 in-process, through PHP's FFI extension. @@ -98,9 +100,12 @@ public function initBare(string $path): void $this->ffi->git_repository_free($this->initRepository($path, true)); } - public function cloneFrom(string $url, string $path, ?string $branch): void + public function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void { NulByte::assertNone($url, $path, $branch ?? ''); + // libgit2 runs no command for a URL, unlike git: refused like by CliDriver. + RemoteUrl::assertSafe($url); + self::assertNotBundle($url); // git_clone() only checks a branch out, where git also checks a tag out, HEAD detached. $tag = null !== $branch && $this->isTagOnly($url, $branch) ? $branch : null; @@ -141,17 +146,21 @@ public function cloneFrom(string $url, string $path, ?string $branch): void self::removeFetchHead($path.'/.git'); } - public function cloneBareFrom(string $url, string $path, ?string $branch): void + public function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void { NulByte::assertNone($url, $path, $branch ?? ''); + RemoteUrl::assertSafe($url); + self::assertNotBundle($url); // Like git, only the branches (and the tags). $this->fetchInto($path, $url, '+refs/heads/*:refs/heads/*', $branch, ['remote.origin.url' => $url]); } - public function mirrorFrom(string $url, string $path): void + public function mirrorFrom(#[\SensitiveParameter] string $url, string $path): void { NulByte::assertNone($url, $path); + RemoteUrl::assertSafe($url); + self::assertNotBundle($url); $this->fetchInto($path, $url, '+refs/*:refs/*', null, [ 'remote.origin.url' => $url, @@ -161,20 +170,35 @@ public function mirrorFrom(string $url, string $path): void ]); } - public function isValidRepository(string $url): bool + public function isValidRepository(#[\SensitiveParameter] string $url): bool { NulByte::assertNone($url); + RemoteUrl::assertSafe($url); return null !== $this->listRemoteReferences($url); } - public function isValidRepositoryAndBranch(string $url, string $branch): bool + public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch): bool { NulByte::assertNone($url, $branch); + RemoteUrl::assertSafe($url); return \in_array('refs/heads/'.$branch, $this->listRemoteReferences($url) ?? [], true); } + /** + * git clones a bundle file like a repository, libgit2 doesn't. + * + * @throws UnsupportedOperationException $url is a bundle + */ + private static function assertNotBundle(string $url): void + { + // The signatures of the formats of bundles. + if (is_file($url) && is_readable($url) && \in_array(file_get_contents($url, length: 16), ["# v2 git bundle\n", "# v3 git bundle\n"], true)) { + throw new UnsupportedOperationException(\sprintf('"%s" is a bundle, which LibGit2Driver doesn\'t support; use CliDriver.', $url)); + } + } + private function initRepository(string $path, bool $bare): CData { $repository = $this->ffi->new('git_repository*'); @@ -189,12 +213,12 @@ private function initRepository(string $path, bool $bare): CData * * @param array $config */ - private function fetchInto(string $path, string $url, string $refspec, ?string $branch, array $config): void + private function fetchInto(string $path, #[\SensitiveParameter] string $url, string $refspec, ?string $branch, #[\SensitiveParameter] array $config): void { // Like git, which refuses to clone into a directory with files. $existing = is_dir($path); if ($existing && [] !== array_diff(scandir($path) ?: [], ['.', '..'])) { - throw new DriverException(\sprintf('Unable to clone "%s": "%s" exists and is not an empty directory.', $url, $path)); + throw new DriverException(\sprintf('Unable to clone "%s": "%s" exists and is not an empty directory.', Credentials::redact($url), $path)); } $repository = $this->initRepository($path, true); @@ -229,7 +253,7 @@ private static function removeFetchHead(string $gitDir): void /** * @return bool Whether $url has a tag named $name, and no branch of this name */ - private function isTagOnly(string $url, string $name): bool + private function isTagOnly(#[\SensitiveParameter] string $url, string $name): bool { $references = $this->listRemoteReferences($url) ?? []; @@ -308,7 +332,7 @@ private static function remove(string $path, bool $keep): void * Fetches $refspec from $url, and points HEAD to $branch, or to the * default branch of $url. */ - private function fetch(CData $repository, string $url, string $refspec, ?string $branch): void + private function fetch(CData $repository, #[\SensitiveParameter] string $url, string $refspec, ?string $branch): void { $remote = $this->ffi->new('git_remote*'); LibGit2::check($this->ffi, $this->ffi->git_remote_create_anonymous(\FFI::addr($remote), $repository, $url), \sprintf('Unable to create a remote for "%s"', $url)); @@ -356,12 +380,12 @@ private function fetch(CData $repository, string $url, string $refspec, ?string /** * @throws DriverException $repository has no tag named $tag */ - private function detachHeadAtTag(CData $repository, string $tag, string $url): void + private function detachHeadAtTag(CData $repository, string $tag, #[\SensitiveParameter] string $url): void { $commit = $this->ffi->new('git_object*'); $rc = $this->ffi->git_revparse_single(\FFI::addr($commit), $repository, 'refs/tags/'.$tag.'^{commit}'); if (LibGit2::GIT_ENOTFOUND === $rc) { - throw new DriverException(\sprintf('Remote branch "%s" not found in "%s".', $tag, $url)); + throw new DriverException(\sprintf('Remote branch "%s" not found in "%s".', $tag, Credentials::redact($url))); } LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve the tag "%s"', $tag)); @@ -394,7 +418,7 @@ private function defaultBranch(CData $remote): ?string /** * @param array $config */ - private function setConfig(CData $repository, array $config): void + private function setConfig(CData $repository, #[\SensitiveParameter] array $config): void { $handle = $this->ffi->new('git_config*'); LibGit2::check($this->ffi, $this->ffi->git_repository_config(\FFI::addr($handle), $repository), 'Unable to open the repository configuration'); @@ -411,7 +435,7 @@ private function setConfig(CData $repository, array $config): void /** * @return string[]|null The full names of the references of $url, or null when it can't be reached */ - private function listRemoteReferences(string $url): ?array + private function listRemoteReferences(#[\SensitiveParameter] string $url): ?array { $remote = $this->ffi->new('git_remote*'); if ($this->ffi->git_remote_create_detached(\FFI::addr($remote), $url) < 0) { diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index f6dfcea7..3162bef3 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -27,8 +27,11 @@ use Gitonomy\Git\Driver\RepositoryDriverInterface; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\FastForward; +use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\NulByte; +use Gitonomy\Git\Util\ReferenceName; use Symfony\Component\String\ByteString; use Symfony\Component\String\Exception\InvalidArgumentException; @@ -60,21 +63,35 @@ private const int GIT_DIFF_FLAG_BINARY = 1 << 0; private const int GIT_DIFF_INDENT_HEURISTIC = 1 << 18; private const int GIT_DIFF_FIND_RENAMES = 1 << 0; + private const int GIT_DIRECTION_FETCH = 0; + private const int GIT_INDEX_ENTRY_INTENT_TO_ADD = 1 << 13; private const int GIT_STATUS_OPT_INCLUDE_UNTRACKED = 1 << 0; private const int GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS = 1 << 4; private const int GIT_STATUS_WT_NEW = 1 << 7; + /** + * The hash of the empty file, which `git add -N` stages. + */ + private const string EMPTY_BLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'; + private CData $repo; /** * @param string $gitDir The git directory * @param string|null $workingDir The working tree, null for a bare repository + * + * @throws UnsupportedOperationException The repository stores its references in the reftable format */ public function __construct( private \FFI $ffi, private string $gitDir, private ?string $workingDir, ) { + // libgit2 1.9 refuses to open it, as any repository with an extension it doesn't know. + if (is_dir($gitDir.'/reftable')) { + throw new UnsupportedOperationException(\sprintf('The repository "%s" stores its references in the reftable format, which LibGit2Driver doesn\'t support; use CliDriver.', $gitDir)); + } + $out = $this->ffi->new('git_repository*'); LibGit2::check($this->ffi, $this->ffi->git_repository_open(\FFI::addr($out), $gitDir), \sprintf('Unable to open the repository "%s"', $gitDir)); @@ -133,6 +150,7 @@ public function getHeadReference(): ?string public function resolveRevision(string $revision): string { NulByte::assertNone($revision); + LeadingDash::assertNone($revision); $object = $this->ffi->new('git_object*'); $rc = $this->ffi->git_revparse_single(\FFI::addr($object), $this->repo, $revision); @@ -151,6 +169,7 @@ public function resolveRevision(string $revision): string public function getCommitData(string $hash): CommitData { NulByte::assertNone($hash); + LeadingDash::assertNone($hash); $commit = $this->lookupCommit($hash); @@ -164,6 +183,7 @@ public function getCommitData(string $hash): CommitData public function getCommitLog(?array $includes, array $excludes, array $paths, int $offset, ?int $limit): array { NulByte::assertNone(...$includes ?? [], ...$excludes, ...$paths); + LeadingDash::assertNone(...$includes ?? [], ...$excludes); $result = []; if (0 === $limit) { @@ -192,6 +212,7 @@ public function getCommitLog(?array $includes, array $excludes, array $paths, in public function countCommits(?array $includes, array $excludes, array $paths): int { NulByte::assertNone(...$includes ?? [], ...$excludes, ...$paths); + LeadingDash::assertNone(...$includes ?? [], ...$excludes); return iterator_count($this->walk($includes, $excludes, $paths)); } @@ -199,6 +220,7 @@ public function countCommits(?array $includes, array $excludes, array $paths): i public function getMergeBases(string $hash1, string $hash2): array { NulByte::assertNone($hash1, $hash2); + LeadingDash::assertNone($hash1, $hash2); $oneOid = $this->hexToOid($hash1); $twoOid = $this->hexToOid($hash2); @@ -224,6 +246,7 @@ public function getMergeBases(string $hash1, string $hash2): array public function getShortHash(string $hash): string { NulByte::assertNone($hash); + LeadingDash::assertNone($hash); $object = $this->lookupObject($hash); @@ -326,8 +349,8 @@ public function getReferences(): array } $fullname = $this->ffi->git_reference_name($ref); - // Only a tag may point to an annotated tag. - $commitHash = str_starts_with($fullname, 'refs/tags/') ? $this->peeledHex($ref) : $hash; + // Like git, through an annotated tag, whatever the name of the reference: a fetch can put one under refs/remotes/. + $commitHash = $this->peeledHex($ref); $result[$fullname] = new ReferenceData($fullname, $commitHash, $commitHash === $hash ? null : $hash); } @@ -340,6 +363,8 @@ public function getReferences(): array public function createReference(string $fullname, string $hash): void { NulByte::assertNone($fullname, $hash); + LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); $oid = $this->hexToOid($hash); $ref = $this->ffi->new('git_reference*'); @@ -350,6 +375,8 @@ public function createReference(string $fullname, string $hash): void public function updateReference(string $fullname, string $hash): void { NulByte::assertNone($fullname, $hash); + LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); $oid = $this->hexToOid($hash); $ref = $this->ffi->new('git_reference*'); @@ -360,6 +387,8 @@ public function updateReference(string $fullname, string $hash): void public function deleteReference(string $fullname): void { NulByte::assertNone($fullname); + LeadingDash::assertNone($fullname); + ReferenceName::assertUnderRefs($fullname); $ref = $this->ffi->new('git_reference*'); $rc = $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname); @@ -379,10 +408,15 @@ public function deleteReference(string $fullname): void public function getIncludingBranches(string $hash): array { NulByte::assertNone($hash); + LeadingDash::assertNone($hash); $tips = []; foreach ($this->iterateBranches() as [$fullname, $ref]) { - $tips[$fullname] = $this->oidToHex($this->ffi->git_reference_target($ref)); + // Like git, through an annotated tag, and without the branches pointing to no commit. + $commit = $this->peeledCommitHex($ref); + if (null !== $commit) { + $tips[$fullname] = $commit; + } } // Like git: the local branches, then the remote ones, sorted by name. ksort($tips, \SORT_STRING); @@ -408,6 +442,7 @@ public function getIncludingBranches(string $hash): array public function getDiff(?string $from, string $to): DiffData { NulByte::assertNone($from ?? '', $to); + LeadingDash::assertNone($from ?? '', $to); $oldTree = null === $from ? null : $this->treeForRevision($from); @@ -429,6 +464,7 @@ public function getDiff(?string $from, string $to): DiffData public function getWorkingTreeDiff(string $hash): DiffData { NulByte::assertNone($hash); + LeadingDash::assertNone($hash); $tree = $this->treeForRevision($hash); @@ -437,7 +473,8 @@ public function getWorkingTreeDiff(string $hash): DiffData $opts = $this->diffOptions(); LibGit2::check($this->ffi, $this->ffi->git_diff_tree_to_workdir_with_index(\FFI::addr($diff), $this->repo, $tree, \FFI::addr($opts)), 'Unable to compute working copy diff'); $unmerged = $this->unmergedPaths($diff); - $files = $this->buildDiff($diff)->files; + // libgit2 keeps a file the working tree changes back to the commit, after the index changed it, or `git add -N` added it. + $files = array_values(array_filter($this->buildDiff($diff)->files, static fn (DiffFileData $file): bool => [] !== $file->changes || $file->oldName !== $file->newName || $file->oldMode !== $file->newMode || $file->oldIndex !== $file->newIndex)); if ([] !== $unmerged) { // Like git diff , which compares the unmerged files, conflict markers included, to the commit. @@ -465,7 +502,30 @@ public function getDiffPending(): DiffData $opts = $this->diffOptions(); LibGit2::check($this->ffi, $this->ffi->git_diff_index_to_workdir(\FFI::addr($diff), $this->repo, null, \FFI::addr($opts)), 'Unable to compute working copy diff'); - return $this->buildDiff($diff); + // Like git since 2.19, which diffs the files `git add -N` added as + // absent from the index: created. Like any git, one deleted since is + // deleted, from the empty file the index has for it. + $intentToAdd = $this->intentToAddFiles(); + $files = []; + foreach ($this->buildDiff($diff)->files as $file) { + if (isset($intentToAdd[$file->name]) && null !== $file->newName) { + $files[] = new DiffFileData(null, $file->newName, null, $file->newMode, null, $file->newIndex, $file->isBinary, $file->isCopy, $file->changes); + } else { + $files[] = $file; + } + unset($intentToAdd[$file->name]); + } + + if ([] !== $intentToAdd) { + // Still empty, as `git add -N` staged them: libgit2 sees no change. + foreach ($intentToAdd as $path => $mode) { + $files[] = new DiffFileData(null, $path, null, $mode, null, self::EMPTY_BLOB, false, false, []); + } + // Sorted by name, like git. + usort($files, static fn (DiffFileData $a, DiffFileData $b): int => strcmp($a->name, $b->name)); + } + + return new DiffData($files, ''); } public function getDiffStaged(): DiffData @@ -482,12 +542,24 @@ public function getDiffStaged(): DiffData } } - return $this->buildDiff($diff); + // Like git since 2.19, which diffs the files `git add -N` added as absent from the index: not staged, or deleted when HEAD has them. + $intentToAdd = $this->intentToAddFiles(); + $files = []; + foreach ($this->buildDiff($diff)->files as $file) { + if (!isset($intentToAdd[$file->name])) { + $files[] = $file; + } elseif (null !== $file->oldName) { + $files[] = new DiffFileData($file->oldName, null, $file->oldMode, null, $file->oldIndex, null, $file->isBinary, $file->isCopy, $file->changes); + } + } + + return new DiffData($files, ''); } public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array { NulByte::assertNone($hash, $path); + LeadingDash::assertNone($hash); $range = null !== $firstLine || null !== $lastLine; $firstLine ??= 1; @@ -514,19 +586,10 @@ public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int for ($i = $firstLine; $i <= min($lastLine ?? $count, $count); ++$i) { $hunk = $this->ffi->git_blame_hunk_byline($blame, $i); $line = $this->ffi->git_blame_line_byindex($blame, $i); - - if (null === $hunk || null === $line) { - // git_blame_linecount() can overcount by one for files not - // ending in a newline; stop rather than dereference NULL. - // (A NULL `const git_blame_line *` return is auto-converted - // to PHP null by FFI, unlike a NULL struct-field pointer.) - break; - } - $content = rtrim(\FFI::string($line->ptr, $line->len), "\n"); - $originalLine = $hunk->orig_start_line_number + ($i - $hunk->final_start_line_number); + $originalNumber = $hunk->orig_start_line_number + ($i - $hunk->final_start_line_number); - $result[] = new BlameLineData($this->oidToHex(\FFI::addr($hunk->final_commit_id)), $originalLine, $i, $content); + $result[] = new BlameLineData($this->oidToHex(\FFI::addr($hunk->final_commit_id)), $i, $originalNumber, $content); } return $result; @@ -563,11 +626,15 @@ public function getUntrackedFiles(): array public function checkout(string $revision): void { NulByte::assertNone($revision); + LeadingDash::assertNone($revision); // `git checkout ` attaches HEAD when names a - // local branch, before a tag of the same name, and detaches it for - // anything else (a tag, a hash, a remote branch, a full reference name...). - $branch = $this->referenceExists('refs/heads/'.$revision) ? 'refs/heads/'.$revision : null; + // local branch, before a tag of the same name, or is @{-}, the nth + // branch checked out before. It leaves HEAD as it is for HEAD, and + // detaches it for anything else (a tag, a hash, a remote branch, a + // full reference name...). + $name = $this->expandPriorCheckout($revision); + $branch = $this->referenceExists('refs/heads/'.$name) ? 'refs/heads/'.$name : null; // Resolved before changing anything. $hash = $this->resolveCommit($branch ?? $revision); @@ -575,15 +642,16 @@ public function checkout(string $revision): void if (null !== $branch) { LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, $branch), 'Unable to update HEAD'); - } else { + } elseif ('HEAD' !== $revision) { $oid = $this->hexToOid($hash); LibGit2::check($this->ffi, $this->ffi->git_repository_set_head_detached($this->repo, \FFI::addr($oid)), 'Unable to detach HEAD'); } } - public function checkoutNewBranch(string $branch, string $startHash): void + public function checkoutNewBranch(string $branch, string $startPoint): void { - NulByte::assertNone($branch, $startHash); + NulByte::assertNone($branch, $startPoint); + LeadingDash::assertNone($branch, $startPoint); if ($this->referenceExists('refs/heads/'.$branch)) { throw new DriverException(\sprintf('A branch named "%s" already exists.', $branch)); @@ -595,28 +663,57 @@ public function checkoutNewBranch(string $branch, string $startHash): void if (0 === $valid->cdata) { throw new DriverException(\sprintf('"%s" is not a valid branch name.', $branch)); } - $commit = $this->lookupCommit($this->resolveCommit($startHash)); + $startHash = $this->resolveCommit($startPoint); + $upstream = $this->upstreamOf($startPoint); + $commit = $this->lookupCommit($startHash); - try { - $this->checkoutRevision($startHash); + $reference = $this->ffi->new('git_reference*'); - $reference = $this->ffi->new('git_reference*'); + try { LibGit2::check($this->ffi, $this->ffi->git_branch_create(\FFI::addr($reference), $this->repo, $branch, $commit, 0), \sprintf('Unable to create branch "%s"', $branch)); - $this->ffi->git_reference_free($reference); } finally { $this->ffi->git_commit_free($commit); } + // The branch and its upstream before the files, which may conflict + // with local changes: then, like git, nothing is left behind. + try { + if (null !== $upstream) { + $this->setUpstream($branch, ...$upstream); + } + $this->checkoutRevision($startHash); + } catch (DriverException $e) { + // Its configuration with it. + LibGit2::check($this->ffi, $this->ffi->git_branch_delete($reference), \sprintf('Unable to delete the branch "%s" after a failed checkout', $branch)); + + throw $e; + } finally { + $this->ffi->git_reference_free($reference); + } + LibGit2::check($this->ffi, $this->ffi->git_repository_set_head($this->repo, 'refs/heads/'.$branch), 'Unable to update HEAD'); } public function merge(string $revision, FastForward $fastForward): void { NulByte::assertNone($revision); + LeadingDash::assertNone($revision); $branch = $this->getHeadReference(); - $headHash = $this->resolveCommit('HEAD'); $theirHash = $this->resolveCommit($revision); + + // Like git, which fast-forwards a branch yet to be born. + if (null !== $branch && !$this->referenceExists($branch)) { + if (FastForward::Never === $fastForward) { + throw new DriverException(\sprintf('Unable to merge "%s" without a fast-forward: the branch "%s" has no commit yet.', $revision, $branch)); + } + + $this->fastForward($branch, $theirHash); + + return; + } + + $headHash = $this->resolveCommit('HEAD'); $head = $this->hexToOid($headHash); $their = $this->hexToOid($theirHash); @@ -631,14 +728,7 @@ public function merge(string $revision, FastForward $fastForward): void } if (FastForward::Never !== $fastForward && $canFastForward) { - $target = $this->revparse($theirHash); - - try { - $this->checkoutObject($target, 'Unable to fast-forward'); - } finally { - $this->ffi->git_object_free($target); - } - $this->moveHead($branch, $theirHash); + $this->fastForward($branch, $theirHash); return; } @@ -743,17 +833,9 @@ private function referencedCommits(): array { $hashes = []; foreach ($this->iterateReferences() as $ref) { - $commit = $this->ffi->new('git_object*'); - $rc = $this->ffi->git_reference_peel(\FFI::addr($commit), $ref, $this->ffi->GIT_OBJECT_COMMIT); - if (\in_array($rc, [LibGit2::GIT_ENOTFOUND, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { - continue; - } - LibGit2::check($this->ffi, $rc, \sprintf('Unable to peel "%s"', $this->ffi->git_reference_name($ref))); - - try { - $hashes[] = $this->oidToHex($this->ffi->git_object_id($commit)); - } finally { - $this->ffi->git_object_free($commit); + $commit = $this->peeledCommitHex($ref); + if (null !== $commit) { + $hashes[] = $commit; } } @@ -762,6 +844,8 @@ private function referencedCommits(): array /** * @param string[] $paths + * + * @throws UnsupportedOperationException A path uses the magic of git's pathspecs */ private function pathFilter(array $paths): ?PathFilter { @@ -769,10 +853,14 @@ private function pathFilter(array $paths): ?PathFilter return null; } - // Like git, "." and "./x" are relative to the root, which "" names. - $paths = array_map(fn (string $path): string => trim((string) preg_replace('#^\.(/|$)#', '', trim($this->toRepoRelativePath($path), '/')), '/'), $paths); + foreach ($paths as $path) { + // git reads magic in a path starting with ":", like ":(exclude)src" or ":/src": libgit2 would match it as a name. + if (str_starts_with($path, ':')) { + throw new UnsupportedOperationException(\sprintf('The path "%s" uses the magic of git\'s pathspecs, which LibGit2Driver doesn\'t support; use CliDriver.', $path)); + } + } - return new PathFilter($this->ffi, $this->repo, array_values($paths), $this->diffOptions()); + return new PathFilter($this->ffi, $this->repo, array_values(array_map($this->toRepoRelativePath(...), $paths)), $this->diffOptions()); } /** @@ -872,23 +960,53 @@ private function simplifyCommit(string $hash, PathFilter $pathFilter, ?array $re } /** - * git also accepts the absolute paths of files in the working tree, where - * libgit2 only deals with paths relative to the repository. + * git normalizes paths, and also accepts the absolute paths of files in + * the working tree, where libgit2 only deals with normalized paths + * relative to the repository. * - * @throws DriverException $path is an absolute path outside the working tree + * @return string $path relative to the root of the repository, which "" names, without "." nor ".." components, nor extra slashes + * + * @throws DriverException $path is outside the repository */ private function toRepoRelativePath(string $path): string { - if (!str_starts_with($path, '/')) { - return $path; + $components = self::pathComponents($path); + + if (str_starts_with($path, '/')) { + $root = null === $this->workingDir ? null : self::pathComponents($this->workingDir); + if (null === $root || \array_slice($components, 0, \count($root)) !== $root) { + // Like git. + throw new DriverException(\sprintf('The path "%s" is outside the repository.', $path)); + } + + $components = \array_slice($components, \count($root)); } - if (null !== $this->workingDir && ($path === $this->workingDir || str_starts_with($path, $this->workingDir.'/'))) { - return substr($path, \strlen($this->workingDir) + 1); + return implode('/', $components); + } + + /** + * @return list The components of $path, once "." and ".." are resolved, like git does + * + * @throws DriverException ".." goes above the first component of $path + */ + private static function pathComponents(string $path): array + { + $components = []; + foreach (explode('/', $path) as $component) { + if ('..' === $component && [] === $components) { + // Like git. + throw new DriverException(\sprintf('The path "%s" is outside the repository.', $path)); + } + + if ('..' === $component) { + array_pop($components); + } elseif ('' !== $component && '.' !== $component) { + $components[] = $component; + } } - // Like git. - throw new DriverException(\sprintf('The path "%s" is outside the repository.', $path)); + return $components; } // -- diff ---------------------------------------------------------------- @@ -928,6 +1046,35 @@ private function unmergedPaths(CData $diff): array return $paths; } + /** + * @return array The mode of the files `git add -N` added to the index, by path + */ + private function intentToAddFiles(): array + { + $index = $this->ffi->new('git_index*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_index(\FFI::addr($index), $this->repo), 'Unable to open the index'); + + try { + LibGit2::check($this->ffi, $this->ffi->git_index_read($index, 0), 'Unable to read the index'); + // git writes an index of version 2, without the extended flags, unless an entry has one. + if ($this->ffi->git_index_version($index) < 3) { + return []; + } + + $files = []; + for ($i = 0, $count = $this->ffi->git_index_entrycount($index); $i < $count; ++$i) { + $entry = $this->ffi->git_index_get_byindex($index, $i); + if (0 !== ($entry->flags_extended & self::GIT_INDEX_ENTRY_INTENT_TO_ADD)) { + $files[$entry->path] = \sprintf('%06o', $entry->mode); + } + } + + return $files; + } finally { + $this->ffi->git_index_free($index); + } + } + /** * Restricts a diff to $paths, matched as they are. * @@ -997,12 +1144,6 @@ private function patchToFile(CData $diff, int $index): DiffFileData $patch = $this->ffi->new('git_patch*'); LibGit2::check($this->ffi, $this->ffi->git_patch_from_diff(\FFI::addr($patch), $diff, $index), 'Unable to compute patch'); - // libgit2 documents no patch for a binary or unchanged file, though - // 1.9 creates one: computing it flagged the delta accordingly. - if (\FFI::isNull($patch)) { - return $this->deltaToFile($this->ffi->git_diff_get_delta($diff, $index), []); - } - try { $changes = []; $hunkCount = $this->ffi->git_patch_num_hunks($patch); @@ -1087,6 +1228,21 @@ private function checkoutObject(CData $treeish, string $context): void LibGit2::check($this->ffi, $this->ffi->git_checkout_tree($this->repo, $treeish, \FFI::addr($opts)), $context); } + /** + * Checks $hash out, and points the checked out branch, or a detached HEAD, to it. + */ + private function fastForward(?string $branch, string $hash): void + { + $target = $this->revparse($hash); + + try { + $this->checkoutObject($target, 'Unable to fast-forward'); + } finally { + $this->ffi->git_object_free($target); + } + $this->moveHead($branch, $hash); + } + /** * Points the checked out branch, or a detached HEAD, to $hash. */ @@ -1174,25 +1330,19 @@ private function createMergeCommit(CData $tree, string $oursHash, string $theirs } /** - * The message `git merge` writes (see git's fmt-merge-msg). + * The message `git merge` writes (see git's merge_name() and fmt-merge-msg). */ private function mergeMessage(string $revision, ?string $branch): string { - $message = null; - // Same lookup order as git's revision parsing. - foreach ([ - 'refs/tags/' => "Merge tag '%s'", - 'refs/heads/' => "Merge branch '%s'", - 'refs/remotes/' => "Merge remote-tracking branch '%s'", - ] as $prefix => $format) { - $name = str_starts_with($revision, $prefix) ? substr($revision, \strlen($prefix)) : $revision; - if ($this->referenceExists($prefix.$name)) { - $message = \sprintf($format, $name); + $object = $this->revparse($revision); - break; - } + try { + $tag = $this->ffi->git_object_type($object) === $this->ffi->GIT_OBJECT_TAG ? $this->oidToHex($this->ffi->git_object_id($object)) : null; + } finally { + $this->ffi->git_object_free($object); } - $message ??= \sprintf("Merge commit '%s'", $revision); + + $message = 'Merge '.$this->mergedName($this->expandPriorCheckout($revision), $tag); // git leaves out the destination when merging into main or master. if (null === $branch) { @@ -1201,11 +1351,249 @@ private function mergeMessage(string $revision, ?string $branch): string $message .= ' into '.substr($branch, \strlen('refs/heads/')); } - return $message."\n"; + // Then the message of the annotated tag, without its signature. + if (null !== $tag) { + $message .= "\n\n".$this->getTagData($tag)->message; + } + + return $this->prettifyMessage($message); + } + + /** + * @param string $name The revision merged, @{-} expanded + * @param string|null $tag The annotated tag $name names, if any + * + * @return string How git's merge_name() names $name: as given, after what it names + */ + private function mergedName(string $name, ?string $tag): string + { + $reference = $this->dwimReference($name) ?? ''; + foreach (['refs/heads/' => 'branch', 'refs/tags/' => 'tag', 'refs/remotes/' => 'remote-tracking branch'] as $prefix => $kind) { + if (str_starts_with($reference, $prefix)) { + return \sprintf("%s '%s'", $kind, $name); + } + } + + // ~ or ^: an early part of the branch, unless is 0. + if (1 === preg_match('/^(.+?)(\^+|~\d*)$/', $name, $match) && $this->referenceExists('refs/heads/'.$match[1])) { + return \sprintf("branch '%s'%s", $match[1], 1 === preg_match('/^~0+$/', $match[2]) ? '' : ' (early part)'); + } + + return null === $tag ? \sprintf("commit '%s'", $name) : \sprintf("tag '%s'", $name); + } + + /** + * Cleans a message up like git does without an editor: without + * whitespace at the end of its lines, nor blank lines at its start, at + * its end or in a row. + */ + private function prettifyMessage(string $message): string + { + $buffer = $this->ffi->new('git_buf'); + LibGit2::check($this->ffi, $this->ffi->git_message_prettify(\FFI::addr($buffer), $message, 0, '#'), 'Unable to clean the message up'); + + try { + return \FFI::string($buffer->ptr, $buffer->size); + } finally { + $this->ffi->git_buf_dispose(\FFI::addr($buffer)); + } } // -- references ---------------------------------------------------------- + /** + * @return string|null The full name of the reference $name names, like git's dwim_ref(), null when it names none + */ + private function dwimReference(string $name): ?string + { + $ref = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_dwim(\FFI::addr($ref), $this->repo, $name); + if (LibGit2::GIT_ENOTFOUND === $rc || self::GIT_EINVALIDSPEC === $rc) { + return null; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up the reference "%s"', $name)); + + try { + return $this->ffi->git_reference_name($ref); + } finally { + $this->ffi->git_reference_free($ref); + } + } + + /** + * Like git's dwim_ref(), which counts the references a name may be the + * short name of, unlike git_reference_dwim(). + * + * @return list The full names of the references $name names, after the symbolic ones: several when $name is ambiguous + */ + private function dwimReferences(string $name): array + { + $references = []; + // The rules of git's ref_rev_parse_rules. + foreach (['%s', 'refs/%s', 'refs/tags/%s', 'refs/heads/%s', 'refs/remotes/%s', 'refs/remotes/%s/HEAD'] as $rule) { + $fullname = \sprintf($rule, $name); + $ref = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname); + if (LibGit2::GIT_ENOTFOUND === $rc || self::GIT_EINVALIDSPEC === $rc) { + continue; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to look up the reference "%s"', $fullname)); + + try { + $resolved = $this->ffi->new('git_reference*'); + $rc = $this->ffi->git_reference_resolve(\FFI::addr($resolved), $ref); + // Like git, which ignores a symbolic reference to nothing. + if (LibGit2::GIT_ENOTFOUND === $rc) { + continue; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to resolve the reference "%s"', $fullname)); + + try { + $references[] = $this->ffi->git_reference_name($resolved); + } finally { + $this->ffi->git_reference_free($resolved); + } + } finally { + $this->ffi->git_reference_free($ref); + } + } + + return $references; + } + + /** + * Like git, replaces @{-} at the start of $revision with the nth + * branch checked out before, or commit when HEAD was detached. + */ + private function expandPriorCheckout(string $revision): string + { + if (1 !== preg_match('/^@\{-([1-9]\d*)\}/', $revision, $match)) { + return $revision; + } + + $prior = $this->priorCheckout((int) $match[1]); + + return null === $prior ? $revision : $prior.substr($revision, \strlen($match[0])); + } + + /** + * @return string|null The nth branch checked out before, or commit when HEAD was detached, read from the reflog of HEAD like git does; null after fewer checkouts + */ + private function priorCheckout(int $n): ?string + { + $reflog = $this->ffi->new('git_reflog*'); + LibGit2::check($this->ffi, $this->ffi->git_reflog_read(\FFI::addr($reflog), $this->repo, 'HEAD'), 'Unable to read the reflog of HEAD'); + + try { + // The newest entry first. + for ($i = 0, $count = $this->ffi->git_reflog_entrycount($reflog); $i < $count; ++$i) { + $message = $this->ffi->git_reflog_entry_message($this->ffi->git_reflog_entry_byindex($reflog, $i)); + if (null !== $message && 1 === preg_match('/^checkout: moving from (.+?) to /', $message, $match) && 0 === --$n) { + return $match[1]; + } + } + + return null; + } finally { + $this->ffi->git_reflog_free($reflog); + } + } + + /** + * Like git with branch.autoSetupMerge=true, its default, a branch created + * from a remote branch tracks it. + * + * @return array{string, string}|null The remote, and its branch, that $startPoint names, like ["origin", "refs/heads/feature"] for "origin/feature"; null when $startPoint names another revision, or a branch no remote fetches + * + * @throws DriverException $startPoint names several references, or a branch several remotes fetch + */ + private function upstreamOf(string $startPoint): ?array + { + $references = $this->dwimReferences($startPoint); + // Like git, which refuses to create a branch from an ambiguous name. + if (\count($references) > 1) { + throw new DriverException(\sprintf('The name "%s" is ambiguous: "%s".', $startPoint, implode('", "', $references))); + } + $reference = $references[0] ?? null; + if (null === $reference || !str_starts_with($reference, 'refs/remotes/')) { + return null; + } + + // Like git, which only tracks the branch of a remote it knows. + $remotes = $this->ffi->new('git_strarray'); + LibGit2::check($this->ffi, $this->ffi->git_remote_list(\FFI::addr($remotes), $this->repo), 'Unable to list the remotes'); + + try { + $upstream = null; + for ($i = 0; $i < $remotes->count; ++$i) { + $remote = \FFI::string($remotes->strings[$i]); + $branch = $this->fetchedBranch($remote, $reference); + if (null === $branch) { + continue; + } + if (null !== $upstream) { + throw new DriverException(\sprintf('Unable to tell the upstream of "%s": the remotes "%s" and "%s" fetch it.', $startPoint, $upstream[0], $remote)); + } + $upstream = [$remote, $branch]; + } + + return $upstream; + } finally { + $this->ffi->git_strarray_dispose(\FFI::addr($remotes)); + } + } + + /** + * @return string|null The branch of $remote a fetch writes to $reference, like "refs/heads/feature" for "refs/remotes/origin/feature", null when no fetch of $remote writes it + */ + private function fetchedBranch(string $remote, string $reference): ?string + { + $handle = $this->ffi->new('git_remote*'); + LibGit2::check($this->ffi, $this->ffi->git_remote_lookup(\FFI::addr($handle), $this->repo, $remote), \sprintf('Unable to look up the remote "%s"', $remote)); + + try { + for ($i = 0, $count = $this->ffi->git_remote_refspec_count($handle); $i < $count; ++$i) { + $refspec = $this->ffi->git_remote_get_refspec($handle, $i); + if (self::GIT_DIRECTION_FETCH !== $this->ffi->git_refspec_direction($refspec) || 0 === $this->ffi->git_refspec_dst_matches($refspec, $reference)) { + continue; + } + + $branch = $this->ffi->new('git_buf'); + LibGit2::check($this->ffi, $this->ffi->git_refspec_rtransform(\FFI::addr($branch), $refspec, $reference), \sprintf('Unable to find the branch of the remote "%s" fetched to "%s"', $remote, $reference)); + + try { + return \FFI::string($branch->ptr, $branch->size); + } finally { + $this->ffi->git_buf_dispose(\FFI::addr($branch)); + } + } + + return null; + } finally { + $this->ffi->git_remote_free($handle); + } + } + + /** + * Writes the configuration git_branch_set_upstream() would, which looks + * for a local branch of the name of the remote one first. + * + * @param string $merge The branch on the remote, like "refs/heads/feature" + */ + private function setUpstream(string $branch, string $remote, string $merge): void + { + $config = $this->ffi->new('git_config*'); + LibGit2::check($this->ffi, $this->ffi->git_repository_config(\FFI::addr($config), $this->repo), 'Unable to open the repository configuration'); + + try { + foreach (['remote' => $remote, 'merge' => $merge] as $key => $value) { + LibGit2::check($this->ffi, $this->ffi->git_config_set_string($config, 'branch.'.$branch.'.'.$key, $value), \sprintf('Unable to set the upstream of "%s"', $branch)); + } + } finally { + $this->ffi->git_config_free($config); + } + } + private function referenceExists(string $fullname): bool { $ref = $this->ffi->new('git_reference*'); @@ -1323,6 +1711,25 @@ private function peeledHex(CData $ref): string } } + /** + * @return string|null The commit $ref points to, through annotated tags, or null when it points to another object or to nothing + */ + private function peeledCommitHex(CData $ref): ?string + { + $commit = $this->ffi->new('git_object*'); + $rc = $this->ffi->git_reference_peel(\FFI::addr($commit), $ref, $this->ffi->GIT_OBJECT_COMMIT); + if (\in_array($rc, [LibGit2::GIT_ENOTFOUND, self::GIT_EINVALIDSPEC, self::GIT_EPEEL], true)) { + return null; + } + LibGit2::check($this->ffi, $rc, \sprintf('Unable to peel "%s"', $this->ffi->git_reference_name($ref))); + + try { + return $this->oidToHex($this->ffi->git_object_id($commit)); + } finally { + $this->ffi->git_object_free($commit); + } + } + private function isDescendantOf(CData $commit, CData $ancestor): bool { $rc = $this->ffi->git_graph_descendant_of($this->repo, \FFI::addr($commit), \FFI::addr($ancestor)); @@ -1342,17 +1749,25 @@ private function revparse(string $spec): CData } /** - * Resolves a revision to a commit hash, peeling annotated tags. + * Resolves a revision to a commit hash, peeling annotated tags: the + * object it names, as "^{commit}" would be part of the text of ":/text". */ private function resolveCommit(string $revision): string { - $object = $this->revparse($revision.'^{commit}'); + $object = $this->revparse($revision); try { - return $this->oidToHex($this->ffi->git_object_id($object)); + $commit = $this->ffi->new('git_object*'); + LibGit2::check($this->ffi, $this->ffi->git_object_peel(\FFI::addr($commit), $object, $this->ffi->GIT_OBJECT_COMMIT), \sprintf('Unable to resolve "%s" to a commit', $revision)); } finally { $this->ffi->git_object_free($object); } + + try { + return $this->oidToHex($this->ffi->git_object_id($commit)); + } finally { + $this->ffi->git_object_free($commit); + } } private function lookupCommit(string $hash): CData @@ -1524,10 +1939,18 @@ private function toUtf8(string $text, ?string $encoding): string } } + /** + * @return \DateTimeImmutable The date at the UTC offset of the signature, like CliDriver's parsers + */ private function signatureDate(CData $signature): \DateTimeImmutable { - // Same timezone as CliDriver's parsers. - return new \DateTimeImmutable('@'.$signature->when->time)->setTimezone(new \DateTimeZone('UTC')); + // In minutes. libgit2 gives 0 for an offset of more than 14 hours or + // 59 minutes, which no timezone has, in a corrupt object. + $offset = $signature->when->offset; + // @phpstan-ignore argument.type (libgit2.h declares the offset an int, which PHPStan can't see.) + $timezone = new \DateTimeZone(\sprintf('%s%02d%02d', $offset < 0 ? '-' : '+', intdiv(abs($offset), 60), abs($offset) % 60)); + + return new \DateTimeImmutable('@'.$signature->when->time)->setTimezone($timezone); } private function objectType(int $type): ObjectType diff --git a/src/Gitonomy/Git/Driver/Ffi/libgit2.h b/src/Gitonomy/Git/Driver/Ffi/libgit2.h index 309cc566..029005f9 100644 --- a/src/Gitonomy/Git/Driver/Ffi/libgit2.h +++ b/src/Gitonomy/Git/Driver/Ffi/libgit2.h @@ -66,6 +66,8 @@ typedef struct git_blob git_blob; typedef struct git_tag git_tag; typedef struct git_reference git_reference; typedef struct git_reference_iterator git_reference_iterator; +typedef struct git_reflog git_reflog; +typedef struct git_reflog_entry git_reflog_entry; typedef struct git_branch_iterator git_branch_iterator; typedef struct git_revwalk git_revwalk; typedef struct git_index git_index; @@ -74,6 +76,7 @@ typedef struct git_blame git_blame; typedef struct git_status_list git_status_list; typedef struct git_patch git_patch; typedef struct git_remote git_remote; +typedef struct git_refspec git_refspec; typedef struct git_config git_config; /* -- global -- */ @@ -81,6 +84,7 @@ typedef struct git_config git_config; int git_libgit2_init(void); int git_libgit2_version(int *major, int *minor, int *rev); int git_libgit2_features(void); +int git_libgit2_opts(int option, ...); /* -- buffers / signatures / arrays -- */ @@ -88,6 +92,8 @@ void git_buf_dispose(git_buf *buffer); int git_signature_default_from_env(git_signature **author_out, git_signature **committer_out, git_repository *repo); void git_signature_free(git_signature *sig); void git_oidarray_dispose(git_oidarray *array); +void git_strarray_dispose(git_strarray *array); +int git_message_prettify(git_buf *out, const char *message, int strip_comments, char comment_char); /* -- errors -- */ @@ -109,6 +115,7 @@ int git_repository_head(git_reference **out, git_repository *repo); int git_repository_set_head(git_repository *repo, const char *refname); int git_repository_set_head_detached(git_repository *repo, const git_oid *committish); int git_repository_config(git_config **out, git_repository *repo); +int git_repository_index(git_index **out, git_repository *repo); /* -- config -- */ @@ -181,6 +188,7 @@ void git_revwalk_free(git_revwalk *walk); /* -- refs / branch -- */ int git_reference_lookup(git_reference **out, git_repository *repo, const char *name); +int git_reference_dwim(git_reference **out, git_repository *repo, const char *shorthand); int git_reference_resolve(git_reference **out, const git_reference *ref); const git_oid *git_reference_target(const git_reference *ref); const char *git_reference_symbolic_target(const git_reference *ref); @@ -194,6 +202,12 @@ int git_reference_next(git_reference **out, git_reference_iterator *iter); void git_reference_iterator_free(git_reference_iterator *iter); int git_reference_peel(git_object **out, const git_reference *ref, git_object_t type); +int git_reflog_read(git_reflog **out, git_repository *repo, const char *name); +size_t git_reflog_entrycount(git_reflog *reflog); +const git_reflog_entry *git_reflog_entry_byindex(const git_reflog *reflog, size_t idx); +const char *git_reflog_entry_message(const git_reflog_entry *entry); +void git_reflog_free(git_reflog *reflog); + int git_branch_create(git_reference **out, git_repository *repo, const char *branch_name, const git_commit *target, int force); int git_branch_delete(git_reference *branch); int git_branch_name_is_valid(int *valid, const char *name); @@ -364,6 +378,30 @@ void git_status_list_free(git_status_list *statuslist); /* -- index -- */ +typedef struct { + int32_t seconds; + uint32_t nanoseconds; +} git_index_time; + +typedef struct git_index_entry { + git_index_time ctime; + git_index_time mtime; + uint32_t dev; + uint32_t ino; + uint32_t mode; + uint32_t uid; + uint32_t gid; + uint32_t file_size; + git_oid id; + uint16_t flags; + uint16_t flags_extended; + const char *path; +} git_index_entry; + +int git_index_read(git_index *index, int force); +unsigned int git_index_version(git_index *index); +size_t git_index_entrycount(const git_index *index); +const git_index_entry *git_index_get_byindex(git_index *index, size_t n); void git_index_free(git_index *index); int git_index_write_tree_to(git_oid *out, git_index *index, git_repository *repo); int git_index_has_conflicts(const git_index *index); @@ -489,3 +527,13 @@ int git_remote_connect(git_remote *remote, int direction, const git_remote_callb int git_remote_ls(const git_remote_head ***out, size_t *size, git_remote *remote); int git_remote_disconnect(git_remote *remote); void git_remote_free(git_remote *remote); +int git_remote_list(git_strarray *out, git_repository *repo); +int git_remote_lookup(git_remote **out, git_repository *repo, const char *name); +size_t git_remote_refspec_count(const git_remote *remote); +const git_refspec *git_remote_get_refspec(const git_remote *remote, size_t n); + +/* -- refspec -- */ + +int git_refspec_direction(const git_refspec *spec); +int git_refspec_dst_matches(const git_refspec *refspec, const char *refname); +int git_refspec_rtransform(git_buf *out, const git_refspec *spec, const char *name); diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index 2d60586f..1cb15960 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -30,11 +30,18 @@ * Implementations know nothing of Repository: they read and write git data, * and the domain objects (Commit, Tree, Reference...) build on it. * - * Every method throws a {@see DriverException} when git reports a failure (an - * unknown revision, a conflict...), an {@see UnsupportedOperationException} - * when the driver can't perform the operation at all, and an - * {@see InvalidArgumentException} when an argument contains a NUL byte, which - * git doesn't accept. + * resolveRevision(), and the methods reading an object by its hash, like + * getCommitData(), throw a {@see ReferenceNotFoundException} when it doesn't + * exist. Every method throws a {@see DriverException} when git reports + * another failure (a conflict, a lock...), an + * {@see UnsupportedOperationException} when the driver can't perform the + * operation at all, and an {@see InvalidArgumentException} when an argument + * contains a NUL byte, which git doesn't accept. + * + * git reads an argument starting with a dash as an option: the revision + * "--output=/path" of a log writes a file. Every method refuses, with an + * {@see InvalidArgumentException}, a revision, a reference or a hash starting + * with a dash, before running git. A path may start with one. * * @author Grégoire Pineau */ @@ -120,14 +127,29 @@ public function getTagData(string $hash): TagData; public function getReferences(): array; /** - * @throws DriverException $fullname exists already + * @param string $fullname The full name of a reference under ``refs/``, like those getReferences() lists + * + * @throws DriverException $fullname exists already + * @throws InvalidArgumentException $fullname isn't under ``refs/``, like "HEAD" or "master" */ public function createReference(string $fullname, string $hash): void; + /** + * A symbolic reference is written itself, never the reference it points to. + * + * @param string $fullname The full name of a reference under ``refs/`` + * + * @throws InvalidArgumentException $fullname isn't under ``refs/``, like "HEAD" or "master" + */ public function updateReference(string $fullname, string $hash): void; /** * Like ``git update-ref -d``, deleting a missing reference does nothing. + * A symbolic reference is deleted itself, never the reference it points to. + * + * @param string $fullname The full name of a reference under ``refs/`` + * + * @throws InvalidArgumentException $fullname isn't under ``refs/``, like "HEAD" or "master" */ public function deleteReference(string $fullname): void; @@ -176,15 +198,37 @@ public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int public function getUntrackedFiles(): array; /** - * Checks a revision out: HEAD points to the branch when $revision is the - * name of a local branch, and to the commit otherwise. + * Checks a revision out, like ``git checkout``: HEAD points to the branch + * when $revision is the name of a local branch, or @{-}, the nth + * branch checked out before, stays as it is for "HEAD", and points to + * the commit otherwise (a tag, a hash, a remote branch...). + * + * $revision names a commit, which WorkingCopy::checkout() checks before: + * it throws a {@see ReferenceNotFoundException} for a name that no + * revision has, where ``git checkout`` would create the local branch of a + * remote one, like "feature" for "origin/feature". $revision is never a + * path either, whose changes ``git checkout`` would discard. */ public function checkout(string $revision): void; - public function checkoutNewBranch(string $branch, string $startHash): void; + /** + * Creates the branch $branch at $startPoint and checks it out, like + * ``git checkout -b``: the branch tracks $startPoint when it is a remote + * branch, like "origin/feature", as with branch.autoSetupMerge=true, + * git's default, whatever the configuration. A failure leaves the + * branches and the files as they were: like ``git checkout -b``, no + * branch is created when the checkout fails. + * + * @param string $startPoint A revision naming a commit, which WorkingCopy::checkoutNewBranch() checks before + * + * @throws DriverException $branch exists already or isn't a valid name, $startPoint is the short name of several references, which git refuses, or the checkout would overwrite local changes + */ + public function checkoutNewBranch(string $branch, string $startPoint): void; /** * Merges $revision, a name the message of the merge commit names, into the current branch. + * + * @param string $revision A revision naming a commit, which WorkingCopy::merge() checks before */ public function merge(string $revision, FastForward $fastForward): void; } diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index 0a008c92..b7668463 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -12,20 +12,36 @@ namespace Gitonomy\Git\Exception; +use Gitonomy\Git\Util\Credentials; use Symfony\Component\Process\Process; final class ProcessException extends DriverException implements GitExceptionInterface { - public function __construct(private readonly Process $process) - { - parent::__construct( - "Error while running git command:\n". - $process->getCommandLine()."\n". - "\n". - $process->getErrorOutput()."\n". - "\n". - $process->getOutput() - ); + /** + * The message is a sentence, with the error of git, from its error + * output: "The git command "clone" failed (exit 128): fatal: ...". It + * hides the credentials of the URLs, unlike getErrorOutput() and getOutput(). + * + * @param string|null $command The git command, like "clone", which the message names + */ + public function __construct( + private readonly Process $process, + ?string $command = null, + ) { + $message = (null === $command ? 'The git command' : \sprintf('The git command "%s"', $command)).\sprintf(' failed (exit %d)', $process->getExitCode()); + // One line of the error output only: getOutput() may be long, and + // hold the data of the repository. The first error, as warnings may + // come before it, like the one of a HOME git can't read; the first + // line otherwise. A colon announces the next lines: "error: Your + // local changes ... would be overwritten by checkout:". + $errorOutput = $process->getErrorOutput(); + $error = 1 === preg_match('/^(?:fatal|error):.*$/m', $errorOutput, $matches) ? $matches[0] : explode("\n", trim($errorOutput), 2)[0]; + $error = rtrim(trim($error), ':'); + if ('' !== $error) { + $message .= ': '.$error; + } + + parent::__construct(Credentials::redact(1 === preg_match('/[.!?]$/', $message) ? $message : $message.'.')); } public function getErrorOutput(): string diff --git a/src/Gitonomy/Git/Exception/UnexpectedValueException.php b/src/Gitonomy/Git/Exception/UnexpectedValueException.php index c88d8ed3..cfe3e4c3 100644 --- a/src/Gitonomy/Git/Exception/UnexpectedValueException.php +++ b/src/Gitonomy/Git/Exception/UnexpectedValueException.php @@ -12,6 +12,9 @@ namespace Gitonomy\Git\Exception; +/** + * @deprecated since gitonomy/gitlib 2.0, gitlib no longer throws it: Tree::resolvePath(), which threw it in 1.x, throws an InvalidArgumentException instead + */ final class UnexpectedValueException extends \UnexpectedValueException implements GitExceptionInterface { } diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index c673f919..8f5d4abc 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -31,10 +31,16 @@ public function __construct( * Tests if repository has a given hook. * * @param string $name Name of the hook + * + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory */ public function has(string $name): bool { - return file_exists($this->getPath($name)); + $path = $this->getPath($name); + + // A symbolic link to a file not deployed yet is a hook too: set() + // would write through it, out of the hooks directory. + return is_link($path) || file_exists($path); } /** @@ -44,7 +50,7 @@ public function has(string $name): bool * * @return string Content of the hook * - * @throws InvalidArgumentException The hook does not exist + * @throws InvalidArgumentException The hook does not exist, or the name isn't valid * @throws RuntimeException The hook can't be read */ public function get(string $name): string @@ -53,7 +59,7 @@ public function get(string $name): string throw new InvalidArgumentException(\sprintf('The hook "%s" does not exist.', $name)); } - $content = file_get_contents($this->getPath($name)); + $content = @file_get_contents($this->getPath($name)); if (false === $content) { throw new RuntimeException(\sprintf('Unable to read the hook "%s".', $name)); } @@ -67,7 +73,8 @@ public function get(string $name): string * @param string $name Name of the hook to insert * @param string $file Target of symlink * - * @throws RuntimeException The hook exists already, or can't be created + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory + * @throws RuntimeException The hook exists already, or can't be created */ public function setSymlink(string $name, string $file): void { @@ -84,7 +91,8 @@ public function setSymlink(string $name, string $file): void * @param string $name Name of the hook * @param string $content Content of the hook * - * @throws RuntimeException The hook exists already, or can't be created + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory + * @throws RuntimeException The hook exists already, or can't be created */ public function set(string $name, string $content): void { @@ -100,7 +108,8 @@ public function set(string $name, string $content): void * * @param string $name Name of the hook * - * @throws RuntimeException The hook doesn't exist, or can't be removed + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory + * @throws RuntimeException The hook doesn't exist, or can't be removed */ public function remove(string $name): void { @@ -131,8 +140,17 @@ private function createPath(string $name): string return $this->getPath($name); } + /** + * @throws InvalidArgumentException The name isn't the one of a file of the hooks directory + */ private function getPath(string $name): string { + // "../config" would be the configuration of the repository. A name may + // hold a dot, but not start with one: git for Windows runs "pre-commit.exe". + if (1 !== preg_match('/^[a-zA-Z0-9_-][a-zA-Z0-9._-]*$/D', $name)) { + throw new InvalidArgumentException(\sprintf('"%s" is not a valid hook name: it must be the name of a file of the hooks directory, like "pre-commit".', addcslashes($name, "\0"))); + } + return $this->getDirectory().'/'.$name; } diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index d2065ead..038edccd 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -35,7 +35,7 @@ final class Log implements \Countable, \IteratorAggregate private ?int $limit; /** - * Instanciates a git log object. + * Instantiates a git log object. * * The revisions are names, ranges ("a..b", "a...b"), names to exclude * ("^a"), or "--all". diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index c5540658..7c5a343d 100644 --- a/src/Gitonomy/Git/Parser/DiffParser.php +++ b/src/Gitonomy/Git/Parser/DiffParser.php @@ -71,6 +71,16 @@ protected function doParse(): void 'C' === $entry['status'], ); } + + $this->consumeUnmergedPaths(); + + // What the parser doesn't know would be lost: the patches of the next + // files, after the log of a submodule of "git diff --submodule=log"... + // The message has the start of its line only: the lines of a diff come + // from the files of the repository. + if (!$this->isFinished()) { + throw new RuntimeException(\sprintf('Unable to parse the diff from "%s".', explode("\n", substr($this->content, $this->cursor, 100), 2)[0])); + } } /** @@ -147,10 +157,7 @@ private function consumeName(bool $nul): string private function consumePatch(?string $oldName, ?string $newName, ?string $oldMode, ?string $newMode, ?string $oldHash, ?string $newHash, bool $isCopy): DiffFileData { - while ($this->expects('* Unmerged path ')) { - $this->consumeTo("\n"); - $this->consumeNewLine(); - } + $this->consumeUnmergedPaths(); // Without --patch. if (!$this->expects('diff --git ')) { @@ -195,6 +202,17 @@ private function consumePatch(?string $oldName, ?string $newName, ?string $oldMo return new DiffFileData($oldName, $newName, $oldMode, $newMode, $oldHash, $newHash, $isBinary, $isCopy, $changes); } + /** + * During a conflict, an unmerged path has this line instead of its patch. + */ + private function consumeUnmergedPaths(): void + { + while ($this->expects('* Unmerged path ')) { + $this->consumeTo("\n"); + $this->consumeNewLine(); + } + } + private function consumeHunk(): FileChange { if (!preg_match('/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[^\n]*\n/A', $this->content, $vars, \PREG_UNMATCHED_AS_NULL, $this->cursor)) { diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index fd61640c..5d1c6eb4 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -84,6 +84,9 @@ protected function consumeHash(): string return $vars[1]; } + /** + * @return array The matches, like the ones of preg_match() + */ protected function consumeRegexp(string $regexp): array { if (!preg_match($regexp.'A', $this->content, $vars, 0, $this->cursor)) { @@ -131,24 +134,34 @@ protected function consumeNewLine(): string */ protected function consumeNameEmailDate(): array { - if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+) [+-]\d{4})/A', $this->content, $vars, 0, $this->cursor)) { + if (!preg_match('/(([^\n]*) <([^\n]*)> (\d+) ([+-]\d{4}))/A', $this->content, $vars, 0, $this->cursor)) { throw new RuntimeException('Unable to parse name, email and date.'); } $this->cursor += \strlen($vars[1]); - return [$vars[2], $vars[3], self::timestamp($vars[4])]; + return [$vars[2], $vars[3], self::signatureDate($vars[4], $vars[5])]; } /** * @param string $timestamp Seconds since the epoch, as git writes them + * @param string $offset The UTC offset of the author, committer or tagger, as git writes it: "+0200", "-0330"... + * + * @return \DateTimeImmutable The date at $offset, like git log shows it * * @throws RuntimeException The date is out of range, in a corrupt object */ - protected static function timestamp(string $timestamp): \DateTimeImmutable + protected static function signatureDate(string $timestamp, string $offset): \DateTimeImmutable { + // Like libgit2, which LibGit2Driver reads dates with: an offset of + // more than 14 hours or 59 minutes, which no timezone has, in a + // corrupt object, gives UTC. + if ((int) substr($offset, 1, 2) > 14 || (int) substr($offset, 3) > 59) { + $offset = '+0000'; + } + try { - return new \DateTimeImmutable('@'.$timestamp)->setTimezone(new \DateTimeZone('UTC')); + return new \DateTimeImmutable('@'.$timestamp)->setTimezone(new \DateTimeZone($offset)); } catch (\DateMalformedStringException $e) { throw new RuntimeException(\sprintf('The date "%s" is out of range.', $timestamp), 0, $e); } diff --git a/src/Gitonomy/Git/Reference.php b/src/Gitonomy/Git/Reference.php index 751fd199..6753aac8 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -15,6 +15,9 @@ /** * Reference in a Git repository. * + * Branch, Tag and Stash extend it: extending it outside gitlib isn't + * supported, and won't be possible in 3.0. + * * @author Alexandre Salomé * @author Julien DIDIER */ @@ -22,6 +25,8 @@ abstract class Reference extends Revision { /** * @param string $commitHash What the reference points to, through an annotated tag + * + * @internal Get it from Repository::getReferences(), which gitlib reads again after its writes */ public function __construct( Repository $repository, @@ -31,6 +36,11 @@ public function __construct( parent::__construct($repository, $fullname); } + /** + * @return string The name, without its prefix: "master", "origin/master" for a remote branch, "0.1" for a tag + */ + abstract public function getName(): string; + public function getFullname(): string { return $this->revision; @@ -42,7 +52,7 @@ public function delete(): void } /** - * @return string The commit the reference points to, when it was read: the commit an annotated tag points to, for one + * @return string The object the reference points to, when it was read, through an annotated tag: a commit, unless a tag points to a tree or a blob */ public function getCommitHash(): string { diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index 17066c33..d316e92b 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -34,6 +34,8 @@ final class Tag extends Reference /** * @param string $commitHash What the tag points to, through its annotated tag object * @param string|null $tagHash The annotated tag object, null for a lightweight tag + * + * @internal Get it from Repository::getReferences(), which gitlib reads again after its writes */ public function __construct( Repository $repository, @@ -78,7 +80,7 @@ public function getTaggerEmail(): ?string } /** - * @return \DateTimeImmutable|null The tagging date, null for a lightweight tag or a tag without tagger + * @return \DateTimeImmutable|null The tagging date, at the UTC offset of the tagger, like git log shows it; null for a lightweight tag or a tag without tagger */ public function getTaggerDate(): ?\DateTimeImmutable { diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 9cfa972b..530d7828 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -35,6 +35,9 @@ final class ReferenceBag implements \Countable, \IteratorAggregate */ private ?array $references = null; + /** + * @internal Get it from Repository::getReferences(), which gitlib reads again after its writes + */ public function __construct( private readonly Repository $repository, ) { @@ -96,6 +99,13 @@ public function createTag(string $name, Revision|string $revision): Tag return $this->getTag($name); } + /** + * Like `git update-ref -d`, deleting a missing reference does nothing. + * + * @param string $fullname fullname of the reference (refs/heads/master, for example) + * + * @throws InvalidArgumentException $fullname isn't the full name of a reference under "refs/", like "HEAD" or "master" + */ public function delete(string $fullname): void { $this->repository->getRepositoryDriver()->deleteReference($fullname); diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index cc599e7e..1d95ca33 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -22,9 +22,11 @@ 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\Util\WeakCache; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; @@ -102,7 +104,7 @@ final class Repository * * @throws InvalidArgumentException $dir doesn't exist or isn't a repository, or an option is unknown * @throws DriverException The driver can't open the repository - * @throws UnsupportedOperationException The repository uses SHA-256 hashes + * @throws UnsupportedOperationException The repository uses SHA-256 hashes, or with LibGit2Driver the reftable format */ public function __construct(string $dir, array|string|null $workingDir = null, DriverInterface $driver = new CliDriver()) { @@ -162,8 +164,9 @@ public static function initBare(string $path, DriverInterface $driver = new CliD * @param string|null $branch The branch to check out, the default branch of $url when null * * @throws DriverException + * @throws UnsupportedOperationException $url is a bundle, with LibGit2Driver */ - public static function cloneFrom(string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self + public static function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self { $driver->cloneFrom($url, $path, $branch); @@ -176,8 +179,9 @@ public static function cloneFrom(string $url, string $path, ?string $branch = nu * @param string|null $branch The branch HEAD points to, the default branch of $url when null * * @throws DriverException + * @throws UnsupportedOperationException $url is a bundle, with LibGit2Driver */ - public static function cloneBareFrom(string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self + public static function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self { $driver->cloneBareFrom($url, $path, $branch); @@ -189,8 +193,9 @@ public static function cloneBareFrom(string $url, string $path, ?string $branch * to mirror $url when fetching. * * @throws DriverException + * @throws UnsupportedOperationException $url is a bundle, with LibGit2Driver */ - public static function mirrorFrom(string $url, string $path, DriverInterface $driver = new CliDriver()): self + public static function mirrorFrom(#[\SensitiveParameter] string $url, string $path, DriverInterface $driver = new CliDriver()): self { $driver->mirrorFrom($url, $path); @@ -201,7 +206,7 @@ public static function mirrorFrom(string $url, string $path, DriverInterface $dr * Checks, without cloning it, that $url is a repository the driver can * read: false when it doesn't exist, can't be reached, or refuses access. */ - public static function isValidRepository(string $url, DriverInterface $driver = new CliDriver()): bool + public static function isValidRepository(#[\SensitiveParameter] string $url, DriverInterface $driver = new CliDriver()): bool { return $driver->isValidRepository($url); } @@ -210,7 +215,7 @@ public static function isValidRepository(string $url, DriverInterface $driver = * Checks, without cloning it, that $url is a repository the driver can * read, and that it has the $branch branch. */ - public static function isValidRepositoryAndBranch(string $url, string $branch, DriverInterface $driver = new CliDriver()): bool + public static function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch, DriverInterface $driver = new CliDriver()): bool { return $driver->isValidRepositoryAndBranch($url, $branch); } @@ -235,24 +240,20 @@ public function isBare(): bool /** * Returns the HEAD resolved as a commit. + * + * @throws ReferenceNotFoundException HEAD points to a branch without commits yet */ public function getHeadCommit(): Commit { - $head = $this->getHead(); - - if ($head instanceof Reference) { - return $head->getCommit(); - } - - return $head; + return $this->getRevision('HEAD')->getCommit(); } /** - * @return Reference|Commit The branch HEAD points to, or the commit when HEAD is detached + * @return Branch|Commit The branch HEAD points to, or the commit when HEAD is detached * - * @throws ReferenceNotFoundException HEAD points to a branch without commits yet + * @throws ReferenceNotFoundException HEAD points to a branch without commits yet, or to a reference that isn't a branch */ - public function getHead(): Reference|Commit + public function getHead(): Branch|Commit { $reference = $this->repositoryDriver->getHeadReference(); @@ -260,7 +261,13 @@ public function getHead(): Reference|Commit return $this->getCommit($this->repositoryDriver->resolveRevision('HEAD')); } - return $this->getReferences()->get($reference); + $head = $this->getReferences()->get($reference); + // git lets HEAD point to any reference under "refs/", a tag included. + if (!$head instanceof Branch) { + throw new ReferenceNotFoundException(\sprintf('HEAD points to "%s", which is not a branch.', $reference)); + } + + return $head; } public function isHeadDetached(): bool @@ -299,7 +306,7 @@ public function getWorkingDir(): ?string } /** - * Instanciates a revision. + * Instantiates a revision. * * @param string $name Name of the revision */ @@ -437,12 +444,12 @@ public function getBlame(Revision|string $revision, string $file, int|string|nul * * All those values can be null, meaning everything. * - * @param RevisionList|Revision|string|array|null $revisions An array of revisions to show logs from. Can be - * any text value type - * @param array|string|null $paths restrict log to modifications occurring on given - * paths - * @param int|null $offset start from a given offset in results - * @param int|null $limit limit number of total results + * @param RevisionList|Revision|string|array|null $revisions An array of revisions to show logs from. Can be + * any text value type + * @param string[]|string|null $paths restrict log to modifications occurring on given + * paths + * @param int|null $offset start from a given offset in results + * @param int|null $limit limit number of total results */ public function getLog(RevisionList|Revision|string|array|null $revisions = null, array|string|null $paths = null, ?int $offset = null, ?int $limit = null): Log { @@ -516,26 +523,52 @@ public function getLegacyDiff(RevisionList|Revision|string|array|null $revisions throw new InvalidArgumentException(\sprintf('Cannot compute a diff of "%s".', implode('", "', $revisions))); } + /** + * @return int The size of the files of the git directory, working tree excluded, in bytes + */ + public function getSizeInBytes(): int + { + $size = 0; + /** @var \SplFileInfo $file */ + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->gitDir, \FilesystemIterator::SKIP_DOTS)) as $file) { + // A link isn't followed: it may point to a file not deployed yet, like a hook. + if ($file->isLink()) { + continue; + } + + try { + $size += $file->getSize(); + } catch (\RuntimeException $e) { + // git deleted it since it was listed, during a gc or a push: + // a lock, a temporary pack... It no longer counts. + if (file_exists($file->getPathname())) { + throw $e; + } + } + } + + return $size; + } + /** * Returns the size of repository, in kilobytes. * * @return int A sum, in kilobytes + * + * @deprecated since gitonomy/gitlib 2.0, use getSizeInBytes() instead */ public function getSize(): int { - $totalBytes = 0; - /** @var \SplFileInfo $file */ - foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->gitDir, \FilesystemIterator::SKIP_DOTS)) as $file) { - $totalBytes += $file->getSize(); - } + trigger_deprecation('gitonomy/gitlib', '2.0', 'The "%s()" method is deprecated, use "getSizeInBytes()" instead.', __METHOD__); - return (int) round($totalBytes / 1000); + return (int) round($this->getSizeInBytes() / 1000); } /** * Executes a shell command on the repository, using PHP pipes. * - * @param string $command The command to execute + * @param string $command The command to execute + * @param array $env Environment variables of the command * * @deprecated since gitonomy/gitlib 2.0, run "git shell" with Symfony Process instead */ @@ -613,15 +646,17 @@ public function setDescription(string $description): static * stderr even when they succeed: use {@see runProcess()} instead if you * need access to it. * - * @param string $command Git command to run (checkout, branch, tag) - * @param array $args Arguments of git command + * @param string $command Git command to run (checkout, branch, tag) + * @param array $args Arguments of git command * * @return string output of the process * * @throws ProcessException Error while executing git command + * @throws ProcessTimedOutException git runs longer than the timeout of the CliDriver + * @throws DriverException git can't start, or is killed * @throws UnsupportedOperationException The driver isn't a CliDriver */ - public function run(string $command, array $args = []): string + public function run(string $command, #[\SensitiveParameter] array $args = []): string { return $this->runProcess($command, $args)->getOutput(); } @@ -634,13 +669,15 @@ public function run(string $command, array $args = []): string * meaningful output to stderr even when they succeed, so it can't be * read through run(). * - * @param string $command Git command to run (checkout, branch, tag) - * @param array $args Arguments of git command + * @param string $command Git command to run (checkout, branch, tag) + * @param array $args Arguments of git command * * @throws ProcessException Error while executing git command + * @throws ProcessTimedOutException git runs longer than the timeout of the CliDriver + * @throws DriverException git can't start, or is killed * @throws UnsupportedOperationException The driver isn't a CliDriver */ - public function runProcess(string $command, array $args = []): Process + public function runProcess(string $command, #[\SensitiveParameter] array $args = []): Process { if (!$this->repositoryDriver instanceof CliRepositoryDriver) { throw new UnsupportedOperationException('Only CliDriver can run git commands.'); @@ -739,7 +776,19 @@ private function fullHash(string $hash, ObjectType $type): string return $hash; } - $fullHash = $this->repositoryDriver->resolveRevision($hash.'^{'.$type->value.'}'); + try { + $fullHash = $this->repositoryDriver->resolveRevision($hash.'^{'.$type->value.'}'); + } catch (ReferenceNotFoundException) { + // One more call, only on failure: an object of another type, or none. + try { + $this->repositoryDriver->resolveRevision($hash); + } catch (ReferenceNotFoundException) { + throw new ReferenceNotFoundException(\sprintf('The object "%s" does not exist.', $hash)); + } + + throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value)); + } + // "^{tree}" also peels a commit to its tree. if (!str_starts_with($fullHash, $hash)) { throw new ReferenceNotFoundException(\sprintf('The object "%s" is not a %s.', $hash, $type->value)); diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index 15430584..8c8f5e96 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -15,16 +15,27 @@ use Gitonomy\Git\Exception\ReferenceNotFoundException; /** + * A name git resolves to a commit: a branch, a tag, a hash, "HEAD~2"... + * + * Commit and Reference extend it: extending it outside gitlib isn't + * supported, and won't be possible in 3.0. + * * @author Alexandre Salomé */ class Revision { + /** + * @internal Get it from Repository::getRevision() + */ public function __construct( protected readonly Repository $repository, protected readonly string $revision, ) { } + /** + * @param string[]|string|null $paths Paths to filter on, with git's wildcards + */ public function getLog(array|string|null $paths = null, ?int $offset = null, ?int $limit = null): Log { return $this->repository->getLog($this, $paths, $offset, $limit); @@ -37,8 +48,13 @@ public function getLog(array|string|null $paths = null, ?int $offset = null, ?in */ public function getCommit(): Commit { + $driver = $this->repository->getRepositoryDriver(); + try { - $hash = $this->repository->getRepositoryDriver()->resolveRevision($this->revision.'^{commit}'); + // "^{commit}" would be part of the text of ":/text", the commit + // whose message matches it: its hash first. + $revision = str_starts_with($this->revision, ':/') ? $driver->resolveRevision($this->revision) : $this->revision; + $hash = $driver->resolveRevision($revision.'^{commit}'); } catch (ReferenceNotFoundException $e) { throw new ReferenceNotFoundException(\sprintf('The revision "%s" does not exist, or names no commit.', $this->revision), 0, $e); } diff --git a/src/Gitonomy/Git/RevisionList.php b/src/Gitonomy/Git/RevisionList.php index c31c0972..da325c2d 100644 --- a/src/Gitonomy/Git/RevisionList.php +++ b/src/Gitonomy/Git/RevisionList.php @@ -16,18 +16,20 @@ /** * @author Alexandre Salomé + * + * @implements \IteratorAggregate */ final readonly class RevisionList implements \IteratorAggregate, \Countable { /** - * @var Revision[] + * @var list */ private array $revisions; /** * Constructs a revision list from a variety of types. * - * @param string|Revision|array $revisions can be a string, a Revision, an array of strings or an array of Revision, Branch, Tag, Commit + * @param string|Revision|array $revisions can be a string, a Revision, an array of strings or an array of Revision, Branch, Tag, Commit */ public function __construct(Repository $repository, string|Revision|array $revisions) { @@ -45,7 +47,7 @@ public function __construct(Repository $repository, string|Revision|array $revis foreach ($revisions as $revision) { if (\is_string($revision)) { $revision = new Revision($repository, $revision); - } elseif (!$revision instanceof Revision) { + } elseif (!$revision instanceof Revision) { // @phpstan-ignore instanceof.alwaysTrue (PHP doesn't check the types of the elements, unlike PHPStan.) throw new InvalidArgumentException(\sprintf('Expected a "Revision", got a "%s".', get_debug_type($revision))); } @@ -56,13 +58,16 @@ public function __construct(Repository $repository, string|Revision|array $revis } /** - * @return Revision[] + * @return list */ public function getAll(): array { return $this->revisions; } + /** + * @return \ArrayIterator + */ public function getIterator(): \ArrayIterator { return new \ArrayIterator($this->revisions); diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index 2eb8e17b..bef883c4 100644 --- a/src/Gitonomy/Git/Tree.php +++ b/src/Gitonomy/Git/Tree.php @@ -25,6 +25,9 @@ final class Tree */ private ?array $entries = null; + /** + * @internal Get it from Repository::getTree(), which returns the same object while the application uses it + */ public function __construct( private readonly Repository $repository, private readonly string $hash, diff --git a/src/Gitonomy/Git/Util/Credentials.php b/src/Gitonomy/Git/Util/Credentials.php new file mode 100644 index 00000000..fbc87192 --- /dev/null +++ b/src/Gitonomy/Git/Util/Credentials.php @@ -0,0 +1,36 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * A URL like "https://user:token@host/repository.git" carries credentials: + * the messages of exceptions and of the logger hide them. + * + * @author Grégoire Pineau + * + * @internal + */ +final class Credentials +{ + /** + * Replaces the user information of the URLs of $text, a token or a user + * and a password, by "***". + */ + public static function redact(#[\SensitiveParameter] string $text): string + { + // Up to the last "@" before the host, as a password may hold one, or a + // "#" or a "?", which git and libgit2 accept unencoded there. + // @phpstan-ignore return.type (possessive quantifiers never reach the backtracking limit, and without "u" there is no UTF-8 to fail on) + return preg_replace('#\b([a-z][a-z0-9+.-]*+://)(?:[^/\s@]*+@)++#i', '$1***@', $text); + } +} diff --git a/src/Gitonomy/Git/Util/LeadingDash.php b/src/Gitonomy/Git/Util/LeadingDash.php new file mode 100644 index 00000000..84ccab62 --- /dev/null +++ b/src/Gitonomy/Git/Util/LeadingDash.php @@ -0,0 +1,42 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * git reads an argument starting with a dash as an option: the revision + * "--output=/path" of a log writes a file, the URL "--upload-pack=..." of + * ls-remote runs a command. git 1.8.4 has no "--end-of-options", and what + * follows "--" is a path for most commands: such a revision, reference, hash + * or URL is refused, by both drivers. + * + * @author Grégoire Pineau + * + * @internal + */ +final class LeadingDash +{ + /** + * @throws InvalidArgumentException One of the values starts with a dash + */ + public static function assertNone(#[\SensitiveParameter] string ...$values): void + { + foreach ($values as $value) { + if (str_starts_with($value, '-')) { + // The value may be a URL. + throw new InvalidArgumentException(Credentials::redact(\sprintf('"%s" starts with a dash, which git would read as an option.', $value))); + } + } + } +} diff --git a/src/Gitonomy/Git/Util/NulByte.php b/src/Gitonomy/Git/Util/NulByte.php index c6b7a8c7..5f3d74e0 100644 --- a/src/Gitonomy/Git/Util/NulByte.php +++ b/src/Gitonomy/Git/Util/NulByte.php @@ -27,11 +27,12 @@ final class NulByte /** * @throws InvalidArgumentException One of the values contains a NUL byte */ - public static function assertNone(string ...$values): void + public static function assertNone(#[\SensitiveParameter] string ...$values): void { foreach ($values as $value) { if (str_contains($value, "\0")) { - throw new InvalidArgumentException(\sprintf('"%s" contains a NUL byte, which git doesn\'t accept.', addcslashes($value, "\0"))); + // The value may be a URL. + throw new InvalidArgumentException(Credentials::redact(\sprintf('"%s" contains a NUL byte, which git doesn\'t accept.', addcslashes($value, "\0")))); } } } diff --git a/src/Gitonomy/Git/Util/ReferenceName.php b/src/Gitonomy/Git/Util/ReferenceName.php new file mode 100644 index 00000000..525a297a --- /dev/null +++ b/src/Gitonomy/Git/Util/ReferenceName.php @@ -0,0 +1,38 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * gitlib writes the references under "refs/" only, the ones it lists. "git + * update-ref" writes any other name: it deletes HEAD, which leaves no + * repository, and writes the short name "master" next to HEAD. Both drivers + * refuse such a name. + * + * @author Grégoire Pineau + * + * @internal + */ +final class ReferenceName +{ + /** + * @throws InvalidArgumentException $fullname isn't under "refs/" + */ + public static function assertUnderRefs(string $fullname): void + { + if (!str_starts_with($fullname, 'refs/')) { + throw new InvalidArgumentException(\sprintf('"%s" is not the full name of a reference under "refs/", like "refs/heads/master".', $fullname)); + } + } +} diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php new file mode 100644 index 00000000..2923b3a4 --- /dev/null +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -0,0 +1,70 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * Refuses the URLs of a repository that make git run a command, as an + * application may get them from its users. + * + * @author Grégoire Pineau + * + * @internal + */ +final class RemoteUrl +{ + /** + * @throws InvalidArgumentException $url starts with a dash, uses the "ext::" transport, or has a host or a user starting with a dash, encoded or not + */ + public static function assertSafe(#[\SensitiveParameter] string $url): void + { + LeadingDash::assertNone($url); + + // "ext::sh -c ..." runs the command, to talk to it: git refuses it by + // default since 2.12 only, through its protocol.allow setting. + if (str_starts_with($url, 'ext::')) { + throw new InvalidArgumentException(\sprintf('"%s" uses the "ext::" transport, which runs a command.', Credentials::redact($url))); + } + + // Before 2.14.1 (CVE-2017-1000117), git passes the "user@host" of an ssh + // URL to ssh, without its brackets: ssh reads "ssh://-oProxyCommand=..." + // as an option, and runs the command. The command of core.gitProxy gets + // the host of a git URL the same way. A valid host never starts with a dash. + $authority = self::authority($url); + if (null !== $authority && 1 === preg_match('/(?:^|@)\[?-/', $authority)) { + throw new InvalidArgumentException(\sprintf('"%s" has a host or a user starting with a dash, which ssh would read as an option.', Credentials::redact($url))); + } + } + + /** + * @return string|null The "user@host:port" of an ssh or git URL, decoded, + * or of an address like "user@host:path", which git + * reaches through ssh; null for any other URL, or a + * local path + */ + private static function authority(#[\SensitiveParameter] string $url): ?string + { + // git decodes a URL before it reads its host: "%2D" is a dash, "%5B" a bracket. + if (1 === preg_match('#^(?:git|ssh|git\+ssh|ssh\+git)://([^/]*)#', $url, $matches)) { + return rawurldecode($matches[1]); + } + + // Like git: a colon before any slash, but not the one of "scheme://". + if (1 === preg_match('#^([^/:]*):(?!//)#', $url, $matches)) { + return $matches[1]; + } + + return null; + } +} diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index 988004ba..a7374be9 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -13,8 +13,10 @@ namespace Gitonomy\Git; use Gitonomy\Git\Diff\Diff; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; /** @@ -65,10 +67,19 @@ public function getDiffStaged(): Diff } /** - * Checks $revision out: HEAD points to it when it is a local branch, to - * its commit otherwise. + * Checks $revision out, like `git checkout`: HEAD points to it when it is + * a local branch, or @{-}, the nth branch checked out before, stays + * as it is for "HEAD", and points to the commit otherwise: a tag, a hash, + * a remote branch... + * + * Unlike `git checkout`, a name that no revision has throws, the name of + * a remote branch alone included, like "feature" for "origin/feature": + * create its local branch with checkoutNewBranch(). * * Passing a branch to create as second argument is deprecated. + * + * @throws ReferenceNotFoundException $revision names no commit + * @throws DriverException The checkout would overwrite local changes */ public function checkout(Revision|string $revision, ?string $branch = null): static { @@ -78,6 +89,10 @@ public function checkout(Revision|string $revision, ?string $branch = null): sta return $this->checkoutNewBranch($branch, $revision); } + // A name that no revision has throws, where "git checkout" would create + // the branch of a remote one, or discard the changes of a file. + ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit(); + try { $this->repository->getRepositoryDriver()->checkout(self::name($revision)); } finally { @@ -88,14 +103,23 @@ public function checkout(Revision|string $revision, ?string $branch = null): sta } /** - * Creates the branch $branch at $startPoint, and checks it out. + * Creates the branch $branch at $startPoint, and checks it out, like + * `git checkout -b`. The branch tracks $startPoint when it is a remote + * branch, like "origin/feature", as with branch.autoSetupMerge=true, + * git's default, whatever the configuration. A failure leaves the + * branches and the files as they were: like `git checkout -b`, no + * branch is created when the checkout fails. + * + * @throws ReferenceNotFoundException $startPoint names no commit + * @throws DriverException $branch exists already or isn't a valid name, $startPoint is the short name of several references, which git refuses, or the checkout would overwrite local changes */ public function checkoutNewBranch(string $branch, Revision|string $startPoint = 'HEAD'): static { - $startHash = ($startPoint instanceof Revision ? $startPoint : $this->repository->getRevision($startPoint))->getCommit()->getHash(); + // Like checkout(). The driver gets the name, which tells a remote branch. + ($startPoint instanceof Revision ? $startPoint : $this->repository->getRevision($startPoint))->getCommit(); try { - $this->repository->getRepositoryDriver()->checkoutNewBranch($branch, $startHash); + $this->repository->getRepositoryDriver()->checkoutNewBranch($branch, $startPoint instanceof Revision ? $startPoint->getRevision() : $startPoint); } finally { $this->repository->getReferences()->invalidate(); } @@ -109,6 +133,9 @@ public function checkoutNewBranch(string $branch, Revision|string $startPoint = * An array of arguments of "git merge" ([], ["--no-ff"] or ["--ff-only"]) is deprecated. * * @param FastForward|string[] $fastForward + * + * @throws ReferenceNotFoundException $revision names no commit + * @throws DriverException The merge fails: a conflict, staged changes, an unrelated history... */ public function merge(Revision|string $revision, FastForward|array $fastForward = FastForward::Allow): static { @@ -123,6 +150,9 @@ public function merge(Revision|string $revision, FastForward|array $fastForward }; } + // Like checkout(). The driver gets the name, which the message of the merge commit has. + ($revision instanceof Revision ? $revision : $this->repository->getRevision($revision))->getCommit(); + try { $this->repository->getRepositoryDriver()->merge(self::name($revision), $fastForward); } finally { diff --git a/test-git-versions.php b/test-git-versions.php index 82cdd48b..787dd32e 100755 --- a/test-git-versions.php +++ b/test-git-versions.php @@ -166,13 +166,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); $versions = $input->getArgument('versions'); - // Old git binaries choke on config values introduced after their release (e.g. - // "merge.conflictstyle = zdiff3", added in 2.35, is a fatal "unknown style" error - // on anything older) if they pick up the invoking user's own ~/.gitconfig. Give - // each git binary an empty, isolated HOME so only gitlib's own fixtures apply. - $isolatedHome = sys_get_temp_dir().'/gitlib-test-git-versions-home-'.bin2hex(random_bytes(4)); - mkdir($isolatedHome); - $results = []; foreach ($versions as $version) { @@ -188,8 +181,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->section(sprintf('Testing against %s', $version)); $io->writeln(sprintf('Command: %s', $binary)); - $env = ['GIT_COMMAND' => $binary, 'HOME' => $isolatedHome, 'XDG_CONFIG_HOME' => $isolatedHome]; - $process = new Process(['vendor/bin/phpunit'], __DIR__, $env, timeout: null); + // The bootstrap of the tests gives git a configuration of its own. + $process = new Process(['vendor/bin/phpunit'], __DIR__, ['GIT_COMMAND' => $binary], timeout: null); $process->run(static function (string $type, string $data) use ($output): void { $output->write($data); }); diff --git a/tests/Gitonomy/Git/Tests/AbstractTestCase.php b/tests/Gitonomy/Git/Tests/AbstractTestCase.php index d68dc773..a67912e5 100644 --- a/tests/Gitonomy/Git/Tests/AbstractTestCase.php +++ b/tests/Gitonomy/Git/Tests/AbstractTestCase.php @@ -257,11 +257,7 @@ protected static function getDriver(): DriverInterface */ protected static function getCliDriver(array $environmentVariables = [], ?float $processTimeout = 60, ?LoggerInterface $logger = null): CliDriver { - // git 2.29 and later run their maintenance after a commit, in the - // background since 2.46: it would write in the repositories the tests delete. - $envs = [...self::gitEnvs(), 'GIT_CONFIG_COUNT' => '1', 'GIT_CONFIG_KEY_0' => 'maintenance.auto', 'GIT_CONFIG_VALUE_0' => 'false']; - - return new CliDriver(self::gitCommand(), [...$envs, ...$environmentVariables], processTimeout: $processTimeout, logger: $logger); + return new CliDriver(self::gitCommand(), [...self::gitEnvs(), ...$environmentVariables], processTimeout: $processTimeout, logger: $logger); } /** @@ -277,6 +273,21 @@ protected static function gitCommand(): string return $command; } + /** + * @return string The version of the git binary of the tests, like "2.25.1" + */ + protected static function gitVersion(): string + { + $process = new Process([self::gitCommand(), 'version']); + $process->mustRun(); + + if (1 !== preg_match('/^git version (\d+\.\d+\.\d+)/', $process->getOutput(), $matches)) { + throw new \UnexpectedValueException(\sprintf('Unable to read the version of git in "%s".', $process->getOutput())); + } + + return $matches[1]; + } + /** * @return array The environment variables of git: GIT_ENVS, which the bootstrap sets on Windows */ diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index b383fc75..d310ab3e 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -13,8 +13,8 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Admin; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Reference\Branch; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; @@ -136,7 +136,8 @@ public function testOptionsConfigureGitAndTheRepository(): void public function testCommandOptionRunsAnotherGitBinary(): void { - $this->expectException(ProcessException::class); + $this->expectException(DriverException::class); + $this->expectExceptionMessage('The git command "init" could not start: '); Admin::init(self::createTempDir(), options: ['command' => 'not-git']); } @@ -147,9 +148,9 @@ public function testInheritEnvironmentVariablesOnlyAppliesToTheRepository(): voi $this->markTestSkipped('The fake git binary is a shell script.'); } - // A git binary logging its first argument, and whether it gets SECRET. + // A git binary logging whether it gets SECRET. $dir = self::createTempDir(); - file_put_contents($dir.'/git', \sprintf("#!/bin/sh\necho \"\$1 \${GITLIB_TEST_SECRET:-none}\" >> %s\nexec %s \"\$@\"\n", escapeshellarg($dir.'/log'), escapeshellarg(self::gitCommand()))); + file_put_contents($dir.'/git', \sprintf("#!/bin/sh\necho \"\${GITLIB_TEST_SECRET:-none}\" >> %s\nexec %s \"\$@\"\n", escapeshellarg($dir.'/log'), escapeshellarg(self::gitCommand()))); chmod($dir.'/git', 0o755); // Like the credentials of an HTTP request, in $_SERVER only. $_SERVER['GITLIB_TEST_SECRET'] = 'secret'; @@ -162,7 +163,7 @@ public function testInheritEnvironmentVariablesOnlyAppliesToTheRepository(): voi } // Like in 1.x, git init doesn't get $_SERVER, the git processes of the repository do. - $this->assertSame("init none\n--git-dir secret\n", file_get_contents($dir.'/log')); + $this->assertSame("none\nsecret\n", file_get_contents($dir.'/log'), 'git init, then git config'); } public function testRemovedOptionsAreRefused(): void diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index 96db58cc..1a367a3b 100644 --- a/tests/Gitonomy/Git/Tests/BlameTest.php +++ b/tests/Gitonomy/Git/Tests/BlameTest.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; @@ -95,6 +96,16 @@ public function testBlameOfARangeAfterTheEndThrows(): void $repository->getBlame(self::LONGFILE_COMMIT, 'README.md', 100)->getLines(); } + public function testBlameOfAPathNormalizesIt(): void + { + $repository = self::createFoobarRepository(); + $lines = static fn (string $path): array => array_map(static fn (Line $line): array => [$line->getCommit()->getHash(), $line->getContent()], $repository->getBlame(self::LONGFILE_COMMIT, $path)->getLines()); + + // Like git. + $this->assertSame($lines('README.md'), $lines('./README.md')); + $this->assertSame($lines('README.md'), $lines('dir/../README.md')); + } + #[DataProvider('provideBare')] #[Group('legacy')] #[IgnoreDeprecations] diff --git a/tests/Gitonomy/Git/Tests/BootstrapTest.php b/tests/Gitonomy/Git/Tests/BootstrapTest.php new file mode 100644 index 00000000..78289031 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/BootstrapTest.php @@ -0,0 +1,55 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Symfony\Component\Process\Process; + +/** + * The tests run git in an environment of their own, whatever the developer's. + */ +class BootstrapTest extends AbstractTestCase +{ + private const string BOOTSTRAP = __DIR__.'/../../../bootstrap.php'; + + public function testTheBootstrapRemovesTheVariablesOfGit(): void + { + // The variables git gives a hook, like pre-commit, running the tests. + $dir = self::createTempDir(); + $environment = [ + 'GIT_AUTHOR_NAME' => 'Hook', + 'GIT_COMMAND' => 'git', + 'GIT_CONFIG_NOSYSTEM' => '0', + 'GIT_DIR' => $dir.'/.git', + 'GIT_INDEX_FILE' => $dir.'/.git/index', + 'GIT_OBJECT_DIRECTORY' => $dir.'/.git/objects', + 'GIT_WORK_TREE' => $dir, + ]; + + $code = <<<'PHP' + require $argv[1]; + $variables = []; + foreach (['getenv' => getenv(), '_ENV' => $_ENV, '_SERVER' => $_SERVER] as $source => $values) { + $variables[$source] = array_filter($values, static fn (string $name): bool => str_starts_with($name, 'GIT_'), \ARRAY_FILTER_USE_KEY); + ksort($variables[$source]); + } + echo json_encode($variables); + PHP; + + // With $_ENV, which php.ini usually leaves empty. + $process = new Process([\PHP_BINARY, '-d', 'variables_order=EGPCS', '-r', $code, self::BOOTSTRAP], env: $environment); + $process->mustRun(); + + $expected = ['GIT_COMMAND' => 'git', 'GIT_CONFIG_NOSYSTEM' => '1']; + $this->assertSame(['getenv' => $expected, '_ENV' => $expected, '_SERVER' => $expected], json_decode($process->getOutput(), true)); + } +} diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 67c813ec..48eb8dbe 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Blob; use Gitonomy\Git\Commit; use Gitonomy\Git\Diff\File; use Gitonomy\Git\Exception\InvalidArgumentException; @@ -46,6 +47,66 @@ public function testADateOutOfRangeThrows(): void $repository->getCommit($hash)->getAuthorDate(); } + /** + * Like git log, at the UTC offsets of the author and of the committer. + */ + public function testTheDatesAreAtTheOffsetsOfTheAuthorAndOfTheCommitter(): void + { + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + $git = new Repository($repository->getPath(), driver: self::getCliDriver([ + 'GIT_AUTHOR_DATE' => '1700000000 +0530', + 'GIT_AUTHOR_EMAIL' => 'author@example.com', + 'GIT_AUTHOR_NAME' => 'Author', + 'GIT_COMMITTER_DATE' => '1700000001 -0330', + 'GIT_COMMITTER_EMAIL' => 'committer@example.com', + 'GIT_COMMITTER_NAME' => 'Committer', + ])); + $hash = trim($git->run('commit-tree', [$tree, '-m', 'Dates'])); + + $commit = $repository->getCommit($hash); + + $this->assertSame('2023-11-15T03:43:20+05:30', $commit->getAuthorDate()->format(\DATE_ATOM)); + $this->assertSame(1700000000, $commit->getAuthorDate()->getTimestamp()); + $this->assertSame('2023-11-14T18:43:21-03:30', $commit->getCommitterDate()->format(\DATE_ATOM)); + $this->assertSame(1700000001, $commit->getCommitterDate()->getTimestamp()); + } + + /** + * Like git log, which shows "-0000" as "+0000". An offset of more than 14 + * hours or 59 minutes, which no timezone has, gives UTC, like libgit2. + */ + #[DataProvider('provideOffsets')] + public function testADateIsAtTheOffsetOfItsSignature(string $offset, string $date): void + { + $repository = self::createEmptyRepository(); + $tree = self::writeObject($repository, 'tree', ''); + $hash = self::writeObject($repository, 'commit', "tree {$tree}\nauthor A 1700000000 {$offset}\ncommitter A 1700000000 {$offset}\n\nOffset\n"); + + $commit = $repository->getCommit($hash); + + $this->assertSame($date, $commit->getAuthorDate()->format(\DATE_ATOM)); + $this->assertSame($date, $commit->getCommitterDate()->format(\DATE_ATOM)); + $this->assertSame(1700000000, $commit->getAuthorDate()->getTimestamp()); + } + + /** + * @return iterable + */ + public static function provideOffsets(): iterable + { + yield 'half an hour' => ['+0530', '2023-11-15T03:43:20+05:30']; + yield 'three quarters of an hour' => ['+0545', '2023-11-15T03:58:20+05:45']; + yield 'negative' => ['-0700', '2023-11-14T15:13:20-07:00']; + yield 'negative, with minutes' => ['-0330', '2023-11-14T18:43:20-03:30']; + yield 'negative, under an hour' => ['-0030', '2023-11-14T21:43:20-00:30']; + yield 'unknown' => ['-0000', '2023-11-14T22:13:20+00:00']; + yield 'easternmost' => ['+1400', '2023-11-15T12:13:20+14:00']; + yield 'westernmost' => ['-1200', '2023-11-14T10:13:20-12:00']; + yield 'beyond 14 hours' => ['+1500', '2023-11-14T22:13:20+00:00']; + yield 'beyond 59 minutes' => ['+0560', '2023-11-14T22:13:20+00:00']; + } + #[DataProvider('provideBare')] public function testGetHash(bool $bare): void { @@ -74,6 +135,17 @@ public function testGetShortHash(bool $bare): void $this->assertSame('4f17752', $commit->getShortHash(), 'Short hash'); } + /** + * With log.showSignature, which the configuration of the tests sets, git + * prints the verification of the signature before the hash. + */ + public function testGetShortHashOfASignedCommit(): void + { + $repository = self::createFoobarRepository(); + + $this->assertSame('e1a83f1', $repository->getCommit(self::SIGNED_COMMIT)->getShortHash()); + } + #[DataProvider('provideBare')] public function testGetParentHashesWithNoParent(bool $bare): void { @@ -147,7 +219,7 @@ public function testGetAuthorDate(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertSame('2012-12-31 14:21:03', $commit->getAuthorDate()->format('Y-m-d H:i:s'), 'Author date'); + $this->assertSame('2012-12-31T15:21:03+01:00', $commit->getAuthorDate()->format(\DATE_ATOM), 'Author date'); } #[DataProvider('provideBare')] @@ -174,7 +246,7 @@ public function testGetCommitterDate(bool $bare): void $repository = self::createFoobarRepository($bare); $commit = $repository->getCommit(self::LONGFILE_COMMIT); - $this->assertSame('2012-12-31 14:21:03', $commit->getCommitterDate()->format('Y-m-d H:i:s'), 'Committer date'); + $this->assertSame('2012-12-31T15:21:03+01:00', $commit->getCommitterDate()->format(\DATE_ATOM), 'Committer date'); } #[DataProvider('provideBare')] @@ -370,4 +442,47 @@ public function testMessageIsReadLikeGitLogPrintsIt(): void $this->assertSame("Subject\n\nBody\n indented\n", $repository->getCommit($hash)->getMessage()); $this->assertSame("Subject\n\nBody\n indented\n", new Repository($repository->getPath(), driver: self::getDriver())->getLog([$hash])->getSingleCommit()->getMessage()); } + + /** + * Whoever can push refs/replace/* to a repository would change what an + * application shows for a hash: like libgit2, gitlib ignores them. + */ + public function testReplacedObjectsAreReadAsTheyAre(): void + { + $repository = self::createFoobarRepository(); + $commit = $repository->getCommit(self::LONGFILE_COMMIT); + $message = $commit->getMessage(); + $blob = $commit->getTree()->resolvePath('README.md'); + $this->assertInstanceOf(Blob::class, $blob); + $content = $blob->getContent(); + self::git($repository, 'replace', [self::LONGFILE_COMMIT, self::writeObject($repository, 'commit', "tree {$commit->getTreeHash()}\nauthor A 1000000000 +0000\ncommitter A 1000000000 +0000\n\nFake\n")]); + self::git($repository, 'replace', [$blob->getHash(), self::writeObject($repository, 'blob', "Fake\n")]); + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + + $this->assertSame($message, $repository->getCommit(self::LONGFILE_COMMIT)->getMessage()); + $this->assertSame($message, $repository->getLog(self::LONGFILE_COMMIT, null, null, 1)->getSingleCommit()->getMessage()); + $this->assertSame($content, $repository->getBlob($blob->getHash())->getContent()); + } + + /** + * Without "--", git reads a revision named like a file of the working + * tree as ambiguous. + */ + public function testAFileNamedLikeACommitChangesNothing(): void + { + $repository = self::createFoobarRepository(false); + $commit = $repository->getCommit(self::LONGFILE_COMMIT); + $parent = $commit->getParentHashes()[0]; + touch($repository->getWorkingDir().'/'.self::LONGFILE_COMMIT); + touch($repository->getWorkingDir().'/'.$parent); + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $commit = $repository->getCommit(self::LONGFILE_COMMIT); + + $this->assertSame('add a long file', $commit->getSubjectMessage()); + $this->assertSame(substr(self::LONGFILE_COMMIT, 0, 7), $commit->getShortHash()); + $this->assertSame(['long.php'], array_map(static fn (File $file): string => $file->getName(), $commit->getDiff()->getFiles())); + $this->assertNotSame([], $repository->getWorkingCopy()->getDiff($parent)->getFiles()); + } } diff --git a/tests/Gitonomy/Git/Tests/CredentialsTest.php b/tests/Gitonomy/Git/Tests/CredentialsTest.php new file mode 100644 index 00000000..1df80bbd --- /dev/null +++ b/tests/Gitonomy/Git/Tests/CredentialsTest.php @@ -0,0 +1,231 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Driver\DriverInterface; +use Gitonomy\Git\Exception\DriverException; +use Gitonomy\Git\Exception\GitExceptionInterface; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\ProcessTimedOutException; +use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\Credentials; +use PHPUnit\Framework\Attributes\DataProvider; +use Psr\Log\LoggerInterface; + +/** + * The credentials of a URL must not end up in the logs or in an error tracker. + */ +class CredentialsTest extends AbstractTestCase +{ + /** + * Nothing listens on port 1: the clone fails. + */ + private const string URL = 'http://user:secret@127.0.0.1:1/repository.git'; + + public function testAFailedCloneHidesTheCredentials(): void + { + try { + Repository::cloneFrom(self::URL, self::createTempDir().'/clone', driver: self::getDriver()); + $this->fail('The clone fails.'); + } catch (DriverException $e) { + // What loggers print: the message, the trace, and the chained exceptions. + $this->assertStringNotContainsString('secret', (string) $e); + if (self::getDriver() instanceof CliDriver) { + // The error of git: recent versions name the URL without its credentials, and the old + // builds of the tests, without http support, do not name it. + $this->assertStringStartsWith('The git command "clone" failed (exit 128): fatal: ', $e->getMessage()); + } else { + $this->assertStringStartsWith('Unable to clone "http://***@127.0.0.1:1/repository.git": ', $e->getMessage()); + } + } + } + + /** + * git writes the name of a branch it refuses in its error output. + */ + public function testTheMessageOfAProcessExceptionHidesTheCredentials(): void + { + $this->skipUnlessCliDriver(); + + try { + self::createFoobarRepository()->run('branch', [self::URL]); + $this->fail('A URL is no valid branch name.'); + } catch (ProcessException $e) { + $this->assertStringStartsWith('The git command "branch" failed (exit 128): fatal: \'http://***@127.0.0.1:1/repository.git\' is not a valid branch name', $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + $this->assertStringContainsString(self::URL, $e->getErrorOutput(), 'The error output is the one of git'); + } + } + + /** + * The exception of Symfony Process holds the command line. + */ + public function testATimeoutHidesTheCredentials(): void + { + $this->skipUnlessCliDriver(); + if (\defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('The slow git is a shell script.'); + } + + $slowGit = self::createTempDir().'/git'; + file_put_contents($slowGit, "#!/bin/sh\nexec sleep 10\n"); + chmod($slowGit, 0o700); + + try { + Repository::cloneFrom(self::URL, self::createTempDir().'/clone', driver: new CliDriver($slowGit, self::gitEnvs(), processTimeout: 0.2)); + $this->fail('The clone times out.'); + } catch (ProcessTimedOutException $e) { + $this->assertSame('The git command "clone" ran longer than 0.2 seconds.', $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + } + } + + /** + * The exception of Symfony Process holds the command line: here, longer + * than what the system accepts. + */ + public function testAProcessThatCannotStartHidesTheCredentials(): void + { + $this->skipUnlessCliDriver(); + + try { + Repository::isValidRepository(self::URL.'/'.str_repeat('a', 4 * 1024 * 1024), self::getDriver()); + $this->fail('The command line is too long.'); + } catch (DriverException $e) { + // One line, the error of the system, like "posix_spawn() failed: Argument list too long", without the command line. + $this->assertMatchesRegularExpression('/^The git command "ls-remote" could not start: [^\n]+\.$/', $e->getMessage()); + $this->assertStringNotContainsString('/repository.git/aaa', $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + } + } + + /** + * The exceptions of the arguments gitlib refuses hide the credentials too. + */ + #[DataProvider('provideRefusedUrls')] + public function testARefusedUrlHidesTheCredentials(string $url, string $message): void + { + try { + Repository::isValidRepository($url, self::getDriver()); + $this->fail('The URL is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertSame($message, $e->getMessage()); + } + } + + /** + * @return iterable + */ + public static function provideRefusedUrls(): iterable + { + yield 'NUL byte' => [self::URL."\0", '"http://***@127.0.0.1:1/repository.git\000" contains a NUL byte, which git doesn\'t accept.']; + yield 'leading dash' => ['--upload-pack='.self::URL, '"--upload-pack=http://***@127.0.0.1:1/repository.git" starts with a dash, which git would read as an option.']; + } + + /** + * With "zend.exception_ignore_args" off, the default of php.ini-development, + * the traces of exceptions hold the arguments of the calls. + */ + #[DataProvider('provideFailingCalls')] + public function testTheTracesHideTheCredentials(\Closure $call): void + { + $ignoreArgs = ini_set('zend.exception_ignore_args', '0'); + + try { + $call(self::getDriver()); + $this->fail('The call fails.'); + } catch (GitExceptionInterface $e) { + for ($exception = $e; null !== $exception; $exception = $exception->getPrevious()) { + foreach ($exception->getTrace() as $frame) { + $this->assertNoSecretIn($frame['args'] ?? []); + } + } + } finally { + ini_set('zend.exception_ignore_args', (string) $ignoreArgs); + } + } + + /** + * @return iterable + */ + public static function provideFailingCalls(): iterable + { + yield 'clone' => [static function (DriverInterface $driver): void { Repository::cloneFrom(self::URL, self::createTempDir().'/clone', driver: $driver); }]; + yield 'bare clone' => [static function (DriverInterface $driver): void { Repository::cloneBareFrom(self::URL, self::createTempDir().'/clone', driver: $driver); }]; + yield 'mirror' => [static function (DriverInterface $driver): void { Repository::mirrorFrom(self::URL, self::createTempDir().'/clone', $driver); }]; + yield 'NUL byte' => [static function (DriverInterface $driver): void { Repository::isValidRepository(self::URL."\0", $driver); }]; + yield 'NUL byte in a branch' => [static function (DriverInterface $driver): void { Repository::isValidRepositoryAndBranch(self::URL, "master\0", $driver); }]; + yield 'ext' => [static function (DriverInterface $driver): void { Repository::cloneFrom('ext::'.self::URL, self::createTempDir().'/clone', driver: $driver); }]; + yield 'run' => [static function (DriverInterface $driver): void { new Repository(self::createFoobarRepository()->getPath(), driver: $driver)->run('remote', ['add', 'private', self::URL."\0"]); }]; + } + + public function testTheLogsHideTheCredentials(): void + { + $this->skipUnlessCliDriver(); + + $messages = []; + $log = static function (string $message) use (&$messages): void { + $messages[] = $message; + }; + $logger = $this->createStub(LoggerInterface::class); + $logger->method('info')->willReturnCallback($log); + $logger->method('debug')->willReturnCallback($log); + + $repository = new Repository(self::createFoobarRepository()->getPath(), driver: self::getCliDriver(logger: $logger)); + // The URL is in the command, then in its output. + $repository->run('remote', ['add', 'private', self::URL]); + $repository->run('remote', ['-v']); + + $this->assertStringContainsString("private\thttp://***@127.0.0.1:1/repository.git (fetch)\n", implode("\n", $messages)); + foreach ($messages as $message) { + $this->assertStringNotContainsString('secret', $message); + } + } + + #[DataProvider('provideTexts')] + public function testRedact(string $expected, string $text): void + { + $this->assertSame($expected, Credentials::redact($text)); + } + + /** + * @return iterable + */ + public static function provideTexts(): iterable + { + yield 'user and password' => ["clone 'https://***@example.com/a.git'", "clone 'https://user:password@example.com/a.git'"]; + yield 'token' => ['https://***@example.com', 'https://ghp_token@example.com']; + yield 'password holding an "@"' => ['https://***@example.com/a.git', 'https://user:p@ss@example.com/a.git']; + yield 'password holding a "#"' => ['https://***@example.com/a.git', 'https://user:p#ss@example.com/a.git']; + yield 'password holding a "?"' => ['https://***@example.com/a.git', 'https://user:p?ss@example.com/a.git']; + yield 'several URLs' => ['ssh://***@a.com/x and https://***@b.com/y', 'ssh://git@a.com/x and https://user:password@b.com/y']; + yield 'no user information' => ['https://example.com/a@b.git', 'https://example.com/a@b.git']; + yield 'no scheme' => ['git@example.com:a.git', 'git@example.com:a.git']; + } + + /** + * @param array $values + */ + private function assertNoSecretIn(array $values): void + { + foreach ($values as $value) { + if (\is_array($value)) { + $this->assertNoSecretIn($value); + } elseif (\is_string($value)) { + $this->assertStringNotContainsString('secret', $value); + } + } + } +} diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 7c5e9afe..05b95bd9 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -63,10 +63,10 @@ public function testGetFilesModification(bool $bare): void $this->assertTrue($files[0]->isModification(), 'image.jpg modified'); - $this->assertSame('image.jpg', $files[0]->getOldName(), 'Second file name is image.jpg'); - $this->assertSame('image.jpg', $files[0]->getNewName(), 'Second file name is image.jpg'); - $this->assertSame('100644', $files[0]->getOldMode(), 'Second file mode is a new file'); - $this->assertSame('100644', $files[0]->getNewMode(), 'Second file mode is correct'); + $this->assertSame('image.jpg', $files[0]->getOldName(), 'Old file name is image.jpg'); + $this->assertSame('image.jpg', $files[0]->getNewName(), 'New file name is image.jpg'); + $this->assertSame('100644', $files[0]->getOldMode(), 'Old file mode is a regular file'); + $this->assertSame('100644', $files[0]->getNewMode(), 'New file mode is a regular file'); $this->assertTrue($files[0]->isBinary(), 'binary file'); $this->assertSame(0, $files[0]->getAdditions(), '0 lines added'); @@ -278,6 +278,41 @@ public function testParseRequiresTheRawInformation(): void Diff::parse("diff --git a/a.out b/a.out\nold mode 100755\nnew mode 100644\n"); } + /** + * What the parser doesn't know would be lost, with the files after it. + * The message has the start of its line only, which may come from a file. + */ + public function testParseRefusesWhatItDoesNotKnow(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to parse the diff from "Submodule sub 1111111..2222222:".'); + + Diff::parse(":160000 160000 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 M\tsub\n\nSubmodule sub 1111111..2222222:\n > Second\n"); + } + + /** + * "git diff --staged" during a conflict, whose unmerged path comes last. + */ + public function testParseLeavesOutTheLastUnmergedPath(): void + { + $diff = Diff::parse(<<<'DIFF' + :000000 100644 0000000000000000000000000000000000000000 e45c9c2666d44e0327c1f9c239a74c508336053e A other + :100644 000000 ba2906d0666cf726c7eaadd2cd3db615dedfdf3a 0000000000000000000000000000000000000000 U zconflict + + diff --git a/other b/other + new file mode 100644 + index 0000000000000000000000000000000000000000..e45c9c2666d44e0327c1f9c239a74c508336053e + --- /dev/null + +++ b/other + @@ -0,0 +1 @@ + +other + * Unmerged path zconflict + + DIFF); + + $this->assertSame([['other', [[0, 0, 1, 1, [['Addition', 'other']]]]]], self::changes($diff)); + } + public function testParseAnEmptyDiff(): void { $this->assertSame([], Diff::parse('')->getFiles()); @@ -543,11 +578,108 @@ public function testDirtySubmodule(): void $this->assertSame('sub', $firstFile->getNewName()); } + /** + * With diff.submodule=log, which the configuration of the tests sets, "git + * diff" writes the log of a submodule instead of its patch. + */ + public function testTheFilesAfterASubmoduleKeepTheirPatch(): void + { + $repository = self::createWorkingCopy(); + $submodule = Repository::init($repository->getWorkingDir().'/sub', self::getCliDriver()); + self::git($submodule, 'config', ['user.name', 'Unit Test']); + self::git($submodule, 'config', ['user.email', 'unit_test@unit-test.com']); + self::git($submodule, 'commit', ['-q', '--allow-empty', '-m', 'First']); + $first = trim(self::git($submodule, 'rev-parse', ['HEAD'])); + self::git($repository, 'update-index', ['--add', '--cacheinfo', '160000', $first, 'sub']); + self::commitFile($repository, 'z.txt', "a\n"); + + self::git($submodule, 'commit', ['-q', '--allow-empty', '-m', 'Second']); + $second = trim(self::git($submodule, 'rev-parse', ['HEAD'])); + file_put_contents($repository->getWorkingDir().'/z.txt', "b\n"); + + $diff = $repository->getWorkingCopy()->getDiffPending(); + + $this->assertSame([ + ['sub', [[1, 1, 1, 1, [['Deletion', 'Subproject commit '.$first], ['Addition', 'Subproject commit '.$second]]]]], + ['z.txt', [[1, 1, 1, 1, [['Deletion', 'a'], ['Addition', 'b']]]]], + ], self::changes($diff)); + $this->assertSame(sha1("blob 2\0b\n"), $diff->getFiles()[1]->getNewIndex()); + } + + /** + * Unlike diff-tree, "git diff" reads diff.algorithm, diff.context, + * diff.orderFile... which the configuration of the tests changes (see + * fixtures/home/.gitconfig): the diff of the working tree is the one of + * its commit all the same. + */ + public function testTheDiffOfTheWorkingTreeIsTheOneOfItsCommit(): void + { + $repository = self::createWorkingCopy(); + // Ten lines between two changes: two hunks. + $lines = array_map(static fn (int $number): string => 'line '.$number, range(1, 20)); + self::commitFile($repository, 'notes.txt', implode("\n", $lines)."\n"); + // The diff algorithm of git keeps "{", histogram keeps "}". + self::commitFile($repository, 'code.c', "c\n}\n{\n"); + + $lines[2] = 'line 3, changed'; + $lines[13] = 'line 14, changed'; + file_put_contents($repository->getWorkingDir().'/notes.txt', implode("\n", $lines)."\n"); + file_put_contents($repository->getWorkingDir().'/code.c', "c\n{\nc\n}\n"); + + $workingTreeDiff = $repository->getWorkingCopy()->getDiff(); + self::git($repository, 'commit', ['-q', '-a', '-m', 'Changes']); + $commitDiff = $repository->getHeadCommit()->getDiff(); + + $this->assertSame(['code.c', 'notes.txt'], array_map(static fn (File $file): string => $file->getName(), $commitDiff->getFiles())); + $this->assertSame(self::changes($commitDiff), self::changes($workingTreeDiff)); + } + + /** + * The indent heuristic of git, which diff.indentHeuristic turns off, + * places an added block after the blank line that ends it. + */ + public function testTheIndentHeuristicIsGits(): void + { + if (version_compare(self::gitVersion(), '2.11.0', '<')) { + $this->markTestSkipped('git has an indent heuristic since 2.11.'); + } + + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'code.c', "\n\nfoo();\n"); + self::commitFile($repository, 'code.c', "\n\nfoo();\n\nfoo();\n"); + + $this->assertSame([['code.c', [[1, 3, 1, 5, [ + ['Context', ''], + ['Context', ''], + ['Addition', 'foo();'], + ['Addition', ''], + ['Context', 'foo();'], + ]]]]], self::changes($repository->getHeadCommit()->getDiff())); + } + + /** + * "git diff" reads diff.noprefix, diff.mnemonicPrefix, diff.srcPrefix and + * diff.dstPrefix too: a patch to apply has the prefixes of git. + */ + public function testTheRawDiffOfTheWorkingTreeHasThePrefixesOfGit(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createWorkingCopy(); + self::commitFile($repository, 'code.c', "a\n"); + file_put_contents($repository->getWorkingDir().'/code.c', "b\n"); + + $rawDiff = $repository->getWorkingCopy()->getDiff()->getRawDiff(); + + $this->assertStringContainsString("diff --git a/code.c b/code.c\n", $rawDiff); + $this->assertStringContainsString("--- a/code.c\n+++ b/code.c\n", $rawDiff); + } + protected function verifyCreateCommitDiff(Diff $diff): void { $files = $diff->getFiles(); - $this->assertCount(2, $files, '1 file in diff'); + $this->assertCount(2, $files, '2 files in diff'); $this->assertTrue($files[0]->isCreation(), 'script_A.php created'); @@ -560,6 +692,20 @@ protected function verifyCreateCommitDiff(Diff $diff): void $this->assertSame(0, $files[0]->getDeletions(), '0 lines deleted'); } + /** + * @return array}>}> The name of each file, with its hunks + */ + private static function changes(Diff $diff): array + { + return array_map(static fn (File $file): array => [$file->getName(), array_map(static fn (FileChange $change): array => [ + $change->getRangeOldStart(), + $change->getRangeOldCount(), + $change->getRangeNewStart(), + $change->getRangeNewCount(), + array_map(static fn (DiffLine $line): array => [$line->getType()->name, $line->getContent()], $change->getLines()), + ], $file->getChanges())], $diff->getFiles()); + } + private static function createWorkingCopy(): Repository { $repository = self::createEmptyRepository(false); diff --git a/tests/Gitonomy/Git/Tests/DriverParityTest.php b/tests/Gitonomy/Git/Tests/DriverParityTest.php index da19c7c1..c27752ef 100644 --- a/tests/Gitonomy/Git/Tests/DriverParityTest.php +++ b/tests/Gitonomy/Git/Tests/DriverParityTest.php @@ -14,6 +14,7 @@ use Gitonomy\Git\Diff\DiffLine; use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Driver\Data\DiffFileData; use Gitonomy\Git\Driver\Data\ObjectType; use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use Gitonomy\Git\Driver\RepositoryDriverInterface; @@ -91,6 +92,22 @@ public function testDiffs(): void } } + /** + * doc/repository.md: rename detection in diffs may pair files differently. + */ + public function testRenameDetectionMayPairFilesDifferently(): void + { + $master = $this->cli->resolveRevision('master'); + $cli = $this->cli->getDiff(self::INITIAL_COMMIT, $master)->files; + $libgit2 = $this->libgit2->getDiff(self::INITIAL_COMMIT, $master)->files; + + // git deletes README and creates README.md, which libgit2 finds similar enough for a rename. + $this->assertSame([['README', null], [null, 'README.md']], self::readmeNames($cli)); + $this->assertSame([['README', 'README.md']], self::readmeNames($libgit2)); + $this->assertCount(13, $cli); + $this->assertCount(12, $libgit2); + } + public function testBlame(): void { $master = $this->cli->resolveRevision('master'); @@ -127,6 +144,18 @@ private function assertSameBlob(string $hash): void } } + /** + * @param list $files + * + * @return list The old and new names of the files named README or README.md + */ + private static function readmeNames(array $files): array + { + $names = array_map(static fn (DiffFileData $file): array => [$file->oldName, $file->newName], $files); + + return array_values(array_filter($names, static fn (array $pair): bool => [] !== array_intersect($pair, ['README', 'README.md']))); + } + private function assertSameData(mixed $expected, mixed $actual, string $message = ''): void { $this->assertSame(self::normalize($expected), self::normalize($actual), $message); diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 75a5f68a..cbde3a5f 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -13,7 +13,6 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Exception\InvalidArgumentException; -use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\BeforeClass; @@ -69,6 +68,52 @@ public function assertNoHook(Repository $repository, string $hook): void $this->assertFileDoesNotExist($file, "Hook {$hook} is not present"); } + /** + * A hook is a file of the hooks directory: "../config" would be the + * configuration of the repository. + */ + #[DataProvider('provideInvalidNames')] + public function testAnInvalidNameIsRefused(string $name): void + { + $repository = self::createFoobarRepository(); + $hooks = $repository->getHooks(); + $calls = [ + 'has' => static fn () => $hooks->has($name), + 'get' => static fn () => $hooks->get($name), + 'set' => static fn () => $hooks->set($name, 'content'), + 'setSymlink' => static fn () => $hooks->setSymlink($name, __FILE__), + 'remove' => static fn () => $hooks->remove($name), + ]; + + foreach ($calls as $method => $call) { + try { + $call(); + $this->fail(\sprintf('%s() refuses "%s".', $method, $name)); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('is not a valid hook name', $e->getMessage()); + } + } + + $this->assertFileExists($repository->getGitDir().'/config'); + $this->assertFileDoesNotExist($repository->getGitDir().'/evil'); + } + + /** + * @return iterable + */ + public static function provideInvalidNames(): iterable + { + yield 'parent directory' => ['../config']; + yield 'new file in the parent directory' => ['../evil']; + yield 'Windows separator' => ['..\config']; + yield 'sub-directory' => ['sub/pre-commit']; + yield 'dot' => ['.']; + yield 'dot dot' => ['..']; + yield 'empty' => ['']; + yield 'hidden file' => ['.pre-commit']; + yield 'NUL byte' => ["pre-commit\0.sample"]; + } + #[DataProvider('provideBare')] public function testHas(bool $bare): void { @@ -83,6 +128,17 @@ public function testGetInvalidNameThrowsException(bool $bare): void { $repository = self::createFoobarRepository($bare); $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"../config" is not a valid hook name: it must be the name of a file of the hooks directory, like "pre-commit".'); + + $repository->getHooks()->get('../config'); + } + + #[DataProvider('provideBare')] + public function testGetMissingThrowsException(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The hook "foo" does not exist.'); $repository->getHooks()->get('foo'); } @@ -119,6 +175,7 @@ public function testSymlinkWithExistingThrows(bool $bare): void { $repository = self::createFoobarRepository($bare); $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The hook "foo" exists already.'); $this->markAsSkippedIfSymlinkIsMissing(); @@ -151,6 +208,7 @@ public function testSetExistingThrows(bool $bare): void $repository->getHooks()->set('foo', 'bar'); $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The hook "foo" exists already.'); $repository->getHooks()->set('foo', 'bar'); } @@ -172,10 +230,47 @@ public function testRemoveNotExistingThrows(bool $bare): void { $repository = self::createFoobarRepository($bare); $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The hook "foo" does not exist.'); $repository->getHooks()->remove('foo'); } + /** + * A symbolic link to a file deployed later: set() would write through it, + * out of the hooks directory. + */ + public function testASymlinkToAMissingFileIsAHook(): void + { + $this->markAsSkippedIfSymlinkIsMissing(); + + $repository = self::createFoobarRepository(); + $target = self::createTempDir().'/pre-receive'; + $hooks = $repository->getHooks(); + $hooks->setSymlink('pre-receive', $target); + + $this->assertTrue($hooks->has('pre-receive')); + + try { + $hooks->get('pre-receive'); + $this->fail('A symbolic link to a missing file can\'t be read.'); + } catch (RuntimeException $e) { + $this->assertSame('Unable to read the hook "pre-receive".', $e->getMessage()); + } + + try { + $hooks->set('pre-receive', "#!/bin/sh\nexit 1\n"); + $this->fail('The hook exists already.'); + } catch (RuntimeException $e) { + $this->assertSame('The hook "pre-receive" exists already.', $e->getMessage()); + } + $this->assertFileDoesNotExist($target, 'Nothing is written through the link'); + + $hooks->remove('pre-receive'); + + $this->assertFalse(is_link($this->hookPath($repository, 'pre-receive'))); + $this->assertFalse($hooks->has('pre-receive')); + } + public function testSetCreatesTheHooksDirectory(): void { $repository = self::createEmptyRepository(); @@ -188,15 +283,14 @@ public function testSetCreatesTheHooksDirectory(): void public function testALinkedWorktreeSharesTheHooksOfItsRepository(): void { - $repository = self::createFoobarRepository(false); - $dir = self::createTempDir().'/worktree'; - - try { - self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); - } catch (ProcessException) { + if (version_compare(self::gitVersion(), '2.5.0', '<')) { $this->markTestSkipped('git worktree requires git 2.5.'); } + $repository = self::createFoobarRepository(false); + $dir = self::createTempDir().'/worktree'; + self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); + $worktree = new Repository($dir, driver: self::getDriver()); $worktree->getHooks()->set('pre-commit', "#!/bin/sh\nexit 1\n"); diff --git a/tests/Gitonomy/Git/Tests/LeadingDashTest.php b/tests/Gitonomy/Git/Tests/LeadingDashTest.php new file mode 100644 index 00000000..8eda91fe --- /dev/null +++ b/tests/Gitonomy/Git/Tests/LeadingDashTest.php @@ -0,0 +1,80 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * git reads an argument starting with a dash as an option: a revision from + * the URL of a web application, like "--output=/path", would write a file. + */ +class LeadingDashTest extends AbstractTestCase +{ + #[DataProvider('provideCalls')] + public function testARevisionStartingWithADashIsRefused(\Closure $call): void + { + $repository = self::createFoobarRepository(false); + $file = self::createTempDir().'/written'; + + try { + $call($repository, '--output='.$file); + $this->fail('A leading dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('starts with a dash', $e->getMessage()); + } + + $this->assertFileDoesNotExist($file); + $this->assertTrue($repository->getReferences()->hasBranch('master'), 'Nothing is done'); + } + + /** + * @return iterable + */ + public static function provideCalls(): iterable + { + yield 'revision' => [static function (Repository $repository, string $option): void { $repository->getRevision($option)->getCommit(); }]; + yield 'range of a log' => [static function (Repository $repository, string $option): void { $repository->getLog($option.'..master')->getCommits(); }]; + yield 'blame' => [static function (Repository $repository, string $option): void { $repository->getBlame($option, 'README.md')->getLines(); }]; + yield 'diff' => [static function (Repository $repository, string $option): void { $repository->getDiff($option, 'master'); }]; + yield 'checkout' => [static function (Repository $repository, string $option): void { $repository->getWorkingCopy()->checkout($option); }]; + yield 'new branch' => [static function (Repository $repository, string $option): void { $repository->getWorkingCopy()->checkoutNewBranch($option, 'master'); }]; + yield 'merge' => [static function (Repository $repository, string $option): void { $repository->getWorkingCopy()->merge($option); }]; + yield 'reference' => [static function (Repository $repository, string $option): void { new Branch($repository, $option, self::LONGFILE_COMMIT)->delete(); }]; + } + + /** + * Unlike a URL, a path may start with a dash: git reads it after "--". + */ + public function testAPathMayStartWithADash(): void + { + $dir = self::createTempDir(); + $previousCwd = getcwd(); + $this->assertIsString($previousCwd); + chdir($dir); + + try { + Repository::init('-repository', self::getDriver()); + Repository::initBare('-bare', self::getDriver()); + Repository::cloneFrom(self::repositoryUrl(), '-clone', driver: self::getDriver()); + } finally { + chdir($previousCwd); + } + + $this->assertDirectoryExists($dir.'/-repository/.git'); + $this->assertFileExists($dir.'/-bare/HEAD'); + $this->assertFileExists($dir.'/-clone/README.md'); + } +} diff --git a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php index 01a65e4c..e48c1ab4 100644 --- a/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php +++ b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php @@ -13,6 +13,9 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Driver\Ffi\LibGit2Driver; +use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use Symfony\Component\Process\Process; @@ -66,4 +69,76 @@ public function testPreloadKeepsLibgit2LoadedForTheProcess(): void $this->assertSame($repository->getHeadCommit()->getHash(), $process->getOutput()); } + + #[RequiresPhpExtension('ffi')] + public function testTheSystemConfigurationIsLeftOutWithGitConfigNoSystem(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + + // libgit2 is initialized once per process. 4 and 2 read the search + // path (GIT_OPT_GET_SEARCH_PATH) of the system configuration + // (GIT_CONFIG_LEVEL_SYSTEM), "/etc" on Linux. + $code = <<<'PHP' + require $argv[1]; + $ffi = Gitonomy\Git\Driver\Ffi\LibGit2::load(null); + $path = $ffi->new('git_buf'); + $ffi->git_libgit2_opts(4, 2, FFI::addr($path)); + echo FFI::string($path->ptr, $path->size); + $ffi->git_buf_dispose(FFI::addr($path)); + PHP; + + $process = new Process([\PHP_BINARY, '-r', $code, self::AUTOLOAD], env: ['GIT_CONFIG_NOSYSTEM' => '1']); + $process->mustRun(); + $this->assertSame('', $process->getOutput()); + + $process = new Process([\PHP_BINARY, '-r', $code, self::AUTOLOAD], env: ['GIT_CONFIG_NOSYSTEM' => false]); + $process->mustRun(); + $this->assertNotSame('', $process->getOutput()); + } + + public function testReftableRepositoriesAreUnsupported(): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + if (version_compare(self::gitVersion(), '2.45.0', '<')) { + $this->markTestSkipped('Reftable repositories require git 2.45.'); + } + + $dir = self::createTempDir(); + new Process([self::gitCommand(), 'init', '-q', '--ref-format=reftable', $dir])->mustRun(); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('stores its references in the reftable format, which LibGit2Driver doesn\'t support; use CliDriver.'); + + new Repository($dir, driver: new LibGit2Driver()); + } + + /** + * @param \Closure(LibGit2Driver, string): void $clone + */ + #[DataProvider('provideClones')] + public function testBundlesAreUnsupported(\Closure $clone): void + { + if (!LibGit2Driver::isSupported()) { + $this->markTestSkipped('libgit2 1.9 is not installed.'); + } + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage(\sprintf('"%s" is a bundle, which LibGit2Driver doesn\'t support; use CliDriver.', self::REPOSITORY_URL)); + + $clone(new LibGit2Driver(), self::createTempDir().'/clone'); + } + + /** + * @return iterable + */ + public static function provideClones(): iterable + { + yield 'clone' => [static fn (LibGit2Driver $driver, string $path) => $driver->cloneFrom(self::REPOSITORY_URL, $path, null)]; + yield 'bare clone' => [static fn (LibGit2Driver $driver, string $path) => $driver->cloneBareFrom(self::REPOSITORY_URL, $path, null)]; + yield 'mirror' => [static fn (LibGit2Driver $driver, string $path) => $driver->mirrorFrom(self::REPOSITORY_URL, $path)]; + } } diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 358bad7d..597001ef 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -13,10 +13,13 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Commit; +use Gitonomy\Git\Diff\File; use Gitonomy\Git\Driver\Cli\Parser\LogParser; +use Gitonomy\Git\Driver\Ffi\LibGit2Driver; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\Log; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; @@ -248,6 +251,52 @@ public function testLogOnWildcardPath(): void $this->assertSame([$text], self::hashes($repository->getLog('main', 'dir/*.txt'))); } + public function testLogOfAPathNormalizesIt(): void + { + $repository = self::createRepositoryToCommitTo(); + mkdir($repository->getWorkingDir().'/dir'); + $file = self::commit($repository, 'dir/file.txt', 'file'); + self::commit($repository, 'other.txt', 'other'); + + // Like git. + foreach (['dir/.', 'dir//file.txt', 'other/../dir/file.txt', './dir/', $repository->getWorkingDir().'/dir/../dir'] as $path) { + $this->assertSame([$file], self::hashes($repository->getLog('main', $path)), $path); + } + } + + public function testLogOfAPathAboveTheRootThrows(): void + { + $repository = self::createRepositoryToCommitTo(); + self::commit($repository, 'file.txt', 'file'); + + // Like git. + $this->expectException(DriverException::class); + if (self::getDriver() instanceof LibGit2Driver) { + $this->expectExceptionMessage('The path "dir/../../file.txt" is outside the repository.'); + } else { + // git 1.8.4 doesn't repeat the path first, and recent versions name the repository after it. + $this->expectExceptionMessageMatches('/^The git command "log" failed \(exit 128\): fatal: (?:dir\/\.\.\/\.\.\/file\.txt: )?\'dir\/\.\.\/\.\.\/file\.txt\' is outside repository/'); + } + + $repository->getLog('main', 'dir/../../file.txt')->getCommits(); + } + + public function testLogOfAPathWithMagic(): void + { + $repository = self::createRepositoryToCommitTo(); + mkdir($repository->getWorkingDir().'/dir'); + self::commit($repository, 'dir/file.txt', 'file'); + $other = self::commit($repository, 'other.txt', 'other'); + + if (self::getDriver() instanceof LibGit2Driver) { + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('The path ":/other.txt" uses the magic of git\'s pathspecs, which LibGit2Driver doesn\'t support; use CliDriver.'); + } + + // From the root of the repository, whatever the current directory. + $this->assertSame([$other], self::hashes($repository->getLog('main', ':/other.txt'))); + } + public function testLogOfAllReferencesIgnoresTheOnesToOtherObjects(): void { $repository = self::createRepositoryToCommitTo(); @@ -298,6 +347,22 @@ public function testLegacySetLimit(): void $this->assertCount(1, $log->getCommits()); } + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetDiffIgnoresThePathsOfTheLog(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Log::getDiff()" method is deprecated, use "Gitonomy\Git\Repository::getDiff()" instead.'); + + $repository = self::createFoobarRepository(); + $diff = $repository->getDiff(self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT)->toArray(); + $legacyDiff = $repository->getLog(self::BEFORE_LONGFILE_COMMIT.'..'.self::LONGFILE_COMMIT, 'README.md')->getDiff(); + + // A log of README.md, but the diff of the range, which changes long.php only. + $this->assertSame(['long.php'], array_map(static fn (File $file): string => $file->getName(), $legacyDiff->getFiles())); + $this->assertSame($diff, $legacyDiff->toArray()); + $this->assertSame($diff, $repository->getLog([self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT])->getDiff()->toArray()); + } + public function testLogOfExcludedAndSymmetricRevisions(): void { $repository = self::createRepositoryToCommitTo(); @@ -315,6 +380,21 @@ public function testLogOfExcludedAndSymmetricRevisions(): void $this->assertSame([$main, $base], self::hashes($repository->getLog('main'))); } + /** + * The command line of a process has a limit, 2 MB by default on Linux: a + * log of thousands of branches would exceed it. + */ + public function testLogOfMoreRevisionsThanACommandLineTakes(): void + { + $repository = self::createFoobarRepository(); + // 4 MB of hashes. + $log = $repository->getLog(array_fill(0, 100_000, $repository->getCommit(self::LONGFILE_COMMIT))); + + $hashes = self::hashes($repository->getLog(self::LONGFILE_COMMIT)); + $this->assertSame($hashes, self::hashes($log)); + $this->assertCount(\count($hashes), $log); + } + public function testLogOfAnUnknownRevisionNamesIt(): void { $repository = self::createFoobarRepository(); diff --git a/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php b/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php new file mode 100644 index 00000000..512d2c08 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php @@ -0,0 +1,116 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Process\Process; + +/** + * The message is a sentence, on one line: the outputs of git are whole in + * getErrorOutput() and getOutput(). + */ +class ProcessExceptionTest extends AbstractTestCase +{ + public function testTheMessageHasTheFirstLineOfTheErrorOutput(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/AUTHORS', 'local change'); + + try { + $repository->run('checkout', ['origin/pagination']); + $this->fail('The checkout would overwrite the local changes.'); + } catch (ProcessException $e) { + $this->assertSame('The git command "checkout" failed (exit 1): error: Your local changes to the following files would be overwritten by checkout.', $e->getMessage()); + $this->assertStringContainsString("\tAUTHORS\n", $e->getErrorOutput()); + } + } + + /** + * git warns about a HOME it can't read before its error. + */ + public function testTheMessageHasTheErrorAfterTheWarnings(): void + { + $this->skipUnlessCliDriver(); + + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/AUTHORS', 'local change'); + $home = self::createTempDir(); + mkdir($home.'/.config'); + $repository = new Repository($repository->getPath(), driver: self::getCliDriver(['HOME' => $home])); + + chmod($home.'/.config', 0); + + try { + if (is_readable($home.'/.config')) { + $this->markTestSkipped('The user of the tests reads any directory, like root.'); + } + + $repository->getWorkingCopy()->checkout('origin/pagination'); + $this->fail('The checkout would overwrite the local changes.'); + } catch (ProcessException $e) { + $this->assertSame('The git command "checkout" failed (exit 1): error: Your local changes to the following files would be overwritten by checkout.', $e->getMessage()); + $this->assertStringStartsWith(\sprintf("warning: unable to access '%s/.config/git/", $home), $e->getErrorOutput()); + } finally { + // Or the directory can't be deleted. + chmod($home.'/.config', 0o700); + } + } + + #[DataProvider('provideErrorOutputs')] + public function testTheMessageHasTheFirstError(string $errorOutput, string $message): void + { + $process = new Process([\PHP_BINARY, '-r', 'fwrite(STDERR, $argv[1]); exit(128);', '--', $errorOutput]); + $process->run(); + + $this->assertSame($message, new ProcessException($process, 'merge')->getMessage()); + } + + /** + * @return iterable + */ + public static function provideErrorOutputs(): iterable + { + yield 'warning first' => ["warning: refname 'feature' is ambiguous.\nfatal: ambiguous object name: 'feature'\n", 'The git command "merge" failed (exit 128): fatal: ambiguous object name: \'feature\'.']; + yield 'errors' => ["error: first\nfatal: second\n", 'The git command "merge" failed (exit 128): error: first.']; + yield 'no error' => ["\nwarning: something\nmerge: nope - not something we can merge\n", 'The git command "merge" failed (exit 128): warning: something.']; + } + + public function testTheMessageOfAFailureWithoutErrorOutputEndsWithTheExitCode(): void + { + $this->skipUnlessCliDriver(); + + try { + self::createFoobarRepository()->run('diff', ['--exit-code', '--no-ext-diff', '--no-textconv', self::INITIAL_COMMIT, self::LONGFILE_COMMIT]); + $this->fail('The commits differ.'); + } catch (ProcessException $e) { + $this->assertSame('The git command "diff" failed (exit 1).', $e->getMessage()); + $this->assertSame('', $e->getErrorOutput()); + $this->assertStringContainsString('diff --git', $e->getOutput()); + } + } + + /** + * Like in 1.x, which had no other argument. + */ + public function testTheCommandIsOptional(): void + { + $process = new Process([self::gitCommand(), 'not-work'], self::createTempDir(), self::gitEnvs()); + $process->run(); + + $this->assertSame('The git command failed (exit 1): git: \'not-work\' is not a git command. See \'git --help\'.', new ProcessException($process)->getMessage()); + } +} diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 4251fdd2..b944031a 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -12,10 +12,12 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Tag; +use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; @@ -38,6 +40,25 @@ public function testGetBranch(bool $bare): void $this->assertSame($branch->getCommitHash(), $branch->getCommit()->getHash(), 'Hash is correctly resolved'); } + public function testEveryReferenceHasAName(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.email', 'test@example.com']); + self::git($repository, 'config', ['user.name', 'Test']); + file_put_contents($repository->getWorkingDir().'/README.md', "stashed\n"); + self::git($repository, 'stash'); + + $names = []; + foreach ($repository->getReferences() as $fullname => $reference) { + $names[$fullname] = $reference->getName(); + } + + $this->assertSame('master', $names['refs/heads/master']); + $this->assertSame('origin/master', $names['refs/remotes/origin/master']); + $this->assertSame('0.1', $names['refs/tags/0.1']); + $this->assertSame('stash', $names['refs/stash']); + } + #[DataProvider('provideBare')] public function testHasBranch(bool $bare): void { @@ -85,6 +106,7 @@ public function testAnnotatedTag(bool $bare): void $this->assertSame('Graham Campbell', $tag->getTaggerName(), 'Tagger name is correct'); $this->assertSame('graham@alt-three.com', $tag->getTaggerEmail(), 'Tagger email is correct'); $this->assertSame(1471428000, $tag->getTaggerDate()?->getTimestamp(), 'Tag date is correct'); + $this->assertSame('2016-08-17T12:00:00+02:00', $tag->getTaggerDate()->format(\DATE_ATOM), 'Tag date is at the offset of the tagger'); // Without an empty line after its first one, the message is one paragraph: its subject, like "git log --format=%s". $this->assertSame('heading body body', $tag->getSubjectMessage(), 'Message heading is correct'); @@ -97,6 +119,26 @@ public function testAnnotatedTag(bool $bare): void $this->assertContains($tag, $tag->getCommit()->resolveReferences()); } + /** + * Like git log, at the UTC offset of the tagger, not of the author or of + * the committer of the commit. + */ + public function testTheTaggerDateIsAtTheOffsetOfTheTagger(): void + { + $repository = self::createFoobarRepository(); + $git = new Repository($repository->getPath(), driver: self::getCliDriver([ + 'GIT_COMMITTER_DATE' => '1700000002 -0700', + 'GIT_COMMITTER_EMAIL' => 'tagger@example.com', + 'GIT_COMMITTER_NAME' => 'Tagger', + ])); + $git->run('tag', ['-a', '-m', 'Dates', 'dates', self::LONGFILE_COMMIT]); + + $date = new Repository($repository->getPath(), driver: self::getDriver())->getReferences()->getTag('dates')->getTaggerDate(); + + $this->assertSame('2023-11-14T15:13:22-07:00', $date?->format(\DATE_ATOM)); + $this->assertSame(1700000002, $date->getTimestamp()); + } + #[DataProvider('provideBare')] public function testAnnotatedTagBodyMessageWithBlankLineSeparator(bool $bare): void { @@ -332,6 +374,109 @@ public function testDeletingADeletedReferenceDoesNothing(): void $this->assertFalse($repository->getReferences()->hasBranch('deleted')); } + /** + * The symbolic reference is deleted, not the branch it points to. + */ + public function testDeletingASymbolicReference(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['refs/remotes/origin/main', self::LONGFILE_COMMIT]); + self::git($repository, 'symbolic-ref', ['refs/remotes/origin/HEAD', 'refs/remotes/origin/main']); + + $repository->getReferences()->delete('refs/remotes/origin/HEAD'); + + $this->assertSame(\sprintf("%s refs/remotes/origin/main\n", self::LONGFILE_COMMIT), self::git($repository, 'for-each-ref', ['--format=%(objectname) %(refname)', 'refs/remotes/origin/'])); + } + + /** + * The symbolic reference moves, not the branch it points to. + * ReferenceBag leaves it out: through the driver, which Branch::moveTo() + * calls. + */ + public function testUpdatingASymbolicReference(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'update-ref', ['refs/remotes/origin/main', self::LONGFILE_COMMIT]); + self::git($repository, 'symbolic-ref', ['refs/remotes/origin/HEAD', 'refs/remotes/origin/main']); + + $repository->getRepositoryDriver()->updateReference('refs/remotes/origin/HEAD', self::INITIAL_COMMIT); + + $this->assertSame(\sprintf("%s refs/remotes/origin/HEAD\n%s refs/remotes/origin/main\n", self::INITIAL_COMMIT, self::LONGFILE_COMMIT), self::git($repository, 'for-each-ref', ['--format=%(objectname) %(refname)', 'refs/remotes/origin/'])); + } + + /** + * "git update-ref -d HEAD" deletes HEAD: git no longer sees a repository. + */ + #[DataProvider('provideBare')] + public function testDeletingHeadIsRefused(bool $bare): void + { + $repository = self::createFoobarRepository($bare); + + try { + $repository->getReferences()->delete('HEAD'); + $this->fail('HEAD is no reference under "refs/".'); + } catch (InvalidArgumentException $e) { + $this->assertSame('"HEAD" is not the full name of a reference under "refs/", like "refs/heads/master".', $e->getMessage()); + } + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + } + + /** + * "git update-ref" writes any name: HEAD, or a short name next to it. + */ + #[DataProvider('provideWritesOfANameOutsideRefs')] + public function testTheDriverWritesNoReferenceOutsideRefs(\Closure $write, string $fullname): void + { + $repository = self::createFoobarRepository(); + + try { + $write($repository, $fullname); + $this->fail('Only a reference under "refs/" is written.'); + } catch (InvalidArgumentException $e) { + $this->assertSame(\sprintf('"%s" is not the full name of a reference under "refs/", like "refs/heads/master".', $fullname), $e->getMessage()); + } + + $this->assertSame('ref: refs/heads/master', trim((string) file_get_contents($repository->getGitDir().'/HEAD'))); + $this->assertFileDoesNotExist($repository->getGitDir().'/master'); + } + + /** + * @return iterable + */ + public static function provideWritesOfANameOutsideRefs(): iterable + { + $writes = [ + 'create' => static function (Repository $repository, string $fullname): void { $repository->getRepositoryDriver()->createReference($fullname, self::INITIAL_COMMIT); }, + 'update' => static function (Repository $repository, string $fullname): void { $repository->getRepositoryDriver()->updateReference($fullname, self::INITIAL_COMMIT); }, + 'delete' => static function (Repository $repository, string $fullname): void { $repository->getRepositoryDriver()->deleteReference($fullname); }, + ]; + + foreach ($writes as $writeName => $write) { + foreach (['HEAD', 'master'] as $fullname) { + yield $writeName.' '.$fullname => [$write, $fullname]; + } + } + } + + /** + * Nothing is created through a symbolic reference to a missing branch: + * git 2.x and libgit2 refuse it, git 1.x replaces it. + */ + public function testCreatingABranchNamedLikeADanglingSymbolicReference(): void + { + $repository = self::createFoobarRepository(); + self::git($repository, 'symbolic-ref', ['refs/heads/dangling', 'refs/heads/missing']); + + try { + $repository->getReferences()->createBranch('dangling', self::LONGFILE_COMMIT); + } catch (DriverException) { + } + + $this->assertFalse($repository->getReferences()->hasBranch('missing')); + } + public function testTheHeadOfARemoteNamedWithASlashIsNoBranch(): void { $repository = self::createFoobarRepository(); @@ -344,6 +489,18 @@ public function testTheHeadOfARemoteNamedWithASlashIsNoBranch(): void $this->assertNotContains('refs/remotes/foo/bar/HEAD', $names); } + public function testABranchPointingToAnAnnotatedTagPointsToItsCommit(): void + { + $repository = self::createFoobarRepository(); + // Like a fetch of the tags of a remote under refs/remotes/. + self::git($repository, 'update-ref', ['refs/remotes/origin/tags/annotated', trim(self::git($repository, 'rev-parse', ['refs/tags/annotated']))]); + + $branch = $repository->getReferences()->getRemoteBranch('origin/tags/annotated'); + + $this->assertSame('fbde681b329a39e08b63dc54b341a3274c0380c0', $branch->getCommitHash()); + $this->assertContains($branch, $repository->getCommit(self::INITIAL_COMMIT)->getIncludingBranches()); + } + /** * Old tags (e.g. in git.git or linux) have no "tagger" line. */ diff --git a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php new file mode 100644 index 00000000..cdb56a2b --- /dev/null +++ b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php @@ -0,0 +1,165 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\Credentials; +use Gitonomy\Git\Util\RemoteUrl; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * An application may check or clone a URL from its users: git must run no + * command for it. + */ +class RemoteUrlTest extends AbstractTestCase +{ + /** + * In a repository with a remote, "git ls-remote --upload-pack=..." runs + * the command to list the references of the remote. + */ + #[DataProvider('provideCalls')] + public function testAUrlStartingWithADashIsRefused(\Closure $call): void + { + $repository = self::createFoobarRepository(false); + $previousCwd = getcwd(); + $this->assertIsString($previousCwd); + chdir($repository->getPath()); + + try { + // A colon before any slash: a URL, never a local path. + $call('--upload-pack=touch pwned;:;git-upload-pack', self::createTempDir().'/clone'); + $this->fail('A leading dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('starts with a dash', $e->getMessage()); + } finally { + chdir($previousCwd); + } + + $this->assertFileDoesNotExist($repository->getPath().'/pwned'); + } + + /** + * git runs the command of an "ext::" URL, unless its protocol.allow + * setting forbids it, as it does by default since git 2.12. + */ + #[DataProvider('provideCalls')] + public function testAnExtUrlIsRefused(\Closure $call): void + { + $file = self::createTempDir().'/pwned'; + + try { + // "% " is a space in the command of an "ext::" URL. + $call('ext::sh -c touch% '.$file, self::createTempDir().'/clone'); + $this->fail('An "ext::" URL is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('uses the "ext::" transport', $e->getMessage()); + } + + $this->assertFileDoesNotExist($file); + } + + /** + * @return iterable + */ + public static function provideCalls(): iterable + { + yield 'valid repository' => [static function (string $url): void { Repository::isValidRepository($url, self::getDriver()); }]; + yield 'valid branch' => [static function (string $url): void { Repository::isValidRepositoryAndBranch($url, 'master', self::getDriver()); }]; + yield 'clone' => [static function (string $url, string $path): void { Repository::cloneFrom($url, $path, driver: self::getDriver()); }]; + yield 'bare clone' => [static function (string $url, string $path): void { Repository::cloneBareFrom($url, $path, driver: self::getDriver()); }]; + yield 'mirror' => [static function (string $url, string $path): void { Repository::mirrorFrom($url, $path, self::getDriver()); }]; + } + + /** + * Before 2.14.1 (CVE-2017-1000117), git passes a host starting with a dash + * to ssh, which reads it as an option: "-oProxyCommand=..." runs the + * command. Recent versions of OpenSSH refuse such a host. + */ + #[DataProvider('provideHostsStartingWithADash')] + public function testAHostStartingWithADashIsRefused(\Closure $call, string $url): void + { + $dir = self::createTempDir(); + $previousCwd = getcwd(); + $this->assertIsString($previousCwd); + chdir($dir); + + try { + $call($url, $dir.'/clone'); + $this->fail('A host starting with a dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertSame(\sprintf('"%s" has a host or a user starting with a dash, which ssh would read as an option.', Credentials::redact($url)), $e->getMessage()); + } finally { + chdir($previousCwd); + } + + $this->assertFileDoesNotExist($dir.'/pwned'); + } + + /** + * @return iterable + */ + public static function provideHostsStartingWithADash(): iterable + { + // "${IFS}": the space of a command in a host. + $command = '-oProxyCommand=touch${IFS}pwned'; + $urls = [ + 'ssh' => 'ssh://'.$command.'/repository', + 'git+ssh' => 'git+ssh://'.$command.'/repository', + 'ssh+git' => 'ssh+git://'.$command.'/repository', + 'host between brackets' => 'ssh://['.$command.']/repository', + 'user' => 'ssh://'.$command.'@example.com/repository', + 'host after a user' => 'ssh://git@'.$command.'/repository', + 'address' => 'git@'.$command.':repository', + 'address between brackets' => '['.$command.']:repository', + // Passed to the command of the core.gitProxy setting. + 'git' => 'git://'.$command.'/repository', + // git decodes a URL before it reads its host. + 'encoded dash' => 'ssh://%2D'.substr($command, 1).'/repository', + 'encoded dash, lowercase' => 'git+ssh://%2d'.substr($command, 1).'/repository', + 'encoded dash of a user' => 'ssh://%2D'.substr($command, 1).'@example.com/repository', + 'encoded brackets' => 'ssh://%5B'.$command.'%5D/repository', + ]; + + foreach (self::provideCalls() as $callName => [$call]) { + foreach ($urls as $urlName => $url) { + yield $callName.', '.$urlName => [$call, $url]; + } + } + } + + #[DataProvider('provideUrlsGitReachesWithoutRunningACommand')] + public function testAUrlGitReachesWithoutRunningACommandIsAccepted(string $url): void + { + RemoteUrl::assertSafe($url); + + $this->addToAssertionCount(1); + } + + /** + * @return iterable + */ + public static function provideUrlsGitReachesWithoutRunningACommand(): iterable + { + yield 'ssh' => ['ssh://git@example.com:2222/repository.git']; + yield 'host between brackets' => ['ssh://git@[::1]/repository.git']; + yield 'address' => ['git@example.com:repository.git']; + yield 'dash in the path of an address' => ['git@example.com:-repository.git']; + yield 'dash in the host' => ['ssh://git@my-host.example.com/repository.git']; + yield 'encoded character in the user' => ['ssh://first%2Dlast@example.com/repository.git']; + // Not reached through ssh: a token may start with a dash. + yield 'https' => ['https://-token@example.com/repository.git']; + yield 'local path' => ['/path/to/repository.git']; + yield 'local path with a colon' => ['./a:b']; + } +} diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 40bad56b..50fd6782 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -24,7 +24,6 @@ use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ProcessTimedOutException; use Gitonomy\Git\Exception\ReferenceNotFoundException; -use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; @@ -55,20 +54,19 @@ public function testInit(): void $this->assertFalse($repository->isBare(), 'Repository is not bare'); $this->assertDirectoryExists($dir.'/.git/objects', 'objects/ folder is present'); $this->assertSame($dir.'/.git', $repository->getGitDir(), 'git dir as subfolder of argument'); - $this->assertSame($dir, $repository->getWorkingDir(), 'working dir present in bare repository'); + $this->assertSame($dir, $repository->getWorkingDir(), 'The folder passed as argument is the working dir'); } public function testOpensALinkedWorktree(): void { - $repository = self::createFoobarRepository(false); - $dir = self::createTempDir().'/worktree'; - - try { - self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); - } catch (ProcessException) { + if (version_compare(self::gitVersion(), '2.5.0', '<')) { $this->markTestSkipped('git worktree requires git 2.5.'); } + $repository = self::createFoobarRepository(false); + $dir = self::createTempDir().'/worktree'; + self::git($repository, 'worktree', ['add', '-b', 'in-worktree', $dir]); + // Its ".git" is a file pointing to a directory of the main repository. $worktree = new Repository($dir, driver: self::getDriver()); @@ -95,13 +93,13 @@ public function testReadsTheHeadOfAReftableRepository(): void { // libgit2 1.9 doesn't read reftable repositories. $this->skipUnlessCliDriver(); - - $dir = self::createTempDir(); - $process = new Process([self::gitCommand(), 'init', '-q', '--ref-format=reftable', '--initial-branch=trunk', $dir]); - if (0 !== $process->run()) { + if (version_compare(self::gitVersion(), '2.45.0', '<')) { $this->markTestSkipped('Reftable repositories require git 2.45.'); } + $dir = self::createTempDir(); + new Process([self::gitCommand(), 'init', '-q', '--ref-format=reftable', '--initial-branch=trunk', $dir])->mustRun(); + $repository = new Repository($dir, driver: self::getDriver()); $this->assertTrue($repository->isHeadAttached()); @@ -123,6 +121,38 @@ public function testDetachedHead(): void $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); } + public function testTheHeadOfARepositoryWithoutCommitsThrows(): void + { + $repository = self::createEmptyRepository(); + + // HEAD points to a branch without commits yet. + $this->assertTrue($repository->isHeadAttached()); + + try { + $repository->getHeadCommit(); + $this->fail('A repository without commits has no HEAD commit.'); + } catch (ReferenceNotFoundException) { + } + + $this->expectException(ReferenceNotFoundException::class); + + $repository->getHead(); + } + + public function testHeadPointingToATagIsNoBranch(): void + { + $repository = self::createFoobarRepository(); + // Like a branch for git, which commits to the tag. + self::git($repository, 'symbolic-ref', ['HEAD', 'refs/tags/0.1']); + + $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('HEAD points to "refs/tags/0.1", which is not a branch.'); + + $repository->getHead(); + } + public function testObjectsAreTheSameWhileUsed(): void { $repository = self::createFoobarRepository(); @@ -134,12 +164,13 @@ public function testObjectsAreTheSameWhileUsed(): void public function testRefusesSha256Repositories(): void { - $dir = self::createTempDir(); - $process = new Process([self::gitCommand(), 'init', '-q', '--object-format=sha256', $dir]); - if (0 !== $process->run()) { + if (version_compare(self::gitVersion(), '2.29.0', '<')) { $this->markTestSkipped('SHA-256 repositories require git 2.29.'); } + $dir = self::createTempDir(); + new Process([self::gitCommand(), 'init', '-q', '--object-format=sha256', $dir])->mustRun(); + $this->expectException(UnsupportedOperationException::class); $this->expectExceptionMessage('uses SHA-256 hashes'); @@ -190,9 +221,13 @@ public function testIsValidRepositoryThrowsWhenGitCantRun(): void { $this->skipUnlessCliDriver(); - $this->expectException(ProcessException::class); - - Repository::isValidRepository(self::repositoryUrl(), new CliDriver('not-git')); + try { + Repository::isValidRepository(self::repositoryUrl(), new CliDriver('not-git')); + $this->fail('git is missing.'); + } catch (DriverException $e) { + // One line, the error of the shell, like "sh: 1: exec: not-git: not found". + $this->assertMatchesRegularExpression('/^The git command "ls-remote" could not start: [^\n]+\.$/', $e->getMessage()); + } } public function testCloneATagAsBranchDetachesHead(): void @@ -305,12 +340,24 @@ public function testGetMergeBase(): void } public function testAnAbbreviatedHashNamesAnObjectOfTheRightType(): void + { + $repository = self::createFoobarRepository(); + $hash = substr($repository->getCommit(self::LONGFILE_COMMIT)->getTreeHash(), 0, 8); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage(\sprintf('The object "%s" is not a blob.', $hash)); + + $repository->getBlob($hash); + } + + public function testAnAbbreviatedHashOfNoObject(): void { $repository = self::createFoobarRepository(); $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The object "12345678" does not exist.'); - $repository->getBlob(substr(self::LONGFILE_COMMIT, 0, 8)); + $repository->getTree('12345678'); } public function testAnAbbreviatedCommitHashNamesNoTree(): void @@ -326,11 +373,13 @@ public function testAnAbbreviatedCommitHashNamesNoTree(): void public function testInitOnAnExistingFileFails(): void { - $this->expectException(RuntimeException::class); - $file = self::createTempDir().'/test'; touch($file); + $this->expectException(DriverException::class); + // git and libgit2 name the file they can't make a directory of. + $this->expectExceptionMessage($file); + Repository::initBare($file, driver: self::getDriver()); } @@ -535,13 +584,36 @@ public function testGetBlobWithExistingWorks(bool $bare): void } #[DataProvider('provideBare')] - public function testGetSize(bool $bare): void + public function testGetSizeInBytes(bool $bare): void { $repository = self::createFoobarRepository($bare); - $size = $repository->getSize(); + $size = $repository->getSizeInBytes(); // The exact figure depends on git's packing/compression, which varies across versions. - $this->assertGreaterThanOrEqual(40, $size, 'Repository is at least 40KB'); - $this->assertLessThan(84, $size, 'Repository is less than 84KB'); + $this->assertGreaterThanOrEqual(40_000, $size, 'Repository is at least 40KB'); + $this->assertLessThan(84_000, $size, 'Repository is less than 84KB'); + } + + public function testGetSizeInBytesFollowsNoLink(): void + { + $repository = self::createEmptyRepository(); + $size = $repository->getSizeInBytes(); + + file_put_contents($repository->getGitDir().'/file', str_repeat('a', 1000)); + // Like a hook linking to a file not deployed yet. + symlink($repository->getGitDir().'/missing', $repository->getGitDir().'/link'); + + $this->assertSame($size + 1000, $repository->getSizeInBytes()); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testLegacyGetSizeInKilobytes(): void + { + $this->expectUserDeprecationMessage('Since gitonomy/gitlib 2.0: The "Gitonomy\Git\Repository::getSize()" method is deprecated, use "getSizeInBytes()" instead.'); + + $repository = self::createFoobarRepository(); + + $this->assertSame((int) round($repository->getSizeInBytes() / 1000), $repository->getSize()); } public function testIsBare(): void @@ -581,7 +653,8 @@ public function testLoggerOk(bool $bare): void #[DataProvider('provideBare')] public function testLoggerNOk(bool $bare): void { - $this->expectException(RuntimeException::class); + $this->expectException(ProcessException::class); + $this->expectExceptionMessage('The git command "not-work" failed (exit 1): git: \'not-work\' is not a git command. See \'git --help\'.'); $logger = $this->createMock(LoggerInterface::class); $logger->expects($this->once()) @@ -611,22 +684,23 @@ public function testRunResolvesRelativePathsAgainstTheRepositoryRegardlessOfCwd( $repository = self::createFoobarRepository(false); - $file = $repository->getWorkingDir().'/README.md'; - $original = file_get_contents($file); - file_put_contents($file, $original."Applied line.\n"); - - $patch = $repository->run('diff', ['--', 'README.md']); - file_put_contents($file, $original); - $patchFile = tempnam(sys_get_temp_dir(), 'gitlib_patch_'); - file_put_contents($patchFile, $patch); + file_put_contents($patchFile, <<<'PATCH' + diff --git a/applied.txt b/applied.txt + new file mode 100644 + --- /dev/null + +++ b/applied.txt + @@ -0,0 +1 @@ + +Applied line. + + PATCH); $previousCwd = getcwd(); $this->assertIsString($previousCwd); chdir(sys_get_temp_dir()); try { - // "README.md" is relative to the repository work-tree, not to the + // "applied.txt" is relative to the repository work-tree, not to the // process cwd (which is an unrelated directory here). This only // works if the git process is run with its cwd set to the repository. $repository->run('apply', [$patchFile]); @@ -635,7 +709,7 @@ public function testRunResolvesRelativePathsAgainstTheRepositoryRegardlessOfCwd( unlink($patchFile); } - $this->assertSame($original."Applied line.\n", file_get_contents($file)); + $this->assertSame("Applied line.\n", file_get_contents($repository->getWorkingDir().'/applied.txt')); } public function testRunIsOnlySupportedByCliDriver(): void diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 4867cdff..87ba273e 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -12,14 +12,18 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Diff\File; +use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\LogicException; -use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\FastForward; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\Attributes\TestWith; class WorkingCopyTest extends AbstractTestCase { @@ -100,6 +104,72 @@ public function testDiffPending(): void $this->assertCount(1, $diffPending->getFiles()); } + public function testFilesAddedWithIntentToAddAreCreatedNotStaged(): void + { + $this->skipUnlessGitHidesIntentToAddFromTheIndex(); + + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/new.txt', "new\n"); + touch($repository->getWorkingDir().'/empty.txt'); + self::git($repository, 'add', ['-N', 'new.txt', 'empty.txt']); + $wc = $repository->getWorkingCopy(); + + $this->assertSame([], $wc->getDiffStaged()->getFiles()); + $files = $wc->getDiffPending()->getFiles(); + $this->assertSame(['empty.txt', 'new.txt'], array_map(static fn (File $file): string => $file->getName(), $files)); + $this->assertTrue($files[0]->isCreation()); + $this->assertTrue($files[1]->isCreation()); + $this->assertSame(1, $files[1]->getAdditions()); + } + + public function testAFileAddedAgainWithIntentToAddIsDeletedFromTheIndex(): void + { + $this->skipUnlessGitHidesIntentToAddFromTheIndex(); + + $repository = self::createFoobarRepository(false); + self::git($repository, 'rm', ['-q', '--cached', 'AUTHORS']); + self::git($repository, 'add', ['-N', 'AUTHORS']); + $wc = $repository->getWorkingCopy(); + + $staged = $wc->getDiffStaged()->getFiles(); + $this->assertCount(1, $staged); + $this->assertTrue($staged[0]->isDeletion()); + $pending = $wc->getDiffPending()->getFiles(); + $this->assertCount(1, $pending); + $this->assertTrue($pending[0]->isCreation()); + $this->assertSame([], $wc->getDiff()->getFiles()); + } + + /** + * Like any version of git: deleted from the empty file the index has for it. + */ + public function testAFileAddedWithIntentToAddThenDeletedIsDeleted(): void + { + $repository = self::createFoobarRepository(false); + file_put_contents($repository->getWorkingDir().'/gone.txt', "gone\n"); + self::git($repository, 'add', ['-N', 'gone.txt']); + unlink($repository->getWorkingDir().'/gone.txt'); + $wc = $repository->getWorkingCopy(); + + $files = $wc->getDiffPending()->getFiles(); + $this->assertSame(['gone.txt'], array_map(static fn (File $file): string => $file->getName(), $files)); + $this->assertTrue($files[0]->isDeletion()); + $this->assertSame('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', $files[0]->getOldIndex()); + $this->assertSame([], $wc->getDiff()->getFiles()); + } + + public function testDiffLeavesOutAFileTheWorkingTreeChangesBack(): void + { + $repository = self::createFoobarRepository(false); + $file = $repository->getWorkingDir().'/AUTHORS'; + $content = file_get_contents($file); + file_put_contents($file, 'staged'); + self::git($repository, 'add', ['AUTHORS']); + file_put_contents($file, $content); + + $this->assertSame([], $repository->getWorkingCopy()->getDiff()->getFiles()); + } + public function testDiffPendingWithAbbreviatedHashesLikeOldGit(): void { $this->skipUnlessCliDriver(); @@ -117,11 +187,104 @@ public function testDiffPendingWithAbbreviatedHashesLikeOldGit(): void public function testCheckoutUnexisting(): void { - $this->expectException(RuntimeException::class); + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The revision "foobar" does not exist, or names no commit.'); self::createFoobarRepository(false)->getWorkingCopy()->checkout('foobar'); } + /** + * "rev-parse --verify" refuses a range, which names two commits, after + * printing them. + */ + public function testCheckoutOfARangeThrows(): void + { + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The revision "master...origin/new-feature" does not exist, or names no commit.'); + + self::createFoobarRepository(false)->getWorkingCopy()->checkout('master...origin/new-feature'); + } + + /** + * Like "git checkout :/text": the youngest commit whose message matches. + */ + public function testCheckoutOfACommitByItsMessage(): void + { + $repository = self::createFoobarRepository(false); + + $repository->getWorkingCopy()->checkout(':/add a long file'); + + $this->assertTrue($repository->isHeadDetached()); + $this->assertSame(self::LONGFILE_COMMIT, $repository->getHeadCommit()->getHash()); + } + + /** + * "git checkout README.md" restores the file, discarding its changes. + */ + #[DataProvider('providePaths')] + public function testCheckoutOfAPathIsRefused(string $path): void + { + $repository = self::createFoobarRepository(false); + $file = $repository->getWorkingDir().'/README.md'; + file_put_contents($file, 'local change'); + + try { + $repository->getWorkingCopy()->checkout($path); + $this->fail('A path is no revision.'); + } catch (ReferenceNotFoundException $e) { + $this->assertSame(\sprintf('The revision "%s" does not exist, or names no commit.', $path), $e->getMessage()); + } + + $this->assertSame('local change', file_get_contents($file)); + } + + /** + * The driver refuses a path too, whatever its caller resolved. + */ + #[DataProvider('providePaths')] + public function testTheDriverChecksOutNoPath(string $path): void + { + $repository = self::createFoobarRepository(false); + $file = $repository->getWorkingDir().'/README.md'; + file_put_contents($file, 'local change'); + + try { + $repository->getRepositoryDriver()->checkout($path); + $this->fail('A path is no revision.'); + } catch (DriverException) { + } + + $this->assertSame('local change', file_get_contents($file)); + } + + /** + * @return iterable + */ + public static function providePaths(): iterable + { + yield 'file' => ['README.md']; + yield 'working tree' => ['.']; + } + + /** + * Unlike "git checkout feature", which creates the local branch of the + * only remote one of this name. + */ + public function testCheckoutOfTheNameOfARemoteBranchIsRefused(): void + { + $repository = self::createFoobarRepository(false); + + try { + $repository->getWorkingCopy()->checkout('new-feature'); + $this->fail('No revision has this name.'); + } catch (ReferenceNotFoundException $e) { + $this->assertSame('The revision "new-feature" does not exist, or names no commit.', $e->getMessage()); + } + + $this->assertFalse($repository->getReferences()->hasBranch('new-feature')); + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + } + public function testAttachedHead(): void { $repository = self::createFoobarRepository(false); @@ -171,6 +334,14 @@ public function testMerge(): void $this->assertFileExists($repository->getWorkingDir().'/script_A.php'); } + public function testMergeOfAnUnknownRevisionThrows(): void + { + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The revision "foobar" does not exist, or names no commit.'); + + self::createFoobarRepository(false)->getWorkingCopy()->merge('foobar'); + } + public function testMergeConflict(): void { $repository = self::createFoobarRepository(false); @@ -240,6 +411,70 @@ public function testMergeOfAnAncestorDoesNothing(): void $this->assertSame($head->getHash(), $repository->getHeadCommit()->getHash()); } + public function testMergeOfAnAnnotatedTagAddsItsMessage(): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + // With whitespace "git tag" would have cleaned up. + $commit = $repository->getReferences()->getRemoteBranch('origin/diff-features')->getCommitHash(); + $tag = self::writeObject($repository, 'tag', "object {$commit}\ntype commit\ntag features\ntagger A 1000000000 +0000\n\nFeatures \n\n\nThe diff ones."); + self::git($repository, 'update-ref', ['refs/tags/features', $tag]); + + $repository->getWorkingCopy()->merge('features'); + + // Cleaned up like git does. Then, with CliDriver, the merged commits: the configuration of the tests sets merge.log. + $this->assertStringStartsWith("Merge tag 'features'\n\nFeatures\n\nThe diff ones.\n", $repository->getHeadCommit()->getMessage()); + } + + #[TestWith(['refs/heads/feature', "Merge branch 'refs/heads/feature'"])] + #[TestWith(['feature~0', "Merge branch 'feature'"])] + #[TestWith(['feature~1', "Merge branch 'feature' (early part)"])] + #[TestWith(['refs/remotes/origin/diff-features', "Merge remote-tracking branch 'refs/remotes/origin/diff-features'"])] + #[TestWith(['@{-1}', "Merge branch 'feature'"])] + public function testMergeNamesTheRevisionLikeGit(string $revision, string $message): void + { + $repository = self::createFoobarRepository(false); + self::git($repository, 'config', ['user.name', 'Unit Test']); + self::git($repository, 'config', ['user.email', 'unit_test@unit-test.com']); + $repository->getReferences()->createBranch('feature', 'origin/diff-features'); + $wc = $repository->getWorkingCopy(); + $wc->checkout('feature'); + $wc->checkout('master'); + + $wc->merge($revision); + + $this->assertSame($message, $repository->getHeadCommit()->getSubjectMessage()); + } + + public function testMergeIntoABranchYetToBeBornFastForwardsIt(): void + { + $repository = self::createFoobarRepository(false); + $master = $repository->getHeadCommit()->getHash(); + self::git($repository, 'checkout', ['-q', '--orphan', 'unborn']); + self::git($repository, 'rm', ['-q', '-r', '-f', '.']); + $wc = $repository->getWorkingCopy(); + + try { + $wc->merge('master', FastForward::Never); + $this->fail('A merge commit should have been refused.'); + } catch (DriverException $e) { + $this->assertSame(self::getDriver() instanceof CliDriver + ? 'The git command "merge" failed (exit 128): fatal: Non-fast-forward commit does not make sense into an empty head.' + : 'Unable to merge "master" without a fast-forward: the branch "refs/heads/unborn" has no commit yet.', $e->getMessage()); + } + $this->assertSame('refs/heads/unborn', $repository->getRepositoryDriver()->getHeadReference()); + $this->assertFalse($repository->getReferences()->hasBranch('unborn'), 'The branch has no commit yet'); + $this->assertSame([], $wc->getUntrackedFiles()); + + $wc->merge('master'); + + $this->assertSame('refs/heads/unborn', self::headBranch($repository)->getFullname()); + $this->assertSame($master, $repository->getHeadCommit()->getHash()); + $this->assertSame([], $wc->getDiffStaged()->getFiles()); + $this->assertSame([], $wc->getDiffPending()->getFiles()); + } + public function testCheckoutKeepsLocalChangesItDoesNotConflictWith(): void { $repository = self::createFoobarRepository(false); @@ -267,6 +502,7 @@ public function testCheckoutRefusesToOverwriteLocalChanges(): void $this->assertSame('local change', file_get_contents($file)); $repository = new Repository($repository->getPath(), driver: self::getDriver()); $this->assertFalse($repository->getReferences()->hasBranch('pagination'), 'No branch is created when the checkout fails'); + $this->assertSame([], self::branchConfiguration($repository, 'pagination'), 'Nor its upstream'); } public function testUntrackedFilesInNewDirectories(): void @@ -295,6 +531,116 @@ public function testCheckoutPrefersTheBranchToATagOfTheSameName(): void $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); } + public function testCheckoutOfHeadLeavesItAttached(): void + { + $repository = self::createFoobarRepository(false); + + $repository->getWorkingCopy()->checkout('HEAD'); + + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + } + + public function testCheckoutOfThePriorBranchPrefersItToATagOfTheSameName(): void + { + $repository = self::createFoobarRepository(false); + $references = $repository->getReferences(); + $references->createTag('twin', self::INITIAL_COMMIT); + $references->createBranch('twin', 'master'); + $wc = $repository->getWorkingCopy(); + $wc->checkout('twin'); + $wc->checkout('master'); + + $wc->checkout('@{-1}'); + + $this->assertSame('refs/heads/twin', self::headBranch($repository)->getFullname()); + } + + /** + * Like "git checkout -b", whatever the configuration: the one of the + * tests sets branch.autoSetupMerge and branch.autoSetupRebase to "always". + * + * @param array $upstream + */ + #[DataProvider('provideStartPoints')] + public function testCheckoutNewBranchTracksARemoteBranch(string $startPoint, array $upstream): void + { + $repository = self::createFoobarRepository(false); + + $repository->getWorkingCopy()->checkoutNewBranch('feature', $startPoint); + + $this->assertSame('refs/heads/feature', self::headBranch($repository)->getFullname()); + $this->assertSame($upstream, self::branchConfiguration($repository, 'feature')); + } + + /** + * @return iterable}> + */ + public static function provideStartPoints(): iterable + { + $upstream = ['branch.feature.merge' => 'refs/heads/new-feature', 'branch.feature.remote' => 'origin']; + + yield 'remote branch' => ['origin/new-feature', $upstream]; + yield 'remote branch by its full name' => ['refs/remotes/origin/new-feature', $upstream]; + yield 'local branch' => ['master', []]; + yield 'tag' => ['0.1', []]; + yield 'hash' => [self::INITIAL_COMMIT, []]; + } + + public function testCheckoutNewBranchFromARemoteBranchObjectTracksIt(): void + { + $repository = self::createFoobarRepository(false); + + $repository->getWorkingCopy()->checkoutNewBranch('feature', $repository->getReferences()->getRemoteBranch('origin/new-feature')); + + $this->assertSame(['branch.feature.merge' => 'refs/heads/new-feature', 'branch.feature.remote' => 'origin'], self::branchConfiguration($repository, 'feature')); + } + + /** + * git_branch_set_upstream() prefers a local branch of the name of the + * remote one, unlike git. + */ + public function testCheckoutNewBranchTracksARemoteBranchByItsFullName(): void + { + $repository = self::createFoobarRepository(false); + $repository->getReferences()->createBranch('origin/new-feature', 'master'); + + $repository->getWorkingCopy()->checkoutNewBranch('feature', 'refs/remotes/origin/new-feature'); + + $this->assertSame('refs/heads/feature', self::headBranch($repository)->getFullname()); + $this->assertSame(['branch.feature.merge' => 'refs/heads/new-feature', 'branch.feature.remote' => 'origin'], self::branchConfiguration($repository, 'feature')); + } + + /** + * Like git, which refuses a start point several references have the + * name of. "git checkout -b" checks it out before, and leaves its files. + */ + public function testCheckoutNewBranchRefusesAnAmbiguousStartPoint(): void + { + $repository = self::createFoobarRepository(false); + $head = $repository->getHeadCommit()->getHash(); + $repository->getReferences()->createBranch('origin/new-feature', self::INITIAL_COMMIT); + + try { + $repository->getWorkingCopy()->checkoutNewBranch('feature', 'origin/new-feature'); + $this->fail('The start point is ambiguous.'); + } catch (DriverException $e) { + if (self::getDriver() instanceof CliDriver) { + // Capitalized, with a period, by older versions of git. + $this->assertMatchesRegularExpression('/^The git command "branch" failed \(exit 128\): fatal: [Aa]mbiguous object name: \'origin\/new-feature\'\.$/', $e->getMessage()); + } else { + $this->assertSame('The name "origin/new-feature" is ambiguous: "refs/heads/origin/new-feature", "refs/remotes/origin/new-feature".', $e->getMessage()); + } + } + + $repository = new Repository($repository->getPath(), driver: self::getDriver()); + $this->assertSame('refs/heads/master', self::headBranch($repository)->getFullname()); + $this->assertSame($head, $repository->getHeadCommit()->getHash()); + $this->assertFalse($repository->getReferences()->hasBranch('feature')); + $this->assertSame([], self::branchConfiguration($repository, 'feature')); + $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); + $this->assertSame([], $repository->getWorkingCopy()->getDiffPending()->getFiles()); + } + public function testCheckoutNewBranchWithAnInvalidNameChangesNothing(): void { $repository = self::createFoobarRepository(false); @@ -339,4 +685,28 @@ public function testMergeRefusesStagedChanges(): void $this->assertSame($head, $repository->getHeadCommit()->getHash()); } + + /** + * @return array The configuration of the branch $branch, sorted by key + */ + private static function branchConfiguration(Repository $repository, string $branch): array + { + $configuration = []; + foreach (explode("\n", self::git($repository, 'config', ['--list'])) as $line) { + if (str_starts_with($line, 'branch.'.$branch.'.')) { + [$key, $value] = explode('=', $line, 2); + $configuration[$key] = $value; + } + } + ksort($configuration); + + return $configuration; + } + + private function skipUnlessGitHidesIntentToAddFromTheIndex(): void + { + if (self::getDriver() instanceof CliDriver && version_compare(self::gitVersion(), '2.19.0', '<')) { + $this->markTestSkipped('Before 2.19, git diffs a file added with "git add -N" as an empty file of the index.'); + } + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8987aca0..00793165 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,6 +12,28 @@ require __DIR__.'/../vendor/autoload.php'; +// git and libgit2 read the configuration of fixtures/home only, whatever the +// developer's: see fixtures/home/.gitconfig. No other variable of git reaches +// them either: run from a hook, the tests would get GIT_DIR or GIT_INDEX_FILE, +// and write the repository of the hook. GIT_COMMAND is the git binary of the +// tests. Symfony Process passes on the variables of getenv() that $_SERVER has +// too, $_ENV taking precedence. +$gitVariables = array_filter(array_keys([...getenv(), ...$_ENV]), static fn (string $name): bool => str_starts_with($name, 'GIT_') && 'GIT_COMMAND' !== $name); +foreach ([ + ...array_fill_keys($gitVariables, null), + 'GIT_CONFIG_NOSYSTEM' => '1', + 'HOME' => __DIR__.'/fixtures/home', + 'XDG_CONFIG_HOME' => null, +] as $name => $value) { + if (null === $value) { + putenv($name); + unset($_ENV[$name], $_SERVER[$name]); + } else { + putenv($name.'='.$value); + $_ENV[$name] = $_SERVER[$name] = $value; + } +} + if (defined('PHP_WINDOWS_VERSION_BUILD')) { $server = array_change_key_case($_SERVER, \CASE_UPPER); $_SERVER['GIT_ENVS'] = []; diff --git a/tests/fixtures/home/.gitconfig b/tests/fixtures/home/.gitconfig new file mode 100644 index 00000000..e6de08bd --- /dev/null +++ b/tests/fixtures/home/.gitconfig @@ -0,0 +1,72 @@ +# The git configuration of the tests, whatever the developer's: the bootstrap +# points HOME to this directory. It changes what git prints or reads wherever +# gitlib must set it back to git's defaults, so that every test checks it. +# +# gitlib follows the rest of the configuration of the user, which isn't here: +# core.abbrev, blame.ignoreRevsFile, init.defaultBranch... + +[blame] + blankBoundary = true + coloring = repeatedLines + date = relative + markIgnoredLines = true + markUnblamableLines = true + showEmail = true + showRoot = true +[branch] + autoSetupMerge = always + autoSetupRebase = always + sort = -committerdate +[color] + branch = always + diff = always + status = always + ui = always +[column] + ui = always +[core] + attributesFile = ~/attributes + quotePath = true +[diff] + algorithm = histogram + colorMoved = zebra + context = 1 + dstPrefix = DST/ + external = false + indentHeuristic = false + interHunkContext = 10 + mnemonicPrefix = true + noprefix = true + orderFile = ~/orderfile + relative = true + renames = copies + srcPrefix = SRC/ + submodule = log + suppressBlankEmpty = true + wsErrorHighlight = all +[diff "hostile"] + textconv = false +[format] + pretty = oneline +[i18n] + logOutputEncoding = ISO-8859-1 +[log] + abbrevCommit = true + date = relative + decorate = full + follow = true + showRoot = false + showSignature = true +[maintenance] + # Not hostile: git 2.29 and later run their maintenance after a commit, in + # the background since 2.46, which would write in the repositories the tests delete. + auto = false +[merge] + ff = false + log = true +[status] + showUntrackedFiles = no +[submodule] + recurse = true +[tag] + sort = -version:refname diff --git a/tests/fixtures/home/attributes b/tests/fixtures/home/attributes new file mode 100644 index 00000000..bf534795 --- /dev/null +++ b/tests/fixtures/home/attributes @@ -0,0 +1 @@ +* diff=hostile diff --git a/tests/fixtures/home/orderfile b/tests/fixtures/home/orderfile new file mode 100644 index 00000000..ae7364c5 --- /dev/null +++ b/tests/fixtures/home/orderfile @@ -0,0 +1,2 @@ +*.txt +*