Skip to content
Open
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
44 changes: 0 additions & 44 deletions src/Exceptions/TiaRequiresRepositoryRoot.php

This file was deleted.

30 changes: 1 addition & 29 deletions src/Plugins/Tia.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\NoAffectedTestsFound;
use Pest\Exceptions\TiaRequiresRepositoryRoot;
use Pest\Panic;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Tia\BaselineSync;
Expand Down Expand Up @@ -629,12 +628,6 @@ private function handleParent(array $arguments, string $projectRoot, bool $force
{
$this->watchPatterns->useDefaults($projectRoot);

$subdirectoryPrefix = $this->gitSubdirectoryPrefix($projectRoot);

if ($subdirectoryPrefix !== null) {
Panic::with(new TiaRequiresRepositoryRoot($subdirectoryPrefix));
}

$this->branch = new ChangedFiles($projectRoot)->currentBranch() ?? 'main';

$fingerprint = Fingerprint::compute($projectRoot);
Expand Down Expand Up @@ -1692,35 +1685,14 @@ private function formatStructuralDrift(array $drift): string
return implode(', ', array_keys($seen));
}

/**
* The path from the git repository root down to $projectRoot (e.g.
* `laravel-app`) when the project is nested inside a larger repo, or `null`
* when the project root is itself the repo root (or git is unavailable).
* TIA requires the two to coincide: git reports and addresses paths
* relative to the repo root, while the dependency graph is project-relative.
*/
private function gitSubdirectoryPrefix(string $projectRoot): ?string
{
$process = new Process(['git', 'rev-parse', '--show-prefix'], $projectRoot);
$process->run();

if (! $process->isSuccessful()) {
return null;
}

$prefix = trim($process->getOutput());

return $prefix === '' ? null : rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/');
}

private function composerLockDelta(string $projectRoot, string $sha): string
{
$current = @file_get_contents($projectRoot.'/composer.lock');
if ($current === false) {
return '';
}

$process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot);
$process = new Process(['git', 'show', $sha.':'.new ChangedFiles($projectRoot)->gitPrefix().'composer.lock'], $projectRoot);
$process->setTimeout(5.0);
$process->run();

Expand Down
59 changes: 54 additions & 5 deletions src/Plugins/Tia/ChangedFiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,58 @@
/**
* @internal
*/
final readonly class ChangedFiles
final class ChangedFiles
{
public function __construct(private string $projectRoot) {}
private ?string $gitPrefix = null;

public function __construct(private readonly string $projectRoot) {}

/**
* The project root's path prefix inside the git repository, with a
* trailing slash (e.g. `backend/`), or an empty string when the project
* root is the repository root itself or git is unavailable. Git prints
* and addresses paths relative to the repository root, while the
* dependency graph is keyed on project-relative paths — the git
* boundaries below translate between the two using this prefix.
*/
public function gitPrefix(): string
{
if ($this->gitPrefix !== null) {
return $this->gitPrefix;
}

$process = new Process(['git', 'rev-parse', '--show-prefix'], $this->projectRoot);
$process->run();

if (! $process->isSuccessful()) {
return $this->gitPrefix = '';
}

return $this->gitPrefix = str_replace(DIRECTORY_SEPARATOR, '/', trim($process->getOutput()));
}

/**
* @param array<int, string> $files repository-relative paths as printed by git.
* @return array<int, string> project-relative paths; files outside the project are dropped.
*/
private function toProjectRelative(array $files): array
{
$prefix = $this->gitPrefix();

if ($prefix === '') {
return $files;
}

$projectFiles = [];

foreach ($files as $file) {
if (str_starts_with($file, $prefix)) {
$projectFiles[] = substr($file, strlen($prefix));
}
}

return $projectFiles;
}

/**
* @param array<int, string> $files project-relative paths.
Expand Down Expand Up @@ -155,7 +204,7 @@ private function filterBehaviourallyUnchanged(array $files, string $sha): array

private function contentAtSha(string $sha, string $path): ?string
{
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
$process = new Process(['git', 'show', $sha.':'.$this->gitPrefix().$path], $this->projectRoot);
$process->setTimeout(5.0);
$process->run();

Expand Down Expand Up @@ -245,7 +294,7 @@ private function diffSinceSha(string $sha): array
throw new MissingDependency('Tia mode', 'git');
}

return $this->splitLines($process->getOutput());
return $this->toProjectRelative($this->splitLines($process->getOutput()));
}

/**
Expand Down Expand Up @@ -297,7 +346,7 @@ private function workingTreeChanges(): array
$files[] = $path;
}

return $files;
return $this->toProjectRelative($files);
}

public function currentSha(): ?string
Expand Down
155 changes: 155 additions & 0 deletions tests/Unit/Plugins/Tia/ChangedFiles.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php

declare(strict_types=1);

use Pest\Plugins\Tia\ChangedFiles;
use Symfony\Component\Process\Process;

function tia_changed_files_run(array $command, string $cwd): void
{
$process = new Process($command, $cwd);
$process->mustRun();
}

/**
* Creates a git repository containing a `backend/` Pest project and a
* `frontend/` sibling package, with one initial commit.
*
* @return array{root: string, sha: string}
*/
function tia_changed_files_monorepo(): array
{
$root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest_tia_monorepo_'.uniqid();

mkdir($root.'/backend/app', 0o777, true);
mkdir($root.'/frontend', 0o777, true);

file_put_contents($root.'/backend/app/Service.php', "<?php\n\$a = 1;\n");
file_put_contents($root.'/backend/composer.lock', '{"packages": []}');
file_put_contents($root.'/frontend/widget.php', "<?php\n\$w = 1;\n");

tia_changed_files_run(['git', 'init', '-q', '-b', 'main'], $root);
tia_changed_files_run(['git', 'config', 'user.email', 'tia@pestphp.com'], $root);
tia_changed_files_run(['git', 'config', 'user.name', 'Tia'], $root);
tia_changed_files_run(['git', 'add', '-A'], $root);
tia_changed_files_run(['git', 'commit', '-q', '-m', 'initial'], $root);

$process = new Process(['git', 'rev-parse', 'HEAD'], $root);
$process->mustRun();

return ['root' => $root, 'sha' => trim($process->getOutput())];
}

function tia_changed_files_rm(string $directory): void
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);

foreach ($iterator as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}

rmdir($directory);
}

describe('gitPrefix()', function (): void {
it('is empty at the repository root', function (): void {
$fixture = tia_changed_files_monorepo();

try {
expect(new ChangedFiles($fixture['root'])->gitPrefix())->toBeEmpty();
} finally {
tia_changed_files_rm($fixture['root']);
}
});

it('is the slash-terminated subdirectory path inside a larger repository', function (): void {
$fixture = tia_changed_files_monorepo();

try {
expect(new ChangedFiles($fixture['root'].'/backend')->gitPrefix())->toBe('backend/');
} finally {
tia_changed_files_rm($fixture['root']);
}
});

it('is empty outside any git repository', function (): void {
$directory = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest_tia_no_repo_'.uniqid();
mkdir($directory);

try {
expect(new ChangedFiles($directory)->gitPrefix())->toBeEmpty();
} finally {
tia_changed_files_rm($directory);
}
});
});

describe('a project in a repository subdirectory', function (): void {
it('reports working-tree changes as project-relative paths and drops sibling packages', function (): void {
$fixture = tia_changed_files_monorepo();

try {
file_put_contents($fixture['root'].'/backend/app/Service.php', "<?php\n\$a = 2;\n");
file_put_contents($fixture['root'].'/backend/app/Untracked.php', "<?php\n\$u = 1;\n");
file_put_contents($fixture['root'].'/frontend/widget.php', "<?php\n\$w = 2;\n");

$changed = new ChangedFiles($fixture['root'].'/backend')->since(null);

sort($changed);

expect($changed)->toBe(['app/Service.php', 'app/Untracked.php']);
} finally {
tia_changed_files_rm($fixture['root']);
}
});

it('reports committed changes since a sha as project-relative paths', function (): void {
$fixture = tia_changed_files_monorepo();

try {
file_put_contents($fixture['root'].'/backend/app/Service.php', "<?php\n\$a = 2;\n");
file_put_contents($fixture['root'].'/frontend/widget.php', "<?php\n\$w = 2;\n");
tia_changed_files_run(['git', 'commit', '-q', '-am', 'change both packages'], $fixture['root']);

expect(new ChangedFiles($fixture['root'].'/backend')->since($fixture['sha']))
->toBe(['app/Service.php']);
} finally {
tia_changed_files_rm($fixture['root']);
}
});

it('filters files whose content is behaviourally unchanged against the baseline sha', function (): void {
$fixture = tia_changed_files_monorepo();

try {
file_put_contents($fixture['root'].'/backend/app/Service.php', "<?php\n\$a = 2;\n");
tia_changed_files_run(['git', 'commit', '-q', '-am', 'change service'], $fixture['root']);

// Reverting the content makes the file diff against the baseline
// sha while hashing identically to it — reachable only when
// `git show` receives the repository-relative path.
file_put_contents($fixture['root'].'/backend/app/Service.php', "<?php\n\$a = 1;\n");

expect(new ChangedFiles($fixture['root'].'/backend')->since($fixture['sha']))->toBe([]);
} finally {
tia_changed_files_rm($fixture['root']);
}
});
});

describe('a project at the repository root', function (): void {
it('still reports repository-relative paths untouched', function (): void {
$fixture = tia_changed_files_monorepo();

try {
file_put_contents($fixture['root'].'/backend/app/Service.php', "<?php\n\$a = 2;\n");

expect(new ChangedFiles($fixture['root'])->since(null))->toBe(['backend/app/Service.php']);
} finally {
tia_changed_files_rm($fixture['root']);
}
});
});