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: 6 additions & 0 deletions .changelog/git-root-whitespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: fixed
type: patch
---

Preserve whitespace and control bytes in checkout root names while rejecting nested, metadata and bare repository directories.
21 changes: 17 additions & 4 deletions src/Git/FileSelection.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,7 @@ public function __construct(private readonly ProcessRunner $process)
*/
public function select(string $directory, ?string $base, ?string $head, ?string $config = null): array
{
$top = trim($this->process->capture(['git', 'rev-parse', '--show-toplevel'], $directory));
if (realpath($top) !== realpath($directory)) {
throw new \InvalidArgumentException('Project directory must be the Git checkout root.');
}
$this->assertCheckoutRoot($directory);
if (null === $base && null === $head) {
return $this->paths($this->process->capture(['git', 'ls-files', '-z'], $directory));
}
Expand Down Expand Up @@ -59,6 +56,22 @@ public function select(string $directory, ?string $base, ?string $head, ?string
], $directory));
}

/**
* Validates Git's scope metadata without interpreting the absolute root pathname.
*
* @throws \InvalidArgumentException When the directory is nested, bare or inside Git metadata.
*/
private function assertCheckoutRoot(string $directory): void
{
$inside = trim($this->process->capture(['git', 'rev-parse', '--is-inside-work-tree'], $directory));
// A nested prefix always ends in '/'; removing line terminators cannot turn it into a root.
$prefix = rtrim($this->process->capture(['git', 'rev-parse', '--show-prefix'], $directory), "\r\n");

if ('true' !== $inside || '' !== $prefix) {
throw new \InvalidArgumentException('Project directory must be the Git checkout root.');
}
}

/** @return list<string> Decodes Git's binary path protocol without trimming file names. */
private function paths(string $output): array
{
Expand Down
32 changes: 32 additions & 0 deletions tests/Integration/StandardsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,38 @@ public function testChangedFilesUseMergeBaseDestinationsAndNulSafePaths(): void
self::assertContains($this->directory . '/-leading.php', $filtered);
}

/** Checks trailing root-path spaces/control bytes without weakening nested-checkout rejection. */
public function testCheckoutRootWhitespaceIsPreservedInAllAndChangedModes(): void
{
$renamed = $this->directory . " \t\n";
rename($this->directory, $renamed);
$this->directory = $renamed;

$base = $this->commit();
$source = "<?php\n\ndeclare(strict_types=1);\n\necho 'ok';\n";
$this->put('source.php', $source);
$head = $this->commit();
$selection = new FileSelection(new NativeProcessRunner());

self::assertSame(['source.php'], $selection->select($this->directory, null, null));
self::assertSame(['source.php'], $selection->select($this->directory, $base, $head));
self::assertSame(0, $this->cli(['check', '--all'])->getExitCode());
self::assertSame($source, file_get_contents($this->directory . '/source.php'));
}

/** Rejects metadata/bare repositories instead of silently checking an empty source selection. */
public function testGitMetadataAndBareRepositoriesAreNotSourceCheckoutRoots(): void
{
$this->git(['init', '--bare', '--initial-branch=main', 'bare.git']);

foreach (['.git', 'bare.git'] as $directory) {
$check = $this->cli(['check', '--all', '--directory=' . $this->directory . '/' . $directory]);

self::assertSame(2, $check->getExitCode(), $directory);
self::assertStringContainsString('Git checkout root', $check->getErrorOutput(), $directory);
}
}

/** Ensures removing a policy file cannot limit checking to an empty changed-file set. */
public function testDeletedConfigurationStillTriggersFullScan(): void
{
Expand Down
Loading