From 4b7ee077e82f2db207976e929a63e9be107c5d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 16:44:34 +0200 Subject: [PATCH 01/18] Refuse arguments that git would read as options git reads an argument starting with a dash as an option, and a value from a user can be one: getRevision('--output=/path') or a checkout, a merge, a reference name of that kind could write a file, and isValidRepository('--upload-pack=...') ran the command in a repository with a remote. git 1.8.4 has no --end-of-options, and "--" starts the paths of most commands, ls-remote taking it only since git 2.8. Both drivers refuse a revision, a reference, a hash or a URL starting with a dash with an InvalidArgumentException (Util\LeadingDash), for the same parameters. A path may start with one: init and clone end the options of git with "--". run() and runProcess() pass their arguments as they are, which the doc says. --- CHANGELOG.md | 7 ++ UPGRADE-2.0.md | 5 + doc/admin.md | 9 ++ doc/repository.md | 12 +- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 17 ++- .../Git/Driver/Cli/CliRepositoryDriver.php | 33 +++++ src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 7 ++ .../Driver/Ffi/LibGit2RepositoryDriver.php | 17 +++ src/Gitonomy/Git/Util/LeadingDash.php | 41 ++++++ tests/Gitonomy/Git/Tests/LeadingDashTest.php | 117 ++++++++++++++++++ 10 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 src/Gitonomy/Git/Util/LeadingDash.php create mode 100644 tests/Gitonomy/Git/Tests/LeadingDashTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 081545f..112de6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,13 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - `Commit::getShortMessage()` and `getFixedShortHash()`, `Repository::shell()`, `PushReference::getReference()` and `getRevision()`. +### 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 `--`. + ### Bug fixes - Fix `Commit::getDiff()` returning an empty diff for a root commit, and throwing for a merge diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 5a611d5..ee97fe8 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -391,6 +391,11 @@ Errors * 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. * Every message is a sentence ending with a dot. Dates diff --git a/doc/admin.md b/doc/admin.md index 3aff0a5..b65353b 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -101,6 +101,15 @@ 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). +A URL starting with a dash throws an `InvalidArgumentException`, with both +drivers: git would read it as an option, like `--upload-pack=...`, which +runs a command. A path may start with one. + +gitlib doesn't check the URLs any further. Old git versions run the command +of an `ext::` URL, which recent ones refuse by default (see the +`protocol.allow` setting of git): with URLs from your users, use a recent +git, or check them yourself. + Driver and logger ----------------- diff --git a/doc/repository.md b/doc/repository.md index d70f523..8348d4d 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -191,8 +191,10 @@ 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. +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. @@ -217,8 +219,10 @@ or empty data instead. Every exception of gitlib implements perform the operation at all, where another driver may: `run()` with `LibGit2Driver`, libgit2 that isn't installed... - `InvalidArgumentException`: an argument gitlib doesn't support, like an - option of `git log` in the revisions of a log, or a string holding a NUL - byte, which git doesn't accept. + option of `git log` in the revisions of a log, a string holding a NUL + byte, which git doesn't accept, or a revision, a reference, a hash or a + URL starting with a dash, which git would read as an option + (`getRevision('--output=/path')`). - `LogicException`: a call that makes no sense, like the working copy of a bare repository. diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index 773555f..1d6d873 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\LeadingDash; use Psr\Log\LoggerInterface; /** @@ -49,12 +50,13 @@ 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 @@ -74,11 +76,16 @@ public function mirrorFrom(string $url, string $path): void public function isValidRepository(string $url): bool { + // ls-remote only takes "--" since git 2.8. + LeadingDash::assertNone($url); + return $this->runner()->run('ls-remote', [self::absoluteUrl($url)])->isSuccessful(); } public function isValidRepositoryAndBranch(string $url, string $branch): bool { + LeadingDash::assertNone($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()) { @@ -103,7 +110,11 @@ public function isValidRepositoryAndBranch(string $url, string $branch): bool */ public function cloneRepository(string $path, string $url, array $args): void { - $this->mustRun('clone', ['-q', ...$args, $url, $path]); + // Like isValidRepository(), whose ls-remote can't take "--". + LeadingDash::assertNone($url); + + // --: a path may start with a dash. + $this->mustRun('clone', ['-q', ...$args, '--', $url, $path]); } /** diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index 2d0a98e..eb54336 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -26,6 +26,7 @@ use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\FastForward; use Gitonomy\Git\Parser\DiffParser; +use Gitonomy\Git\Util\LeadingDash; use Symfony\Component\Process\Process; /** @@ -81,6 +82,8 @@ 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. @@ -97,11 +100,15 @@ public function resolveRevision(string $revision): string public function getShortHash(string $hash): string { + LeadingDash::assertNone($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])); @@ -116,6 +123,8 @@ 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 []; } @@ -136,6 +145,8 @@ public function getCommitLog(?array $includes, array $excludes, array $paths, in public function countCommits(?array $includes, array $excludes, array $paths): int { + LeadingDash::assertNone(...$includes ?? [], ...$excludes); + if ([] === $includes) { return 0; } @@ -145,6 +156,8 @@ public function countCommits(?array $includes, array $excludes, array $paths): i 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 @@ -203,22 +216,30 @@ public function getReferences(): array public function createReference(string $fullname, string $hash): void { + LeadingDash::assertNone($fullname, $hash); + // Zeros as the old value: git refuses to update a reference that exists. $this->run('update-ref', [$fullname, $hash, str_repeat('0', 40)]); } public function updateReference(string $fullname, string $hash): void { + LeadingDash::assertNone($fullname, $hash); + $this->run('update-ref', [$fullname, $hash]); } public function deleteReference(string $fullname): void { + LeadingDash::assertNone($fullname); + $this->run('update-ref', ['-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 +249,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,6 +273,8 @@ public function getDiffStaged(): DiffData public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $lastLine): array { + LeadingDash::assertNone($hash); + $args = ['-p']; if (null !== $firstLine || null !== $lastLine) { $args[] = '-L'; @@ -274,16 +301,22 @@ public function getUntrackedFiles(): array public function checkout(string $revision): void { + LeadingDash::assertNone($revision); + $this->run('checkout', [$revision]); } public function checkoutNewBranch(string $branch, string $startHash): void { + LeadingDash::assertNone($branch, $startHash); + $this->run('checkout', ['-b', $branch, $startHash]); } public function merge(string $revision, FastForward $fastForward): void { + LeadingDash::assertNone($revision); + $option = match ($fastForward) { FastForward::Allow => '--ff', FastForward::Never => '--no-ff', diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index ef69d86..19ec327 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -17,6 +17,7 @@ use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\NulByte; /** @@ -101,6 +102,8 @@ public function initBare(string $path): void public function cloneFrom(string $url, string $path, ?string $branch): void { NulByte::assertNone($url, $path, $branch ?? ''); + // Like CliDriver, where git would read it as an option. + LeadingDash::assertNone($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; @@ -144,6 +147,7 @@ public function cloneFrom(string $url, string $path, ?string $branch): void public function cloneBareFrom(string $url, string $path, ?string $branch): void { NulByte::assertNone($url, $path, $branch ?? ''); + LeadingDash::assertNone($url); // Like git, only the branches (and the tags). $this->fetchInto($path, $url, '+refs/heads/*:refs/heads/*', $branch, ['remote.origin.url' => $url]); @@ -152,6 +156,7 @@ public function cloneBareFrom(string $url, string $path, ?string $branch): void public function mirrorFrom(string $url, string $path): void { NulByte::assertNone($url, $path); + LeadingDash::assertNone($url); $this->fetchInto($path, $url, '+refs/*:refs/*', null, [ 'remote.origin.url' => $url, @@ -164,6 +169,7 @@ public function mirrorFrom(string $url, string $path): void public function isValidRepository(string $url): bool { NulByte::assertNone($url); + LeadingDash::assertNone($url); return null !== $this->listRemoteReferences($url); } @@ -171,6 +177,7 @@ public function isValidRepository(string $url): bool public function isValidRepositoryAndBranch(string $url, string $branch): bool { NulByte::assertNone($url, $branch); + LeadingDash::assertNone($url); return \in_array('refs/heads/'.$branch, $this->listRemoteReferences($url) ?? [], true); } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index f6dfcea..62ebd7c 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -28,6 +28,7 @@ use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\FastForward; +use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\NulByte; use Symfony\Component\String\ByteString; use Symfony\Component\String\Exception\InvalidArgumentException; @@ -133,6 +134,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 +153,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 +167,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 +196,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 +204,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 +230,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); @@ -340,6 +347,7 @@ public function getReferences(): array public function createReference(string $fullname, string $hash): void { NulByte::assertNone($fullname, $hash); + LeadingDash::assertNone($fullname, $hash); $oid = $this->hexToOid($hash); $ref = $this->ffi->new('git_reference*'); @@ -350,6 +358,7 @@ public function createReference(string $fullname, string $hash): void public function updateReference(string $fullname, string $hash): void { NulByte::assertNone($fullname, $hash); + LeadingDash::assertNone($fullname, $hash); $oid = $this->hexToOid($hash); $ref = $this->ffi->new('git_reference*'); @@ -360,6 +369,7 @@ public function updateReference(string $fullname, string $hash): void public function deleteReference(string $fullname): void { NulByte::assertNone($fullname); + LeadingDash::assertNone($fullname); $ref = $this->ffi->new('git_reference*'); $rc = $this->ffi->git_reference_lookup(\FFI::addr($ref), $this->repo, $fullname); @@ -379,6 +389,7 @@ 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]) { @@ -408,6 +419,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 +441,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); @@ -488,6 +501,7 @@ public function getDiffStaged(): DiffData 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; @@ -563,6 +577,7 @@ 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 @@ -584,6 +599,7 @@ public function checkout(string $revision): void public function checkoutNewBranch(string $branch, string $startHash): void { NulByte::assertNone($branch, $startHash); + LeadingDash::assertNone($branch, $startHash); if ($this->referenceExists('refs/heads/'.$branch)) { throw new DriverException(\sprintf('A branch named "%s" already exists.', $branch)); @@ -613,6 +629,7 @@ public function checkoutNewBranch(string $branch, string $startHash): void public function merge(string $revision, FastForward $fastForward): void { NulByte::assertNone($revision); + LeadingDash::assertNone($revision); $branch = $this->getHeadReference(); $headHash = $this->resolveCommit('HEAD'); diff --git a/src/Gitonomy/Git/Util/LeadingDash.php b/src/Gitonomy/Git/Util/LeadingDash.php new file mode 100644 index 0000000..538908d --- /dev/null +++ b/src/Gitonomy/Git/Util/LeadingDash.php @@ -0,0 +1,41 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\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(string ...$values): void + { + foreach ($values as $value) { + if (str_starts_with($value, '-')) { + throw new InvalidArgumentException(\sprintf('"%s" starts with a dash, which git would read as an option.', $value)); + } + } + } +} diff --git a/tests/Gitonomy/Git/Tests/LeadingDashTest.php b/tests/Gitonomy/Git/Tests/LeadingDashTest.php new file mode 100644 index 0000000..1a6d791 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/LeadingDashTest.php @@ -0,0 +1,117 @@ + + * (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(); }]; + } + + /** + * In a repository with a remote, "git ls-remote --upload-pack=..." runs + * the command to list the references of the remote. + */ + #[DataProvider('provideUrlCalls')] + 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'); + } + + /** + * @return iterable + */ + public static function provideUrlCalls(): 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()); }]; + } + + /** + * 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'); + } +} From 755484bb3acde43765bce709a2c421a8e078adea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 16:45:18 +0200 Subject: [PATCH 02/18] Hide the credentials of URLs in exceptions and logs A URL like https://user:token@host/repository.git ended up, token included, in the message of the ProcessException of a failed clone, through its command line, and in the logs of CliDriver, through the command and its output (git remote -v...). The messages of LibGit2Driver named the URL too. Util\Credentials::redact() replaces the user information of the URLs by "***" in the messages of ProcessException and of libgit2's errors, and in the logs. getOutput() and getErrorOutput() of ProcessException still return the output of git as it is. --- CHANGELOG.md | 2 + UPGRADE-2.0.md | 3 + doc/admin.md | 3 +- doc/repository.md | 7 +- src/Gitonomy/Git/Driver/Cli/GitRunner.php | 8 +- src/Gitonomy/Git/Driver/Ffi/LibGit2.php | 4 +- src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 5 +- .../Git/Exception/ProcessException.php | 8 +- src/Gitonomy/Git/Util/Credentials.php | 35 ++++++++ tests/Gitonomy/Git/Tests/CredentialsTest.php | 83 +++++++++++++++++++ 10 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 src/Gitonomy/Git/Util/Credentials.php create mode 100644 tests/Gitonomy/Git/Tests/CredentialsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 112de6e..a8098be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). 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 `--`. +- Hide the credentials of URLs (`https://user:token@host`) in the messages of exceptions and + in the logs. ### Bug fixes diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index ee97fe8..1a9d25f 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -396,6 +396,9 @@ Errors `getRevision('--output=/path')` writing a file, or `isValidRepository('--upload-pack=...')` running a command. A path may start with a dash. + * The messages of exceptions 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. Dates diff --git a/doc/admin.md b/doc/admin.md index b65353b..322b181 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -103,7 +103,8 @@ run, like a missing binary. See [Errors](repository.md#errors). A URL starting with a dash throws an `InvalidArgumentException`, with both drivers: git would read it as an option, like `--upload-pack=...`, which -runs a command. A path may start with one. +runs a command. A path may start with one. The message of an exception +hides the credentials of the URL (`https://***@example.com/repository.git`). gitlib doesn't check the URLs any further. Old git versions run the command of an `ext::` URL, which recent ones refuse by default (see the diff --git a/doc/repository.md b/doc/repository.md index 8348d4d..69c394a 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -236,6 +236,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 ----------------- @@ -456,7 +460,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/src/Gitonomy/Git/Driver/Cli/GitRunner.php b/src/Gitonomy/Git/Driver/Cli/GitRunner.php index 9637221..e1a5970 100644 --- a/src/Gitonomy/Git/Driver/Cli/GitRunner.php +++ b/src/Gitonomy/Git/Driver/Cli/GitRunner.php @@ -16,6 +16,7 @@ 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\ProcessTimedOutException as SymfonyProcessTimedOutException; @@ -63,7 +64,8 @@ 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); try { @@ -83,9 +85,9 @@ public function run(string $command, array $args, array $globalOptions = [], ?st 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()))); } } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php index ad8a570..df82a63 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. @@ -100,7 +101,8 @@ public static function preload(?string $libraryPath): void public static function check(\FFI $ffi, int $rc, 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)))); } } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index 19ec327..c2b552f 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -17,6 +17,7 @@ use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\UnsupportedOperationException; +use Gitonomy\Git\Util\Credentials; use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\NulByte; @@ -201,7 +202,7 @@ private function fetchInto(string $path, string $url, string $refspec, ?string $ // 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); @@ -368,7 +369,7 @@ private function detachHeadAtTag(CData $repository, string $tag, string $url): v $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)); diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index 0a008c9..c747e5b 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -12,20 +12,24 @@ namespace Gitonomy\Git\Exception; +use Gitonomy\Git\Util\Credentials; use Symfony\Component\Process\Process; final class ProcessException extends DriverException implements GitExceptionInterface { + /** + * The message hides the credentials of the URLs, unlike getErrorOutput() and getOutput(). + */ public function __construct(private readonly Process $process) { - parent::__construct( + parent::__construct(Credentials::redact( "Error while running git command:\n". $process->getCommandLine()."\n". "\n". $process->getErrorOutput()."\n". "\n". $process->getOutput() - ); + )); } public function getErrorOutput(): string diff --git a/src/Gitonomy/Git/Util/Credentials.php b/src/Gitonomy/Git/Util/Credentials.php new file mode 100644 index 0000000..4d19dee --- /dev/null +++ b/src/Gitonomy/Git/Util/Credentials.php @@ -0,0 +1,35 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\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(string $text): string + { + // Up to the last "@" before the host, as a password may hold one. + // @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/tests/Gitonomy/Git/Tests/CredentialsTest.php b/tests/Gitonomy/Git/Tests/CredentialsTest.php new file mode 100644 index 0000000..f97112a --- /dev/null +++ b/tests/Gitonomy/Git/Tests/CredentialsTest.php @@ -0,0 +1,83 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Exception\DriverException; +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) { + $this->assertStringNotContainsString('secret', $e->getMessage()); + $this->assertStringContainsString('http://***@127.0.0.1:1/repository.git', $e->getMessage()); + } + } + + 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 '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']; + } +} From e8c7a563ac0cedebcd64e6d9c7364e99ba68eb1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 16:45:54 +0200 Subject: [PATCH 03/18] Refuse hook names outside the hooks directory Hooks joined the name to the hooks directory as it was: set('../evil', ...) wrote .git/evil, and remove('../config') deleted the configuration of the repository. A hook name is made of letters, digits, "-", "_" and ".", and doesn't start with a dot, as git for Windows also runs "pre-commit.exe": every method of Hooks throws an InvalidArgumentException for any other name. --- CHANGELOG.md | 3 ++ UPGRADE-2.0.md | 2 ++ doc/hooks.md | 5 +++ src/Gitonomy/Git/Hooks.php | 22 +++++++++--- tests/Gitonomy/Git/Tests/HooksTest.php | 46 ++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8098be..6cb2ad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,9 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). start with a dash: `init()` and the clones end the options of git with `--`. - Hide the credentials of URLs (`https://user:token@host`) in the messages of exceptions and in the logs. +- 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. ### Bug fixes diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 1a9d25f..57ef0d5 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -396,6 +396,8 @@ Errors `getRevision('--output=/path')` writing a file, or `isValidRepository('--upload-pack=...')` running a command. A path may start with a dash. + * `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 and the logs hide the credentials of URLs (`https://***@example.com/repository.git`). `getErrorOutput()` and `getOutput()` of `ProcessException` don't. diff --git a/doc/hooks.md b/doc/hooks.md index 8bc1189..663a506 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)). diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index c673f91..a8847ce 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -31,6 +31,8 @@ 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 { @@ -44,7 +46,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 @@ -67,7 +69,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 +87,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 +104,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 +136,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/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 75a5f68..2646fa3 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -69,6 +69,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 { From 2bc07d3b749eb6095e6a9db6b7ce1464a349d70b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 16:46:36 +0200 Subject: [PATCH 04/18] Read git whatever the configuration of the user git reads the configuration of the user running PHP, which changed what CliDriver parsed: color.ui=always, log.showSignature, diff.noprefix, diff.external, diff.suppressBlankEmpty, textconv filters or column.ui made reads throw or return wrong data, a short hash prefixed with the verification of a signature for instance. diff.algorithm, diff.context, diff.orderFile... changed the diff of the working tree, unlike the one of a commit, which diff-tree reads without them, and log.follow and branch.sort the logs and the branches of a commit. CliRepositoryDriver sets them back to git's defaults: with the options of its commands (--no-color, --no-ext-diff, --no-textconv, the prefixes, the diff algorithm, the context...), and with -c on every command, run() included, where git 1.8.4 has none. Identity, credentials, URL rewrites, core.abbrev or blame.ignoreRevsFile still apply, like with git. The tests no longer depend on the configuration of the developer: the bootstrap points HOME to tests/fixtures/home, whose .gitconfig sets everything gitlib sets back, so every run checks it. test-git-versions needs no HOME of its own anymore, and leaves nothing in the temporary directory. --- CHANGELOG.md | 8 ++ UPGRADE-2.0.md | 11 +++ doc/repository.md | 25 ++++++ .../Git/Driver/Cli/CliRepositoryDriver.php | 39 +++++++-- test-git-versions.php | 11 +-- tests/Gitonomy/Git/Tests/AbstractTestCase.php | 21 +++-- tests/Gitonomy/Git/Tests/AdminTest.php | 6 +- tests/Gitonomy/Git/Tests/CommitTest.php | 11 +++ tests/Gitonomy/Git/Tests/DiffTest.php | 83 +++++++++++++++++++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 21 ++--- tests/bootstrap.php | 22 +++++ tests/fixtures/home/.gitconfig | 70 ++++++++++++++++ tests/fixtures/home/attributes | 1 + tests/fixtures/home/orderfile | 2 + 14 files changed, 296 insertions(+), 35 deletions(-) create mode 100644 tests/fixtures/home/.gitconfig create mode 100644 tests/fixtures/home/attributes create mode 100644 tests/fixtures/home/orderfile diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb2ad6..4170038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,12 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). ### Bug fixes +- Fix the configuration of the user changing what `CliDriver` reads: `color.ui=always`, + `log.showSignature`, `diff.noprefix`, `diff.external`, 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::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()` @@ -180,6 +186,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - CI tests git 1.8.4 and 2.25.1, analyses the tests and the libgit2 driver with PHPStan, checks the archive Composer installs, and the suite deletes what it creates, in any order. ([#253](https://github.com/gitonomy/gitlib/pull/253)) +- 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. - 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 57ef0d5..cb43da6 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -403,6 +403,17 @@ Errors `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, or what a read returns: colors, columns, + `branch.sort`, `log.follow`, `log.showSignature`, and for diffs + `diff.algorithm`, `diff.context`, `diff.external`, `diff.orderFile`, 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). + Dates ----- diff --git a/doc/repository.md b/doc/repository.md index 69c394a..2d1232a 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -435,6 +435,31 @@ 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, or what a read returns, `CliDriver` sets it back +to git's defaults: + +- colors and columns; +- `branch.sort`, `log.follow` and `log.showSignature`; +- for diffs, `diff.algorithm`, `diff.context`, `diff.external`, + `diff.indentHeuristic`, `diff.interHunkContext`, `diff.orderFile`, + `diff.suppressBlankEmpty`, the prefixes (`diff.noprefix`, + `diff.mnemonicPrefix`...), and the textconv filters, of blames too. + +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.sort`, `log.follow`, +`log.showSignature`, `diff.indentHeuristic` and `diff.suppressBlankEmpty` set +back too, but not the rest, which gitlib sets with the options of the +commands it runs: pass `--no-color` or `--no-ext-diff` yourself. + Logger ------ diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index eb54336..d5a26b7 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -38,9 +38,31 @@ */ 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 or reads, where git 1.8.4 has no option for it. A git + * version that doesn't know a key ignores it. + * + * diff.indentHeuristic is the default since git 2.14, and LibGit2Driver's. + */ + private const array CONFIG = [ + '-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... + */ + 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/', '-U3', + ]; /** * The tree without any file: git knows it, even when no commit has it. @@ -110,7 +132,7 @@ 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. @@ -130,7 +152,7 @@ public function getCommitLog(?array $includes, array $excludes, array $paths, in } // 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'; @@ -275,7 +297,8 @@ public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int { LeadingDash::assertNone($hash); - $args = ['-p']; + // --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; @@ -437,7 +460,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); @@ -456,7 +479,7 @@ private function listBranches(array $args, string $prefix): array */ private function execute(string $command, array $args, ?string $input = null): Process { - $globalOptions = ['--git-dir', $this->gitDir]; + $globalOptions = [...self::CONFIG, '--git-dir', $this->gitDir]; if (null !== $this->workingDir) { $globalOptions = [...$globalOptions, '--work-tree', $this->workingDir]; } diff --git a/test-git-versions.php b/test-git-versions.php index 82cdd48..787dd32 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 d68dc77..a67912e 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 b383fc7..86a3cf2 100644 --- a/tests/Gitonomy/Git/Tests/AdminTest.php +++ b/tests/Gitonomy/Git/Tests/AdminTest.php @@ -147,9 +147,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 +162,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/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 67c813e..8992cbb 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -74,6 +74,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 { diff --git a/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index 7c5e9af..f22434b 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -543,6 +543,75 @@ public function testDirtySubmodule(): void $this->assertSame('sub', $firstFile->getNewName()); } + /** + * 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(); @@ -560,6 +629,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/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 40bad56..44d07e6 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -611,22 +611,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 +636,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/bootstrap.php b/tests/bootstrap.php index 8987aca..4293779 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. Symfony Process passes on the +// variables of getenv() that $_SERVER has too, $_ENV taking precedence. +foreach ([ + 'GIT_CONFIG' => null, + 'GIT_CONFIG_COUNT' => null, + 'GIT_CONFIG_GLOBAL' => null, + 'GIT_CONFIG_NOSYSTEM' => '1', + 'GIT_CONFIG_PARAMETERS' => null, + 'GIT_CONFIG_SYSTEM' => null, + '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 0000000..8dc6cdb --- /dev/null +++ b/tests/fixtures/home/.gitconfig @@ -0,0 +1,70 @@ +# 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] + 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 0000000..bf53479 --- /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 0000000..ae7364c --- /dev/null +++ b/tests/fixtures/home/orderfile @@ -0,0 +1,2 @@ +*.txt +* From b26e6b8e2bf26117e0e811f8ae13a159396df724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 13 Oct 2026 17:02:19 +0200 Subject: [PATCH 05/18] Refuse ext:: URLs git runs the command an "ext::" URL names, to talk to it: an application checking or cloning a URL from its users, isValidRepository('ext::sh -c ...') for instance, ran the command with git before 2.12, which made the protocol.allow setting forbid ext by default. git 1.8.4, still supported, runs it. Both drivers refuse such a URL with an InvalidArgumentException, like a URL starting with a dash: Util\RemoteUrl checks both. The tests of the URLs move to RemoteUrlTest. --- CHANGELOG.md | 2 + UPGRADE-2.0.md | 2 + doc/admin.md | 20 ++--- doc/repository.md | 7 +- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 8 +- src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 14 ++-- src/Gitonomy/Git/Util/RemoteUrl.php | 40 +++++++++ tests/Gitonomy/Git/Tests/LeadingDashTest.php | 37 --------- tests/Gitonomy/Git/Tests/RemoteUrlTest.php | 81 +++++++++++++++++++ 9 files changed, 151 insertions(+), 60 deletions(-) create mode 100644 src/Gitonomy/Git/Util/RemoteUrl.php create mode 100644 tests/Gitonomy/Git/Tests/RemoteUrlTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4170038..4392740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). 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. - Hide the credentials of URLs (`https://user:token@host`) in the messages of exceptions and in the logs. - Refuse a hook name that isn't the one of a file of the hooks directory: diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index cb43da6..8f8537a 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -396,6 +396,8 @@ Errors `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. * `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 and the logs hide the credentials of URLs diff --git a/doc/admin.md b/doc/admin.md index 322b181..6b50273 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -101,15 +101,17 @@ 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). -A URL starting with a dash throws an `InvalidArgumentException`, with both -drivers: git would read it as an option, like `--upload-pack=...`, which -runs a command. A path may start with one. The message of an exception -hides the credentials of the URL (`https://***@example.com/repository.git`). - -gitlib doesn't check the URLs any further. Old git versions run the command -of an `ext::` URL, which recent ones refuse by default (see the -`protocol.allow` setting of git): with URLs from your users, use a recent -git, or check them yourself. +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. + +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/repository.md b/doc/repository.md index 2d1232a..ddc0053 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -220,9 +220,10 @@ or empty data instead. Every exception of gitlib implements `LibGit2Driver`, libgit2 that isn't installed... - `InvalidArgumentException`: an argument gitlib doesn't support, like an option of `git log` in the revisions of a log, a string holding a NUL - byte, which git doesn't accept, or a revision, a reference, a hash or a - URL starting with a dash, which git would read as an option - (`getRevision('--output=/path')`). + 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')`), or an `ext::` URL, whose command git + would run. - `LogicException`: a call that makes no sense, like the working copy of a bare repository. diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index 1d6d873..8c77704 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -15,7 +15,7 @@ use Gitonomy\Git\Driver\DriverInterface; use Gitonomy\Git\Driver\RepositoryPaths; use Gitonomy\Git\Exception\ProcessException; -use Gitonomy\Git\Util\LeadingDash; +use Gitonomy\Git\Util\RemoteUrl; use Psr\Log\LoggerInterface; /** @@ -77,14 +77,14 @@ public function mirrorFrom(string $url, string $path): void public function isValidRepository(string $url): bool { // ls-remote only takes "--" since git 2.8. - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); return $this->runner()->run('ls-remote', [self::absoluteUrl($url)])->isSuccessful(); } public function isValidRepositoryAndBranch(string $url, string $branch): bool { - LeadingDash::assertNone($url); + 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]); @@ -111,7 +111,7 @@ public function isValidRepositoryAndBranch(string $url, string $branch): bool public function cloneRepository(string $path, string $url, array $args): void { // Like isValidRepository(), whose ls-remote can't take "--". - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); // --: a path may start with a dash. $this->mustRun('clone', ['-q', ...$args, '--', $url, $path]); diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index c2b552f..d2a00d8 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -18,8 +18,8 @@ use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\UnsupportedOperationException; use Gitonomy\Git\Util\Credentials; -use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\NulByte; +use Gitonomy\Git\Util\RemoteUrl; /** * Runs libgit2 1.9 in-process, through PHP's FFI extension. @@ -103,8 +103,8 @@ public function initBare(string $path): void public function cloneFrom(string $url, string $path, ?string $branch): void { NulByte::assertNone($url, $path, $branch ?? ''); - // Like CliDriver, where git would read it as an option. - LeadingDash::assertNone($url); + // libgit2 runs no command for a URL, unlike git: refused like by CliDriver. + RemoteUrl::assertSafe($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; @@ -148,7 +148,7 @@ public function cloneFrom(string $url, string $path, ?string $branch): void public function cloneBareFrom(string $url, string $path, ?string $branch): void { NulByte::assertNone($url, $path, $branch ?? ''); - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); // Like git, only the branches (and the tags). $this->fetchInto($path, $url, '+refs/heads/*:refs/heads/*', $branch, ['remote.origin.url' => $url]); @@ -157,7 +157,7 @@ public function cloneBareFrom(string $url, string $path, ?string $branch): void public function mirrorFrom(string $url, string $path): void { NulByte::assertNone($url, $path); - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); $this->fetchInto($path, $url, '+refs/*:refs/*', null, [ 'remote.origin.url' => $url, @@ -170,7 +170,7 @@ public function mirrorFrom(string $url, string $path): void public function isValidRepository(string $url): bool { NulByte::assertNone($url); - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); return null !== $this->listRemoteReferences($url); } @@ -178,7 +178,7 @@ public function isValidRepository(string $url): bool public function isValidRepositoryAndBranch(string $url, string $branch): bool { NulByte::assertNone($url, $branch); - LeadingDash::assertNone($url); + RemoteUrl::assertSafe($url); return \in_array('refs/heads/'.$branch, $this->listRemoteReferences($url) ?? [], true); } diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php new file mode 100644 index 0000000..c67197e --- /dev/null +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -0,0 +1,40 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\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, or uses the "ext::" transport + */ + public static function assertSafe(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))); + } + } +} diff --git a/tests/Gitonomy/Git/Tests/LeadingDashTest.php b/tests/Gitonomy/Git/Tests/LeadingDashTest.php index 1a6d791..8eda91f 100644 --- a/tests/Gitonomy/Git/Tests/LeadingDashTest.php +++ b/tests/Gitonomy/Git/Tests/LeadingDashTest.php @@ -55,43 +55,6 @@ public static function provideCalls(): iterable yield 'reference' => [static function (Repository $repository, string $option): void { new Branch($repository, $option, self::LONGFILE_COMMIT)->delete(); }]; } - /** - * In a repository with a remote, "git ls-remote --upload-pack=..." runs - * the command to list the references of the remote. - */ - #[DataProvider('provideUrlCalls')] - 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'); - } - - /** - * @return iterable - */ - public static function provideUrlCalls(): 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()); }]; - } - /** * Unlike a URL, a path may start with a dash: git reads it after "--". */ diff --git a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php new file mode 100644 index 0000000..9167bd8 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php @@ -0,0 +1,81 @@ + + * (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 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()); }]; + } +} From 8caefe0c745c2ed2b2dae0d9fb070970791768ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 06/18] Refuse ssh hosts starting with a dash, and references outside refs/ Before 2.14.1 (CVE-2017-1000117), git passes the "user@host" of an ssh URL to ssh, which reads "ssh://-oProxyCommand=.../repository" as an option and runs the command. git passes the host of a git:// URL to the command of core.gitProxy the same way. Util\RemoteUrl refuses, with an InvalidArgumentException, an ssh, scp-like or git URL whose host or user starts with a dash, even encoded ("%2D", or "%5B" for a bracket), as git decodes the URL before it reads its host. "git update-ref" writes any name: ReferenceBag::delete('HEAD') deleted .git/HEAD with CliDriver, which left no repository, and creating the short name "master" wrote it next to HEAD. Util\ReferenceName makes both drivers refuse, with an InvalidArgumentException, to create, update or delete a reference outside refs/. --- CHANGELOG.md | 6 ++ UPGRADE-2.0.md | 9 ++ doc/admin.md | 6 +- doc/references.md | 5 ++ doc/repository.md | 6 +- .../Git/Driver/Cli/CliRepositoryDriver.php | 4 + .../Driver/Ffi/LibGit2RepositoryDriver.php | 4 + .../Git/Driver/RepositoryDriverInterface.php | 14 +++- src/Gitonomy/Git/ReferenceBag.php | 7 ++ src/Gitonomy/Git/Util/ReferenceName.php | 38 +++++++++ src/Gitonomy/Git/Util/RemoteUrl.php | 32 ++++++- tests/Gitonomy/Git/Tests/ReferenceTest.php | 57 +++++++++++++ tests/Gitonomy/Git/Tests/RemoteUrlTest.php | 84 +++++++++++++++++++ 13 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 src/Gitonomy/Git/Util/ReferenceName.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4392740..3e89dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,11 +108,17 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). 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`) in the messages of exceptions and in the logs. - 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 `ReferenceBag::delete('HEAD')` deleting `HEAD`, which left no repository: a name outside + `refs/` throws an `InvalidArgumentException`. ### Bug fixes diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 8f8537a..c7110ea 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -181,6 +181,9 @@ 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. + * `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 ------- @@ -398,6 +401,12 @@ Errors 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 and the logs hide the credentials of URLs diff --git a/doc/admin.md b/doc/admin.md index 6b50273..0b95e4a 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -107,7 +107,11 @@ 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. + `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 diff --git a/doc/references.md b/doc/references.md index ec658d6..a9c0ed1 100644 --- a/doc/references.md +++ b/doc/references.md @@ -99,6 +99,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 diff --git a/doc/repository.md b/doc/repository.md index ddc0053..8375413 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -222,8 +222,10 @@ or empty data instead. Every exception of gitlib implements 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')`), or an `ext::` URL, whose command git - would run. + (`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`. - `LogicException`: a call that makes no sense, like the working copy of a bare repository. diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index d5a26b7..aa94283 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -27,6 +27,7 @@ use Gitonomy\Git\FastForward; use Gitonomy\Git\Parser\DiffParser; use Gitonomy\Git\Util\LeadingDash; +use Gitonomy\Git\Util\ReferenceName; use Symfony\Component\Process\Process; /** @@ -239,6 +240,7 @@ 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)]); @@ -247,6 +249,7 @@ public function createReference(string $fullname, string $hash): void public function updateReference(string $fullname, string $hash): void { LeadingDash::assertNone($fullname, $hash); + ReferenceName::assertUnderRefs($fullname); $this->run('update-ref', [$fullname, $hash]); } @@ -254,6 +257,7 @@ public function updateReference(string $fullname, string $hash): void public function deleteReference(string $fullname): void { LeadingDash::assertNone($fullname); + ReferenceName::assertUnderRefs($fullname); $this->run('update-ref', ['-d', $fullname]); } diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index 62ebd7c..72c83b8 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -30,6 +30,7 @@ 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; @@ -348,6 +349,7 @@ 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*'); @@ -359,6 +361,7 @@ 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*'); @@ -370,6 +373,7 @@ 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); diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index 2d60586..6ed6bf1 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -120,14 +120,26 @@ 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; + /** + * @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. + * + * @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; diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 9cfa972..85395b5 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -96,6 +96,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/Util/ReferenceName.php b/src/Gitonomy/Git/Util/ReferenceName.php new file mode 100644 index 0000000..525a297 --- /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 index c67197e..b8dcb30 100644 --- a/src/Gitonomy/Git/Util/RemoteUrl.php +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -25,7 +25,7 @@ final class RemoteUrl { /** - * @throws InvalidArgumentException $url starts with a dash, or uses the "ext::" transport + * @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(string $url): void { @@ -36,5 +36,35 @@ public static function assertSafe(string $url): void 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(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/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 4251fdd..46e071d 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -16,6 +16,7 @@ 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; @@ -332,6 +333,62 @@ public function testDeletingADeletedReferenceDoesNothing(): void $this->assertFalse($repository->getReferences()->hasBranch('deleted')); } + /** + * "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]; + } + } + } + public function testTheHeadOfARemoteNamedWithASlashIsNoBranch(): void { $repository = self::createFoobarRepository(); diff --git a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php index 9167bd8..cdb56a2 100644 --- a/tests/Gitonomy/Git/Tests/RemoteUrlTest.php +++ b/tests/Gitonomy/Git/Tests/RemoteUrlTest.php @@ -14,6 +14,8 @@ use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\Credentials; +use Gitonomy\Git\Util\RemoteUrl; use PHPUnit\Framework\Attributes\DataProvider; /** @@ -78,4 +80,86 @@ public static function provideCalls(): iterable 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']; + } } From a9eb1766ecbc7acef338c3f66bf615e6f339fa4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 07/18] Hide the credentials of URLs in traces and chained exceptions The credentials of a URL still leaked: - Credentials::redact() stopped at a "#" or a "?" of the password, which git and libgit2 accept unencoded there; - the exceptions of Symfony Process, the previous exception of a timeout or of a process that couldn't start, hold the command line; - the InvalidArgumentException of a URL holding a NUL byte or starting with a dash named it as it was; - with zend.exception_ignore_args off, the default of php.ini-development, the traces of the exceptions hold the arguments of the calls. redact() reads the user information up to the last "@" before the host, the exceptions of Symfony are no longer chained, NulByte and LeadingDash redact their messages, and every parameter taking a URL, or arguments of git, is #[\SensitiveParameter]. --- CHANGELOG.md | 6 +- UPGRADE-2.0.md | 6 +- src/Gitonomy/Git/Admin.php | 12 +- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 16 +-- .../Git/Driver/Cli/CliRepositoryDriver.php | 4 +- src/Gitonomy/Git/Driver/Cli/GitRunner.php | 10 +- src/Gitonomy/Git/Driver/Ffi/LibGit2.php | 2 +- src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 22 ++-- src/Gitonomy/Git/Repository.php | 14 +- src/Gitonomy/Git/Util/Credentials.php | 7 +- src/Gitonomy/Git/Util/LeadingDash.php | 5 +- src/Gitonomy/Git/Util/NulByte.php | 5 +- src/Gitonomy/Git/Util/RemoteUrl.php | 4 +- tests/Gitonomy/Git/Tests/CredentialsTest.php | 123 +++++++++++++++++- 14 files changed, 183 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e89dbc..697da8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,8 +112,10 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). `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`) in the messages of exceptions and - in the logs. +- 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. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index c7110ea..ef62103 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -375,7 +375,8 @@ 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. @@ -409,7 +410,8 @@ Errors 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 and the logs hide the credentials of URLs + * 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. diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 3799838..83e2d7b 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/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index 8c77704..a798580 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -59,22 +59,22 @@ public function initBare(string $path): void $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); @@ -82,7 +82,7 @@ public function isValidRepository(string $url): bool 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); @@ -108,7 +108,7 @@ 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 { // Like isValidRepository(), whose ls-remote can't take "--". RemoteUrl::assertSafe($url); @@ -141,7 +141,7 @@ 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); @@ -155,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 aa94283..256b10a 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -361,7 +361,7 @@ 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); @@ -481,7 +481,7 @@ 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 = [...self::CONFIG, '--git-dir', $this->gitDir]; if (null !== $this->workingDir) { diff --git a/src/Gitonomy/Git/Driver/Cli/GitRunner.php b/src/Gitonomy/Git/Driver/Cli/GitRunner.php index e1a5970..8a86119 100644 --- a/src/Gitonomy/Git/Driver/Cli/GitRunner.php +++ b/src/Gitonomy/Git/Driver/Cli/GitRunner.php @@ -53,7 +53,7 @@ public function __construct( * @throws InvalidArgumentException An argument contains a NUL byte * @throws ProcessException The git binary can't be run */ - public function run(string $command, array $args, array $globalOptions = [], ?string $workingDirectory = null, ?string $input = null): Process + public function run(string $command, #[\SensitiveParameter] array $args, array $globalOptions = [], ?string $workingDirectory = null, ?string $input = null): Process { NulByte::assertNone(...$globalOptions, ...$args); @@ -68,13 +68,15 @@ public function run(string $command, array $args, array $globalOptions = [], ?st $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); + } catch (SymfonyProcessTimedOutException) { + throw new ProcessTimedOutException(\sprintf('The git command "%s" ran longer than %s seconds.', $command, $this->timeout)); } 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); + 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. diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php index df82a63..6615a06 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php @@ -98,7 +98,7 @@ 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) { // $context and libgit2 may name a URL holding credentials. diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php index d2a00d8..44cc003 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -100,7 +100,7 @@ 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. @@ -145,7 +145,7 @@ 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); @@ -154,7 +154,7 @@ public function cloneBareFrom(string $url, string $path, ?string $branch): void $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); @@ -167,7 +167,7 @@ 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); @@ -175,7 +175,7 @@ public function isValidRepository(string $url): bool 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); @@ -197,7 +197,7 @@ 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); @@ -237,7 +237,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) ?? []; @@ -316,7 +316,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)); @@ -364,7 +364,7 @@ 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}'); @@ -402,7 +402,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'); @@ -419,7 +419,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/Repository.php b/src/Gitonomy/Git/Repository.php index cc599e7..424b5f7 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -163,7 +163,7 @@ public static function initBare(string $path, DriverInterface $driver = new CliD * * @throws DriverException */ - 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); @@ -177,7 +177,7 @@ public static function cloneFrom(string $url, string $path, ?string $branch = nu * * @throws DriverException */ - 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); @@ -190,7 +190,7 @@ public static function cloneBareFrom(string $url, string $path, ?string $branch * * @throws DriverException */ - 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 +201,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 +210,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); } @@ -621,7 +621,7 @@ public function setDescription(string $description): static * @throws ProcessException Error while executing git command * @throws UnsupportedOperationException The driver isn't a CliDriver */ - public function run(string $command, array $args = []): string + public function run(string $command, #[\SensitiveParameter] array $args = []): string { return $this->runProcess($command, $args)->getOutput(); } @@ -640,7 +640,7 @@ public function run(string $command, array $args = []): string * @throws ProcessException Error while executing git command * @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.'); diff --git a/src/Gitonomy/Git/Util/Credentials.php b/src/Gitonomy/Git/Util/Credentials.php index 4d19dee..fbc8719 100644 --- a/src/Gitonomy/Git/Util/Credentials.php +++ b/src/Gitonomy/Git/Util/Credentials.php @@ -26,10 +26,11 @@ 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(string $text): string + public static function redact(#[\SensitiveParameter] string $text): string { - // Up to the last "@" before the host, as a password may hold one. + // 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); + 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 index 538908d..84ccab6 100644 --- a/src/Gitonomy/Git/Util/LeadingDash.php +++ b/src/Gitonomy/Git/Util/LeadingDash.php @@ -30,11 +30,12 @@ final class LeadingDash /** * @throws InvalidArgumentException One of the values starts with a dash */ - public static function assertNone(string ...$values): void + public static function assertNone(#[\SensitiveParameter] string ...$values): void { foreach ($values as $value) { if (str_starts_with($value, '-')) { - throw new InvalidArgumentException(\sprintf('"%s" starts with a dash, which git would read as an option.', $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 c6b7a8c..5f3d74e 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/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php index b8dcb30..2923b3a 100644 --- a/src/Gitonomy/Git/Util/RemoteUrl.php +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -27,7 +27,7 @@ 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(string $url): void + public static function assertSafe(#[\SensitiveParameter] string $url): void { LeadingDash::assertNone($url); @@ -53,7 +53,7 @@ public static function assertSafe(string $url): void * reaches through ssh; null for any other URL, or a * local path */ - private static function authority(string $url): ?string + 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)) { diff --git a/tests/Gitonomy/Git/Tests/CredentialsTest.php b/tests/Gitonomy/Git/Tests/CredentialsTest.php index f97112a..b52bdea 100644 --- a/tests/Gitonomy/Git/Tests/CredentialsTest.php +++ b/tests/Gitonomy/Git/Tests/CredentialsTest.php @@ -12,7 +12,12 @@ 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\ProcessTimedOutException; use Gitonomy\Git\Repository; use Gitonomy\Git\Util\Credentials; use PHPUnit\Framework\Attributes\DataProvider; @@ -34,11 +39,111 @@ public function testAFailedCloneHidesTheCredentials(): void Repository::cloneFrom(self::URL, self::createTempDir().'/clone', driver: self::getDriver()); $this->fail('The clone fails.'); } catch (DriverException $e) { - $this->assertStringNotContainsString('secret', $e->getMessage()); + // What loggers print: the message, the trace, and the chained exceptions. + $this->assertStringNotContainsString('secret', (string) $e); $this->assertStringContainsString('http://***@127.0.0.1:1/repository.git', $e->getMessage()); } } + /** + * 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) { + $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(); @@ -76,8 +181,24 @@ 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); + } + } + } } From d3abe748773e2dddb705aa814e3d188c94191aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 08/18] Read replaced objects and symbolic references as they are git reads the replacements of objects (git replace, refs/replace/): whoever could push such a reference to a repository changed the message of a commit, or the content of a blob, CliDriver read for a hash, where libgit2 ignores them. CliDriver passes --no-replace-objects to every command, run() included. "git update-ref" writes through a symbolic reference: deleting refs/remotes/origin/HEAD deleted the branch it points to, origin/main, and moving it moved that branch. CliDriver passes --no-deref, which gives the behavior LibGit2Driver already had. --- CHANGELOG.md | 5 ++ UPGRADE-2.0.md | 5 ++ doc/repository.md | 8 +++- .../Git/Driver/Cli/CliRepositoryDriver.php | 12 +++-- .../Git/Driver/RepositoryDriverInterface.php | 3 ++ tests/Gitonomy/Git/Tests/CommitTest.php | 23 +++++++++ tests/Gitonomy/Git/Tests/ReferenceTest.php | 48 +++++++++++++++++++ 7 files changed, 99 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 697da8a..307f43e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,11 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - 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. +- 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 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`. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index ef62103..7f65df3 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -181,6 +181,8 @@ 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. @@ -426,6 +428,9 @@ Git configuration 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 ----- diff --git a/doc/repository.md b/doc/repository.md index 8375413..bc18b35 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -453,6 +453,11 @@ to git's defaults: `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 @@ -460,7 +465,8 @@ hashes, with `LibGit2Driver` too. `run()` and `runProcess()` get `branch.sort`, `log.follow`, `log.showSignature`, `diff.indentHeuristic` and `diff.suppressBlankEmpty` set -back too, but not the rest, which gitlib sets with the options of the +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 diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index 256b10a..fe13150 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -243,7 +243,7 @@ public function createReference(string $fullname, string $hash): void 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 @@ -251,7 +251,9 @@ public function updateReference(string $fullname, string $hash): void LeadingDash::assertNone($fullname, $hash); ReferenceName::assertUnderRefs($fullname); - $this->run('update-ref', [$fullname, $hash]); + // --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 @@ -259,7 +261,7 @@ public function deleteReference(string $fullname): void LeadingDash::assertNone($fullname); ReferenceName::assertUnderRefs($fullname); - $this->run('update-ref', ['-d', $fullname]); + $this->run('update-ref', ['--no-deref', '-d', $fullname]); } public function getIncludingBranches(string $hash): array @@ -483,7 +485,9 @@ private function listBranches(array $args, string $prefix): array */ private function execute(string $command, #[\SensitiveParameter] array $args, ?string $input = null): Process { - $globalOptions = [...self::CONFIG, '--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/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index 6ed6bf1..f921f73 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -128,6 +128,8 @@ public function getReferences(): array; 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" @@ -136,6 +138,7 @@ 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/`` * diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 8992cbb..e9bdf79 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; @@ -381,4 +382,26 @@ 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()); + } } diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 46e071d..b941c93 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -12,6 +12,7 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Exception\DriverException; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; @@ -333,6 +334,36 @@ 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. */ @@ -389,6 +420,23 @@ public static function provideWritesOfANameOutsideRefs(): iterable } } + /** + * 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(); From 72b118f3d313a7c825bf96bf8de27775f6a35b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 09/18] Write no hook through a dangling symbolic link Hooks::has() used file_exists(), false for a symbolic link to a file not deployed yet: set() then wrote through the link, out of the hooks directory. A link is a hook, whatever its target: set() and setSymlink() refuse it, as a hook that exists already, get() throws a RuntimeException, as it can't read it, and remove() removes the link. --- CHANGELOG.md | 2 ++ UPGRADE-2.0.md | 4 +++ doc/hooks.md | 3 +++ src/Gitonomy/Git/Hooks.php | 8 ++++-- tests/Gitonomy/Git/Tests/HooksTest.php | 36 ++++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 307f43e..e8d066c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - 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. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 7f65df3..234deeb 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -510,3 +510,7 @@ 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. diff --git a/doc/hooks.md b/doc/hooks.md index 663a506..21ca84d 100644 --- a/doc/hooks.md +++ b/doc/hooks.md @@ -49,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/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index a8847ce..8f5d4ab 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -36,7 +36,11 @@ public function __construct( */ 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); } /** @@ -55,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)); } diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 2646fa3..03db421 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -222,6 +222,42 @@ public function testRemoveNotExistingThrows(bool $bare): void $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(); From c987534a25be8e02c7b5c150d19e06ff2f0b3ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 10/18] Never read a revision as a path When no revision had the name, "git checkout " restored the file of this name, discarding its changes: WorkingCopy::checkout('README.md') did. git also read a revision named like a file of the working tree as ambiguous, which made getShortHash(), the data of a commit and the diffs fail. CliDriver ends the revisions with "--". WorkingCopy::checkout() and merge() resolve the revision first, and throw a ReferenceNotFoundException for a name that no revision has, with both drivers. checkout('feature') created the local branch of origin/feature with CliDriver, where LibGit2Driver threw: gitlib doesn't guess anymore, checkoutNewBranch() creates it. A range, which "rev-parse --verify" prints before exiting with 1, throws a ReferenceNotFoundException too, and ":/text" is resolved before "^{commit}", which would be part of its text. checkoutNewBranch() passes the name of its start point to the driver, instead of its hash: the branch tracks it when it is a remote branch, like with "git checkout -b" and branch.autoSetupMerge=true, git's default, whatever the configuration. CliDriver sets branch.autoSetupMerge and branch.autoSetupRebase back, and LibGit2Driver finds the remote and its branch through the refspecs of the remotes. Like git, both refuse an ambiguous start point, and create no branch when the checkout fails. The configuration of the tests sets both to "always", so every run checks it. --- CHANGELOG.md | 7 + UPGRADE-2.0.md | 31 ++- doc/repository.md | 17 +- doc/workingcopy.md | 26 ++- .../Git/Driver/Cli/CliRepositoryDriver.php | 46 +++- .../Driver/Ffi/LibGit2RepositoryDriver.php | 183 ++++++++++++++- src/Gitonomy/Git/Driver/Ffi/libgit2.h | 12 + .../Git/Driver/RepositoryDriverInterface.php | 22 +- src/Gitonomy/Git/Revision.php | 7 +- src/Gitonomy/Git/WorkingCopy.php | 34 ++- tests/Gitonomy/Git/Tests/CommitTest.php | 21 ++ tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 210 +++++++++++++++++- tests/fixtures/home/.gitconfig | 2 + 13 files changed, 568 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d066c..808660f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ 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)) +- `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 @@ -124,6 +127,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). - 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 @@ -137,6 +142,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). `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 `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()` diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 234deeb..21bbd42 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -298,6 +298,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. @@ -422,12 +439,14 @@ Git configuration ----------------- * `CliDriver` sets back to git's defaults the configuration of the user that - changes what git prints, or what a read returns: colors, columns, - `branch.sort`, `log.follow`, `log.showSignature`, and for diffs - `diff.algorithm`, `diff.context`, `diff.external`, `diff.orderFile`, 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). + 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`, 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. diff --git a/doc/repository.md b/doc/repository.md index bc18b35..69579ae 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -308,8 +308,6 @@ Differences with `CliDriver`: - `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 @@ -443,10 +441,13 @@ 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, or what a read returns, `CliDriver` sets it back -to git's defaults: +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`, @@ -463,10 +464,10 @@ 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.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 +`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 diff --git a/doc/workingcopy.md b/doc/workingcopy.md index c2b3447..d467015 100644 --- a/doc/workingcopy.md +++ b/doc/workingcopy.md @@ -37,9 +37,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 +54,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 +94,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: diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index fe13150..85a4f31 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -22,6 +22,7 @@ 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; @@ -41,12 +42,16 @@ { /** * Sets back to git's defaults the configuration of the user that changes - * what git prints or reads, where git 1.8.4 has no option for it. A git - * version that doesn't know a key ignores it. + * 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', @@ -109,8 +114,9 @@ public function resolveRevision(string $revision): string $process = $this->execute('rev-parse', ['--verify', '--quiet', $revision]); - // --quiet: git exits with 1, without output, when nothing has this name. - if (1 === $process->getExitCode() && '' === $process->getOutput()) { + // --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)); } @@ -125,7 +131,8 @@ public function getShortHash(string $hash): string { LeadingDash::assertNone($hash); - return trim($this->runOnObject($hash, null, 'log', ['--abbrev-commit', '--format=%h', '-n', '1', $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 @@ -133,7 +140,7 @@ public function getCommitData(string $hash): CommitData LeadingDash::assertNone($hash); $parser = new LogParser(); - $parser->parse($this->runOnObject($hash, ObjectType::Commit, 'log', ['--encoding=UTF-8', '--format=raw', '--no-color', '-n', '1', $hash])); + $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. @@ -332,14 +339,30 @@ public function checkout(string $revision): void { LeadingDash::assertNone($revision); - $this->run('checkout', [$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 { - LeadingDash::assertNone($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]); - $this->run('checkout', ['-b', $branch, $startHash]); + throw $e; + } } public function merge(string $revision, FastForward $fastForward): void @@ -389,7 +412,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); diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index 72c83b8..67f1cee 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -62,6 +62,7 @@ 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_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; @@ -600,10 +601,10 @@ public function checkout(string $revision): void } } - public function checkoutNewBranch(string $branch, string $startHash): void + public function checkoutNewBranch(string $branch, string $startPoint): void { - NulByte::assertNone($branch, $startHash); - LeadingDash::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)); @@ -615,18 +616,34 @@ 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'); } @@ -1227,6 +1244,142 @@ private function mergeMessage(string $revision, ?string $branch): string // -- references ---------------------------------------------------------- + /** + * 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 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*'); @@ -1363,17 +1516,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 diff --git a/src/Gitonomy/Git/Driver/Ffi/libgit2.h b/src/Gitonomy/Git/Driver/Ffi/libgit2.h index 309cc56..6b0eca7 100644 --- a/src/Gitonomy/Git/Driver/Ffi/libgit2.h +++ b/src/Gitonomy/Git/Driver/Ffi/libgit2.h @@ -74,6 +74,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 -- */ @@ -88,6 +89,7 @@ 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); /* -- errors -- */ @@ -489,3 +491,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 f921f73..4e592b9 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -193,13 +193,33 @@ 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. + * + * $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/Revision.php b/src/Gitonomy/Git/Revision.php index 1543058..a23b04f 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -37,8 +37,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/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index 988004b..de19f55 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; /** @@ -68,7 +70,14 @@ public function getDiffStaged(): Diff * Checks $revision out: HEAD points to it when it is a local branch, to * its commit otherwise. * + * 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 +87,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 +101,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 +131,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 +148,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/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index e9bdf79..24d1491 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -404,4 +404,25 @@ public function testReplacedObjectsAreReadAsTheyAre(): void $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/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 4867cdf..8c22a64 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -12,12 +12,14 @@ namespace Gitonomy\Git\Tests; +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; @@ -117,11 +119,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 +266,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); @@ -267,6 +370,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 +399,91 @@ public function testCheckoutPrefersTheBranchToATagOfTheSameName(): void $this->assertSame([], $repository->getWorkingCopy()->getDiffStaged()->getFiles()); } + /** + * 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) { + $this->assertMatchesRegularExpression('/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 +528,21 @@ 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; + } } diff --git a/tests/fixtures/home/.gitconfig b/tests/fixtures/home/.gitconfig index 8dc6cdb..e6de08b 100644 --- a/tests/fixtures/home/.gitconfig +++ b/tests/fixtures/home/.gitconfig @@ -14,6 +14,8 @@ showEmail = true showRoot = true [branch] + autoSetupMerge = always + autoSetupRebase = always sort = -committerdate [color] branch = always From a44391d6922c941921c5e7cd63b436ade141cac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 11/18] Read the diff of submodules, and refuse output the parser doesn't know With diff.submodule=log, "git diff" writes the log of a submodule instead of its patch: DiffParser stopped there, silently, and the files after the submodule were lost. CliDriver passes --submodule=short to the diffs of the working tree. DiffParser reads an unmerged path ending a diff, and throws a RuntimeException for anything left it can't read. Its message quotes the start of the line only, as the lines of a diff come from the files of the repository. --- CHANGELOG.md | 4 +- UPGRADE-2.0.md | 6 +- doc/diff.md | 11 ++-- doc/repository.md | 5 +- src/Gitonomy/Git/Diff/Diff.php | 2 + .../Git/Driver/Cli/CliRepositoryDriver.php | 5 +- src/Gitonomy/Git/Parser/DiffParser.php | 26 ++++++-- tests/Gitonomy/Git/Tests/DiffTest.php | 63 +++++++++++++++++++ 8 files changed, 105 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 808660f..324113d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,8 +137,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). ### Bug fixes - Fix the configuration of the user changing what `CliDriver` reads: `color.ui=always`, - `log.showSignature`, `diff.noprefix`, `diff.external`, textconv filters, `column.ui`... - made reads throw or return wrong data, and `diff.algorithm`, `diff.context`, + `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). diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 21bbd42..b7660f5 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -443,9 +443,9 @@ Git configuration branch: colors, columns, `branch.autoSetupMerge`, `branch.autoSetupRebase`, `branch.sort`, `log.follow`, `log.showSignature`, and for diffs `diff.algorithm`, `diff.context`, - `diff.external`, `diff.orderFile`, 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 + `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. diff --git a/doc/diff.md b/doc/diff.md index 415816a..18660de 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/repository.md b/doc/repository.md index 69579ae..fef7698 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -451,8 +451,9 @@ creates, `CliDriver` sets it back to git's defaults: - `branch.sort`, `log.follow` and `log.showSignature`; - for diffs, `diff.algorithm`, `diff.context`, `diff.external`, `diff.indentHeuristic`, `diff.interHunkContext`, `diff.orderFile`, - `diff.suppressBlankEmpty`, the prefixes (`diff.noprefix`, - `diff.mnemonicPrefix`...), and the textconv filters, of blames too. + `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 diff --git a/src/Gitonomy/Git/Diff/Diff.php b/src/Gitonomy/Git/Diff/Diff.php index 43e1392..bb26cc6 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/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index 85a4f31..acb40c7 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -63,11 +63,12 @@ * -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... + * 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/', '-U3', + '--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', ]; /** diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php index c554065..7c5a343 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/tests/Gitonomy/Git/Tests/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index f22434b..c207f66 100644 --- a/tests/Gitonomy/Git/Tests/DiffTest.php +++ b/tests/Gitonomy/Git/Tests/DiffTest.php @@ -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,6 +578,34 @@ 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 From 8bfb31df3fd46588e9f39dc44d6cc62857d481bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 12/18] Keep the UTC offset of dates The dates of commits and tags were in UTC: the offset of the author, committer or tagger was lost, as in 1.x. Both drivers return them at this offset, like git log shows them. The instant doesn't change, nor do comparisons and getTimestamp(), but format() returns the local time of the author. An offset of more than 14 hours or 59 minutes, which no timezone has, in a corrupt object, gives UTC, like libgit2 does. --- CHANGELOG.md | 3 + UPGRADE-2.0.md | 17 +++++ doc/commit.md | 6 ++ doc/references.md | 5 +- src/Gitonomy/Git/Commit.php | 6 +- .../Git/Driver/Cli/Parser/LogParser.php | 4 +- src/Gitonomy/Git/Driver/Data/CommitData.php | 4 +- src/Gitonomy/Git/Driver/Data/TagData.php | 3 +- .../Driver/Ffi/LibGit2RepositoryDriver.php | 12 +++- src/Gitonomy/Git/Parser/ParserBase.php | 18 ++++-- src/Gitonomy/Git/Reference/Tag.php | 2 +- tests/Gitonomy/Git/Tests/CommitTest.php | 64 ++++++++++++++++++- tests/Gitonomy/Git/Tests/ReferenceTest.php | 21 ++++++ 13 files changed, 148 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 324113d..721a42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ 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)) +- 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 6.4/7.4/8.1+: most classes are `final` (some `readonly`), and every method has native types. ([#238](https://github.com/gitonomy/gitlib/pull/238)) - Remove `Gitonomy\Git\Util\StringHelper`, replaced by `symfony/string`, and its configurable diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index b7660f5..6ad136e 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -458,6 +458,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 ---------------- diff --git a/doc/commit.md b/doc/commit.md index f75b119..5b06c42 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/references.md b/doc/references.md index a9c0ed1..d6e3dce 100644 --- a/doc/references.md +++ b/doc/references.md @@ -126,8 +126,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/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 4a1af38..f56668a 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -226,7 +226,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 { @@ -250,7 +251,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/Driver/Cli/Parser/LogParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/LogParser.php index ac729fd..1fe8dec 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/CommitData.php b/src/Gitonomy/Git/Driver/Data/CommitData.php index f71aef3..e01dca7 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/TagData.php b/src/Gitonomy/Git/Driver/Data/TagData.php index 03b5983..87b8044 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/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index 67f1cee..9333536 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -1706,10 +1706,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/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index fd61640..bfb7f07 100644 --- a/src/Gitonomy/Git/Parser/ParserBase.php +++ b/src/Gitonomy/Git/Parser/ParserBase.php @@ -131,24 +131,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/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index 17066c3..ee6a4a8 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -78,7 +78,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/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php index 24d1491..48eb8db 100644 --- a/tests/Gitonomy/Git/Tests/CommitTest.php +++ b/tests/Gitonomy/Git/Tests/CommitTest.php @@ -47,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 { @@ -159,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')] @@ -186,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')] diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index b941c93..2085eb6 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -87,6 +87,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'); @@ -99,6 +100,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 { From f93f723d49438b2b8d8c6fe6c3f19449d0ce8710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 13/18] Align LibGit2Driver with git LibGit2Driver differed from git, and from CliDriver: - paths weren't normalized: "./README.md", "dir/../file" or "dir//file" matched nothing in a log or a blame, and a path starting with ":", git's magic, matched a file of this name. It now throws an UnsupportedOperationException; - a reference other than a tag pointing to an annotated tag, like one a fetch puts under refs/remotes/, wasn't peeled to its commit, nor listed among the branches of the commit; - checkout('HEAD') detached HEAD, and @{-1}, the branch checked out before, wasn't understood; - the message of a merge named a full reference name, "branch~1" or @{-1} unlike git, without the message of an annotated tag, and a merge into a branch yet to be born threw instead of fast-forwarding it; - a file added with "git add -N" was a staged empty file, unlike git since 2.19, and a file the working tree changed back after staging it was listed in the diff of the working tree; - a reftable repository or a bundle threw a DriverException, instead of the UnsupportedOperationException of an operation the driver can't perform; - /etc/gitconfig was read whatever GIT_CONFIG_NOSYSTEM. The checks for a blame hunk and a patch that libgit2 1.9 never leaves null go: they were dead code. --- doc/log.md | 4 + doc/repository.md | 20 +- doc/workingcopy.md | 12 +- src/Gitonomy/Git/Driver/Ffi/LibGit2.php | 12 + src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php | 16 + .../Driver/Ffi/LibGit2RepositoryDriver.php | 377 ++++++++++++++---- src/Gitonomy/Git/Driver/Ffi/libgit2.h | 36 ++ .../Git/Driver/RepositoryDriverInterface.php | 6 +- src/Gitonomy/Git/Repository.php | 5 +- src/Gitonomy/Git/WorkingCopy.php | 6 +- tests/Gitonomy/Git/Tests/BlameTest.php | 11 + .../Gitonomy/Git/Tests/LibGit2DriverTest.php | 75 ++++ tests/Gitonomy/Git/Tests/LogTest.php | 47 +++ tests/Gitonomy/Git/Tests/ReferenceTest.php | 12 + tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 163 ++++++++ 15 files changed, 717 insertions(+), 85 deletions(-) diff --git a/doc/log.md b/doc/log.md index 29bdea2..ceb9df8 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/repository.md b/doc/repository.md index fef7698..a0411b0 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -216,8 +216,9 @@ or empty data instead. Every exception of gitlib implements `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, a string holding a NUL byte, which git doesn't accept, a revision, a reference, a hash or a URL @@ -300,11 +301,18 @@ 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. @@ -312,7 +320,7 @@ Differences with `CliDriver`: 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. diff --git a/doc/workingcopy.md b/doc/workingcopy.md index d467015..52431f1 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* @@ -153,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/src/Gitonomy/Git/Driver/Ffi/LibGit2.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php index 6615a06..05feed0 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2.php @@ -28,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 @@ -219,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 44cc003..dbb3e54 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2Driver.php @@ -105,6 +105,7 @@ public function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?str 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; @@ -149,6 +150,7 @@ public function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, { 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]); @@ -158,6 +160,7 @@ public function mirrorFrom(#[\SensitiveParameter] string $url, string $path): vo { NulByte::assertNone($url, $path); RemoteUrl::assertSafe($url); + self::assertNotBundle($url); $this->fetchInto($path, $url, '+refs/*:refs/*', null, [ 'remote.origin.url' => $url, @@ -183,6 +186,19 @@ public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, s 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*'); diff --git a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index 9333536..9f33f60 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -27,6 +27,7 @@ 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; @@ -63,21 +64,34 @@ 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)); @@ -335,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); } @@ -398,7 +412,11 @@ public function getIncludingBranches(string $hash): array $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); @@ -455,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. @@ -483,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 @@ -500,7 +542,18 @@ 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 @@ -533,15 +586,6 @@ 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); @@ -585,9 +629,12 @@ public function checkout(string $revision): void 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); @@ -595,7 +642,7 @@ 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'); } @@ -653,8 +700,20 @@ public function merge(string $revision, FastForward $fastForward): void 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); @@ -669,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; } @@ -781,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; } } @@ -800,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 { @@ -807,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()); } /** @@ -910,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 ---------------------------------------------------------------- @@ -966,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. * @@ -1035,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); @@ -1125,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. */ @@ -1212,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) { @@ -1239,11 +1351,75 @@ 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(). @@ -1285,6 +1461,44 @@ private function dwimReferences(string $name): array 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. @@ -1497,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)); diff --git a/src/Gitonomy/Git/Driver/Ffi/libgit2.h b/src/Gitonomy/Git/Driver/Ffi/libgit2.h index 6b0eca7..029005f 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; @@ -82,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 -- */ @@ -90,6 +93,7 @@ int git_signature_default_from_env(git_signature **author_out, git_signature **c 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 -- */ @@ -111,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 -- */ @@ -183,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); @@ -196,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); @@ -366,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); diff --git a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index 4e592b9..2a8cd55 100644 --- a/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php +++ b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php @@ -191,8 +191,10 @@ 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 diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 424b5f7..db3dd4c 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -102,7 +102,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,6 +162,7 @@ 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(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self { @@ -176,6 +177,7 @@ public static function cloneFrom(#[\SensitiveParameter] string $url, string $pat * @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(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, DriverInterface $driver = new CliDriver()): self { @@ -189,6 +191,7 @@ public static function cloneBareFrom(#[\SensitiveParameter] string $url, string * to mirror $url when fetching. * * @throws DriverException + * @throws UnsupportedOperationException $url is a bundle, with LibGit2Driver */ public static function mirrorFrom(#[\SensitiveParameter] string $url, string $path, DriverInterface $driver = new CliDriver()): self { diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index de19f55..a7374be 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -67,8 +67,10 @@ 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": diff --git a/tests/Gitonomy/Git/Tests/BlameTest.php b/tests/Gitonomy/Git/Tests/BlameTest.php index 96db58c..1a367a3 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/LibGit2DriverTest.php b/tests/Gitonomy/Git/Tests/LibGit2DriverTest.php index 01a65e4..e48c1ab 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 358bad7..c0a844e 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -14,9 +14,11 @@ use Gitonomy\Git\Commit; 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 +250,51 @@ 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 { + $this->expectExceptionMessageMatches('/\'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(); diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 2085eb6..80a420e 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -470,6 +470,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/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 8c22a64..5ac082f 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -12,6 +12,7 @@ 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; @@ -22,6 +23,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\Attributes\TestWith; class WorkingCopyTest extends AbstractTestCase { @@ -102,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(); @@ -343,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->assertStringContainsString(self::getDriver() instanceof CliDriver + ? '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); @@ -399,6 +531,30 @@ 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". @@ -545,4 +701,11 @@ private static function branchConfiguration(Repository $repository, string $bran 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.'); + } + } } From eea720c045724940edac36e87efde62324293c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 14/18] Pass the revisions of a log on the standard input A log of thousands of revisions, the branches of a large repository for instance, exceeded the limit of the command line, 2 MB by default on Linux: git couldn't start. CliDriver passes the hashes of a log and of countCommits() to git on its standard input, with --stdin. --- CHANGELOG.md | 2 ++ .../Git/Driver/Cli/CliRepositoryDriver.php | 26 ++++++++++++++----- tests/Gitonomy/Git/Tests/LogTest.php | 15 +++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 721a42d..e3a8a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,8 @@ All are removed in 3.0; see [UPGRADE-2.0.md](UPGRADE-2.0.md). ["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()` diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index acb40c7..cf6b796 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -169,7 +169,7 @@ 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; } @@ -182,7 +182,7 @@ public function countCommits(?array $includes, array $excludes, array $paths): i 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 @@ -473,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. + * + * The hashes go through the standard input: thousands of them would + * exceed the limit of the command line. * - * @return list + * @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(); } /** diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index c0a844e..2be8bcd 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -362,6 +362,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(); From 85640b36f6628425159a4d06719e9447d655c798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 15/18] Make the message of a failed git command one sentence The message of a ProcessException held the command line, the whole error output and the whole output of git, over many lines: no sentence, and the data of the repository in the logs. It is one line, with the git command, its exit code and the error of git, the first line of its error output starting with "fatal:" or "error:", after the warnings, or its first line otherwise: 'The git command "clone" failed (exit 128): fatal: ...'. getErrorOutput() and getOutput() still return the outputs of git. The constructor takes the git command, optional. When git can't start, a missing binary, a command line too long or a working directory removed, CliDriver throws a DriverException instead of a ProcessException, which says so in one line, without the command line: 'The git command "clone" could not start: ...'. --- CHANGELOG.md | 8 ++ UPGRADE-2.0.md | 17 ++- doc/admin.md | 4 +- doc/repository.md | 25 ++-- src/Gitonomy/Git/Driver/Cli/CliDriver.php | 2 +- .../Git/Driver/Cli/CliRepositoryDriver.php | 10 +- src/Gitonomy/Git/Driver/Cli/GitRunner.php | 40 +++++- .../Git/Exception/ProcessException.php | 34 +++-- src/Gitonomy/Git/Repository.php | 2 + tests/Gitonomy/Git/Tests/AdminTest.php | 5 +- tests/Gitonomy/Git/Tests/CredentialsTest.php | 29 ++++- tests/Gitonomy/Git/Tests/LogTest.php | 3 +- .../Git/Tests/ProcessExceptionTest.php | 116 ++++++++++++++++++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 10 +- tests/Gitonomy/Git/Tests/WorkingCopyTest.php | 7 +- 15 files changed, 266 insertions(+), 46 deletions(-) create mode 100644 tests/Gitonomy/Git/Tests/ProcessExceptionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a8a6b..5a0e58a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,14 @@ 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)) +- `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()`. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 6ad136e..5b88df0 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: @@ -399,6 +399,18 @@ Errors * `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; @@ -410,7 +422,8 @@ Errors 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. diff --git a/doc/admin.md b/doc/admin.md index 0b95e4a..6e42f23 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -98,8 +98,8 @@ 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: diff --git a/doc/repository.md b/doc/repository.md index a0411b0..a2cbe63 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -189,12 +189,13 @@ $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. 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. +Both throw a `ProcessException` when git fails, a `DriverException` when it +can't start, 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. @@ -211,10 +212,14 @@ 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()`, cloning a bundle or opening a reftable repository with `LibGit2Driver`, libgit2 diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index a798580..2f73b71 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -146,7 +146,7 @@ private function mustRun(string $command, #[\SensitiveParameter] array $args): v $process = $this->runner()->run($command, $args); if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, $command); } } diff --git a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php index cf6b796..025699e 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliRepositoryDriver.php @@ -103,7 +103,7 @@ public function getHeadReference(): ?string } if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, 'symbolic-ref'); } return trim($process->getOutput()); @@ -122,7 +122,7 @@ public function resolveRevision(string $revision): string } if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, 'rev-parse'); } return trim($process->getOutput()); @@ -198,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())); @@ -236,7 +236,7 @@ public function getReferences(): array } if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, 'show-ref'); } $parser = new ReferenceParser(); @@ -392,7 +392,7 @@ public function runProcess(string $command, #[\SensitiveParameter] array $args = $process = $this->execute($command, array_map(strval(...), $args), $input); if (!$process->isSuccessful()) { - throw new ProcessException($process); + throw new ProcessException($process, $command); } return $process; diff --git a/src/Gitonomy/Git/Driver/Cli/GitRunner.php b/src/Gitonomy/Git/Driver/Cli/GitRunner.php index 8a86119..268343e 100644 --- a/src/Gitonomy/Git/Driver/Cli/GitRunner.php +++ b/src/Gitonomy/Git/Driver/Cli/GitRunner.php @@ -14,11 +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; @@ -51,7 +51,7 @@ 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, #[\SensitiveParameter] array $args, array $globalOptions = [], ?string $workingDirectory = null, ?string $input = null): Process { @@ -71,17 +71,32 @@ public function run(string $command, #[\SensitiveParameter] array $args, array $ // The exceptions of Symfony aren't chained: their messages may hold the // command line, with the credentials of its URLs. try { - $process->run(); + $process->start(); + } catch (SymfonyRuntimeException $e) { + // Too many arguments (E2BIG), a working directory removed since the + // repository was opened... + $error = $e->getMessage(); + if ($e instanceof ProcessStartFailedException) { + // Its message ends with the error, after the command line. + $lines = explode("\n", $error); + $error = substr(end($lines), \strlen('Error: ')); + } + + throw self::notStarted($command, $error); + } + + try { + $process->wait(); } catch (SymfonyProcessTimedOutException) { throw new ProcessTimedOutException(\sprintf('The git command "%s" ran longer than %s seconds.', $command, $this->timeout)); } catch (SymfonyRuntimeException $e) { - // A working directory removed since the repository was opened... + // 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) { @@ -95,4 +110,19 @@ public function run(string $command, #[\SensitiveParameter] array $args, array $ 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/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index c747e5b..b766846 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -18,18 +18,30 @@ final class ProcessException extends DriverException implements GitExceptionInterface { /** - * The message hides the credentials of the URLs, unlike getErrorOutput() and 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) - { - parent::__construct(Credentials::redact( - "Error while running git command:\n". - $process->getCommandLine()."\n". - "\n". - $process->getErrorOutput()."\n". - "\n". - $process->getOutput() - )); + 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/Repository.php b/src/Gitonomy/Git/Repository.php index db3dd4c..7746bf5 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -622,6 +622,7 @@ public function setDescription(string $description): static * @return string output of the process * * @throws ProcessException Error while executing git command + * @throws DriverException git can't start, or is killed * @throws UnsupportedOperationException The driver isn't a CliDriver */ public function run(string $command, #[\SensitiveParameter] array $args = []): string @@ -641,6 +642,7 @@ public function run(string $command, #[\SensitiveParameter] array $args = []): s * @param array $args Arguments of git command * * @throws ProcessException Error while executing git command + * @throws DriverException git can't start, or is killed * @throws UnsupportedOperationException The driver isn't a CliDriver */ public function runProcess(string $command, #[\SensitiveParameter] array $args = []): Process diff --git a/tests/Gitonomy/Git/Tests/AdminTest.php b/tests/Gitonomy/Git/Tests/AdminTest.php index 86a3cf2..d310ab3 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']); } diff --git a/tests/Gitonomy/Git/Tests/CredentialsTest.php b/tests/Gitonomy/Git/Tests/CredentialsTest.php index b52bdea..1df80bb 100644 --- a/tests/Gitonomy/Git/Tests/CredentialsTest.php +++ b/tests/Gitonomy/Git/Tests/CredentialsTest.php @@ -17,6 +17,7 @@ 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; @@ -41,7 +42,30 @@ public function testAFailedCloneHidesTheCredentials(): void } catch (DriverException $e) { // What loggers print: the message, the trace, and the chained exceptions. $this->assertStringNotContainsString('secret', (string) $e); - $this->assertStringContainsString('http://***@127.0.0.1:1/repository.git', $e->getMessage()); + 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'); } } @@ -80,6 +104,9 @@ public function testAProcessThatCannotStartHidesTheCredentials(): void 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); } } diff --git a/tests/Gitonomy/Git/Tests/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index 2be8bcd..fd6e311 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -273,7 +273,8 @@ public function testLogOfAPathAboveTheRootThrows(): void if (self::getDriver() instanceof LibGit2Driver) { $this->expectExceptionMessage('The path "dir/../../file.txt" is outside the repository.'); } else { - $this->expectExceptionMessageMatches('/\'dir\/\.\.\/\.\.\/file\.txt\' is outside repository/'); + // 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(); diff --git a/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php b/tests/Gitonomy/Git/Tests/ProcessExceptionTest.php new file mode 100644 index 0000000..512d2c0 --- /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/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 44d07e6..4745357 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -190,9 +190,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 diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php index 5ac082f..87ba273 100644 --- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php +++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php @@ -459,8 +459,8 @@ public function testMergeIntoABranchYetToBeBornFastForwardsIt(): void $wc->merge('master', FastForward::Never); $this->fail('A merge commit should have been refused.'); } catch (DriverException $e) { - $this->assertStringContainsString(self::getDriver() instanceof CliDriver - ? 'fatal: Non-fast-forward commit does not make sense into an empty head' + $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()); @@ -625,7 +625,8 @@ public function testCheckoutNewBranchRefusesAnAmbiguousStartPoint(): void $this->fail('The start point is ambiguous.'); } catch (DriverException $e) { if (self::getDriver() instanceof CliDriver) { - $this->assertMatchesRegularExpression('/fatal: [Aa]mbiguous object name: \'origin\/new-feature\'/', $e->getMessage()); + // 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()); } From 8f2f50e6b884b875c59f62d03007d108d2ddfd71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 16/18] Tighten the API - Repository::getHead() returns a Reference\Branch or a Commit: Reference didn't declare getName(), which the doc calls. It now does, abstract. HEAD pointing to a tag, which git allows, throws a ReferenceNotFoundException. - The constructors of Commit, Tree, Blob, Revision, the references and ReferenceBag are internal: Repository 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. - getSize() returned kilobytes, followed links, and threw for a link to a missing file in the git directory, like a hook not deployed yet. getSizeInBytes() returns bytes, follows no link, and leaves out a file git deletes meanwhile. getSize() is deprecated. - Exception\UnexpectedValueException, which gitlib no longer throws, is deprecated. - getBlob(), getTree() or getCommit() of an abbreviated hash throws 'The object "56953e3" is not a blob.' or 'The object "56953e3" does not exist.', instead of 'The revision "56953e3^{blob}" does not exist.'. - BlameLineData names its line numbers like Blame\Line: number and originalNumber. --- CHANGELOG.md | 12 ++- UPGRADE-2.0.md | 27 ++++++- doc/repository.md | 14 +++- src/Gitonomy/Git/Blame.php | 2 +- src/Gitonomy/Git/Blob.php | 9 ++- src/Gitonomy/Git/Commit.php | 2 + .../Git/Driver/Cli/Parser/BlameParser.php | 2 +- .../Git/Driver/Data/BlameLineData.php | 8 +- .../Driver/Ffi/LibGit2RepositoryDriver.php | 4 +- .../Exception/UnexpectedValueException.php | 3 + src/Gitonomy/Git/Reference.php | 10 +++ src/Gitonomy/Git/Reference/Tag.php | 2 + src/Gitonomy/Git/ReferenceBag.php | 3 + src/Gitonomy/Git/Repository.php | 76 +++++++++++++----- src/Gitonomy/Git/Revision.php | 8 ++ src/Gitonomy/Git/Tree.php | 3 + tests/Gitonomy/Git/Tests/ReferenceTest.php | 19 +++++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 77 +++++++++++++++++-- 18 files changed, 239 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0e58a..f2625a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ 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 @@ -66,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`, @@ -113,6 +118,8 @@ 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 @@ -187,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 diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 5b88df0..ddd2c01 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -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` @@ -289,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 ------------ @@ -416,6 +421,9 @@ Errors 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 @@ -501,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 @@ -563,3 +578,7 @@ Other changes `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/repository.md b/doc/repository.md index a2cbe63..f57532c 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 -------------------- diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index 7901ed3..009ce6e 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 c857a2a..4d4d4f8 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 f56668a..ed3b35b 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) { diff --git a/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php b/src/Gitonomy/Git/Driver/Cli/Parser/BlameParser.php index 20281f4..844805a 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/Data/BlameLineData.php b/src/Gitonomy/Git/Driver/Data/BlameLineData.php index 31ed9ce..fc651c9 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/Ffi/LibGit2RepositoryDriver.php b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php index 9f33f60..3162bef 100644 --- a/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php +++ b/src/Gitonomy/Git/Driver/Ffi/LibGit2RepositoryDriver.php @@ -587,9 +587,9 @@ public function getBlameLines(string $hash, string $path, ?int $firstLine, ?int $hunk = $this->ffi->git_blame_hunk_byline($blame, $i); $line = $this->ffi->git_blame_line_byindex($blame, $i); $content = rtrim(\FFI::string($line->ptr, $line->len), "\n"); - $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; diff --git a/src/Gitonomy/Git/Exception/UnexpectedValueException.php b/src/Gitonomy/Git/Exception/UnexpectedValueException.php index c88d8ed..cfe3e4c 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/Reference.php b/src/Gitonomy/Git/Reference.php index 751fd19..1f97796 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; diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index ee6a4a8..d316e92 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, diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 85395b5..530d782 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, ) { diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 7746bf5..49c6de5 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -25,6 +25,7 @@ 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; @@ -238,24 +239,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(); @@ -263,7 +260,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 @@ -519,20 +522,45 @@ 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); } /** @@ -744,7 +772,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 a23b04f..bfafe0b 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -15,10 +15,18 @@ 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, diff --git a/src/Gitonomy/Git/Tree.php b/src/Gitonomy/Git/Tree.php index 2eb8e17..bef883c 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/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index 80a420e..b944031 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -40,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 { diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 4745357..a42e2b9 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -123,6 +123,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(); @@ -311,10 +343,22 @@ 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(substr(self::LONGFILE_COMMIT, 0, 8)); + $repository->getBlob($hash); + } + + public function testAnAbbreviatedHashOfNoObject(): void + { + $repository = self::createFoobarRepository(); + + $this->expectException(ReferenceNotFoundException::class); + $this->expectExceptionMessage('The object "12345678" does not exist.'); + + $repository->getTree('12345678'); } public function testAnAbbreviatedCommitHashNamesNoTree(): void @@ -539,13 +583,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 From 5dfc7407ebe4c1821949f6ad90603942b4adff68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 17/18] Fix the doc of the drivers - DriverInterface and RepositoryDriverInterface list the exceptions their methods throw: a ReferenceNotFoundException for a missing object, an UnsupportedOperationException, and the URLs and arguments they refuse. - DiffFileData::$newIndex is the hash of the content of a file of the working tree, which both drivers give, not null. - getCommitHash() is the object a tag points to, which may be a tree or a blob. - benchmark.php needs a clone of gitlib: Composer installs neither it nor symfony/console. - LibGit2Driver::isSupported() takes no path: the doc shows how to check one. - The errors list RuntimeException, and the ProcessTimedOutException of run(). - The CHANGELOG no longer announces Symfony 6.4, which gitlib doesn't support. - Typos. --- CHANGELOG.md | 8 ++-- doc/references.md | 9 +++-- doc/repository.md | 37 +++++++++++++++---- src/Gitonomy/Git/Commit.php | 2 +- src/Gitonomy/Git/Driver/Data/DiffFileData.php | 2 +- src/Gitonomy/Git/Driver/DriverInterface.php | 31 +++++++++++++--- .../Git/Driver/RepositoryDriverInterface.php | 17 ++++++--- src/Gitonomy/Git/Log.php | 2 +- src/Gitonomy/Git/Reference.php | 2 +- src/Gitonomy/Git/Repository.php | 5 ++- 10 files changed, 85 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2625a4..a992c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ deprecated until 3.0. - 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 6.4/7.4/8.1+: most classes are `final` (some `readonly`), +- 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)) @@ -55,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 diff --git a/doc/references.md b/doc/references.md index d6e3dce..9feb1de 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 ---------------------------- diff --git a/doc/repository.md b/doc/repository.md index f57532c..998a6ad 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -196,12 +196,13 @@ echo $process->getErrorOutput(); ``` Both throw a `ProcessException` when git fails, a `DriverException` when it -can't start, 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. +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. @@ -238,6 +239,10 @@ or empty data instead. Every exception of gitlib implements 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. @@ -304,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 @@ -418,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 ``` diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index ed3b35b..150f935 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -245,7 +245,7 @@ public function getCommitterName(): string } /** - * Returns the comitter email. + * Returns the committer email. */ public function getCommitterEmail(): string { diff --git a/src/Gitonomy/Git/Driver/Data/DiffFileData.php b/src/Gitonomy/Git/Driver/Data/DiffFileData.php index 4ce29b6..aecb868 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/DriverInterface.php b/src/Gitonomy/Git/Driver/DriverInterface.php index ea5c76c..9323c76 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/RepositoryDriverInterface.php b/src/Gitonomy/Git/Driver/RepositoryDriverInterface.php index 2a8cd55..1cb1596 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 */ diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index d2065ea..038edcc 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/Reference.php b/src/Gitonomy/Git/Reference.php index 1f97796..6753aac 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -52,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/Repository.php b/src/Gitonomy/Git/Repository.php index 49c6de5..6acd377 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -22,6 +22,7 @@ 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; @@ -305,7 +306,7 @@ public function getWorkingDir(): ?string } /** - * Instanciates a revision. + * Instantiates a revision. * * @param string $name Name of the revision */ @@ -650,6 +651,7 @@ public function setDescription(string $description): static * @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 */ @@ -670,6 +672,7 @@ public function run(string $command, #[\SensitiveParameter] array $args = []): s * @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 */ From 53cffe3d9f378002811922c471a94f3d0a0b8758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 14 Oct 2026 14:51:53 +0200 Subject: [PATCH 18/18] Harden the test suite - The bootstrap removes every variable of git from the environment of the tests, but GIT_COMMAND: run from a hook, like pre-commit, they got GIT_DIR or GIT_INDEX_FILE, and wrote the repository of the hook. BootstrapTest checks it. - The tests of worktrees, reftable and SHA-256 skip by version of git, instead of on any failure of git. - The tests expecting a RuntimeException expect the exception gitlib throws, and check the messages, of hooks too. - Log::getDiff(), which ignores the paths of the log, and the pairing of renames, which differs between the drivers, have tests. - shivammathur/setup-php, a third-party action, is pinned by commit, like setup-castor. - PHPStan runs without baseline: the types it reported are declared. --- .gitattributes | 1 - .github/workflows/tests.yml | 10 +-- CHANGELOG.md | 7 ++- phpstan-baseline.neon | 62 ------------------- phpstan.neon | 1 - src/Gitonomy/Git/Parser/ParserBase.php | 3 + src/Gitonomy/Git/Repository.php | 23 +++---- src/Gitonomy/Git/Revision.php | 3 + src/Gitonomy/Git/RevisionList.php | 13 ++-- tests/Gitonomy/Git/Tests/BootstrapTest.php | 55 ++++++++++++++++ tests/Gitonomy/Git/Tests/DiffTest.php | 10 +-- tests/Gitonomy/Git/Tests/DriverParityTest.php | 29 +++++++++ tests/Gitonomy/Git/Tests/HooksTest.php | 26 +++++--- tests/Gitonomy/Git/Tests/LogTest.php | 17 +++++ tests/Gitonomy/Git/Tests/RepositoryTest.php | 38 ++++++------ tests/bootstrap.php | 14 ++--- 16 files changed, 189 insertions(+), 123 deletions(-) delete mode 100644 phpstan-baseline.neon create mode 100644 tests/Gitonomy/Git/Tests/BootstrapTest.php diff --git a/.gitattributes b/.gitattributes index f8886cc..cbf8b34 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 23a1fb8..c25cb5a 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 a992c0a..a6b03ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -231,10 +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. + (`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/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index 60e89c1..0000000 --- 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 7a65f6c..781747b 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/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php index bfb7f07..5d1c6eb 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)) { diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index 6acd377..1d95ca3 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -444,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 { @@ -567,7 +567,8 @@ public function getSize(): int /** * 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 */ @@ -645,8 +646,8 @@ 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 * @@ -668,8 +669,8 @@ public function run(string $command, #[\SensitiveParameter] array $args = []): s * 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 diff --git a/src/Gitonomy/Git/Revision.php b/src/Gitonomy/Git/Revision.php index bfafe0b..8c8f5e9 100644 --- a/src/Gitonomy/Git/Revision.php +++ b/src/Gitonomy/Git/Revision.php @@ -33,6 +33,9 @@ public function __construct( ) { } + /** + * @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); diff --git a/src/Gitonomy/Git/RevisionList.php b/src/Gitonomy/Git/RevisionList.php index c31c097..da325c2 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/tests/Gitonomy/Git/Tests/BootstrapTest.php b/tests/Gitonomy/Git/Tests/BootstrapTest.php new file mode 100644 index 0000000..7828903 --- /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/DiffTest.php b/tests/Gitonomy/Git/Tests/DiffTest.php index c207f66..05b95bd 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'); @@ -679,7 +679,7 @@ 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'); diff --git a/tests/Gitonomy/Git/Tests/DriverParityTest.php b/tests/Gitonomy/Git/Tests/DriverParityTest.php index da19c7c..c27752e 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 03db421..cbde3a5 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; @@ -129,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'); } @@ -165,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(); @@ -197,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'); } @@ -218,6 +230,7 @@ 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'); } @@ -270,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/LogTest.php b/tests/Gitonomy/Git/Tests/LogTest.php index fd6e311..597001e 100644 --- a/tests/Gitonomy/Git/Tests/LogTest.php +++ b/tests/Gitonomy/Git/Tests/LogTest.php @@ -13,6 +13,7 @@ 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; @@ -346,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(); diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index a42e2b9..50fd678 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()); @@ -166,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'); @@ -374,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()); } @@ -652,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()) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4293779..0079316 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -13,15 +13,15 @@ require __DIR__.'/../vendor/autoload.php'; // git and libgit2 read the configuration of fixtures/home only, whatever the -// developer's: see fixtures/home/.gitconfig. Symfony Process passes on the -// variables of getenv() that $_SERVER has too, $_ENV taking precedence. +// 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 ([ - 'GIT_CONFIG' => null, - 'GIT_CONFIG_COUNT' => null, - 'GIT_CONFIG_GLOBAL' => null, + ...array_fill_keys($gitVariables, null), 'GIT_CONFIG_NOSYSTEM' => '1', - 'GIT_CONFIG_PARAMETERS' => null, - 'GIT_CONFIG_SYSTEM' => null, 'HOME' => __DIR__.'/fixtures/home', 'XDG_CONFIG_HOME' => null, ] as $name => $value) {