setTimeout(15); $process->run(); expect($process->isSuccessful()) ->toBeTrue(trim($process->getErrorOutput()) ?: trim($process->getOutput())); return trim($process->getOutput()); } function branchTopologyCommit(string $repository, string $path, string $contents, string $message): string { $absolutePath = $repository.DIRECTORY_SEPARATOR.$path; $directory = dirname($absolutePath); if (! is_dir($directory)) { mkdir($directory, 0777, true); } file_put_contents($absolutePath, $contents); branchTopologyGit($repository, 'add', $path); branchTopologyGit($repository, 'commit', '-m', $message); return branchTopologyGit($repository, 'rev-parse', 'HEAD'); } function branchTopologyRepository(): string { $repository = sys_get_temp_dir().DIRECTORY_SEPARATOR.'tenantpilot-branch-topology-'.bin2hex(random_bytes(8)); mkdir($repository, 0777, true); branchTopologyGit($repository, 'init', '-b', 'main'); branchTopologyGit($repository, 'config', 'user.email', 'branch-topology@example.test'); branchTopologyGit($repository, 'config', 'user.name', 'Branch Topology Fixture'); branchTopologyCommit($repository, 'baseline.txt', "baseline\n", 'baseline'); return $repository; } function branchTopologyRemoveDirectory(string $directory): void { if (! is_dir($directory)) { return; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST, ); foreach ($iterator as $item) { $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); } rmdir($directory); } /** * @return array{expectedBranch: string, targetCommit: string, featureCommit: string} */ function branchTopologyPrepareStream( string $repository, string $family, bool $remoteRef, bool $localRef = true, bool $diverged = false, ): array { $expectedBranch = $family === 'platform' ? 'platform-dev' : 'website-dev'; $baseline = branchTopologyGit($repository, 'rev-parse', 'main'); branchTopologyGit($repository, 'branch', $expectedBranch, $baseline); if ($diverged) { branchTopologyGit($repository, 'checkout', $expectedBranch); $targetCommit = branchTopologyCommit( $repository, $family.'-integration.txt', $family." integration\n", $family.' integration', ); } else { $targetCommit = $baseline; } branchTopologyGit($repository, 'checkout', '-b', 'feat/'.$family.'-fixture', $baseline); $featureCommit = branchTopologyCommit( $repository, $family.'-feature.txt', $family." feature\n", $family.' feature', ); if ($remoteRef) { branchTopologyGit($repository, 'update-ref', 'refs/remotes/origin/'.$expectedBranch, $targetCommit); } if (! $localRef) { branchTopologyGit($repository, 'branch', '-D', $expectedBranch); } return compact('expectedBranch', 'targetCommit', 'featureCommit'); } /** * @param list $arguments * @param array $environment */ function branchTopologyResolve( string $repository, array $arguments, array $environment = [], bool $machineReadable = true, ): Process { $isolatedEnvironment = [ 'TENANTATLAS_BRANCH_FAMILY' => false, 'TENANTATLAS_WORK_CLASSIFICATION' => false, 'TENANTATLAS_PR_TARGET' => false, 'TENANTATLAS_ACTIVE_SPEC' => false, 'TENANTATLAS_BASE_REF' => false, 'TENANTATLAS_REPO_SCOPE' => false, 'GITHUB_BASE_REF' => false, 'GITEA_BASE_REF' => false, ]; $command = [repo_path('scripts/resolve-feature-base')]; if ($machineReadable) { $command[] = '--format=kv'; } $process = new Process( [...$command, ...$arguments], $repository, array_replace($isolatedEnvironment, $environment), ); $process->setTimeout(15); $process->run(); return $process; } /** * @return array */ function branchTopologyKeyValues(string $output): array { $values = []; foreach (preg_split('/\R/', trim($output)) ?: [] as $line) { if (! str_contains($line, '=')) { continue; } [$key, $value] = explode('=', $line, 2); $values[$key] = $value; } return $values; } function branchTopologyFixture(Closure $assertions): void { $repository = branchTopologyRepository(); try { $assertions($repository); } finally { branchTopologyRemoveDirectory($repository); } } it('[BS-001] selects the platform remote ref with its merge-base result', function (): void { branchTopologyFixture(function (string $repository): void { $stream = branchTopologyPrepareStream($repository, 'platform', remoteRef: true); $process = branchTopologyResolve($repository, ['--branch-family=platform']); $values = branchTopologyKeyValues($process->getOutput()); expect($process->getExitCode())->toBe(0) ->and($values['branch_family'] ?? null)->toBe('platform') ->and($values['expected_branch'] ?? null)->toBe('platform-dev') ->and($values['selected_ref'] ?? null)->toBe('origin/platform-dev') ->and($values['target_commit'] ?? null)->toBe($stream['targetCommit']) ->and($values['merge_base'] ?? null)->toBe($stream['targetCommit']) ->and($values['base_range'] ?? null)->toBe($stream['targetCommit'].'...HEAD'); }); }); it('[BS-002] falls back only to the local platform ref with its merge-base result', function (): void { branchTopologyFixture(function (string $repository): void { $stream = branchTopologyPrepareStream($repository, 'platform', remoteRef: false); $process = branchTopologyResolve($repository, ['--branch-family=platform']); $values = branchTopologyKeyValues($process->getOutput()); expect($process->getExitCode())->toBe(0) ->and($values['branch_family'] ?? null)->toBe('platform') ->and($values['expected_branch'] ?? null)->toBe('platform-dev') ->and($values['selected_ref'] ?? null)->toBe('platform-dev') ->and($values['target_commit'] ?? null)->toBe($stream['targetCommit']) ->and($values['merge_base'] ?? null)->toBe($stream['targetCommit']) ->and($values['base_range'] ?? null)->toBe($stream['targetCommit'].'...HEAD'); }); }); it('[BS-003] selects the website remote ref with its merge-base result', function (): void { branchTopologyFixture(function (string $repository): void { $stream = branchTopologyPrepareStream($repository, 'website', remoteRef: true); $process = branchTopologyResolve($repository, ['--branch-family=website']); $values = branchTopologyKeyValues($process->getOutput()); expect($process->getExitCode())->toBe(0) ->and($values['branch_family'] ?? null)->toBe('website') ->and($values['expected_branch'] ?? null)->toBe('website-dev') ->and($values['selected_ref'] ?? null)->toBe('origin/website-dev') ->and($values['target_commit'] ?? null)->toBe($stream['targetCommit']) ->and($values['merge_base'] ?? null)->toBe($stream['targetCommit']) ->and($values['base_range'] ?? null)->toBe($stream['targetCommit'].'...HEAD'); }); }); it('[BS-004] falls back only to the local website ref with its merge-base result', function (): void { branchTopologyFixture(function (string $repository): void { $stream = branchTopologyPrepareStream($repository, 'website', remoteRef: false); $process = branchTopologyResolve($repository, ['--branch-family=website']); $values = branchTopologyKeyValues($process->getOutput()); expect($process->getExitCode())->toBe(0) ->and($values['branch_family'] ?? null)->toBe('website') ->and($values['expected_branch'] ?? null)->toBe('website-dev') ->and($values['selected_ref'] ?? null)->toBe('website-dev') ->and($values['target_commit'] ?? null)->toBe($stream['targetCommit']) ->and($values['merge_base'] ?? null)->toBe($stream['targetCommit']) ->and($values['base_range'] ?? null)->toBe($stream['targetCommit'].'...HEAD'); }); }); it('[BS-005] accepts dev only for an explicitly classified promotion', function (): void { branchTopologyFixture(function (string $repository): void { $baseline = branchTopologyGit($repository, 'rev-parse', 'main'); branchTopologyGit($repository, 'branch', 'dev', $baseline); branchTopologyGit($repository, 'update-ref', 'refs/remotes/origin/dev', $baseline); branchTopologyGit($repository, 'checkout', '-b', 'feat/promotion-fixture', $baseline); branchTopologyCommit($repository, 'promotion.txt', "promotion\n", 'promotion feature'); $process = branchTopologyResolve($repository, [ '--branch-family=repository-wide', '--work-classification=promotion', '--pull-request-target=dev', ]); $values = branchTopologyKeyValues($process->getOutput()); expect($process->getExitCode())->toBe(0) ->and($values['branch_family'] ?? null)->toBe('repository-wide') ->and($values['work_classification'] ?? null)->toBe('promotion') ->and($values['expected_branch'] ?? null)->toBe('dev') ->and($values['selected_ref'] ?? null)->toBe('origin/dev') ->and($values['target_commit'] ?? null)->toBe($baseline) ->and($values['merge_base'] ?? null)->toBe($baseline) ->and($values['base_range'] ?? null)->toBe($baseline.'...HEAD'); }); }); it('[BS-006] fails closed when platform family refs are missing', function (): void { branchTopologyFixture(function (string $repository): void { $baseline = branchTopologyGit($repository, 'rev-parse', 'main'); branchTopologyGit($repository, 'branch', 'dev', $baseline); branchTopologyGit($repository, 'update-ref', 'refs/remotes/origin/dev', $baseline); $process = branchTopologyResolve($repository, ['--branch-family=platform']); $error = $process->getErrorOutput(); expect($process->getExitCode())->toBe(2) ->and($error)->toContain('status=failed', 'reason=missing-family-ref') ->and($error)->toContain( 'branch_family=platform', 'expected_branch=platform-dev', 'attempted_refs=origin/platform-dev,platform-dev', 'guidance=Declare a consistent branch family', 'diff_ran=false', ) ->and($error)->not->toContain( 'selected_ref=origin/dev', 'selected_ref=dev', 'HEAD~1', 'merge_base=', 'base_range=', ); }); }); it('[BS-007] fails closed when website family refs are missing', function (): void { branchTopologyFixture(function (string $repository): void { $baseline = branchTopologyGit($repository, 'rev-parse', 'main'); branchTopologyGit($repository, 'branch', 'platform-dev', $baseline); branchTopologyGit($repository, 'branch', 'dev', $baseline); branchTopologyGit($repository, 'update-ref', 'refs/remotes/origin/dev', $baseline); $process = branchTopologyResolve($repository, ['--branch-family=website']); $error = $process->getErrorOutput(); expect($process->getExitCode())->toBe(2) ->and($error)->toContain( 'reason=missing-family-ref', 'branch_family=website', 'expected_branch=website-dev', 'attempted_refs=origin/website-dev,website-dev', 'guidance=Declare a consistent branch family', 'diff_ran=false', ) ->and($error)->not->toContain( 'selected_ref=origin/dev', 'selected_ref=dev', 'HEAD~1', 'merge_base=', 'base_range=', ); }); }); it('[BS-008] returns only the platform feature path for diverged history', function (): void { branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'platform', remoteRef: true, diverged: true); $process = branchTopologyResolve($repository, ['--branch-family=platform']); $values = branchTopologyKeyValues($process->getOutput()); $changed = branchTopologyGit($repository, 'diff', '--name-only', $values['base_range']); expect($process->getExitCode())->toBe(0) ->and($changed)->toBe('platform-feature.txt') ->and($values['merge_base'] ?? null)->not->toBe($values['target_commit'] ?? null); }); }); it('[BS-009] returns only the website feature path for diverged history', function (): void { branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'website', remoteRef: true, diverged: true); $process = branchTopologyResolve($repository, ['--branch-family=website']); $values = branchTopologyKeyValues($process->getOutput()); $changed = branchTopologyGit($repository, 'diff', '--name-only', $values['base_range']); expect($process->getExitCode())->toBe(0) ->and($changed)->toBe('website-feature.txt') ->and($values['merge_base'] ?? null)->not->toBe($values['target_commit'] ?? null); }); }); it('[BS-010] rejects an explicit platform family plus website PR target', function (): void { branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'platform', remoteRef: true); $process = branchTopologyResolve($repository, [ '--branch-family=platform', '--pull-request-target=website-dev', ]); expect($process->getExitCode())->toBe(2) ->and($process->getErrorOutput())->toContain( 'reason=branch-family-conflict', 'explicit=platform', 'pull_request_target=website', 'guidance=Declare a consistent branch family', 'diff_ran=false', ); }); }); it('[BS-011] rejects conflicting branch-name and repository-scope signals', function (): void { branchTopologyFixture(function (string $repository): void { $specPath = $repository.'/spec.md'; file_put_contents($specPath, "# Ambiguous fixture\n\nNo branch metadata.\n"); $process = branchTopologyResolve($repository, [ '--branch-name=feat/platform-fixture', '--repo-scope=website', '--spec='.$specPath, ]); expect($process->getExitCode())->toBe(2) ->and($process->getErrorOutput())->toContain( 'reason=ambiguous-branch-family', 'branch_name=feat/platform-fixture', 'repo_scope=website', 'spec_path='.$specPath, 'guidance=Declare a consistent branch family', 'diff_ran=false', ); }); }); it('[BS-012] rejects an explicit ref from the wrong family before merge-base work', function (): void { branchTopologyFixture(function (string $repository): void { $baseline = branchTopologyGit($repository, 'rev-parse', 'main'); branchTopologyGit($repository, 'branch', 'platform-dev', $baseline); branchTopologyGit($repository, 'branch', 'website-dev', $baseline); branchTopologyGit($repository, 'update-ref', 'refs/remotes/origin/website-dev', $baseline); $process = branchTopologyResolve($repository, [ '--branch-family=platform', '--ref=origin/website-dev', ]); expect($process->getExitCode())->toBe(2) ->and($process->getErrorOutput())->toContain( 'reason=wrong-family-ref', 'expected_branch=platform-dev', 'explicit_ref=origin/website-dev', 'guidance=Declare a consistent branch family', 'diff_ran=false', ) ->and($process->getErrorOutput())->not->toContain( 'target_commit=', 'merge_base=', 'base_range=', ); }); }); it('proves Stage 1 priority, diagnostics, ancestry failure, and executable portability', function (): void { branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'website', remoteRef: true); $process = branchTopologyResolve($repository, ['--pull-request-target=website-dev']); $values = branchTopologyKeyValues($process->getOutput()); expect($process->getExitCode())->toBe(0) ->and($values['branch_family'] ?? null)->toBe('website') ->and($values['family_source'] ?? null)->toBe('pull-request-target'); }); branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'platform', remoteRef: true); $specPath = $repository.'/spec.md'; file_put_contents($specPath, "**Feature Base**: platform-dev\n**Normal Merge Target**: platform-dev\n"); $process = branchTopologyResolve($repository, ['--spec='.$specPath]); $values = branchTopologyKeyValues($process->getOutput()); expect($process->getExitCode())->toBe(0) ->and($values['branch_family'] ?? null)->toBe('platform') ->and($values['family_source'] ?? null)->toBe('spec-metadata'); }); branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'platform', remoteRef: true); $process = branchTopologyResolve($repository, [ '--branch-name=feat/platform-policy-fixture', '--repo-scope=platform', ]); $values = branchTopologyKeyValues($process->getOutput()); expect($process->getExitCode())->toBe(0) ->and($values['branch_family'] ?? null)->toBe('platform') ->and($values['family_source'] ?? null)->toBe('branch-and-scope'); }); branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'platform', remoteRef: true); $process = branchTopologyResolve($repository, ['--branch-family=platform']); $values = branchTopologyKeyValues($process->getOutput()); expect($process->getExitCode())->toBe(0) ->and($values)->toHaveKeys([ 'status', 'branch_family', 'work_classification', 'family_source', 'expected_branch', 'selected_ref', 'target_commit', 'merge_base', 'base_range', ]) ->and($values['status'])->toBe('resolved') ->and($values['target_commit'])->toMatch('/^[0-9a-f]{40}$/') ->and($values['merge_base'])->toMatch('/^[0-9a-f]{40}$/') ->and($values['base_range'])->toBe($values['merge_base'].'...HEAD'); }); branchTopologyFixture(function (string $repository): void { $baseline = branchTopologyGit($repository, 'rev-parse', 'main'); branchTopologyGit($repository, 'branch', 'dev', $baseline); $process = branchTopologyResolve($repository, ['--pull-request-target=dev']); expect($process->getExitCode())->toBe(2) ->and($process->getErrorOutput())->toContain( 'reason=explicit-promotion-classification-required', 'diff_ran=false', ); }); branchTopologyFixture(function (string $repository): void { $process = branchTopologyResolve($repository, ['--branch-family=unknown']); expect($process->getExitCode())->toBe(2) ->and($process->getErrorOutput())->toContain( 'reason=invalid-branch-family', 'explicit=unknown', 'diff_ran=false', ); }); branchTopologyFixture(function (string $repository): void { $specPath = $repository.'/spec.md'; file_put_contents($specPath, "**Feature Base**: platform-dev\n**Normal Merge Target**: platform-dev\n"); $process = branchTopologyResolve($repository, [ '--pull-request-target=website-dev', '--spec='.$specPath, ]); expect($process->getExitCode())->toBe(2) ->and($process->getErrorOutput())->toContain( 'reason=branch-family-conflict', 'selected=website', 'spec_metadata=platform', 'diff_ran=false', ); }); branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'platform', remoteRef: true); $process = branchTopologyResolve($repository, ['--branch-name=feat/platform-name-only']); expect($process->getExitCode())->toBe(2) ->and($process->getErrorOutput())->toContain( 'reason=ambiguous-branch-family', 'diff_ran=false', ); }); branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'platform', remoteRef: true); $process = branchTopologyResolve( $repository, [], ['TENANTATLAS_BRANCH_FAMILY' => 'platform'], ); $values = branchTopologyKeyValues($process->getOutput()); expect($process->getExitCode())->toBe(0) ->and($values['branch_family'] ?? null)->toBe('platform') ->and($values['family_source'] ?? null)->toBe('explicit'); }); branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'website', remoteRef: true); $process = branchTopologyResolve( $repository, [], ['GITHUB_BASE_REF' => 'website-dev'], ); $values = branchTopologyKeyValues($process->getOutput()); expect($process->getExitCode())->toBe(0) ->and($values['branch_family'] ?? null)->toBe('website') ->and($values['family_source'] ?? null)->toBe('pull-request-target'); }); branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'platform', remoteRef: true); $process = branchTopologyResolve($repository, [ '--branch-family=platform', '--ref=platform-dev', ]); expect($process->getExitCode())->toBe(0) ->and(branchTopologyKeyValues($process->getOutput())['selected_ref'] ?? null)->toBe('platform-dev'); }); branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'platform', remoteRef: true); $process = branchTopologyResolve( $repository, ['--pull-request-target=platform-dev'], ['GITHUB_BASE_REF' => 'refs/heads/platform-dev'], ); expect($process->getExitCode())->toBe(0) ->and(branchTopologyKeyValues($process->getOutput())['branch_family'] ?? null)->toBe('platform'); }); branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'platform', remoteRef: true); $process = branchTopologyResolve($repository, [ '--branch-family=platform', '--branch-name=feat/website-stale-hint', '--repo-scope=website', ]); $values = branchTopologyKeyValues($process->getOutput()); expect($process->getExitCode())->toBe(0) ->and($values['branch_family'] ?? null)->toBe('platform') ->and($values['family_source'] ?? null)->toBe('explicit'); }); branchTopologyFixture(function (string $repository): void { $baseline = branchTopologyGit($repository, 'rev-parse', 'main'); branchTopologyGit($repository, 'branch', 'dev', $baseline); branchTopologyGit($repository, 'update-ref', 'refs/remotes/origin/dev', $baseline); $process = branchTopologyResolve($repository, [ '--branch-family=repository-wide', '--work-classification=promotion', ]); expect($process->getExitCode())->toBe(2) ->and($process->getErrorOutput())->toContain( 'reason=explicit-target-required', 'work_classification=promotion', 'diff_ran=false', ); }); branchTopologyFixture(function (string $repository): void { branchTopologyPrepareStream($repository, 'platform', remoteRef: true); $process = branchTopologyResolve( $repository, ['--branch-family=platform'], [], machineReadable: false, ); expect($process->getExitCode())->toBe(0) ->and($process->getOutput())->toContain( 'Feature base resolved.', 'Branch family: platform', 'Expected integration branch: platform-dev', 'Selected ref: origin/platform-dev', 'Target commit:', 'Merge base:', 'Base range:', ); }); branchTopologyFixture(function (string $repository): void { branchTopologyGit($repository, 'checkout', '--orphan', 'platform-dev'); branchTopologyGit($repository, 'rm', '-rf', '.'); branchTopologyCommit($repository, 'unrelated.txt', "unrelated\n", 'unrelated root'); branchTopologyGit($repository, 'checkout', 'main'); $process = branchTopologyResolve($repository, ['--branch-family=platform']); expect($process->getExitCode())->toBe(2) ->and($process->getErrorOutput())->toContain( 'reason=no-common-merge-base', 'selected_ref=platform-dev', 'diff_ran=false', ); }); $resolver = repo_path('scripts/resolve-feature-base'); $syntax = new Process(['bash', '-n', $resolver], repo_root()); $syntax->run(); expect(is_executable($resolver))->toBeTrue() ->and($syntax->getExitCode())->toBe(0, $syntax->getErrorOutput()); });