Skip to content

Commit 62faec2

Browse files
lyrixxchris114782
andcommitted
Read the .git file of a working tree like git
Since 2.0, a working tree whose .git is a file pointing to its git directory opens: a linked worktree, a submodule, or a clone with --separate-git-dir. Only linked worktrees had a test, and the file was read unlike git: - the "\r" of a file written on Windows stayed in the path, which then didn't exist; - "gitdir: " was accepted on any line, where git requires it first; - a NUL byte in the path made realpath() throw a ValueError, which isn't an exception of gitlib; - a file that can't be read, without permission for instance, was reported as one that doesn't point to a git directory. Both drivers read the file like git. A submodule, whose .git file holds a relative path, and a clone with --separate-git-dir, whose one holds an absolute path, have tests. Fixes #200. Co-authored-by: Chris Stretton <chriss@datainterconnect.co.uk>
1 parent 22dcfed commit 62faec2

5 files changed

Lines changed: 146 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ deprecated until 3.0.
6262
by default, configured through its constructor (git binary, environment, timeout and logger,
6363
which gets every command, clones included), or any `Driver\DriverInterface`, to replace it in
6464
tests; see ["Using your own driver"](doc/repository.md#using-your-own-driver). The driver
65-
finds the repository: linked worktrees, submodules and reftable repositories open, and a
66-
directory that isn't a repository throws right away.
67-
([#253](https://github.com/gitonomy/gitlib/pull/253))
65+
finds the repository: linked worktrees, submodules, clones with `--separate-git-dir` and
66+
reftable repositories open, and a directory that isn't a repository throws right away.
67+
([#253](https://github.com/gitonomy/gitlib/pull/253), fixes #200)
6868
- Add static methods to `Repository` creating repositories, `init()`, `initBare()`,
6969
`cloneFrom()`, `cloneBareFrom()` and `mirrorFrom()`, and checking a repository URL,
7070
`isValidRepository()` and `isValidRepositoryAndBranch()`; see

‎UPGRADE-2.0.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ Opening a repository
6767
ignored.
6868
* The `debug` option is deprecated, and setting it to `false` throws an
6969
`InvalidArgumentException`: see [Errors](#errors).
70-
* `$path` may be a linked worktree or a submodule, whose `.git` is a file.
70+
* `$path` may be a linked worktree, a submodule or a clone with
71+
`--separate-git-dir`, whose `.git` is a file.
7172
* A directory that isn't a repository throws an `InvalidArgumentException`
7273
right away, instead of a `ProcessException` at the first operation.
7374

‎doc/repository.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ The path may be:
2323
- a bare repository;
2424
- a working tree, with its `.git` directory;
2525
- a working tree whose `.git` is a file pointing to its git directory: a
26-
linked worktree (`git worktree add`) or a submodule.
26+
linked worktree (`git worktree add`), a submodule, or a clone with
27+
`--separate-git-dir`.
2728

2829
The constructor takes two more arguments:
2930

‎src/Gitonomy/Git/Driver/RepositoryPaths.php‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ private function __construct(
3535
* @param string|null $workingDir The working tree of the git directory $dir
3636
*
3737
* @throws InvalidArgumentException $dir doesn't exist, or isn't a repository, or a path contains a NUL byte
38-
* @throws DriverException The ".git" file of $dir doesn't point to a git directory
38+
* @throws DriverException The ".git" file of $dir can't be read, or doesn't point to a git directory
3939
* @throws UnsupportedOperationException The repository uses SHA-256 hashes
4040
*/
4141
public static function resolve(string $dir, ?string $workingDir): self
@@ -50,7 +50,7 @@ public static function resolve(string $dir, ?string $workingDir): self
5050
$paths = match (true) {
5151
null !== $workingDir => new self($realDir, $workingDir),
5252
is_dir($realDir.'/.git') => new self($realDir.'/.git', $realDir),
53-
// In a linked worktree or a submodule, ".git" is a file pointing to the git directory.
53+
// In a linked worktree, a submodule or a clone with --separate-git-dir, ".git" is a file pointing to the git directory.
5454
is_file($realDir.'/.git') => new self(self::readGitFile($realDir), $realDir),
5555
default => new self($realDir, null),
5656
};
@@ -69,17 +69,29 @@ public static function resolve(string $dir, ?string $workingDir): self
6969
}
7070

7171
/**
72+
* Reads the file like git: "gitdir: " then the path, absolute or relative
73+
* to $dir, without the line ending, "\r\n" included. The path may end with
74+
* a space.
75+
*
7276
* @return string The git directory the ".git" file of $dir points to
77+
*
78+
* @throws DriverException The file can't be read, or doesn't point to a git directory
7379
*/
7480
private static function readGitFile(string $dir): string
7581
{
76-
$content = file_get_contents($dir.'/.git');
77-
if (false === $content || !preg_match('/^gitdir: (.+)$/m', $content, $match)) {
82+
$content = @file_get_contents($dir.'/.git');
83+
if (false === $content) {
84+
throw new DriverException(\sprintf('Unable to read the "%s/.git" file.', $dir));
85+
}
86+
87+
$path = rtrim(substr($content, \strlen('gitdir: ')), "\r\n");
88+
// realpath() refuses a NUL byte, where git would read the path up to it.
89+
if (!str_starts_with($content, 'gitdir: ') || '' === $path || str_contains($path, "\0")) {
7890
throw new DriverException(\sprintf('The "%s/.git" file doesn\'t point to a git directory.', $dir));
7991
}
8092

81-
$gitDir = str_starts_with($match[1], '/') || preg_match('/^[a-zA-Z]:[\\\\\/]/', $match[1]) ? $match[1] : $dir.'/'.$match[1];
93+
$gitDir = str_starts_with($path, '/') || preg_match('/^[a-zA-Z]:[\\\\\/]/', $path) ? $path : $dir.'/'.$path;
8294

83-
return realpath($gitDir) ?: throw new DriverException(\sprintf('The git directory "%s" of "%s" does not exist.', $match[1], $dir));
95+
return realpath($gitDir) ?: throw new DriverException(\sprintf('The git directory "%s" of "%s" does not exist.', $path, $dir));
8496
}
8597
}

‎tests/Gitonomy/Git/Tests/RepositoryTest.php‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,115 @@ public function testOpensALinkedWorktree(): void
7979
$this->assertSame($repository->getHeadCommit()->getHash(), $worktree->getHeadCommit()->getHash());
8080
}
8181

82+
/**
83+
* The ".git" file of a submodule points to its git directory, in the one
84+
* of the superproject, with a relative path.
85+
*/
86+
public function testOpensASubmodule(): void
87+
{
88+
$superproject = self::createFoobarRepository(false);
89+
$source = self::createFoobarRepository();
90+
// git refuses to clone a local submodule since 2.38.1, unless protocol.file.allow allows it.
91+
new Process([self::gitCommand(), '-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', $source->getGitDir(), 'sub'], $superproject->getWorkingDir(), self::gitEnvs())->mustRun();
92+
$dir = $superproject->getWorkingDir().'/sub';
93+
$this->assertFileMatchesFormat('gitdir: ../.git/modules/sub', $dir.'/.git');
94+
95+
$submodule = new Repository($dir, driver: self::getDriver());
96+
97+
$this->assertSame(realpath($dir), $submodule->getWorkingDir());
98+
$this->assertSame(realpath($superproject->getGitDir().'/modules/sub'), $submodule->getGitDir());
99+
$this->assertFalse($submodule->isBare());
100+
$this->assertSame($source->getHeadCommit()->getHash(), $submodule->getHeadCommit()->getHash());
101+
}
102+
103+
/**
104+
* "git clone --separate-git-dir" writes a ".git" file with the absolute
105+
* path of the git directory.
106+
*/
107+
public function testOpensACloneWithASeparateGitDir(): void
108+
{
109+
$source = self::createFoobarRepository();
110+
[$dir, $gitDir] = self::cloneWithASeparateGitDir($source);
111+
112+
$repository = new Repository($dir, driver: self::getDriver());
113+
114+
$this->assertSame(realpath($dir), $repository->getWorkingDir());
115+
$this->assertSame(realpath($gitDir), $repository->getGitDir());
116+
$this->assertFalse($repository->isBare());
117+
$this->assertSame($source->getHeadCommit()->getHash(), $repository->getHeadCommit()->getHash());
118+
}
119+
120+
/**
121+
* Like git, which leaves out the line ending of a ".git" file only: a
122+
* path may end with a space.
123+
*/
124+
#[DataProvider('provideLineEndings')]
125+
public function testOpensAGitFileWhateverItsLineEnding(string $lineEnding): void
126+
{
127+
[$dir, $gitDir] = self::cloneWithASeparateGitDir(self::createFoobarRepository());
128+
file_put_contents($dir.'/.git', 'gitdir: '.$gitDir.$lineEnding);
129+
130+
$this->assertSame(realpath($gitDir), new Repository($dir, driver: self::getDriver())->getGitDir());
131+
}
132+
133+
/**
134+
* @return iterable<string, array{string}>
135+
*/
136+
public static function provideLineEndings(): iterable
137+
{
138+
yield 'none' => [''];
139+
yield 'LF' => ["\n"];
140+
yield 'CRLF, written on Windows' => ["\r\n"];
141+
}
142+
143+
#[DataProvider('provideGitFilesPointingToNoGitDirectory')]
144+
public function testAGitFilePointingToNoGitDirectoryThrows(string $content, string $message): void
145+
{
146+
$dir = self::createTempDir();
147+
file_put_contents($dir.'/.git', $content);
148+
149+
$this->expectException(DriverException::class);
150+
$this->expectExceptionMessage(\sprintf($message, $dir));
151+
152+
new Repository($dir, driver: self::getDriver());
153+
}
154+
155+
/**
156+
* @return iterable<string, array{string, string}>
157+
*/
158+
public static function provideGitFilesPointingToNoGitDirectory(): iterable
159+
{
160+
$invalid = 'The "%s/.git" file doesn\'t point to a git directory.';
161+
162+
yield 'another format' => ["ref: refs/heads/master\n", $invalid];
163+
// Like git, which reads "gitdir: " at the start of the file only.
164+
yield 'gitdir on the second line' => ["\ngitdir: missing\n", $invalid];
165+
yield 'no path' => ["gitdir: \n", $invalid];
166+
yield 'NUL byte' => ["gitdir: a\0b\n", $invalid];
167+
yield 'missing directory' => ["gitdir: missing\n", 'The git directory "missing" of "%s" does not exist.'];
168+
}
169+
170+
public function testAnUnreadableGitFileThrows(): void
171+
{
172+
$dir = self::createTempDir();
173+
file_put_contents($dir.'/.git', "gitdir: missing\n");
174+
chmod($dir.'/.git', 0);
175+
176+
try {
177+
if (is_readable($dir.'/.git')) {
178+
$this->markTestSkipped('The user of the tests reads any file, like root.');
179+
}
180+
181+
$this->expectException(DriverException::class);
182+
$this->expectExceptionMessage(\sprintf('Unable to read the "%s/.git" file.', $dir));
183+
184+
new Repository($dir, driver: self::getDriver());
185+
} finally {
186+
// Or the file can't be deleted on Windows.
187+
chmod($dir.'/.git', 0o600);
188+
}
189+
}
190+
82191
public function testOpeningADirectoryThatIsNotARepositoryThrows(): void
83192
{
84193
$dir = self::createTempDir();
@@ -820,4 +929,16 @@ public function testDebugOptionSetToFalseIsRefused(): void
820929

821930
new Repository(self::createTempDir(), ['debug' => false]);
822931
}
932+
933+
/**
934+
* @return array{string, string} The working tree of the clone, and its git directory
935+
*/
936+
private static function cloneWithASeparateGitDir(Repository $source): array
937+
{
938+
$dir = self::createTempDir().'/clone';
939+
$gitDir = self::createTempDir().'/git';
940+
new Process([self::gitCommand(), 'clone', '-q', '--separate-git-dir='.$gitDir, $source->getGitDir(), $dir], null, self::gitEnvs())->mustRun();
941+
942+
return [$dir, $gitDir];
943+
}
823944
}

0 commit comments

Comments
 (0)