diff --git a/CHANGELOG.md b/CHANGELOG.md
index 77e0af6..ccd727f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,39 @@
All notable changes to this project are documented in this file.
+## [v1.7.2] - Unreleased
+
+Changes since [v1.7.1](https://github.com/gitonomy/gitlib/releases/tag/v1.7.1).
+
+### Security
+
+- Fix `Repository::isValidRepository()` and `isValidRepositoryAndBranch()` reading the
+ configuration of a repository of the temporary directory, where they run `git ls-remote`:
+ another user of the machine may create a `.git` there, whose rewrites of URLs
+ (`url..insteadOf`) make git run a command, or check another repository.
+- Refuse a `git://` URL whose port starts with a dash, even encoded, like
+ `git://host:-oProxyCommand=.../repository`: old versions of git, 1.8.4 for instance, pass it
+ to the command of `core.gitProxy`, which reads it as an option.
+
+### Bug fixes
+
+- Fix a `TypeError` when hiding the credentials of a long text, the megabytes of a blob in the
+ logs for instance: with a logger, reading a file with a long password in a URL failed. It
+ no longer takes seconds either, without the JIT of PCRE.
+- Fix `WorkingCopy::getUntrackedFiles()` returning the former name of a staged rename, when
+ it starts with `?? `.
+- Fix a hash ending with a new line read as a hash: `Repository::getCommit()` returned a
+ commit holding it, instead of throwing a `ReferenceNotFoundException`, and `getTree()` and
+ `getBlob()` did not trigger the deprecation of a name that isn't a hash.
+- Read the annotated tags of other tools like git does: with a header after the tagger, or
+ without the empty line before the message, `Tag::getMessage()` and the methods reading the
+ tagger threw a `RuntimeException`.
+
+### Deprecations
+
+- `Parser\DiffParser` and `Parser\ParserBase`, internal in 2.0: parse a diff with
+ `Diff::parse()`.
+
## [v1.7.1] - 2026-11-07
Changes since [v1.7.0](https://github.com/gitonomy/gitlib/releases/tag/v1.7.0).
@@ -165,6 +198,7 @@ _Earlier versions are not documented here. See the
[GitHub releases](https://github.com/gitonomy/gitlib/releases) and
[commit history](https://github.com/gitonomy/gitlib/commits/v1.6.0) for prior changes._
+[v1.7.2]: https://github.com/gitonomy/gitlib/compare/v1.7.1...v1.7.2
[v1.7.1]: https://github.com/gitonomy/gitlib/compare/v1.7.0...v1.7.1
[v1.7.0]: https://github.com/gitonomy/gitlib/compare/v1.6.0...v1.7.0
[v1.6.0]: https://github.com/gitonomy/gitlib/releases/tag/v1.6.0
diff --git a/composer.json b/composer.json
index 076b075..a932950 100644
--- a/composer.json
+++ b/composer.json
@@ -43,6 +43,7 @@
},
"require-dev": {
"ext-fileinfo": "*",
+ "ext-zlib": "*",
"phpspec/prophecy-phpunit": "^2.1",
"phpunit/phpunit": "^7.5.20 || ^8.5.20 || ^9.5.9",
"psr/log": "^1.0",
diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php
index 38859bf..91158b5 100644
--- a/src/Gitonomy/Git/Commit.php
+++ b/src/Gitonomy/Git/Commit.php
@@ -51,7 +51,7 @@ public function __construct(Repository $repository, $hash, array $data = [])
trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing data to "%s()" is deprecated, the constructor is internal in 2.0, and takes a "CommitData" instead of an array: get the commit from "%s::getCommit()".', __METHOD__, Repository::class);
}
- if (!preg_match('/^[a-f0-9]{40}$/', $hash)) {
+ if (!preg_match('/^[a-f0-9]{40}$/D', $hash)) {
throw new ReferenceNotFoundException($hash);
}
diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php
index 4d43463..c482a7b 100644
--- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php
+++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php
@@ -29,6 +29,12 @@
*/
final class CliDriver
{
+ /**
+ * A path under a file is never a directory, whatever another user of the
+ * machine creates: git finds no repository there.
+ */
+ private const NO_GIT_DIR = __FILE__.'/none';
+
/**
* @param string $command The git binary
* @param array $environmentVariables Set on every git process, false removing an inherited one
@@ -193,12 +199,7 @@ public function cloneRepository(#[\SensitiveParameter] string $url, string $path
*/
public function createProcess(string $command, array $args): Process
{
- $process = new Process(array_merge([$this->command, $command], $args));
- $process->setEnv($this->environmentVariables);
- $process->setTimeout($this->processTimeout);
- $process->setIdleTimeout($this->processTimeout);
-
- return $process;
+ return $this->createGitProcess(array_merge([$command], $args));
}
/**
@@ -206,17 +207,34 @@ public function createProcess(string $command, array $args): Process
* repository, like the checkout of an application: ls-remote would read
* its configuration, and a relative path as the name of one of its remotes.
*
+ * --git-dir keeps git from looking for a repository, and reading its
+ * configuration: another user of the machine may create a ".git" in the
+ * temporary directory, whose rewrites of URLs run a command.
+ *
* @param string[] $options
* @param string[] $patterns
*/
private function createLsRemoteProcess(array $options, string $url, array $patterns): Process
{
- $process = $this->createProcess('ls-remote', array_merge($options, [self::absoluteUrl($url)], $patterns));
+ $process = $this->createGitProcess(array_merge(['--git-dir', self::NO_GIT_DIR, 'ls-remote'], $options, [self::absoluteUrl($url)], $patterns));
$process->setWorkingDirectory(sys_get_temp_dir());
return $process;
}
+ /**
+ * @param string[] $args The options of git, its command, and the arguments of the command
+ */
+ private function createGitProcess(array $args): Process
+ {
+ $process = new Process(array_merge([$this->command], $args));
+ $process->setEnv($this->environmentVariables);
+ $process->setTimeout($this->processTimeout);
+ $process->setIdleTimeout($this->processTimeout);
+
+ return $process;
+ }
+
/**
* A relative path, relative to the working directory of PHP.
*/
diff --git a/src/Gitonomy/Git/Parser/DiffParser.php b/src/Gitonomy/Git/Parser/DiffParser.php
index cd74101..89cea85 100644
--- a/src/Gitonomy/Git/Parser/DiffParser.php
+++ b/src/Gitonomy/Git/Parser/DiffParser.php
@@ -19,6 +19,8 @@
/**
* @final since gitonomy/gitlib 1.7
+ *
+ * @deprecated since gitonomy/gitlib 1.7.2, gitlib 2.0 makes it internal: parse a diff with Diff::parse()
*/
class DiffParser extends ParserBase
{
diff --git a/src/Gitonomy/Git/Parser/ParserBase.php b/src/Gitonomy/Git/Parser/ParserBase.php
index 45a8002..bf2e8a2 100644
--- a/src/Gitonomy/Git/Parser/ParserBase.php
+++ b/src/Gitonomy/Git/Parser/ParserBase.php
@@ -14,6 +14,9 @@
use Gitonomy\Git\Exception\RuntimeException;
+/**
+ * @deprecated since gitonomy/gitlib 1.7.2, gitlib 2.0 makes it internal, like the parsers extending it
+ */
abstract class ParserBase
{
protected $cursor;
diff --git a/src/Gitonomy/Git/Parser/TagParser.php b/src/Gitonomy/Git/Parser/TagParser.php
index bfe8b6b..96fa47e 100644
--- a/src/Gitonomy/Git/Parser/TagParser.php
+++ b/src/Gitonomy/Git/Parser/TagParser.php
@@ -47,7 +47,14 @@ protected function doParse()
$this->taggerDate = $this->parseDate($taggerDate);
$this->consumeNewLine();
- $this->consumeNewLine();
+
+ // Like git, the message starts after the first empty line: the tags
+ // of other tools may have more headers, or no message at all, not
+ // even the empty line.
+ while (!$this->isFinished() && !$this->expects("\n")) {
+ $this->consumeTo("\n");
+ $this->consumeNewLine();
+ }
try {
$this->message = $this->consumeTo('-----BEGIN PGP SIGNATURE-----');
diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php
index cb358f9..2fa16e5 100644
--- a/src/Gitonomy/Git/Repository.php
+++ b/src/Gitonomy/Git/Repository.php
@@ -488,7 +488,7 @@ public function getCommit($hash)
*/
public function getTree($hash)
{
- if (!preg_match('/^[0-9a-f]{4,40}$/', $hash)) {
+ if (!preg_match('/^[0-9a-f]{4,40}$/D', $hash)) {
trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing "%s", which is not a hash, to "%s()" is deprecated, it throws in 2.0: use "getRevision($name)->getCommit()->getTree()" instead.', $hash, __METHOD__);
}
@@ -510,7 +510,7 @@ public function getTree($hash)
*/
public function getBlob($hash)
{
- if (!preg_match('/^[0-9a-f]{4,40}$/', $hash)) {
+ if (!preg_match('/^[0-9a-f]{4,40}$/D', $hash)) {
trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing "%s", which is not a hash, to "%s()" is deprecated, it throws in 2.0: use "Tree::resolvePath()" instead.', $hash, __METHOD__);
}
diff --git a/src/Gitonomy/Git/Util/Credentials.php b/src/Gitonomy/Git/Util/Credentials.php
index 7863f99..45a307c 100644
--- a/src/Gitonomy/Git/Util/Credentials.php
+++ b/src/Gitonomy/Git/Util/Credentials.php
@@ -25,15 +25,44 @@
*/
final class Credentials
{
+ /**
+ * What ends the "user:password@host" of a URL: a slash or a whitespace.
+ */
+ private const DELIMITERS = "/ \t\n\r\v\f";
+
+ private const LETTERS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+ private const SCHEME_CHARACTERS = self::LETTERS.'0123456789+.-';
+
/**
* Replaces the user information of the URLs of $text, a token or a user
* and a password, by "***".
+ *
+ * Without a regular expression over $text, which may be the megabytes of
+ * a blob in the logs: it would reach the limits of PCRE, or take seconds.
*/
public static function redact(#[\SensitiveParameter] string $text): string
{
- // Up to the last "@" before the host, as a password may hold one, or a
- // "#" or a "?", which git accepts unencoded there.
- return preg_replace('#\b([a-z][a-z0-9+.-]*+://)(?:[^/\s@]*+@)++#i', '$1***@', $text);
+ $redacted = '';
+ $offset = 0;
+ // Where a scheme may start: after the last "@" read.
+ $afterAt = 0;
+ while (false !== $at = strpos($text, '@', $offset)) {
+ // The characters around the "@", from $offset at most: one pass over $text.
+ $start = $at - strcspn(strrev(substr($text, $offset, $at - $offset)), self::DELIMITERS);
+ $end = $at + strcspn($text, self::DELIMITERS, $at);
+ // Up to the last "@" before the host, as a password may hold one,
+ // or a "#" or a "?", which git accepts unencoded there.
+ $host = $end - strcspn(strrev(substr($text, $at, $end - $at)), '@');
+
+ $redacted .= substr($text, $offset, $start - $offset);
+ $redacted .= self::isAfterScheme($text, $afterAt, $start) ? '***@' : substr($text, $start, $host - $start);
+ $redacted .= substr($text, $host, $end - $host);
+ $offset = $end;
+ $afterAt = $host;
+ }
+
+ return $redacted.substr($text, $offset);
}
/**
@@ -51,4 +80,28 @@ public static function runProcess(Process $process): void
throw $e;
}
}
+
+ /**
+ * @param int $afterAt Where the scheme may start in $text
+ * @param int $start Where the "user:password@host" starts
+ */
+ private static function isAfterScheme(#[\SensitiveParameter] string $text, int $afterAt, int $start): bool
+ {
+ if ($start - 3 <= $afterAt || '://' !== substr($text, $start - 3, 3)) {
+ return false;
+ }
+
+ $end = $start - 3;
+ $length = strspn(strrev(substr($text, $afterAt, $end - $afterAt)), self::SCHEME_CHARACTERS);
+
+ // A scheme starts with a letter, which starts a word: the one of
+ // "git+ssh" or its "ssh", but not the "https" of "_https".
+ for ($i = $end - $length; $i < $end; ++$i) {
+ if (str_contains(self::LETTERS, $text[$i]) && (0 === $i || !str_contains(self::LETTERS.'0123456789_', $text[$i - 1]))) {
+ return true;
+ }
+ }
+
+ return false;
+ }
}
diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php
index 01f0d4e..6caee68 100644
--- a/src/Gitonomy/Git/Util/RemoteUrl.php
+++ b/src/Gitonomy/Git/Util/RemoteUrl.php
@@ -46,10 +46,11 @@ public static function unsafeReason(#[\SensitiveParameter] string $url): ?string
// Before 2.14.1 (CVE-2017-1000117), git passes the "user@host" of an ssh
// URL to ssh, without its brackets: ssh reads "ssh://-oProxyCommand=..."
// as an option, and runs the command. The command of core.gitProxy gets
- // the host of a git URL the same way. A valid host never starts with a dash.
+ // the host of a git URL the same way, and whatever follows its colon as
+ // the port. A valid host or port never starts with a dash.
$authority = self::authority($url);
- if (null !== $authority && 1 === preg_match('/(?:^|@)\[?-/', $authority)) {
- return sprintf('"%s" has a host or a user starting with a dash, which ssh, or the command of core.gitProxy, would read as an option.', Credentials::redact($url));
+ if (null !== $authority && 1 === preg_match('/(?:^|[@:])\[?-/', $authority)) {
+ return sprintf('"%s" has a host, a user or a port starting with a dash, which ssh, or the command of core.gitProxy, would read as an option.', Credentials::redact($url));
}
return null;
diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php
index 94ea247..12f937d 100644
--- a/src/Gitonomy/Git/WorkingCopy.php
+++ b/src/Gitonomy/Git/WorkingCopy.php
@@ -43,14 +43,18 @@ public function __construct(Repository $repository)
public function getUntrackedFiles()
{
$lines = explode("\0", $this->run('status', ['--porcelain', '--untracked-files=all', '-z']));
- $lines = array_filter($lines, function ($l) {
- return substr($l, 0, 3) === '?? ';
- });
- $lines = array_map(function ($l) {
- return substr($l, 3);
- }, $lines);
-
- return $lines;
+ $files = [];
+ for ($i = 0; $i < count($lines); ++$i) {
+ // "XY ": the path a renamed or copied file had before comes
+ // next, alone, and may start with "?? " itself.
+ if (false !== strpbrk(substr($lines[$i], 0, 2), 'CR')) {
+ ++$i;
+ } elseif (substr($lines[$i], 0, 3) === '?? ') {
+ $files[$i] = substr($lines[$i], 3);
+ }
+ }
+
+ return $files;
}
/**
diff --git a/tests/Gitonomy/Git/Tests/CommitTest.php b/tests/Gitonomy/Git/Tests/CommitTest.php
index ba74c7a..8f557ad 100644
--- a/tests/Gitonomy/Git/Tests/CommitTest.php
+++ b/tests/Gitonomy/Git/Tests/CommitTest.php
@@ -59,6 +59,13 @@ public function testInvalideHashThrowException($repository)
new Commit($repository, 'that-hash-doest-not-exists');
}
+ public function testAHashEndingWithANewLineIsRefused()
+ {
+ $this->expectException(ReferenceNotFoundException::class);
+
+ self::createFoobarRepository()->getCommit(self::LONGFILE_COMMIT."\n");
+ }
+
/**
* @dataProvider provideFoobar
*/
diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php
index 158b518..568529f 100644
--- a/tests/Gitonomy/Git/Tests/ReferenceTest.php
+++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php
@@ -111,6 +111,38 @@ public function testAnnotatedTag($repository)
$this->assertEquals('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommit()->getHash(), 'Tag commit is correct');
}
+ /**
+ * Like git, which reads the tags other tools write: with a header after
+ * the tagger, or without the empty line before the message.
+ *
+ * @dataProvider provideTagsOfOtherTools
+ */
+ public function testATagOfAnotherToolIsReadLikeGit(string $end, string $message)
+ {
+ $repository = self::createFoobarRepository();
+ // A loose object, without the checks recent versions of git apply to it.
+ $object = sprintf("object %s\ntype commit\ntag other\ntagger A 1700000000 +0000\n%s", self::LONGFILE_COMMIT, $end);
+ $object = 'tag '.strlen($object)."\0".$object;
+ $hash = sha1($object);
+ if (!is_dir($dir = $repository->getGitDir().'/objects/'.substr($hash, 0, 2))) {
+ mkdir($dir);
+ }
+ file_put_contents($dir.'/'.substr($hash, 2), gzcompress($object));
+ $repository->run('update-ref', ['refs/tags/other', $hash]);
+
+ $tag = $repository->getReferences()->getTag('other');
+
+ $this->assertSame('A', $tag->getTaggerName());
+ $this->assertSame($message, $tag->getMessage());
+ $this->assertSame(self::LONGFILE_COMMIT, $tag->getCommit()->getHash());
+ }
+
+ public static function provideTagsOfOtherTools()
+ {
+ yield 'a header after the tagger' => ["encoding ISO-8859-1\n\nMessage\n", "Message\n"];
+ yield 'no message, without the empty line' => ['', ''];
+ }
+
/**
* @dataProvider provideFoobar
*/
diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php
index 3928545..2b010a5 100644
--- a/tests/Gitonomy/Git/Tests/RepositoryTest.php
+++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php
@@ -405,6 +405,23 @@ public function testIsValidRepositoryReadsARelativePathAsAPath()
}
}
+ /**
+ * "$" matched before the new line.
+ *
+ * @group legacy
+ */
+ public function testAHashEndingWithANewLineIsNotAHash()
+ {
+ $repository = self::createFoobarRepository();
+ $hash = $repository->getCommit(self::LONGFILE_COMMIT)->getTreeHash();
+
+ $this->expectDeprecation(sprintf('Since gitonomy/gitlib 1.7: Passing "%s", which is not a hash, to "Gitonomy\\Git\\Repository::getTree()" is deprecated, it throws in 2.0: use "getRevision($name)->getCommit()->getTree()" instead.', $hash."\n"));
+ $this->expectDeprecation(sprintf('Since gitonomy/gitlib 1.7: Passing "%s", which is not a hash, to "Gitonomy\\Git\\Repository::getBlob()" is deprecated, it throws in 2.0: use "Tree::resolvePath()" instead.', $hash."\n"));
+
+ $repository->getTree($hash."\n");
+ $repository->getBlob($hash."\n");
+ }
+
/**
* @group legacy
*/
diff --git a/tests/Gitonomy/Git/Tests/SecurityTest.php b/tests/Gitonomy/Git/Tests/SecurityTest.php
index 66150c9..99e8a29 100644
--- a/tests/Gitonomy/Git/Tests/SecurityTest.php
+++ b/tests/Gitonomy/Git/Tests/SecurityTest.php
@@ -212,6 +212,33 @@ public function testCheckingAUrlStartingWithADashRunsNothing(\Closure $check)
$this->assertFileIsAbsent($file);
}
+ /**
+ * "git ls-remote" reads the configuration of the repository it runs in:
+ * another user of the machine may create one in the temporary directory,
+ * whose rewrites of URLs run a command. Here, it rewrites the URL to a
+ * path that doesn't exist.
+ */
+ public function testARepositoryOfTheTemporaryDirectoryIsNotRead()
+ {
+ $planted = sys_get_temp_dir().'/.git';
+ if (file_exists($planted)) {
+ $this->markTestSkipped('The temporary directory holds a repository already.');
+ }
+
+ $url = self::createFoobarRepository()->getGitDir();
+ Repository::initBare($planted, self::getDriver());
+ file_put_contents($planted.'/config', sprintf("[url \"/nonexistent/\"]\n\tinsteadOf = %s\n", $url), FILE_APPEND);
+
+ try {
+ $this->assertTrue(Repository::isValidRepository($url, self::getDriver()));
+ $this->assertTrue(Repository::isValidRepository('file://'.$url, self::getDriver()));
+ $this->assertTrue(Repository::isValidRepositoryAndBranch($url, 'master', self::getDriver()));
+ $this->assertFalse(Repository::isValidRepositoryAndBranch($url, 'none', self::getDriver()));
+ } finally {
+ self::deleteDir($planted);
+ }
+ }
+
public static function provideUrlChecks()
{
$options = self::getOptions();
@@ -331,6 +358,10 @@ public static function provideHostsStartingWithADash()
yield 'address' => ['git@'.$command.':repository'];
yield 'address between brackets' => ['['.$command.']:repository'];
yield 'git, for the command of core.gitProxy' => ['git://'.$command.'/repository'];
+ yield 'port of git, for the command of core.gitProxy' => ['git://example.com:'.$command.'/repository'];
+ yield 'port after a host between brackets' => ['git://[example.com]:'.$command.'/repository'];
+ yield 'port without a host' => ['git://:'.$command.'/repository'];
+ yield 'encoded dash of a port' => ['git://example.com:%2D'.substr($command, 1).'/repository'];
yield 'encoded dash' => ['ssh://%2D'.substr($command, 1).'/repository'];
yield 'encoded dash, lowercase' => ['git+ssh://%2d'.substr($command, 1).'/repository'];
yield 'encoded dash of a user' => ['ssh://%2D'.substr($command, 1).'@example.com/repository'];
@@ -349,6 +380,7 @@ public static function provideUrlsGitReachesWithoutRunningACommand()
{
yield 'ssh' => ['ssh://git@example.com:2222/repository.git'];
yield 'host between brackets' => ['ssh://git@[::1]/repository.git'];
+ yield 'host between brackets and port' => ['git://[::1]:9418/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'];
@@ -573,6 +605,59 @@ public static function provideTexts()
yield 'no credentials' => ['https://example.com/a.git', 'https://example.com/a.git'];
yield 'address' => ['git@example.com:a.git', 'git@example.com:a.git'];
yield 'an email after a URL' => ['https://example.com/ user@example.com', 'https://example.com/ user@example.com'];
+ yield 'a URL after the host of another' => ['http://***@a://***@b', 'http://u:p@a://u:p@b'];
+ yield 'the last part of a scheme' => ['git+ssh://***@example.com/a.git', 'git+ssh://user:secret@example.com/a.git'];
+ yield 'uppercase scheme' => ['HTTPS://***@example.com/a.git', 'HTTPS://user:secret@example.com/a.git'];
+ yield 'tabulation after the URL' => ["https://***@example.com\tuser@example.com", "https://user:secret@example.com\tuser@example.com"];
+ yield 'no scheme, unlike 2.0' => ['fatal: unable to look up user:secret@example.com', 'fatal: unable to look up user:secret@example.com'];
+ yield 'no scheme before the slashes' => ['://user:secret@example.com', '://user:secret@example.com'];
+ yield 'at sign in the path' => ['https://example.com/user:secret@example.com', 'https://example.com/user:secret@example.com'];
+ }
+
+ /**
+ * With a logger, the output of every command is redacted: megabytes, for
+ * a blob. The regular expression reached the limits of PCRE on them, and
+ * threw a TypeError.
+ *
+ * @dataProvider provideLongTexts
+ */
+ public function testRedactTakesALongText(string $expected, string $text)
+ {
+ $this->assertSame($expected, Credentials::redact($text));
+ }
+
+ public static function provideLongTexts()
+ {
+ yield 'a password of "@"' => ['https://***@example.com/a.git', 'https://x:'.str_repeat('a@', 1100000).'example.com/a.git'];
+ $text = 'x:'.str_repeat('a@', 1100000);
+ yield 'no scheme' => [$text, $text];
+ $text = str_repeat('a-', 250000)."\n";
+ yield 'a long word before a URL' => [$text.'https://***@example.com', $text.'https://user:secret@example.com'];
+ $text = str_repeat('1+', 1000000);
+ yield 'a long scheme without a letter' => [$text.'://user:secret@example.com', $text.'://user:secret@example.com'];
+ $text = str_repeat("a.b-c+d e:f g@h\n", 100000);
+ yield 'no credentials' => [$text, $text];
+ }
+
+ public function testTheLogsTakeALongOutput()
+ {
+ $logger = new class() extends AbstractLogger {
+ public $messages = [];
+
+ public function log($level, $message, array $context = [])
+ {
+ $this->messages[] = $message;
+ }
+ };
+
+ $options = self::getOptions();
+ $repository = new Repository(self::createFoobarRepository()->getGitDir(), null, new CliDriver($options['command'], $options['environment_variables'], 60, $logger));
+ $content = 'https://user:secret'.str_repeat('@a', 1100000).'@example.com/a.git';
+ file_put_contents($file = self::createTempDir().'/blob', $content);
+ $hash = trim($repository->run('hash-object', ['-w', $file]));
+
+ $this->assertSame($content, $repository->getBlob($hash)->getContent());
+ $this->assertStringNotContainsString('user:secret', implode("\n", $logger->messages));
}
/**
diff --git a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php
index cdb1a4d..b839c4c 100644
--- a/tests/Gitonomy/Git/Tests/WorkingCopyTest.php
+++ b/tests/Gitonomy/Git/Tests/WorkingCopyTest.php
@@ -172,6 +172,28 @@ public function testGetUntracked()
$this->assertContains('untracked.txt', $wc->getUntrackedFiles());
}
+ /**
+ * "git status -z" gives the name a renamed file had as an entry of its
+ * own: here, one that reads like the entry of an untracked file.
+ */
+ public function testTheFormerNameOfAStagedRenameIsNotUntracked()
+ {
+ if (defined('PHP_WINDOWS_VERSION_BUILD')) {
+ $this->markTestSkipped('A file name can\'t hold a "?" on Windows.');
+ }
+
+ $repository = self::createFoobarRepository(false);
+ $repository->run('config', ['user.name', 'Unit Test']);
+ $repository->run('config', ['user.email', 'unit_test@unit-test.com']);
+ file_put_contents($repository->getWorkingDir().'/?? fake', "foo\n");
+ $repository->run('add', ['?? fake']);
+ $repository->run('commit', ['-q', '-m', 'Add a file']);
+ $repository->run('mv', ['?? fake', 'renamed']);
+ file_put_contents($repository->getWorkingDir().'/untracked.txt', 'foo');
+
+ $this->assertSame(['untracked.txt'], array_values($repository->getWorkingCopy()->getUntrackedFiles()));
+ }
+
public function testDiffThrowsForAnUnknownRevision()
{
$this->expectException(ReferenceNotFoundException::class);