From 2f9946abd210fff1335f19c52a798bca31e7980f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 22 Jul 2026 14:13:41 +0530 Subject: [PATCH 1/4] Fix GitLab adapter treating './' and '.' as a literal path instead of repository root GitLab's tree/file endpoints receive path as a literal query parameter or URL segment value, which is never subject to URI dot-segment normalization the way GitHub's contents API path segments are. Callers that pass './' or '.' to mean "repository root" (as is conventional in git and POSIX shells) get an empty tree / 404 instead of root contents, since GitLab looks for a file/directory literally named '.'. Normalize './' and '.' to an empty root path, and strip a leading './' from nested paths, in both listRepositoryContents() and getRepositoryContent(). --- src/VCS/Adapter/Git/GitLab.php | 30 ++++++++++++++++++++++++-- tests/VCS/Adapter/GitLabTest.php | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 269d68d2..edd09410 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -122,6 +122,30 @@ private function getOwnerPath(string $owner): string return $owner; } + /** + * Normalize a repository-relative path. + * + * GitLab's file/tree endpoints receive the path as a literal query + * parameter or URL segment value, not a URI path segment, so it is + * never subject to dot-segment normalization (unlike e.g. GitHub's + * contents API). Callers commonly pass './' or '.' to mean "repository + * root" (as `.` conventionally does on POSIX shells and in git itself), + * so normalize those to an empty root path and strip a leading './' + * from nested paths before they reach the GitLab API. + */ + private function normalizeRepositoryPath(string $path): string + { + if ($path === '.' || $path === './') { + return ''; + } + + if (str_starts_with($path, './')) { + return substr($path, 2); + } + + return $path; + } + /** * Extract namespace ID from "id:path" format */ @@ -369,7 +393,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 +426,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..171c39ca 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -1305,6 +1305,43 @@ 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, './'); + + $this->assertNotEmpty($empty); + $this->assertEquals(array_column($empty, 'name'), array_column($dot, 'name')); + $this->assertEquals(array_column($empty, 'name'), array_column($dotSlash, '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'); + + $this->assertEquals($direct['content'], $prefixed['content']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testGetUser(): void { $result = $this->vcsAdapter->getUser('root'); From 402bf3183be7b3aea18865e0c79dd8093684b109 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 22 Jul 2026 15:34:07 +0530 Subject: [PATCH 2/4] Trim overly verbose docblock --- src/VCS/Adapter/Git/GitLab.php | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index edd09410..c7d8ee77 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -123,15 +123,8 @@ private function getOwnerPath(string $owner): string } /** - * Normalize a repository-relative path. - * - * GitLab's file/tree endpoints receive the path as a literal query - * parameter or URL segment value, not a URI path segment, so it is - * never subject to dot-segment normalization (unlike e.g. GitHub's - * contents API). Callers commonly pass './' or '.' to mean "repository - * root" (as `.` conventionally does on POSIX shells and in git itself), - * so normalize those to an empty root path and strip a leading './' - * from nested paths before they reach the GitLab API. + * 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 { From 28a4a070c5db6e574e952cf049ee0b65938866f0 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 22 Jul 2026 15:36:09 +0530 Subject: [PATCH 3/4] Address review: strip repeated './' prefixes, not just the first one './././' was only stripped once, leaving a literal './' that GitLab would still look up as-is. Loop the strip and collapse a leftover '.' to root too. --- src/VCS/Adapter/Git/GitLab.php | 10 +++------- tests/VCS/Adapter/GitLabTest.php | 5 +++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index c7d8ee77..55d7fcb3 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -128,15 +128,11 @@ private function getOwnerPath(string $owner): string */ private function normalizeRepositoryPath(string $path): string { - if ($path === '.' || $path === './') { - return ''; - } - - if (str_starts_with($path, './')) { - return substr($path, 2); + while (str_starts_with($path, './')) { + $path = substr($path, 2); } - return $path; + return $path === '.' ? '' : $path; } /** diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 171c39ca..a882f35d 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -1317,9 +1317,12 @@ public function testListRepositoryContentsRootSentinels(): void $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); } @@ -1335,8 +1338,10 @@ public function testGetRepositoryContentRootSentinelPrefix(): void $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); } From 4a2c36a07208b10c857b6860cbccebd3357f0bfc Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 22 Jul 2026 15:37:51 +0530 Subject: [PATCH 4/4] Rewrite normalization as path-segment filtering, not leading-prefix stripping The leading-'./' loop only handled prefixes; it left embedded dot segments (src/./main.php), double slashes (src//main.php), and trailing slashes untouched, all of which GitLab still resolves literally. Filter empty and '.' segments out of the whole path instead of special-casing the start of the string. --- src/VCS/Adapter/Git/GitLab.php | 9 +++++---- tests/VCS/Adapter/GitLabTest.php | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 55d7fcb3..0cadaeeb 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -128,11 +128,12 @@ private function getOwnerPath(string $owner): string */ private function normalizeRepositoryPath(string $path): string { - while (str_starts_with($path, './')) { - $path = substr($path, 2); - } + $segments = array_filter( + explode('/', $path), + fn (string $segment): bool => $segment !== '' && $segment !== '.' + ); - return $path === '.' ? '' : $path; + return implode('/', $segments); } /** diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index a882f35d..3eba2ad5 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -1347,6 +1347,26 @@ public function testGetRepositoryContentRootSentinelPrefix(): void } } + 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');