diff --git a/CHANGELOG.md b/CHANGELOG.md index e4427a8..1d9bfe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,9 +62,9 @@ deprecated until 3.0. by default, configured through its constructor (git binary, environment, timeout and logger, which gets every command, clones included), or any `Driver\DriverInterface`, to replace it in tests; see ["Using your own driver"](doc/repository.md#using-your-own-driver). The driver - finds the repository: linked worktrees, submodules and reftable repositories open, and a - directory that isn't a repository throws right away. - ([#253](https://github.com/gitonomy/gitlib/pull/253)) + finds the repository: linked worktrees, submodules, clones with `--separate-git-dir` and + reftable repositories open, and a directory that isn't a repository throws right away. + ([#253](https://github.com/gitonomy/gitlib/pull/253), fixes #200) - Add static methods to `Repository` creating repositories, `init()`, `initBare()`, `cloneFrom()`, `cloneBareFrom()` and `mirrorFrom()`, and checking a repository URL, `isValidRepository()` and `isValidRepositoryAndBranch()`; see diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index e44b082..d308148 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -67,7 +67,8 @@ Opening a repository ignored. * The `debug` option is deprecated, and setting it to `false` throws an `InvalidArgumentException`: see [Errors](#errors). -* `$path` may be a linked worktree or a submodule, whose `.git` is a file. +* `$path` may be a linked worktree, a submodule or a clone with + `--separate-git-dir`, whose `.git` is a file. * A directory that isn't a repository throws an `InvalidArgumentException` right away, instead of a `ProcessException` at the first operation. diff --git a/doc/repository.md b/doc/repository.md index 998a6ad..1eb3277 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -23,7 +23,8 @@ The path may be: - a bare repository; - a working tree, with its `.git` directory; - a working tree whose `.git` is a file pointing to its git directory: a - linked worktree (`git worktree add`) or a submodule. + linked worktree (`git worktree add`), a submodule, or a clone with + `--separate-git-dir`. The constructor takes two more arguments: diff --git a/src/Gitonomy/Git/Driver/RepositoryPaths.php b/src/Gitonomy/Git/Driver/RepositoryPaths.php index 7cede83..71141e7 100644 --- a/src/Gitonomy/Git/Driver/RepositoryPaths.php +++ b/src/Gitonomy/Git/Driver/RepositoryPaths.php @@ -35,7 +35,7 @@ private function __construct( * @param string|null $workingDir The working tree of the git directory $dir * * @throws InvalidArgumentException $dir doesn't exist, or isn't a repository, or a path contains a NUL byte - * @throws DriverException The ".git" file of $dir doesn't point to a git directory + * @throws DriverException The ".git" file of $dir can't be read, or doesn't point to a git directory * @throws UnsupportedOperationException The repository uses SHA-256 hashes */ public static function resolve(string $dir, ?string $workingDir): self @@ -50,7 +50,7 @@ public static function resolve(string $dir, ?string $workingDir): self $paths = match (true) { null !== $workingDir => new self($realDir, $workingDir), is_dir($realDir.'/.git') => new self($realDir.'/.git', $realDir), - // In a linked worktree or a submodule, ".git" is a file pointing to the git directory. + // In a linked worktree, a submodule or a clone with --separate-git-dir, ".git" is a file pointing to the git directory. is_file($realDir.'/.git') => new self(self::readGitFile($realDir), $realDir), default => new self($realDir, null), }; @@ -69,17 +69,29 @@ public static function resolve(string $dir, ?string $workingDir): self } /** + * Reads the file like git: "gitdir: " then the path, absolute or relative + * to $dir, without the line ending, "\r\n" included. The path may end with + * a space. + * * @return string The git directory the ".git" file of $dir points to + * + * @throws DriverException The file can't be read, or doesn't point to a git directory */ private static function readGitFile(string $dir): string { - $content = file_get_contents($dir.'/.git'); - if (false === $content || !preg_match('/^gitdir: (.+)$/m', $content, $match)) { + $content = @file_get_contents($dir.'/.git'); + if (false === $content) { + throw new DriverException(\sprintf('Unable to read the "%s/.git" file.', $dir)); + } + + $path = rtrim(substr($content, \strlen('gitdir: ')), "\r\n"); + // realpath() refuses a NUL byte, where git would read the path up to it. + if (!str_starts_with($content, 'gitdir: ') || '' === $path || str_contains($path, "\0")) { throw new DriverException(\sprintf('The "%s/.git" file doesn\'t point to a git directory.', $dir)); } - $gitDir = str_starts_with($match[1], '/') || preg_match('/^[a-zA-Z]:[\\\\\/]/', $match[1]) ? $match[1] : $dir.'/'.$match[1]; + $gitDir = str_starts_with($path, '/') || preg_match('/^[a-zA-Z]:[\\\\\/]/', $path) ? $path : $dir.'/'.$path; - return realpath($gitDir) ?: throw new DriverException(\sprintf('The git directory "%s" of "%s" does not exist.', $match[1], $dir)); + return realpath($gitDir) ?: throw new DriverException(\sprintf('The git directory "%s" of "%s" does not exist.', $path, $dir)); } } diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index 50fd678..2900f1c 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -79,6 +79,115 @@ public function testOpensALinkedWorktree(): void $this->assertSame($repository->getHeadCommit()->getHash(), $worktree->getHeadCommit()->getHash()); } + /** + * The ".git" file of a submodule points to its git directory, in the one + * of the superproject, with a relative path. + */ + public function testOpensASubmodule(): void + { + $superproject = self::createFoobarRepository(false); + $source = self::createFoobarRepository(); + // git refuses to clone a local submodule since 2.38.1, unless protocol.file.allow allows it. + new Process([self::gitCommand(), '-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', $source->getGitDir(), 'sub'], $superproject->getWorkingDir(), self::gitEnvs())->mustRun(); + $dir = $superproject->getWorkingDir().'/sub'; + $this->assertFileMatchesFormat('gitdir: ../.git/modules/sub', $dir.'/.git'); + + $submodule = new Repository($dir, driver: self::getDriver()); + + $this->assertSame(realpath($dir), $submodule->getWorkingDir()); + $this->assertSame(realpath($superproject->getGitDir().'/modules/sub'), $submodule->getGitDir()); + $this->assertFalse($submodule->isBare()); + $this->assertSame($source->getHeadCommit()->getHash(), $submodule->getHeadCommit()->getHash()); + } + + /** + * "git clone --separate-git-dir" writes a ".git" file with the absolute + * path of the git directory. + */ + public function testOpensACloneWithASeparateGitDir(): void + { + $source = self::createFoobarRepository(); + [$dir, $gitDir] = self::cloneWithASeparateGitDir($source); + + $repository = new Repository($dir, driver: self::getDriver()); + + $this->assertSame(realpath($dir), $repository->getWorkingDir()); + $this->assertSame(realpath($gitDir), $repository->getGitDir()); + $this->assertFalse($repository->isBare()); + $this->assertSame($source->getHeadCommit()->getHash(), $repository->getHeadCommit()->getHash()); + } + + /** + * Like git, which leaves out the line ending of a ".git" file only: a + * path may end with a space. + */ + #[DataProvider('provideLineEndings')] + public function testOpensAGitFileWhateverItsLineEnding(string $lineEnding): void + { + [$dir, $gitDir] = self::cloneWithASeparateGitDir(self::createFoobarRepository()); + file_put_contents($dir.'/.git', 'gitdir: '.$gitDir.$lineEnding); + + $this->assertSame(realpath($gitDir), new Repository($dir, driver: self::getDriver())->getGitDir()); + } + + /** + * @return iterable + */ + public static function provideLineEndings(): iterable + { + yield 'none' => ['']; + yield 'LF' => ["\n"]; + yield 'CRLF, written on Windows' => ["\r\n"]; + } + + #[DataProvider('provideGitFilesPointingToNoGitDirectory')] + public function testAGitFilePointingToNoGitDirectoryThrows(string $content, string $message): void + { + $dir = self::createTempDir(); + file_put_contents($dir.'/.git', $content); + + $this->expectException(DriverException::class); + $this->expectExceptionMessage(\sprintf($message, $dir)); + + new Repository($dir, driver: self::getDriver()); + } + + /** + * @return iterable + */ + public static function provideGitFilesPointingToNoGitDirectory(): iterable + { + $invalid = 'The "%s/.git" file doesn\'t point to a git directory.'; + + yield 'another format' => ["ref: refs/heads/master\n", $invalid]; + // Like git, which reads "gitdir: " at the start of the file only. + yield 'gitdir on the second line' => ["\ngitdir: missing\n", $invalid]; + yield 'no path' => ["gitdir: \n", $invalid]; + yield 'NUL byte' => ["gitdir: a\0b\n", $invalid]; + yield 'missing directory' => ["gitdir: missing\n", 'The git directory "missing" of "%s" does not exist.']; + } + + public function testAnUnreadableGitFileThrows(): void + { + $dir = self::createTempDir(); + file_put_contents($dir.'/.git', "gitdir: missing\n"); + chmod($dir.'/.git', 0); + + try { + if (is_readable($dir.'/.git')) { + $this->markTestSkipped('The user of the tests reads any file, like root.'); + } + + $this->expectException(DriverException::class); + $this->expectExceptionMessage(\sprintf('Unable to read the "%s/.git" file.', $dir)); + + new Repository($dir, driver: self::getDriver()); + } finally { + // Or the file can't be deleted on Windows. + chmod($dir.'/.git', 0o600); + } + } + public function testOpeningADirectoryThatIsNotARepositoryThrows(): void { $dir = self::createTempDir(); @@ -820,4 +929,16 @@ public function testDebugOptionSetToFalseIsRefused(): void new Repository(self::createTempDir(), ['debug' => false]); } + + /** + * @return array{string, string} The working tree of the clone, and its git directory + */ + private static function cloneWithASeparateGitDir(Repository $source): array + { + $dir = self::createTempDir().'/clone'; + $gitDir = self::createTempDir().'/git'; + new Process([self::gitCommand(), 'clone', '-q', '--separate-git-dir='.$gitDir, $source->getGitDir(), $dir], null, self::gitEnvs())->mustRun(); + + return [$dir, $gitDir]; + } }