Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion UPGRADE-2.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion doc/repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
24 changes: 18 additions & 6 deletions src/Gitonomy/Git/Driver/RepositoryPaths.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
};
Expand All @@ -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));
}
}
121 changes: 121 additions & 0 deletions tests/Gitonomy/Git/Tests/RepositoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{string}>
*/
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<string, array{string, string}>
*/
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();
Expand Down Expand Up @@ -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];
}
}