diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 269d68d2..0cadaeeb 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -122,6 +122,20 @@ private function getOwnerPath(string $owner): string return $owner; } + /** + * GitLab passes path as a literal query/URL value, so unlike GitHub it + * never resolves './' or '.' to the repository root on its own. + */ + private function normalizeRepositoryPath(string $path): string + { + $segments = array_filter( + explode('/', $path), + fn (string $segment): bool => $segment !== '' && $segment !== '.' + ); + + return implode('/', $segments); + } + /** * Extract namespace ID from "id:path" format */ @@ -369,7 +383,7 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri { $ownerPath = $this->getOwnerPath($owner); $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); - $encodedPath = urlencode($path); + $encodedPath = urlencode($this->normalizeRepositoryPath($path)); $url = "/projects/{$projectPath}/repository/files/{$encodedPath}?ref=" . urlencode(empty($ref) ? 'HEAD' : $ref); $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); @@ -402,11 +416,13 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri public function listRepositoryContents(string $owner, string $repositoryName, string $path = '', string $ref = ''): array { + $path = $this->normalizeRepositoryPath($path); + $ownerPath = $this->getOwnerPath($owner); $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $url = "/projects/{$projectPath}/repository/tree" . (empty($ref) ? '' : '?ref=' . urlencode($ref)); - if (!empty($path)) { + if ($path !== '') { $url .= (empty($ref) ? '?' : '&') . 'path=' . urlencode($path); } diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 1aec3577..3eba2ad5 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -1305,6 +1305,68 @@ public function testListRepositoryContents(): void } } + public function testListRepositoryContentsRootSentinels(): void + { + $repositoryName = 'test-list-repository-contents-root-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $empty = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, ''); + $dot = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, '.'); + $dotSlash = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, './'); + + $repeatedDotSlash = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, './././'); + + $this->assertNotEmpty($empty); + $this->assertEquals(array_column($empty, 'name'), array_column($dot, 'name')); + $this->assertEquals(array_column($empty, 'name'), array_column($dotSlash, 'name')); + $this->assertEquals(array_column($empty, 'name'), array_column($repeatedDotSlash, 'name')); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetRepositoryContentRootSentinelPrefix(): void + { + $repositoryName = 'test-get-repository-content-root-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $direct = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'README.md'); + $prefixed = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, './README.md'); + $repeatedPrefix = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, './././README.md'); + + $this->assertEquals($direct['content'], $prefixed['content']); + $this->assertEquals($direct['content'], $repeatedPrefix['content']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testListRepositoryContentsMalformedNestedPath(): void + { + $repositoryName = 'test-list-repository-contents-malformed-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'src/main.php', 'vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, 'src'); + $embeddedDot = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, 'src/.'); + $doubleSlash = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, 'src//'); + + $this->assertNotEmpty($clean); + $this->assertEquals(array_column($clean, 'name'), array_column($embeddedDot, 'name')); + $this->assertEquals(array_column($clean, 'name'), array_column($doubleSlash, 'name')); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testGetUser(): void { $result = $this->vcsAdapter->getUser('root');