diff --git a/src/Command/Push/PushArtifactCommand.php b/src/Command/Push/PushArtifactCommand.php index 385dc035..37f5b245 100644 --- a/src/Command/Push/PushArtifactCommand.php +++ b/src/Command/Push/PushArtifactCommand.php @@ -111,7 +111,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ]); $this->checklist->addItem('Preparing artifact directory'); - $this->cloneSourceBranch($outputCallback, $artifactDir, $destinationGitUrls[0], $sourceGitBranch); + $this->cloneSourceBranch($outputCallback, $artifactDir, $destinationGitUrls, $sourceGitBranch); $this->checklist->completePreviousItem(); } @@ -175,8 +175,10 @@ private function determineDestinationGitUrls(): array /** * Prepare a directory to build the artifact. + * + * @param string[] $vcsUrls */ - private function cloneSourceBranch(Closure $outputCallback, string $artifactDir, string $vcsUrl, string $vcsPath): void + private function cloneSourceBranch(Closure $outputCallback, string $artifactDir, array $vcsUrls, string $vcsPath): void { $fs = $this->localMachineHelper->getFilesystem(); @@ -185,39 +187,75 @@ private function cloneSourceBranch(Closure $outputCallback, string $artifactDir, $outputCallback('out', "Initializing Git in $artifactDir"); $this->localMachineHelper->checkRequiredBinariesExist(['git']); + $printOutput = $this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL; $process = $this->localMachineHelper->execute([ 'git', 'clone', '--depth=1', - $vcsUrl, + $vcsUrls[0], $artifactDir, - ], $outputCallback, null, ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL)); + ], $outputCallback, null, $printOutput); if (!$process->isSuccessful()) { throw new AcquiaCliException('Failed to clone repository from the Cloud Platform: {message}', ['message' => $process->getErrorOutput()]); } - $process = $this->localMachineHelper->execute([ - 'git', - 'fetch', - '--depth=1', - '--update-head-ok', - $vcsUrl, - $vcsPath . ':' . $vcsPath, - ], $outputCallback, $artifactDir, ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL)); - if (!$process->isSuccessful()) { - // Remote branch does not exist. Just create it locally. This will create - // the new branch off of the current commit. + + // Fetch the branch tip from every destination so out-of-sync remotes + // are detected before the artifact is built and pushed. + $tips = []; + foreach ($vcsUrls as $vcsUrl) { + $outputCallback('out', "Fetching $vcsPath from $vcsUrl"); + $process = $this->localMachineHelper->execute([ + 'git', + 'fetch', + '--depth=1', + $vcsUrl, + $vcsPath, + ], $outputCallback, $artifactDir, $printOutput); + if (!$process->isSuccessful()) { + // The branch does not exist on this remote yet. The push will + // create it. + continue; + } + $process = $this->localMachineHelper->execute([ + 'git', + 'rev-parse', + 'FETCH_HEAD', + ], null, $artifactDir, false); + if (!$process->isSuccessful()) { + throw new AcquiaCliException('Failed to resolve fetched tip for the {branch} branch from {url}: {message}', [ + 'branch' => $vcsPath, + 'message' => $process->getErrorOutput() . $process->getOutput(), + 'url' => $vcsUrl, + ]); + } + $tip = trim($process->getOutput()); + if ($tip === '') { + throw new AcquiaCliException('Failed to resolve fetched tip for the {branch} branch from {url}: empty output', [ + 'branch' => $vcsPath, + 'url' => $vcsUrl, + ]); + } + $tips[$vcsUrl] = $tip; + } + + if ($tips === []) { + // The branch does not exist on any remote. Just create it locally. + // This will create the new branch off of the current commit. $process = $this->localMachineHelper->execute([ 'git', 'checkout', '-b', $vcsPath, - ], $outputCallback, $artifactDir, ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL)); + ], $outputCallback, $artifactDir, $printOutput); } else { + $baseTip = $this->determineBaseTip($tips, $vcsPath, $artifactDir); $process = $this->localMachineHelper->execute([ 'git', 'checkout', + '-B', $vcsPath, - ], $outputCallback, $artifactDir, ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL)); + $baseTip, + ], $outputCallback, $artifactDir, $printOutput); } if (!$process->isSuccessful()) { throw new AcquiaCliException("Could not checkout $vcsPath branch locally: {message}", ['message' => $process->getErrorOutput() . $process->getOutput()]); @@ -392,12 +430,85 @@ private function generateCommitMessage(string $commitHash): array|string return "Automated commit by Acquia CLI (source commit: $commitHash)"; } + /** + * Pick the branch tip to base the artifact on. + * + * Ensures every destination can fast-forward to the artifact commit. If + * the tips differ but are ancestor-related, the most advanced tip wins. + * Truly diverged tips abort the push before anything is built. + * + * @param array $tips + */ + private function determineBaseTip(array $tips, string $vcsPath, string $artifactDir): string + { + $uniqueTips = array_values(array_unique($tips)); + if (count($uniqueTips) === 1) { + return $uniqueTips[0]; + } + + // The tips differ. Deepen the shallow history so ancestry between + // them can be established. + foreach (array_keys($tips) as $vcsUrl) { + $process = $this->localMachineHelper->execute([ + 'git', + 'fetch', + '--deepen=50', + $vcsUrl, + $vcsPath, + ], null, $artifactDir, false); + if (!$process->isSuccessful()) { + throw new AcquiaCliException('Failed to deepen history for the {branch} branch from {url}: {message}', [ + 'branch' => $vcsPath, + 'message' => $process->getErrorOutput() . $process->getOutput(), + 'url' => $vcsUrl, + ]); + } + } + foreach ($uniqueTips as $candidate) { + foreach ($uniqueTips as $other) { + if ($other === $candidate) { + continue; + } + $process = $this->localMachineHelper->execute([ + 'git', + 'merge-base', + '--is-ancestor', + $other, + $candidate, + ], null, $artifactDir, false); + $exitCode = $process->getExitCode(); + if ($exitCode === 0) { + continue; + } + if ($exitCode === 1) { + continue 2; + } + throw new AcquiaCliException('Failed to compare ancestry between {other} and {candidate}: {message}', [ + 'candidate' => $candidate, + 'message' => $process->getErrorOutput() . $process->getOutput(), + 'other' => $other, + ]); + } + return $candidate; + } + + $remoteTips = []; + foreach ($tips as $vcsUrl => $tip) { + $remoteTips[] = "$vcsUrl ($tip)"; + } + throw new AcquiaCliException('The destination git repositories are out of sync for the {branch} branch: {tips}. Reconcile them (e.g. delete the stale artifact branch from the out-of-date remote or push the desired tip to it) and try again.', [ + 'branch' => $vcsPath, + 'tips' => implode(', ', $remoteTips), + ]); + } + /** * Push the artifact. */ private function pushArtifact(Closure $outputCallback, string $artifactDir, array $vcsUrls, string $destGitBranch): void { $this->localMachineHelper->checkRequiredBinariesExist(['git']); + $failures = []; foreach ($vcsUrls as $vcsUrl) { $outputCallback('out', "Pushing changes to Acquia Git ($vcsUrl)"); $args = [ @@ -408,9 +519,14 @@ private function pushArtifact(Closure $outputCallback, string $artifactDir, arra ]; $process = $this->localMachineHelper->execute($args, $outputCallback, $artifactDir, ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL)); if (!$process->isSuccessful()) { - throw new AcquiaCliException("Unable to push artifact: {message}", ['message' => $process->getOutput() . $process->getErrorOutput()]); + // Keep pushing to the remaining remotes so a single failure + // does not leave them further out of sync. + $failures[] = "$vcsUrl: " . $process->getOutput() . $process->getErrorOutput(); } } + if ($failures !== []) { + throw new AcquiaCliException("Unable to push artifact: {message}", ['message' => implode(PHP_EOL, $failures)]); + } } /** diff --git a/tests/phpunit/src/Commands/Push/PushArtifactCommandTest.php b/tests/phpunit/src/Commands/Push/PushArtifactCommandTest.php index bdbbed73..63da2414 100644 --- a/tests/phpunit/src/Commands/Push/PushArtifactCommandTest.php +++ b/tests/phpunit/src/Commands/Push/PushArtifactCommandTest.php @@ -6,6 +6,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Push\PushArtifactCommand; +use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\Commands\Pull\PullCommandTestBase; use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; @@ -20,6 +21,10 @@ */ class PushArtifactCommandTest extends PullCommandTestBase { + private const PROCESS_OUTPUT = 'process stdout.'; + + private const PROCESS_ERROR_OUTPUT = 'process stderr.'; + protected function createCommand(): CommandBase { return $this->injectCommand(PushArtifactCommand::class); @@ -165,10 +170,233 @@ public function testPushArtifactWithArgs(): void $output = $this->getDisplay(); + $this->assertStringContainsString('Fetching master from https://github.com/example1/cli.git', $output); + $this->assertStringContainsString('Fetching master from https://github.com/example2/cli.git', $output); $this->assertStringContainsString('Pushing changes to Acquia Git (https://github.com/example1/cli.git)', $output); $this->assertStringContainsString('Pushing changes to Acquia Git (https://github.com/example2/cli.git)', $output); } + public function testPushArtifactToDivergedRemotes(): void + { + $destinationGitUrls = [ + 'https://github.com/example1/cli.git', + 'https://github.com/example2/cli.git', + ]; + $localMachineHelper = $this->mockLocalMachineHelper(); + $artifactDir = $this->mockArtifactSourceCheckout($localMachineHelper); + $this->mockCloneShallow($localMachineHelper, 'master', $destinationGitUrls, $artifactDir, true, [ + $destinationGitUrls[0] => 'sha1', + $destinationGitUrls[1] => 'sha2', + ]); + $this->mockGitDeepen($localMachineHelper, 'master', $destinationGitUrls); + $this->mockGitMergeBase($localMachineHelper, 'sha2', 'sha1', false); + $this->mockGitMergeBase($localMachineHelper, 'sha1', 'sha2', false); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('The destination git repositories are out of sync for the master branch: https://github.com/example1/cli.git (sha1), https://github.com/example2/cli.git (sha2).'); + $this->executeCommand([ + '--destination-git-branch' => 'master', + '--destination-git-urls' => $destinationGitUrls, + ]); + } + + public function testPushArtifactToRemoteDivergedFromOneOfThreeRemotes(): void + { + $destinationGitUrls = [ + 'https://github.com/example1/cli.git', + 'https://github.com/example2/cli.git', + 'https://github.com/example3/cli.git', + ]; + $localMachineHelper = $this->mockLocalMachineHelper(); + $artifactDir = $this->mockArtifactSourceCheckout($localMachineHelper); + $this->mockCloneShallow($localMachineHelper, 'master', $destinationGitUrls, $artifactDir, true, [ + $destinationGitUrls[0] => 'newsha', + $destinationGitUrls[1] => 'oldsha', + $destinationGitUrls[2] => 'divergedsha', + ]); + $this->mockGitDeepen($localMachineHelper, 'master', $destinationGitUrls); + // The second remote is behind the first, so the first remains a + // candidate until the diverged third remote rules it out. + $this->mockGitMergeBase($localMachineHelper, 'oldsha', 'newsha', true); + $this->mockGitMergeBase($localMachineHelper, 'divergedsha', 'newsha', false); + $this->mockGitMergeBase($localMachineHelper, 'newsha', 'oldsha', false); + $this->mockGitMergeBase($localMachineHelper, 'newsha', 'divergedsha', false); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('The destination git repositories are out of sync for the master branch'); + $this->executeCommand([ + '--destination-git-branch' => 'master', + '--destination-git-urls' => $destinationGitUrls, + ]); + } + + public function testPushArtifactFailsWhenFetchedTipCannotBeResolved(): void + { + $destinationGitUrls = ['https://github.com/example1/cli.git']; + $localMachineHelper = $this->mockLocalMachineHelper(); + $artifactDir = $this->mockArtifactSourceCheckout($localMachineHelper); + $this->mockGitCloneShallow($localMachineHelper, $destinationGitUrls[0], $artifactDir); + $this->mockGitFetchBranch($localMachineHelper, 'master', $destinationGitUrls[0]); + $this->mockGitRevParse($localMachineHelper, ['sha1'], false); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Failed to resolve fetched tip for the master branch from https://github.com/example1/cli.git: ' . self::PROCESS_ERROR_OUTPUT . self::PROCESS_OUTPUT); + $this->executeCommand([ + '--destination-git-branch' => 'master', + '--destination-git-urls' => $destinationGitUrls, + ]); + } + + public function testPushArtifactFailsWhenFetchedTipIsEmpty(): void + { + $destinationGitUrls = ['https://github.com/example1/cli.git']; + $localMachineHelper = $this->mockLocalMachineHelper(); + $artifactDir = $this->mockArtifactSourceCheckout($localMachineHelper); + $this->mockGitCloneShallow($localMachineHelper, $destinationGitUrls[0], $artifactDir); + $this->mockGitFetchBranch($localMachineHelper, 'master', $destinationGitUrls[0]); + $this->mockGitRevParse($localMachineHelper, ['']); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Failed to resolve fetched tip for the master branch from https://github.com/example1/cli.git: empty output'); + $this->executeCommand([ + '--destination-git-branch' => 'master', + '--destination-git-urls' => $destinationGitUrls, + ]); + } + + public function testPushArtifactFailsWhenHistoryCannotBeDeepened(): void + { + $destinationGitUrls = [ + 'https://github.com/example1/cli.git', + 'https://github.com/example2/cli.git', + ]; + $localMachineHelper = $this->mockLocalMachineHelper(); + $artifactDir = $this->mockArtifactSourceCheckout($localMachineHelper); + $this->mockCloneShallow($localMachineHelper, 'master', $destinationGitUrls, $artifactDir, true, [ + $destinationGitUrls[0] => 'sha1', + $destinationGitUrls[1] => 'sha2', + ]); + $this->mockGitDeepen($localMachineHelper, 'master', [$destinationGitUrls[0]], false); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Failed to deepen history for the master branch from https://github.com/example1/cli.git: ' . self::PROCESS_ERROR_OUTPUT . self::PROCESS_OUTPUT); + $this->executeCommand([ + '--destination-git-branch' => 'master', + '--destination-git-urls' => $destinationGitUrls, + ]); + } + + public function testPushArtifactFailsWhenAncestryCannotBeCompared(): void + { + $destinationGitUrls = [ + 'https://github.com/example1/cli.git', + 'https://github.com/example2/cli.git', + ]; + $localMachineHelper = $this->mockLocalMachineHelper(); + $artifactDir = $this->mockArtifactSourceCheckout($localMachineHelper); + $this->mockCloneShallow($localMachineHelper, 'master', $destinationGitUrls, $artifactDir, true, [ + $destinationGitUrls[0] => 'sha1', + $destinationGitUrls[1] => 'sha2', + ]); + $this->mockGitDeepen($localMachineHelper, 'master', $destinationGitUrls); + $this->mockGitMergeBaseExitCode($localMachineHelper, 'sha2', 'sha1', 128); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Failed to compare ancestry between sha2 and sha1: ' . self::PROCESS_ERROR_OUTPUT . self::PROCESS_OUTPUT); + $this->executeCommand([ + '--destination-git-branch' => 'master', + '--destination-git-urls' => $destinationGitUrls, + ]); + } + + public function testPushArtifactWithBranchMissingOnFirstRemote(): void + { + $destinationGitUrls = [ + 'https://github.com/example1/cli.git', + 'https://github.com/example2/cli.git', + ]; + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->setUpPushArtifact($localMachineHelper, 'master', $destinationGitUrls, 'master:master', true, true, true, true, [ + $destinationGitUrls[0] => null, + $destinationGitUrls[1] => 'secondremotesha', + ]); + $this->executeCommand([ + '--destination-git-branch' => 'master', + '--destination-git-urls' => $destinationGitUrls, + ]); + + $output = $this->getDisplay(); + + $this->assertStringContainsString('Pushing changes to Acquia Git (https://github.com/example1/cli.git)', $output); + $this->assertStringContainsString('Pushing changes to Acquia Git (https://github.com/example2/cli.git)', $output); + } + + public function testPushArtifactWithRemoteBehind(): void + { + $destinationGitUrls = [ + 'https://github.com/example1/cli.git', + 'https://github.com/example2/cli.git', + ]; + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->setUpPushArtifact($localMachineHelper, 'master', $destinationGitUrls, 'master:master', true, true, true, true, [ + $destinationGitUrls[0] => 'newsha', + $destinationGitUrls[1] => 'oldsha', + ]); + $this->mockGitDeepen($localMachineHelper, 'master', $destinationGitUrls); + $this->mockGitMergeBase($localMachineHelper, 'oldsha', 'newsha', true); + $this->mockGitCheckoutBase($localMachineHelper, 'master', 'newsha'); + $this->executeCommand([ + '--destination-git-branch' => 'master', + '--destination-git-urls' => $destinationGitUrls, + ]); + + $output = $this->getDisplay(); + + $this->assertStringContainsString('Pushing changes to Acquia Git (https://github.com/example1/cli.git)', $output); + $this->assertStringContainsString('Pushing changes to Acquia Git (https://github.com/example2/cli.git)', $output); + } + + public function testPushArtifactWithNewBranchOnAllRemotes(): void + { + $destinationGitUrls = [ + 'https://github.com/example1/cli.git', + 'https://github.com/example2/cli.git', + ]; + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->setUpPushArtifact($localMachineHelper, 'feature-1-build', $destinationGitUrls, 'feature-1-build:feature-1-build', true, true, true, true, [ + $destinationGitUrls[0] => null, + $destinationGitUrls[1] => null, + ]); + $this->executeCommand([ + '--destination-git-branch' => 'feature-1-build', + '--destination-git-urls' => $destinationGitUrls, + ]); + + $output = $this->getDisplay(); + + $this->assertStringContainsString('Pushing changes to Acquia Git (https://github.com/example1/cli.git)', $output); + $this->assertStringContainsString('Pushing changes to Acquia Git (https://github.com/example2/cli.git)', $output); + } + + public function testPushArtifactPushFailureStillPushesRemainingRemotes(): void + { + $destinationGitUrls = [ + 'https://github.com/example1/cli.git', + 'https://github.com/example2/cli.git', + ]; + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->setUpPushArtifact($localMachineHelper, 'master', $destinationGitUrls, 'master:master', true, true, false); + $artifactDir = Path::join(sys_get_temp_dir(), 'acli-push-artifact'); + $this->mockGitPush($destinationGitUrls, $localMachineHelper, $artifactDir, 'master:master', true, [$destinationGitUrls[0]]); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Unable to push artifact: https://github.com/example1/cli.git: ' . self::PROCESS_OUTPUT . self::PROCESS_ERROR_OUTPUT); + $this->executeCommand([ + '--destination-git-branch' => 'master', + '--destination-git-urls' => $destinationGitUrls, + ]); + } + public function testPushArtifactNoPush(): void { $applications = $this->mockRequest('getApplications'); @@ -201,7 +429,7 @@ public function testPushArtifactWithoutAutoloadRuntime(): void $this->mockRequest('getApplicationByUuid', $applications[0]->uuid); $environments = $this->mockRequest('getApplicationEnvironments', $applications[0]->uuid); $localMachineHelper = $this->mockLocalMachineHelper(); - $this->setUpPushArtifact($localMachineHelper, $environments[0]->vcs->path, [$environments[0]->vcs->url], 'master:master', true, true, false, true, false); + $this->setUpPushArtifact($localMachineHelper, $environments[0]->vcs->path, [$environments[0]->vcs->url], 'master:master', true, true, false, hasAutoloadRuntime: false); $inputs = [ // Would you like Acquia CLI to search for a Cloud application that matches your local git config? 'n', @@ -267,7 +495,7 @@ public function testPushArtifactNoClone(): void $this->assertStringNotContainsString('Adding and committing changed files', $output); $this->assertStringNotContainsString('Pushing changes to Acquia Git (site@svn-3.hosted.acquia-sites.com:site.git)', $output); } - protected function setUpPushArtifact(ObjectProphecy $localMachineHelper, string $vcsPath, array $vcsUrls, string $destGitRef = 'master:master', bool $clone = true, bool $commit = true, bool $push = true, bool $printOutput = true, bool $hasAutoloadRuntime = true): void + protected function setUpPushArtifact(ObjectProphecy $localMachineHelper, string $vcsPath, array $vcsUrls, string $destGitRef = 'master:master', bool $clone = true, bool $commit = true, bool $push = true, bool $printOutput = true, ?array $tips = null, bool $hasAutoloadRuntime = true): void { touch(Path::join($this->projectDir, 'composer.json')); mkdir(Path::join($this->projectDir, 'docroot')); @@ -288,7 +516,7 @@ protected function setUpPushArtifact(ObjectProphecy $localMachineHelper, string if ($clone) { $this->mockLocalGitConfig($localMachineHelper, $artifactDir, $printOutput); - $this->mockCloneShallow($localMachineHelper, $vcsPath, $vcsUrls[0], $artifactDir, $printOutput); + $this->mockCloneShallow($localMachineHelper, $vcsPath, $vcsUrls, $artifactDir, $printOutput, $tips); } if ($commit) { $this->mockGitAddCommit($localMachineHelper, $artifactDir, $commitHash, $printOutput, $hasAutoloadRuntime); @@ -298,10 +526,69 @@ protected function setUpPushArtifact(ObjectProphecy $localMachineHelper, string } } - protected function mockCloneShallow(ObjectProphecy $localMachineHelper, string $vcsPath, string $vcsUrl, string $artifactDir, bool $printOutput = true): void + /** + * @param array|null $tips + * Map of vcs url to the branch tip sha on that remote. A null value + * means the branch does not exist on that remote. Defaults to every + * url sharing the same tip. + */ + protected function mockCloneShallow(ObjectProphecy $localMachineHelper, string $vcsPath, array $vcsUrls, string $artifactDir, bool $printOutput = true, ?array $tips = null): void + { + if ($tips === null) { + $tips = array_fill_keys($vcsUrls, 'mainbranchsha'); + } + $this->mockGitCloneShallow($localMachineHelper, $vcsUrls[0], $artifactDir, $printOutput); + + $revParseTips = []; + foreach ($vcsUrls as $vcsUrl) { + $tip = $tips[$vcsUrl] ?? null; + $this->mockGitFetchBranch($localMachineHelper, $vcsPath, $vcsUrl, $printOutput, $tip !== null); + if ($tip !== null) { + $revParseTips[] = $tip; + } + } + if ($revParseTips !== []) { + $this->mockGitRevParse($localMachineHelper, $revParseTips); + } + + $uniqueTips = array_values(array_unique(array_filter($tips, static fn ($tip) => $tip !== null))); + if ($uniqueTips === []) { + $localMachineHelper->execute([ + 'git', + 'checkout', + '-b', + $vcsPath, + ], Argument::type('callable'), Argument::type('string'), $printOutput) + ->willReturn($this->mockProcess()->reveal())->shouldBeCalled(); + } elseif (count($uniqueTips) === 1) { + $this->mockGitCheckoutBase($localMachineHelper, $vcsPath, $uniqueTips[0], $printOutput); + } + // Multiple distinct tips: the test mocks deepen, merge-base, and + // checkout calls itself. + } + + /** + * Mock everything the command does before it clones the destination. + * + * @return string + * The artifact directory. + */ + protected function mockArtifactSourceCheckout(ObjectProphecy $localMachineHelper): string + { + touch(Path::join($this->projectDir, 'composer.json')); + mkdir(Path::join($this->projectDir, 'docroot')); + $this->createMockGitConfigFile(); + $fs = $this->prophet->prophesize(Filesystem::class); + $localMachineHelper->getFilesystem()->willReturn($fs); + $this->mockExecuteGitStatus(false, $localMachineHelper, $this->projectDir); + $this->mockGetLocalCommitHash($localMachineHelper, $this->projectDir, 'abc123'); + $localMachineHelper->checkRequiredBinariesExist(['git']) + ->shouldBeCalled(); + return Path::join(sys_get_temp_dir(), 'acli-push-artifact'); + } + + protected function mockGitCloneShallow(ObjectProphecy $localMachineHelper, string $vcsUrl, string $artifactDir, bool $printOutput = true): void { - $process = $this->prophet->prophesize(Process::class); - $process->isSuccessful()->willReturn(true)->shouldBeCalled(); $localMachineHelper->execute([ 'git', 'clone', @@ -309,24 +596,98 @@ protected function mockCloneShallow(ObjectProphecy $localMachineHelper, string $ $vcsUrl, $artifactDir, ], Argument::type('callable'), null, $printOutput) - ->willReturn($process->reveal())->shouldBeCalled(); + ->willReturn($this->mockProcess()->reveal())->shouldBeCalled(); + } + + protected function mockGitFetchBranch(ObjectProphecy $localMachineHelper, string $vcsPath, string $vcsUrl, bool $printOutput = true, bool $success = true): void + { $localMachineHelper->execute([ 'git', 'fetch', '--depth=1', - '--update-head-ok', $vcsUrl, - $vcsPath . ':' . $vcsPath, + $vcsPath, ], Argument::type('callable'), Argument::type('string'), $printOutput) - ->willReturn($process->reveal())->shouldBeCalled(); + ->willReturn($this->mockProcess($success)->reveal())->shouldBeCalled(); + } + + /** + * @param string[] $tips + * The sha each consecutive rev-parse call resolves to. + */ + protected function mockGitRevParse(ObjectProphecy $localMachineHelper, array $tips, bool $success = true): void + { + $processes = []; + foreach ($tips as $tip) { + $process = $success ? $this->mockProcess() : $this->mockFailedProcess(); + if ($success) { + $process->getOutput()->willReturn($tip === '' ? '' : $tip . PHP_EOL); + } + $processes[] = $process->reveal(); + } + $localMachineHelper->execute([ + 'git', + 'rev-parse', + 'FETCH_HEAD', + ], null, Argument::type('string'), false) + ->willReturn(...$processes)->shouldBeCalled(); + } + + protected function mockGitCheckoutBase(ObjectProphecy $localMachineHelper, string $vcsPath, string $baseTip, bool $printOutput = true): void + { + $process = $this->mockProcess(); $localMachineHelper->execute([ 'git', 'checkout', + '-B', $vcsPath, + $baseTip, ], Argument::type('callable'), Argument::type('string'), $printOutput) ->willReturn($process->reveal())->shouldBeCalled(); } + protected function mockGitDeepen(ObjectProphecy $localMachineHelper, string $vcsPath, array $vcsUrls, bool $success = true): void + { + foreach ($vcsUrls as $vcsUrl) { + $process = $success ? $this->mockProcess() : $this->mockFailedProcess(); + $localMachineHelper->execute([ + 'git', + 'fetch', + '--deepen=50', + $vcsUrl, + $vcsPath, + ], null, Argument::type('string'), false) + ->willReturn($process->reveal())->shouldBeCalled(); + } + } + + protected function mockFailedProcess(): ObjectProphecy + { + $process = $this->mockProcess(false); + $process->getOutput()->willReturn(self::PROCESS_OUTPUT); + $process->getErrorOutput()->willReturn(self::PROCESS_ERROR_OUTPUT); + return $process; + } + + protected function mockGitMergeBase(ObjectProphecy $localMachineHelper, string $ancestor, string $descendant, bool $isAncestor): void + { + $this->mockGitMergeBaseExitCode($localMachineHelper, $ancestor, $descendant, $isAncestor ? 0 : 1); + } + + protected function mockGitMergeBaseExitCode(ObjectProphecy $localMachineHelper, string $ancestor, string $descendant, int $exitCode): void + { + $process = $exitCode === 0 ? $this->mockProcess() : $this->mockFailedProcess(); + $process->getExitCode()->willReturn($exitCode); + $localMachineHelper->execute([ + 'git', + 'merge-base', + '--is-ancestor', + $ancestor, + $descendant, + ], null, Argument::type('string'), false) + ->willReturn($process->reveal())->shouldBeCalled(); + } + protected function mockLocalGitConfig(ObjectProphecy $localMachineHelper, string $artifactDir, bool $printOutput = true): void { $process = $this->prophet->prophesize(Process::class); @@ -446,10 +807,11 @@ protected function mockReadComposerJson(ObjectProphecy $localMachineHelper, stri ->willReturn($composerJson); } - protected function mockGitPush(array $gitUrls, ObjectProphecy $localMachineHelper, string $artifactDir, string $destGitRef, bool $printOutput): void + protected function mockGitPush(array $gitUrls, ObjectProphecy $localMachineHelper, string $artifactDir, string $destGitRef, bool $printOutput, array $failingUrls = []): void { - $process = $this->mockProcess(); foreach ($gitUrls as $gitUrl) { + $succeeds = !in_array($gitUrl, $failingUrls, true); + $process = $succeeds ? $this->mockProcess() : $this->mockFailedProcess(); $localMachineHelper->execute([ 'git', 'push',