Skip to content

Commit a3262b5

Browse files
committed
[#92] Added --fail-on-missing-branch flag.
Closes #92
1 parent 58b60f9 commit a3262b5

10 files changed

Lines changed: 596 additions & 13 deletions

phpstan.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ parameters:
1313
excludePaths:
1414
- vendor/*
1515
- node_modules/*
16+
- tests/Unit/Traits/FilesystemTraitTestClass.php
1617

1718
ignoreErrors:
1819
-

src/Commands/ArtifactCommand.php

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace DrevOps\GitArtifact\Commands;
66

77
use CzProject\GitPhp\GitException;
8+
use DrevOps\GitArtifact\Exception\BranchNotFoundException;
89
use DrevOps\GitArtifact\Git\ArtifactGitRepository;
910
use DrevOps\GitArtifact\Traits\FilesystemTrait;
1011
use DrevOps\GitArtifact\Traits\LoggerTrait;
@@ -105,6 +106,19 @@ class ArtifactCommand extends Command {
105106
*/
106107
protected bool $showChanges = FALSE;
107108

109+
/**
110+
* Flag to fail deployment when branch is missing.
111+
*
112+
* By default (false), deployment is skipped with exit code 0.
113+
* When true, deployment fails with exit code 1.
114+
*/
115+
protected bool $failOnMissingBranch = FALSE;
116+
117+
/**
118+
* Flag indicating deployment was skipped due to missing branch.
119+
*/
120+
protected bool $deploymentSkipped = FALSE;
121+
108122
/**
109123
* Flag to specify if push was successful.
110124
*/
@@ -150,17 +164,18 @@ protected function configure(): void {
150164
// phpcs:disable Generic.Functions.FunctionCallArgumentSpacing.TooMuchSpaceAfterComma
151165
// phpcs:disable Drupal.WhiteSpace.Comma.TooManySpaces
152166
$this
153-
->addOption('branch', NULL, InputOption::VALUE_REQUIRED, 'Destination branch with optional tokens.', '[branch]')
154-
->addOption('dry-run', NULL, InputOption::VALUE_NONE, 'Run without pushing to the remote repository.')
155-
->addOption('gitignore', NULL, InputOption::VALUE_REQUIRED, 'Path to gitignore file to replace current .gitignore. Leave empty to use current .gitignore.')
156-
->addOption('message', NULL, InputOption::VALUE_REQUIRED, 'Commit message with optional tokens.', 'Deployment commit')
157-
->addOption('mode', NULL, InputOption::VALUE_REQUIRED, 'Mode of artifact build: branch, force-push. Defaults to force-push.', static::MODE_FORCE_PUSH)
158-
->addOption('no-cleanup', NULL, InputOption::VALUE_NONE, 'Do not cleanup after run.')
159-
->addOption('now', NULL, InputOption::VALUE_REQUIRED, 'Internal value used to set internal time.')
160-
->addOption('log', NULL, InputOption::VALUE_REQUIRED, 'Path to the log file.')
161-
->addOption('root', NULL, InputOption::VALUE_REQUIRED, 'Path to the root for file path resolution. If not specified, current directory is used.')
162-
->addOption('show-changes', NULL, InputOption::VALUE_NONE, 'Show changes made to the repo by the build in the output.')
163-
->addOption('src', NULL, InputOption::VALUE_REQUIRED, 'Directory where source repository is located. If not specified, root directory is used.');
167+
->addOption('branch', NULL, InputOption::VALUE_REQUIRED, 'Destination branch with optional tokens.', '[branch]')
168+
->addOption('dry-run', NULL, InputOption::VALUE_NONE, 'Run without pushing to the remote repository.')
169+
->addOption('gitignore', NULL, InputOption::VALUE_REQUIRED, 'Path to gitignore file to replace current .gitignore. Leave empty to use current .gitignore.')
170+
->addOption('message', NULL, InputOption::VALUE_REQUIRED, 'Commit message with optional tokens.', 'Deployment commit')
171+
->addOption('mode', NULL, InputOption::VALUE_REQUIRED, 'Mode of artifact build: branch, force-push. Defaults to force-push.', static::MODE_FORCE_PUSH)
172+
->addOption('no-cleanup', NULL, InputOption::VALUE_NONE, 'Do not cleanup after run.')
173+
->addOption('now', NULL, InputOption::VALUE_REQUIRED, 'Internal value used to set internal time.')
174+
->addOption('log', NULL, InputOption::VALUE_REQUIRED, 'Path to the log file.')
175+
->addOption('root', NULL, InputOption::VALUE_REQUIRED, 'Path to the root for file path resolution. If not specified, current directory is used.')
176+
->addOption('show-changes', NULL, InputOption::VALUE_NONE, 'Show changes made to the repo by the build in the output.')
177+
->addOption('src', NULL, InputOption::VALUE_REQUIRED, 'Directory where source repository is located. If not specified, root directory is used.')
178+
->addOption('fail-on-missing-branch', NULL, InputOption::VALUE_NONE, 'Fail deployment if source branch cannot be determined. By default, deployment is skipped gracefully.');
164179
// @formatter:on
165180
// phpcs:enable Generic.Functions.FunctionCallArgumentSpacing.TooMuchSpaceAfterComma
166181
// phpcs:enable Drupal.WhiteSpace.Comma.TooManySpaces
@@ -194,6 +209,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
194209

195210
$this->resolveOptions($remote, $input->getOptions());
196211

212+
// Check if deployment was skipped due to missing branch.
213+
if ($this->deploymentSkipped) {
214+
return Command::SUCCESS;
215+
}
216+
197217
$this->doExecute();
198218
}
199219
catch (\Exception $exception) {
@@ -317,6 +337,7 @@ protected function resolveOptions(string $url, array $options): void {
317337
$this->showChanges = !empty($options['show-changes']);
318338
$this->needCleanup = empty($options['no-cleanup']);
319339
$this->isDryRun = !empty($options['dry-run']);
340+
$this->failOnMissingBranch = !empty($options['fail-on-missing-branch']);
320341
$this->logFile = empty($options['log']) ? '' : $this->fsGetAbsolutePath($options['log']);
321342

322343
$this->setMode($options['mode'], $options);
@@ -327,7 +348,28 @@ protected function resolveOptions(string $url, array $options): void {
327348
$this->repo = new ArtifactGitRepository($this->sourceDir, NULL, $this->logger);
328349

329350
// Set original, destination, artifact branch names.
330-
$this->originalBranch = $this->repo->getOriginalBranch();
351+
try {
352+
$this->originalBranch = $this->repo->getOriginalBranch();
353+
}
354+
catch (BranchNotFoundException $exception) {
355+
if ($this->failOnMissingBranch) {
356+
// Strict mode: fail deployment.
357+
throw new \RuntimeException('Unable to determine source branch. Deployment failed. ' . $exception->getMessage(), $exception->getCode(), $exception);
358+
}
359+
360+
$commit_hash = $exception->getCommitHash();
361+
$this->logger->notice('Source branch not found. Deployment will be skipped.');
362+
$this->logger->notice('Commit: ' . $commit_hash);
363+
364+
$this->output->writeln('<comment>Source branch not found. Deployment skipped.</comment>');
365+
$this->output->writeln('<comment>Commit: ' . $commit_hash . '</comment>');
366+
$this->output->writeln('<info>Use --fail-on-missing-branch to fail deployment instead.</info>');
367+
368+
// Set flag to skip deployment and return early from execute().
369+
$this->deploymentSkipped = TRUE;
370+
371+
return;
372+
}
331373

332374
$branch = $this->tokenProcess($options['branch']);
333375
if (!ArtifactGitRepository::isValidBranchName($branch)) {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DrevOps\GitArtifact\Exception;
6+
7+
/**
8+
* Exception when branch cannot be determined.
9+
*
10+
* This can occur in two scenarios:
11+
* 1. Detached HEAD state with no traceable source branch.
12+
* 2. Branch was deleted while CI is still running.
13+
*/
14+
class BranchNotFoundException extends GitException {
15+
16+
/**
17+
* Constructor.
18+
*
19+
* @param string $message
20+
* The exception message.
21+
* @param string $commitHash
22+
* The commit hash where the repository is currently at.
23+
* @param \Throwable|null $previous
24+
* Previous exception.
25+
*/
26+
public function __construct(
27+
string $message = 'Unable to determine source branch',
28+
protected string $commitHash = '',
29+
?\Throwable $previous = NULL,
30+
) {
31+
parent::__construct($message, 0, $previous);
32+
}
33+
34+
/**
35+
* Get the commit hash.
36+
*
37+
* @return string
38+
* The commit hash.
39+
*/
40+
public function getCommitHash(): string {
41+
return $this->commitHash;
42+
}
43+
44+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DrevOps\GitArtifact\Exception;
6+
7+
/**
8+
* Base exception for all git-artifact exceptions.
9+
*/
10+
class GitArtifactException extends \RuntimeException {
11+
12+
}

src/Exception/GitException.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DrevOps\GitArtifact\Exception;
6+
7+
/**
8+
* Exception for Git-related errors.
9+
*/
10+
class GitException extends GitArtifactException {
11+
12+
}

src/Git/ArtifactGitRepository.php

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use CzProject\GitPhp\GitRepository;
99
use CzProject\GitPhp\IRunner;
1010
use CzProject\GitPhp\RunnerResult;
11+
use DrevOps\GitArtifact\Exception\BranchNotFoundException;
1112
use DrevOps\GitArtifact\Traits\FilesystemTrait;
1213
use DrevOps\GitArtifact\Traits\LoggerTrait;
1314
use Psr\Log\LoggerInterface;
@@ -265,7 +266,35 @@ public function getOriginalBranch(): string {
265266
}
266267

267268
if (empty($branch)) {
268-
throw new \Exception('Unable to determine a detachment source');
269+
// Get current commit hash.
270+
$commit_hash = $this->execute(['rev-parse', 'HEAD'])[0] ?? '';
271+
272+
throw new BranchNotFoundException(
273+
'Unable to determine a detachment source',
274+
$commit_hash
275+
);
276+
}
277+
278+
// Validate that the extracted value is actually a branch or tag, not just
279+
// a commit hash. If it's only a commit hash, we cannot determine the
280+
// original branch.
281+
try {
282+
$this->execute(['show-ref', '--verify', 'refs/heads/' . $branch]);
283+
}
284+
catch (GitException $e1) {
285+
try {
286+
$this->execute(['show-ref', '--verify', 'refs/tags/' . $branch]);
287+
}
288+
catch (GitException $e2) {
289+
// Not a branch or tag - just a commit hash.
290+
// Get current commit hash.
291+
$commit_hash = $this->execute(['rev-parse', 'HEAD'])[0] ?? '';
292+
293+
throw new BranchNotFoundException(
294+
'Unable to determine a detachment source',
295+
$commit_hash
296+
);
297+
}
269298
}
270299
}
271300

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DrevOps\GitArtifact\Tests\Functional;
6+
7+
use CzProject\GitPhp\Git;
8+
use DrevOps\GitArtifact\Commands\ArtifactCommand;
9+
use DrevOps\GitArtifact\Git\ArtifactGitRepository;
10+
use PHPUnit\Framework\Attributes\CoversClass;
11+
12+
#[CoversClass(ArtifactCommand::class)]
13+
#[CoversClass(ArtifactGitRepository::class)]
14+
class MissingBranchTest extends FunctionalTestCase {
15+
16+
/**
17+
* Test deployment skips gracefully when branch is missing (default).
18+
*
19+
* This simulates the scenario where a branch was deleted after PR merge.
20+
* Default behavior should skip deployment with exit code 0.
21+
*/
22+
public function testMissingBranchDefaultBehavior(): void {
23+
$this->gitCreateFixtureCommits(1);
24+
25+
// Create an orphaned commit (not on any branch).
26+
$repo = (new Git())->open($this->src);
27+
28+
// Create orphaned branch.
29+
$repo->run('checkout', '--orphan', 'orphan-branch');
30+
31+
// Create a commit on orphan branch.
32+
$this->fixtureCreateFile($this->src, 'f_orphan');
33+
$repo->addAllChanges();
34+
$repo->commit('Orphan commit');
35+
36+
// Get the commit hash.
37+
$commits = $repo->execute(['rev-parse', 'HEAD']);
38+
$commit_hash = $commits[0];
39+
40+
// Switch back to main branch and delete orphan branch.
41+
$repo->checkout($this->currentBranch);
42+
$repo->run('branch', '-D', 'orphan-branch');
43+
44+
// Checkout the orphaned commit - now it's truly not on any branch.
45+
$repo->checkout($commit_hash);
46+
47+
// Default behavior: should skip deployment with exit code 0.
48+
// Use explicit branch name (no tokens) to avoid token processing issues.
49+
$output = $this->runArtifactCommand([
50+
'--branch' => 'testbranch',
51+
'--dry-run' => TRUE,
52+
]);
53+
54+
$this->assertStringContainsString('Source branch not found. Deployment skipped.', $output);
55+
$this->assertStringContainsString('Commit: ' . $commit_hash, $output);
56+
$this->assertStringContainsString('Use --fail-on-missing-branch to fail deployment instead', $output);
57+
$this->assertStringNotContainsString('Processing failed with an error:', $output);
58+
}
59+
60+
/**
61+
* Test deployment fails when branch is missing with flag.
62+
*
63+
* With --fail-on-missing-branch flag, deployment should fail.
64+
*/
65+
public function testMissingBranchWithFlag(): void {
66+
$this->gitCreateFixtureCommits(1);
67+
68+
// Create an orphaned commit (not on any branch).
69+
$repo = (new Git())->open($this->src);
70+
71+
// Create orphaned branch.
72+
$repo->run('checkout', '--orphan', 'orphan-branch-2');
73+
74+
// Create a commit on orphan branch.
75+
$this->fixtureCreateFile($this->src, 'f_orphan_2');
76+
$repo->addAllChanges();
77+
$repo->commit('Orphan commit 2');
78+
79+
// Get the commit hash.
80+
$commits = $repo->execute(['rev-parse', 'HEAD']);
81+
$commit_hash = $commits[0];
82+
83+
// Switch back to main branch and delete orphan branch.
84+
$repo->checkout($this->currentBranch);
85+
$repo->run('branch', '-D', 'orphan-branch-2');
86+
87+
// Checkout the orphaned commit - now it's truly not on any branch.
88+
$repo->checkout($commit_hash);
89+
90+
// With --fail-on-missing-branch, should fail.
91+
// Use explicit branch name (no tokens) to avoid token processing issues.
92+
$output = $this->runArtifactCommand([
93+
'--branch' => 'testbranch',
94+
'--fail-on-missing-branch' => TRUE,
95+
'--dry-run' => TRUE,
96+
], TRUE);
97+
98+
$this->assertStringContainsString('Processing failed with an error:', $output);
99+
$this->assertStringContainsString('Unable to determine source branch', $output);
100+
}
101+
102+
/**
103+
* Test normal deployment when branch exists.
104+
*
105+
* Verifies that normal operation still works when branch is available.
106+
*/
107+
public function testNormalDeploymentWithBranch(): void {
108+
$this->gitCreateFixtureCommits(1);
109+
110+
// Normal deployment with existing branch should work.
111+
$output = $this->assertArtifactCommandSuccess();
112+
113+
$this->assertStringContainsString('Pushed branch "testbranch" with commit message "Deployment commit"', $output);
114+
$this->assertStringContainsString('Deployment finished successfully.', $output);
115+
116+
// Verify the branch exists in destination.
117+
$this->gitCheckout($this->dst, 'testbranch');
118+
$this->assertFilesExist($this->dst, 'f1');
119+
}
120+
121+
/**
122+
* Test deployment works with tag in detached HEAD state.
123+
*
124+
* When checked out at a tag, getOriginalBranch() should validate that
125+
* the tag exists and allow deployment to proceed.
126+
*/
127+
public function testDeploymentWithTagDetachedHead(): void {
128+
$this->gitCreateFixtureCommits(1);
129+
130+
$repo = (new Git())->open($this->src);
131+
132+
// Create a tag at the current commit.
133+
$repo->run('tag', 'v1.0.0');
134+
135+
// Checkout the tag to enter detached HEAD state.
136+
$repo->checkout('v1.0.0');
137+
138+
// Deployment should work because tag is a valid detachment source.
139+
$output = $this->assertArtifactCommandSuccess();
140+
141+
$this->assertStringContainsString('Pushed branch "testbranch" with commit message "Deployment commit"', $output);
142+
$this->assertStringContainsString('Deployment finished successfully.', $output);
143+
}
144+
145+
}

0 commit comments

Comments
 (0)