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.

15 changes: 1 addition & 14 deletions src/Plugins/Tia.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
use Pest\Exceptions\TiaRequiresCommit;
use Pest\Exceptions\TiaRequiresDefaultBranch;
use Pest\Exceptions\TiaRequiresRemote;
use Pest\Exceptions\TiaRequiresRepositoryRoot;
use Pest\Panic;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Tia\BaselineSync;
Expand All @@ -33,7 +32,6 @@
use Pest\Plugins\Tia\TableExtractor;
use Pest\Plugins\Tia\WatchPatterns;
use Pest\Support\Container;
use Pest\Support\Git;
use Pest\Support\View;
use Pest\TestCaseFilters\TiaTestCaseFilter;
use Pest\TestSuite;
Expand Down Expand Up @@ -822,12 +820,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));
}

try {
$this->resolveBranch($projectRoot);
} catch (MissingDependency $missingGit) {
Expand Down Expand Up @@ -2154,19 +2146,14 @@ private function formatStructuralDrift(array $drift): string
return implode(', ', array_keys($seen));
}

private function gitSubdirectoryPrefix(string $projectRoot): ?string
{
return new Git($projectRoot)->subdirectoryPrefix();
}

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

$baseline = new Git($projectRoot)->show($sha, 'composer.lock');
$baseline = new ChangedFiles($projectRoot)->contentAtSha($sha, 'composer.lock');

if ($baseline === null) {
return '';
Expand Down
4 changes: 2 additions & 2 deletions src/Plugins/Tia/BaselineSync.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,9 @@ private function isCi(): bool

private function detectGitHubRepo(string $projectRoot): ?string
{
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
$gitConfig = GitRepository::configPath($projectRoot);

if (! is_file($gitConfig)) {
if ($gitConfig === null) {
return null;
}

Expand Down
58 changes: 53 additions & 5 deletions src/Plugins/Tia/ChangedFiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,17 @@
{
private Git $git;

private string $repoPrefix;

public function __construct(private string $projectRoot)
{
$this->git = new Git($projectRoot);
$this->repoPrefix = $this->detectRepoPrefix();
}

public function repoPrefix(): string
{
return $this->repoPrefix;
}

/**
Expand Down Expand Up @@ -158,9 +166,9 @@ private function filterBehaviourallyUnchanged(array $files, string $sha): array
return $remaining;
}

private function contentAtSha(string $sha, string $path): ?string
public function contentAtSha(string $sha, string $path): ?string
{
return $this->git->show($sha, $path);
return $this->git->show($sha, $this->repoPrefix.$path);
}

/**
Expand Down Expand Up @@ -305,13 +313,15 @@ private function shaIsReachable(string $sha): bool
*/
private function diffSinceSha(string $sha): array
{
$output = $this->scan()->raw(['diff', '--name-only', '--no-renames', $sha.'..HEAD']);
$output = $this->scan()->raw(['diff', '--name-only', '-z', '--no-renames', $sha.'..HEAD']);

if ($output === null) {
throw new MissingDependency('Tia mode', 'git');
}

return $this->splitLines($output);
$paths = explode("\x00", rtrim($output, "\x00"));

return $this->toProjectRelative(array_values(array_filter($paths, static fn (string $path): bool => $path !== '')));
}

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

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

public function currentSha(): ?string
Expand All @@ -373,6 +383,44 @@ public function currentSha(): ?string
return $sha === '' ? null : $sha;
}

/**
* @param array<int, string> $repoRelativePaths
* @return array<int, string>
*/
private function toProjectRelative(array $repoRelativePaths): array
{
if ($this->repoPrefix === '') {
return $repoRelativePaths;
}

$projectRelative = [];

foreach ($repoRelativePaths as $path) {
if (str_starts_with($path, $this->repoPrefix)) {
$projectRelative[] = substr($path, strlen($this->repoPrefix));
}
}

return $projectRelative;
}

private function detectRepoPrefix(): string
{
static $cache = [];

if (isset($cache[$this->projectRoot])) {
return $cache[$this->projectRoot];
}

$prefix = $this->git->subdirectoryPrefix();

if ($prefix === null || $prefix === '') {
return $cache[$this->projectRoot] = '';
}

return $cache[$this->projectRoot] = $prefix.'/';
}

/**
* @return array<int, string>
*/
Expand Down
2 changes: 1 addition & 1 deletion src/Plugins/Tia/Fingerprint.php
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ private static function isTrackedByGit(string $projectRoot, string $relativePath
return $cache[$key];
}

if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) {
if (GitRepository::locate($projectRoot) === null) {
return $cache[$key] = true;
}

Expand Down
85 changes: 85 additions & 0 deletions src/Plugins/Tia/GitRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace Pest\Plugins\Tia;

/**
* Locates the git repository governing a path, which for projects living in
* a subdirectory of a monorepo sits in an ancestor directory rather than the
* project root itself. Paths are walked as strings, so callers pass
* canonical (realpath'd) roots.
*
* @internal
*/
final class GitRepository
{
/**
* The nearest ancestor `.git` entry governing the given path — a
* directory for regular repositories, a file for worktrees and
* submodules — or `null` when the path is not inside a repository.
*/
public static function locate(string $path): ?string
{
$dir = $path;

while (true) {
$dotGit = $dir.DIRECTORY_SEPARATOR.'.git';

if (is_dir($dotGit) || is_file($dotGit)) {
return $dotGit;
}

$parent = dirname($dir);

if ($parent === $dir) {
return null;
}

$dir = $parent;
}
}

/**
* The governing repository's config file, or `null` when there is none
* or it cannot be resolved by static inspection.
*/
public static function configPath(string $path): ?string
{
$dotGit = self::locate($path);

if ($dotGit === null || ! is_dir($dotGit)) {
return null;
}

$config = $dotGit.DIRECTORY_SEPARATOR.'config';

return is_file($config) ? $config : null;
}

/**
* The project's path relative to the governing repository's root, with a
* trailing slash (`apps/api/`), or an empty string when the project is
* the repository root or sits outside any repository.
*/
public static function subdirectoryPrefix(string $projectRoot): string
{
$dotGit = self::locate($projectRoot);

if ($dotGit === null) {
return '';
}

$repoRoot = dirname($dotGit);
$realRepoRoot = @realpath($repoRoot);
$realProjectRoot = @realpath($projectRoot);

if ($realRepoRoot === false || $realProjectRoot === false || $realRepoRoot === $realProjectRoot) {
return '';
}

$prefix = substr($realProjectRoot, strlen(rtrim($realRepoRoot, DIRECTORY_SEPARATOR)) + 1);

return str_replace(DIRECTORY_SEPARATOR, '/', rtrim($prefix, DIRECTORY_SEPARATOR)).'/';
}
}
12 changes: 10 additions & 2 deletions src/Plugins/Tia/Storage.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ private static function projectKey(string $projectRoot): string
{
$origin = self::originIdentity($projectRoot);

if ($origin !== null) {
$prefix = GitRepository::subdirectoryPrefix($projectRoot);

if ($prefix !== '') {
$origin .= '#'.$prefix;
}
}

$realpath = @realpath($projectRoot);
$input = $origin ?? ($realpath === false ? $projectRoot : $realpath);

Expand Down Expand Up @@ -117,9 +125,9 @@ private static function originIdentity(string $projectRoot): ?string

private static function rawOriginUrl(string $projectRoot): ?string
{
$config = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
$config = GitRepository::configPath($projectRoot);

if (! is_file($config)) {
if ($config === null) {
return null;
}

Expand Down
8 changes: 3 additions & 5 deletions tests/Features/Tia/BranchShapes.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,16 +155,14 @@
->and($project->graph()['baselines']['master']['results'])->toHaveCount(Project::TOTAL_TESTS);
})->skipOnWindows();

test('a project below the git repository root refuses to run and writes nothing', function (array $arguments): void {
test('a project below the git repository root runs from the subdirectory', function (array $arguments): void {
$project = Project::make('master');
$nested = $project->nested();

$result = $project->pestIn($nested, '--tia', ...$arguments);

expect($result->exitCode)->toBe(1, $result->describe())
->and($result->output)->toContain('Tia mode requires the git repository root')
->and($project->path('.home/.pest'))->not->toBeDirectory()
->and($nested.DIRECTORY_SEPARATOR.'.pest')->not->toBeDirectory();
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();

test('a repository with no commits says so, and leaves plain runs alone', function (): void {
Expand Down
Loading