.
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Package and push files to a remote repository
7 |
8 |
9 |
10 | [](https://github.com/drevops/git-artifact/issues)
11 | [](https://github.com/drevops/git-artifact/pulls)
12 | 
13 | [](https://codecov.io/gh/drevops/git-artifact)
14 | [](https://packagist.org/packages/drevops/git-artifact)
15 | 
16 | 
17 |
18 | [](https://github.com/drevops/git-artifact/actions/workflows/test-php.yml)
19 | [](https://circleci.com/gh/drevops/git-artifact)
20 |
21 |
22 |
23 | ---
24 |
25 | ## 🌟 With Git Artifact, you can:
26 |
27 | 📦 Assemble a code artifact locally or in CI
28 | 🧹 Exclude any unwanted files using a deployment `.gitignore`
29 | 📤 Transfer the final artifact to a destination Git repository for deployment
30 | 🔁 Choose between `force-push` or `branch` modes to fit your workflow
31 |
32 | See example of deployed artifact
33 | in [Artifact branches](https://github.com/drevops/git-artifact-destination/branches).
34 |
35 | ## 🔀 Workflow
36 |
37 | 1️⃣ 🧑💻 Develop in the _source_ repository
38 | 2️⃣ 📦 CI installs dependencies and runs **git-artifact** to package and push code to _destination_ repository
39 | 3️⃣ 🚀 Deployment triggered whan code received
40 |
41 | ## 🎚️ Modes
42 |
43 | ### `force-push` mode (default)
44 |
45 | Push the packaged artifact to the **same branch** in the _destination_ repository.
46 | This will carry over the branch history from the _source_ repository and will overwrite
47 | the existing branch history in the _destination_ repository.
48 |
49 | ```
50 | ==================================================
51 | 🏃 Run 1
52 | ==================================================
53 |
54 | Local repo Remote repo
55 | +------------------+
56 | | Artifact commit | 💥 New commit
57 | +------------------+
58 | +-----------+ +------------------+
59 | | Commit 2 | | Commit 2 | \
60 | +-----------+ == 📦 ==> +------------------+ ) 👍 Source commit
61 | | Commit 1 | | Commit 1 | / history preserved
62 | +-----------+ +------------------+
63 | `mybranch` `mybranch`
64 |
65 | 👆
66 | Branch name identical to source
67 |
68 | ==================================================
69 | 🏃 Run 2
70 | ==================================================
71 |
72 | Local repo Remote repo
73 | +------------------+
74 | | Artifact commit | 💥 New commit
75 | +------------------+
76 | +-----------+ +------------------+
77 | | Commit 4 | | Commit 4 | \
78 | +-----------+ +------------------+ \
79 | | Commit 3 | | Commit 3 | \
80 | +-----------+ == 📦 ==> +------------------+ ) 👍 Source commit
81 | | Commit 2 | | Commit 2 | / history preserved
82 | +-----------+ +------------------+ /
83 | | Commit 1 | | Commit 1 | /
84 | +-----------+ +------------------+
85 | `mybranch` `mybranch`
86 |
87 | 👆
88 | Branch name identical to source
89 |
90 | ```
91 |
92 | #### Use case
93 |
94 | Forwarding all changes in the _source_ repository to the _destination_
95 | repository **as-is** for **every branch**: for example, a commit in the _source_
96 | repository branch `feature/123` would create a commit in the _destination_
97 | repository branch `feature/123`. The next commit to the _source_ repository
98 | branch `feature/123` would update the _destination_ repository branch
99 | `feature/123` with the changes, but would overwrite the last "artifact commit".
100 |
101 | ### `branch` mode
102 |
103 | Push the packaged artifact to the **new branch** in the _destination_ repository.
104 | This will carry over the branch history from the _source_ repository to a
105 | dedicated branch in the _destination_ repository. The follow-up pushes to the
106 | branch in the _destination_ repository will be blocked.
107 |
108 | ```
109 | ==================================================
110 | 🏃 Run 1
111 | ==================================================
112 |
113 | Local repo Remote repo
114 | +------------------+
115 | | Artifact commit | 💥 New commit
116 | +------------------+
117 | +-----------+ +------------------+
118 | | Commit 2 | | Commit 2 | \
119 | +-----------+ == 📦 ==> +------------------+ ) 👍 Source commit
120 | | Commit 1 | | Commit 1 | / history preserved
121 | +-----------+ +------------------+
122 |
123 | `mybranch` `deployment/1.2.3`
124 | tagged with
125 | `1.2.3`
126 |
127 | 👆 👆
128 | Tagged branch New branch based on tag
129 |
130 | ==================================================
131 | 🏃 Run 2
132 | ==================================================
133 |
134 | Local repo Remote repo
135 | +------------------+
136 | | Artifact commit | 💥 New commit
137 | +------------------+
138 | +-----------+ +------------------+
139 | | Commit 4 | | Commit 4 | \
140 | +-----------+ +------------------+ \
141 | | Commit 3 | | Commit 3 | \
142 | +-----------+ == 📦 ==> +------------------+ ) 👍 Source commit
143 | | Commit 2 | | Commit 2 | / history preserved
144 | +-----------+ +------------------+ /
145 | | Commit 1 | | Commit 1 | /
146 | +-----------+ +------------------+
147 |
148 | `mybranch` `deployment/1.2.4`
149 | tagged with
150 | `1.2.4` 👈 New tag 1.2.4
151 |
152 | 👆 👆
153 | Tagged branch New branch based on a new tag
154 | with a new tag
155 |
156 | ```
157 |
158 | #### Use case
159 |
160 | Creating a **new branch** in the _destination_ repository for every **tag**
161 | created in the _source_ repository: for example, a tag `1.2.3` in the source
162 | repository would create a branch `deployment/1.2.3` in the destination
163 | repository. The addition of the new tags would create new unique branches in the
164 | destination repository.
165 |
166 | ## 📥 Installation
167 |
168 | This package is intended to be used as a standalone binary. You will need to
169 | have PHP installed on your system to run the binary.
170 |
171 | Download the latest release from the [GitHub releases page](https://github.com/drevops/git-artifact/releases/latest).
172 |
173 | You may also install the package globally using Composer:
174 | ```shell
175 | composer global require --dev drevops/git-artifact
176 | ```
177 |
178 | ## ▶️ Usage
179 |
180 | ```shell
181 | ./git-artifact git@github.com:yourorg/your-repo-destination.git
182 | ```
183 |
184 | This will create an artifact from current directory and will send it to the
185 | specified remote repository into the same branch as a current one.
186 |
187 | Avoid including development dependencies in your artifacts. Instead, configure
188 | your CI to build with production-only dependencies, export the resulting code,
189 | and use that as the artifact source. See our CI examples below.
190 |
191 | Call from the CI configuration or deployment script:
192 |
193 | ```shell
194 | export DEPLOY_BRANCH=
195 | ./git-artifact git@github.com:yourorg/your-repo-destination.git \
196 | --branch="${DEPLOY_BRANCH}" \
197 | --push
198 | ```
199 |
200 | CI providers may report branches differently when running builds triggered by tags.
201 | We encourage you to explore our continuously and automatically tested examples:
202 |
203 | - [GitHub Actions](.github/workflows/test-php.yml)
204 | - [CircleCI](.circleci/config.yml)
205 |
206 | See extended and
207 | fully-configured [example in the Vortex project](https://github.com/drevops/vortex/blob/develop/scripts/vortex/deploy-artifact.sh).
208 |
209 |
210 | ## 🎛️ Options
211 |
212 | | Name | Default value | Description |
213 | |------------------------|---------------------|------------------------------------------------------------------------------------------------|
214 | | `--mode` | `force-push` | Mode of artifact build: `branch`, `force-push` |
215 | | `--branch` | `[branch]` | Destination branch with optional tokens (see below) |
216 | | `--gitignore` | | Path to the `.gitignore` file to replace the current `.gitignore` |
217 | | `--src` | | Directory where source repository is located. Uses root directory if not specified |
218 | | `--root` | | Path to the root for file path resolution. Uses current directory if not specified |
219 | | `--message` | `Deployment commit` | Commit message with optional tokens (see below) |
220 | | `--no-cleanup` | | Do not cleanup after run |
221 | | `--log` | | Path to the log file |
222 | | `--show-changes` | | Show changes made to the repo by the build in the output |
223 | | `--dry-run` | | Run without pushing to the remote repository |
224 | | `--now` | | Internal value used to set internal time |
225 | | `-h, --help` | | Display help for the given command |
226 | | `-q, --quiet` | | Do not output any messages |
227 | | `-V, --version` | | Display this application version |
228 | | `--ansi` | | Force ANSI output. Use `--no-ansi` to disable |
229 | | `-n, --no-interaction` | | Do not ask any interactive question |
230 | | `-v, --verbose` | | Increase the verbosity of messages: 1 for normal, 2 for more verbose, 3 for debug |
231 |
232 | ## 🧹 Modifying artifact content
233 |
234 | `--gitignore` option allows to specify the path to the artifact's `.gitignore`
235 | file that replaces existing `.gitignore` (if any) during the build. Any files no
236 | longer ignored by the replaced artifact's `.gitignore` are added into the
237 | deployment commit. If there are no no-longer-excluded files, the deployment
238 | commit is still created, to make sure that the deployment timestamp is
239 | captured.
240 |
241 | ## 🏷️ Token support
242 |
243 | Tokens are pre-defined strings surrounded by `[` and `]` and may contain
244 | optional formatter. For example, `[timestamp:Y-m-d]` is
245 | replaced with the current timestamp in format `Y-m-d` (token formatter), which
246 | is PHP [`date()`](https://www.php.net/manual/en/function.date.php) expected format.
247 |
248 | Both `--branch` and `--message` option values support token replacement.
249 |
250 | Available tokens:
251 |
252 | - `[timestamp:FORMAT]` - current time with a PHP [`date()`](https://www.php.net/manual/en/function.date.php)-compatible `FORMAT`.
253 | - `[branch]` - current branch in the source repository.
254 | - `[safebranch]` - current branch in the source repository with with all non-alphanumeric characters replaced with `-` and lowercased.
255 | - `[tags:DELIMITER]` - tags from the latest commit in the source repository
256 | separated by a `DELIMITER`.
257 |
258 | ## Maintenance
259 |
260 | ### 🧪 Testing
261 |
262 | Packaging and deployment of artifacts is a mission-critical process, so we maintain
263 | a set of unit, functional and integration tests to make sure that everything works
264 | as expected.
265 |
266 | You can see examples of the branches created by the Git Artifact in the [example _destination_ repository](https://github.com/drevops/git-artifact-destination/branches).
267 |
268 | ### Lint and fix code
269 |
270 | ```bash
271 | composer lint
272 | composer lint-fix
273 | ```
274 |
275 | ### Run tests
276 |
277 | ```bash
278 | composer test
279 | ```
280 |
281 | ---
282 | _Repository created using https://getscaffold.dev/ project scaffold template_
283 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "drevops/git-artifact",
3 | "description": "Build artifact from your codebase in CI and push it to a separate git repo.",
4 | "license": "GPL-2.0-or-later",
5 | "type": "package",
6 | "authors": [
7 | {
8 | "name": "Alex Skrypnyk",
9 | "email": "alex@drevops.com"
10 | }
11 | ],
12 | "homepage": "https://github.com/drevops/git-artifact",
13 | "support": {
14 | "issues": "https://github.com/drevops/git-artifact/issues",
15 | "source": "https://github.com/drevops/git-artifact"
16 | },
17 | "require": {
18 | "php": ">=8.2",
19 | "czproject/git-php": "^4.3",
20 | "monolog/monolog": "^3.5",
21 | "symfony/console": "^7",
22 | "symfony/filesystem": "^7",
23 | "symfony/finder": "^7",
24 | "symfony/monolog-bridge": "^7",
25 | "symfony/process": "^7"
26 | },
27 | "require-dev": {
28 | "bamarni/composer-bin-plugin": "^1.8",
29 | "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
30 | "drupal/coder": "^8.3",
31 | "ergebnis/composer-normalize": "^2.43",
32 | "phpstan/phpstan": "^2",
33 | "phpunit/phpunit": "^11",
34 | "rector/rector": "^2"
35 | },
36 | "autoload": {
37 | "psr-4": {
38 | "DrevOps\\GitArtifact\\": "src"
39 | }
40 | },
41 | "autoload-dev": {
42 | "psr-4": {
43 | "DrevOps\\GitArtifact\\Tests\\": "tests"
44 | }
45 | },
46 | "bin": [
47 | "git-artifact"
48 | ],
49 | "config": {
50 | "allow-plugins": {
51 | "bamarni/composer-bin-plugin": true,
52 | "dealerdirect/phpcodesniffer-composer-installer": true,
53 | "ergebnis/composer-normalize": true
54 | }
55 | },
56 | "scripts": {
57 | "build": [
58 | "@composer bin box require --dev humbug/box",
59 | "box validate",
60 | "box compile"
61 | ],
62 | "lint": [
63 | "phpcs",
64 | "phpstan --memory-limit=-1",
65 | "rector --clear-cache --dry-run"
66 | ],
67 | "lint-fix": [
68 | "rector --clear-cache",
69 | "phpcbf"
70 | ],
71 | "reset": "rm -Rf vendor vendor-bin",
72 | "test": "phpunit --no-coverage",
73 | "test-coverage": "phpunit"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/git-artifact:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | fs = is_null($fs) ? new Filesystem() : $fs;
137 | }
138 |
139 | /**
140 | * Configure command.
141 | */
142 | protected function configure(): void {
143 | $this->setName('artifact');
144 |
145 | $this->setDescription('Assemble a code artifact from your codebase, remove unnecessary files, and push it into a separate Git repository.');
146 |
147 | $this->addArgument('remote', InputArgument::REQUIRED, 'Path to the remote git repository.');
148 |
149 | // @formatter:off
150 | // phpcs:disable Generic.Functions.FunctionCallArgumentSpacing.TooMuchSpaceAfterComma
151 | // phpcs:disable Drupal.WhiteSpace.Comma.TooManySpaces
152 | $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.');
164 | // @formatter:on
165 | // phpcs:enable Generic.Functions.FunctionCallArgumentSpacing.TooMuchSpaceAfterComma
166 | // phpcs:enable Drupal.WhiteSpace.Comma.TooManySpaces
167 | }
168 |
169 | /**
170 | * Perform actual command.
171 | *
172 | * @param \Symfony\Component\Console\Input\InputInterface $input
173 | * Input.
174 | * @param \Symfony\Component\Console\Output\OutputInterface $output
175 | * Output.
176 | *
177 | * @return int
178 | * Status code.
179 | *
180 | * @throws \Exception
181 | */
182 | protected function execute(InputInterface $input, OutputInterface $output): int {
183 | $this->output = $output;
184 |
185 | $this->loggerInit((string) $this->getName(), $input, $output);
186 |
187 | $remote = $input->getArgument('remote');
188 | if (!is_string($remote) || empty(trim($remote))) {
189 | throw new \RuntimeException('Remote argument must be a non-empty string');
190 | }
191 |
192 | try {
193 | $this->checkRequirements();
194 |
195 | $this->resolveOptions($remote, $input->getOptions());
196 |
197 | $this->doExecute();
198 | }
199 | catch (\Exception $exception) {
200 | $this->output->writeln([
201 | 'Processing failed with an error:',
202 | '' . $exception->getMessage() . '',
203 | ]);
204 |
205 | return Command::FAILURE;
206 | }
207 |
208 | $this->output->writeln('Deployment finished successfully.');
209 |
210 | return Command::SUCCESS;
211 | }
212 |
213 | /**
214 | * Assemble a code artifact from your codebase.
215 | */
216 | protected function doExecute(): void {
217 | $error = NULL;
218 |
219 | try {
220 | $this->repo->addRemote($this->remoteName, $this->remoteUrl);
221 |
222 | $this->showInfo();
223 |
224 | // Do not optimize this into a chained call to make it easier to debug.
225 | $repo = $this->repo;
226 | $repo->switchToBranch($this->artifactBranch, TRUE);
227 | $repo->removeSubRepositories();
228 | $repo->disableLocalExclude();
229 | $repo->replaceGitignoreFromCustom();
230 | // Custom .gitignore may contain rules that will change the list of
231 | // ignored files. We need to add these files as changes so that they
232 | // could be reported as excluded by the command below.
233 | $repo->addAllChanges();
234 | $repo->removeIgnoredFiles();
235 | $repo->removeOtherFiles();
236 | $changes = $repo->commitAllChanges($this->commitMessage);
237 |
238 | if ($this->showChanges) {
239 | $this->output->writeln(sprintf('Added changes: %s', implode("\n", $changes)));
240 | $this->logger->notice(sprintf('Added changes: %s', implode("\n", $changes)));
241 | }
242 |
243 | if ($this->isDryRun) {
244 | $this->output->writeln('Cowardly refusing to push to remote. Use without --dry-run to perform an actual push.');
245 | }
246 | else {
247 | $ref = sprintf('refs/heads/%s:refs/heads/%s', $this->artifactBranch, $this->destinationBranch);
248 |
249 | if ($this->mode === self::MODE_FORCE_PUSH) {
250 | $this->repo->push([$this->remoteName, $ref], ['--force']);
251 | }
252 | else {
253 | $this->repo->push([$this->remoteName, $ref]);
254 | }
255 |
256 | $this->output->writeln(sprintf('Pushed branch "%s" with commit message "%s"', $this->destinationBranch, $this->commitMessage));
257 | }
258 | }
259 | catch (GitException $exception) {
260 | $result = $exception->getRunnerResult();
261 | if (!$result) {
262 | // @codeCoverageIgnoreStart
263 | throw new \Exception('Unknown error occurred', $exception->getCode(), $exception);
264 | // @codeCoverageIgnoreEnd
265 | }
266 |
267 | $error = $result->getOutputAsString();
268 | if (!empty($result->hasErrorOutput())) {
269 | $error .= PHP_EOL . $result->getErrorOutputAsString();
270 | }
271 | }
272 | catch (\Exception $exception) {
273 | // Capture message to allow showing a report.
274 | $error = $exception->getMessage();
275 | }
276 |
277 | $this->showReport(is_null($error));
278 |
279 | if ($this->needCleanup && is_null($error)) {
280 | $this->logger->notice('Cleaning up');
281 | $this->repo->resetToPreviousCommit();
282 | $this->repo->restoreGitignoreToCustom();
283 | $this->repo->restoreLocalExclude();
284 | $this->repo->unstageAllChanges();
285 | $this->repo->switchToBranch($this->originalBranch);
286 | $this->repo->removeBranch($this->artifactBranch, TRUE);
287 | $this->repo->removeRemote($this->remoteName);
288 | }
289 |
290 | // Dump log to a file.
291 | if (!empty($this->logFile)) {
292 | $this->loggerDump($this->logFile);
293 | }
294 |
295 | if (!is_null($error)) {
296 | $error = empty($error) ? 'Unknown error occurred' : $error;
297 | throw new \Exception($error);
298 | }
299 | }
300 |
301 | /**
302 | * Resolve and validate CLI options values into internal values.
303 | *
304 | * @param string $url
305 | * Remote URL.
306 | * @param array $options
307 | * Array of CLI options.
308 | */
309 | protected function resolveOptions(string $url, array $options): void {
310 | if (!empty($options['root']) && is_scalar($options['root'])) {
311 | $this->fsSetRootDir(strval($options['root']));
312 | }
313 |
314 | $this->remoteUrl = $url;
315 | $this->now = empty($options['now']) ? time() : (int) $options['now'];
316 | $this->remoteName = sprintf('%s-%s-%s', self::GIT_REMOTE_NAME, $this->now, rand(1000, 9999));
317 | $this->showChanges = !empty($options['show-changes']);
318 | $this->needCleanup = empty($options['no-cleanup']);
319 | $this->isDryRun = !empty($options['dry-run']);
320 | $this->logFile = empty($options['log']) ? '' : $this->fsGetAbsolutePath($options['log']);
321 |
322 | $this->setMode($options['mode'], $options);
323 |
324 | $this->sourceDir = empty($options['src']) ? $this->fsGetRootDir() : $this->fsGetAbsolutePath($options['src']);
325 |
326 | // Setup Git repository from source path.
327 | $this->repo = new ArtifactGitRepository($this->sourceDir, NULL, $this->logger);
328 |
329 | // Set original, destination, artifact branch names.
330 | $this->originalBranch = $this->repo->getOriginalBranch();
331 |
332 | $branch = $this->tokenProcess($options['branch']);
333 | if (!ArtifactGitRepository::isValidBranchName($branch)) {
334 | throw new \RuntimeException(sprintf('Incorrect value "%s" specified for git remote branch', $branch));
335 | }
336 | $this->destinationBranch = $branch;
337 |
338 | $this->artifactBranch = $this->destinationBranch . '-artifact';
339 |
340 | $this->commitMessage = $this->tokenProcess($options['message']);
341 |
342 | if (!empty($options['gitignore'])) {
343 | $gitignore = $this->fsGetAbsolutePath($options['gitignore']);
344 | $this->fsAssertPathsExist($gitignore);
345 |
346 | $contents = file_get_contents($gitignore);
347 | if (!$contents) {
348 | // @codeCoverageIgnoreStart
349 | throw new \Exception('Unable to load contents of ' . $gitignore);
350 | // @codeCoverageIgnoreEnd
351 | }
352 |
353 | $this->logger->debug('-----Custom .gitignore---------');
354 | $this->logger->debug($contents);
355 | $this->logger->debug('-----.gitignore---------');
356 |
357 | $this->gitignoreFile = $gitignore;
358 | $this->repo->setGitignoreCustom($this->gitignoreFile);
359 | }
360 | }
361 |
362 | /**
363 | * Show artifact build information.
364 | */
365 | protected function showInfo(): void {
366 | $lines[] = ('----------------------------------------------------------------------');
367 | $lines[] = (' Artifact information');
368 | $lines[] = ('----------------------------------------------------------------------');
369 | $lines[] = (' Build timestamp: ' . date('Y/m/d H:i:s', $this->now));
370 | $lines[] = (' Mode: ' . $this->mode);
371 | $lines[] = (' Source repository: ' . $this->sourceDir);
372 | $lines[] = (' Remote repository: ' . $this->remoteUrl);
373 | $lines[] = (' Remote branch: ' . $this->destinationBranch);
374 | $lines[] = (' Gitignore file: ' . ($this->gitignoreFile ?: 'No'));
375 | $lines[] = (' Will push: ' . ($this->isDryRun ? 'No' : 'Yes'));
376 | $lines[] = ('----------------------------------------------------------------------');
377 |
378 | $this->output->writeln($lines);
379 |
380 | foreach ($lines as $line) {
381 | $this->logger->notice($line);
382 | }
383 | }
384 |
385 | /**
386 | * Dump artifact report to a file.
387 | */
388 | protected function showReport(bool $result): void {
389 | $lines[] = '----------------------------------------------------------------------';
390 | $lines[] = ' Artifact report';
391 | $lines[] = '----------------------------------------------------------------------';
392 | $lines[] = ' Build timestamp: ' . date('Y/m/d H:i:s', $this->now);
393 | $lines[] = ' Mode: ' . $this->mode;
394 | $lines[] = ' Source repository: ' . $this->sourceDir;
395 | $lines[] = ' Remote repository: ' . $this->remoteUrl;
396 | $lines[] = ' Remote branch: ' . $this->destinationBranch;
397 | $lines[] = ' Gitignore file: ' . ($this->gitignoreFile ?: 'No');
398 | $lines[] = ' Commit message: ' . $this->commitMessage;
399 | $lines[] = ' Push result: ' . ($result ? 'Success' : 'Failure');
400 | $lines[] = '----------------------------------------------------------------------';
401 |
402 | foreach ($lines as $line) {
403 | $this->logger->notice($line);
404 | }
405 | }
406 |
407 | /**
408 | * Set build mode.
409 | *
410 | * @param string $mode
411 | * Mode to set.
412 | * @param array $options
413 | * Array of CLI options.
414 | */
415 | protected function setMode(string $mode, array $options): void {
416 | switch ($mode) {
417 | case self::MODE_FORCE_PUSH:
418 | // Intentionally empty.
419 | break;
420 |
421 | case self::MODE_BRANCH:
422 | if (is_scalar($options['branch'] ?? NULL) && !self::tokenExists(strval($options['branch']))) {
423 | $this->output->writeln('WARNING! Provided branch name does not have a token.
424 | Pushing of the artifact into this branch will fail on second and follow-up pushes to remote.
425 | Consider adding tokens with unique values to the branch name.');
426 | }
427 | break;
428 |
429 | default:
430 | throw new \RuntimeException(sprintf('Invalid mode provided. Allowed modes are: %s', implode(', ', [
431 | self::MODE_FORCE_PUSH,
432 | self::MODE_BRANCH,
433 | ])));
434 | }
435 |
436 | $this->mode = $mode;
437 | }
438 |
439 | /**
440 | * Check that there all requirements are met in order to to run this command.
441 | */
442 | protected function checkRequirements(): void {
443 | $this->logger->notice('Checking requirements');
444 |
445 | if (!$this->fsIsCommandAvailable('git')) {
446 | // @codeCoverageIgnoreStart
447 | throw new \RuntimeException('Git command is not available');
448 | // @codeCoverageIgnoreEnd
449 | }
450 |
451 | $this->logger->notice('All requirements were met');
452 | }
453 |
454 | /**
455 | * Token callback to get current branch.
456 | *
457 | * @return string
458 | * Branch name.
459 | *
460 | * @throws \Exception
461 | */
462 | protected function getTokenBranch(): string {
463 | return $this->repo->getCurrentBranchName();
464 | }
465 |
466 | /**
467 | * Token callback to get current branch as a safe string.
468 | *
469 | * @return string
470 | * Branch name.
471 | *
472 | * @throws \Exception
473 | */
474 | protected function getTokenSafebranch(): string {
475 | $name = $this->repo->getCurrentBranchName();
476 |
477 | $replacement = preg_replace('/[^a-z0-9-]/i', '-', strtolower($name));
478 |
479 | if (empty($replacement)) {
480 | // @codeCoverageIgnoreStart
481 | throw new \Exception('Safe branch name is empty');
482 | // @codeCoverageIgnoreEnd
483 | }
484 |
485 | return $replacement;
486 | }
487 |
488 | /**
489 | * Token callback to get tags.
490 | *
491 | * @param string|null $delimiter
492 | * Token delimiter. Defaults to '-'.
493 | *
494 | * @return string
495 | * String of tags.
496 | *
497 | * @throws \Exception
498 | */
499 | protected function getTokenTags(?string $delimiter): string {
500 | $delimiter = $delimiter ?? '-';
501 |
502 | return implode($delimiter, $this->repo->listTagsPointingToHead());
503 | }
504 |
505 | /**
506 | * Token callback to get current timestamp.
507 | *
508 | * @param string $format
509 | * Date format suitable for date() function.
510 | *
511 | * @return string
512 | * Date string.
513 | */
514 | protected function getTokenTimestamp(string $format = 'Y-m-d_H-i-s'): string {
515 | return date($format, $this->now);
516 | }
517 |
518 | }
519 |
--------------------------------------------------------------------------------
/src/Git/ArtifactGitRepository.php:
--------------------------------------------------------------------------------
1 | fs = new Filesystem();
42 |
43 | if ($logger instanceof LoggerInterface) {
44 | $this->logger = $logger;
45 | }
46 |
47 | $this->gitignore = $this->getRepositoryPath() . DIRECTORY_SEPARATOR . '.gitignore';
48 | }
49 |
50 | /**
51 | * {@inheritdoc}
52 | */
53 | public function run(...$args): RunnerResult {
54 | $command = reset($args);
55 |
56 | $no_pager = [
57 | 'add',
58 | 'commit',
59 | 'checkout',
60 | 'clone',
61 | 'init',
62 | 'status',
63 | 'config',
64 | 'push',
65 | 'pull',
66 | 'fetch',
67 | 'merge',
68 | 'rebase',
69 | 'reset',
70 | ];
71 | if (!in_array($command, $no_pager)) {
72 | array_unshift($args, '--no-pager');
73 | }
74 |
75 | return parent::run(...$args);
76 | }
77 |
78 | /**
79 | * Set gitignore file.
80 | */
81 | public function setGitignoreCustom(string $filename): static {
82 | $this->gitignoreCustom = $filename;
83 |
84 | return $this;
85 | }
86 |
87 | /**
88 | * {@inheritdoc}
89 | */
90 | public function addRemote($name, $url, ?array $options = NULL): static {
91 | if (!self::isValidRemote($url)) {
92 | throw new \InvalidArgumentException(sprintf('Invalid remote URL provided: %s', $url));
93 | }
94 |
95 | return parent::addRemote($name, $url, $options);
96 | }
97 |
98 | /**
99 | * {@inheritdoc}
100 | */
101 | public function removeRemote($name): static {
102 | if (in_array($name, $this->listRemotes())) {
103 | $this->run('remote', 'remove', $name);
104 | }
105 |
106 | return $this;
107 | }
108 |
109 | /**
110 | * Switch to new branch.
111 | *
112 | * @param string $branch
113 | * Branch name.
114 | * @param bool $create_new
115 | * Optional flag to also create a branch before switching. Default false.
116 | *
117 | * @return static
118 | * The git repository.
119 | */
120 | public function switchToBranch(string $branch, bool $create_new = FALSE): static {
121 | if (!$create_new) {
122 | return $this->checkout($branch);
123 | }
124 |
125 | return $this->createBranch($branch, TRUE);
126 | }
127 |
128 | /**
129 | * Remove branch.
130 | *
131 | * @param string $name
132 | * Branch name.
133 | * @param bool $force
134 | * Force remove or not.
135 | *
136 | * @return static
137 | * Git repository
138 | */
139 | public function removeBranch($name, bool $force = FALSE): static {
140 | if (empty($name)) {
141 | return $this;
142 | }
143 |
144 | $branches = $this->getBranches();
145 |
146 | if (empty($branches)) {
147 | return $this;
148 | }
149 |
150 | if (!in_array($name, $branches)) {
151 | return $this;
152 | }
153 |
154 | if (!$force) {
155 | return parent::removeBranch($name);
156 | }
157 |
158 | $this->run('branch', ['-D' => $name]);
159 |
160 | return $this;
161 | }
162 |
163 | /**
164 | * Commit all files to git repo.
165 | *
166 | * @param string $message
167 | * The commit message.
168 | *
169 | * @return array
170 | * The changes.
171 | */
172 | public function commitAllChanges(string $message): array {
173 | $this->addAllChanges();
174 |
175 | // We do not use the commit method because we need return the output.
176 | return $this->execute('commit', '--allow-empty', [
177 | '-m' => $message,
178 | ]);
179 | }
180 |
181 | /**
182 | * Disable local exclude file (.git/info/exclude).
183 | */
184 | public function disableLocalExclude(): static {
185 | $filename = $this->getRepositoryPath() . DIRECTORY_SEPARATOR . '.git' . DIRECTORY_SEPARATOR . 'info' . DIRECTORY_SEPARATOR . 'exclude';
186 |
187 | if ($this->fs->exists($filename)) {
188 | $this->logger->debug('Disabling local exclude');
189 | $this->fs->rename($filename, $filename . '.bak');
190 | }
191 |
192 | return $this;
193 | }
194 |
195 | /**
196 | * Restore previously disabled local exclude file.
197 | */
198 | public function restoreLocalExclude(): static {
199 | $filename = $this->getRepositoryPath() . DIRECTORY_SEPARATOR . '.git' . DIRECTORY_SEPARATOR . 'info' . DIRECTORY_SEPARATOR . 'exclude';
200 |
201 | if ($this->fs->exists($filename . '.bak')) {
202 | $this->logger->debug('Restoring local exclude');
203 | $this->fs->rename($filename . '.bak', $filename);
204 | }
205 |
206 | return $this;
207 | }
208 |
209 | /**
210 | * List remotes.
211 | *
212 | * @return array
213 | * Remotes.
214 | */
215 | protected function listRemotes(): array {
216 | return $this->extractFromCommand(['remote']) ?: [];
217 | }
218 |
219 | /**
220 | * Get tag pointing to HEAD.
221 | *
222 | * @return string[]
223 | * Array of tags from the latest commit.
224 | *
225 | * @throws \Exception
226 | * If no tags found in the latest commit.
227 | */
228 | public function listTagsPointingToHead(): array {
229 | $tags = $this->extractFromCommand(['tag', ['--points-at', 'HEAD']]);
230 |
231 | if (empty($tags)) {
232 | throw new \Exception('No tags found in the latest commit.');
233 | }
234 |
235 | return $tags;
236 | }
237 |
238 | /**
239 | * Ger original branch, accounting for detached repository state.
240 | *
241 | * Usually, repository become detached when a tag is checked out.
242 | *
243 | * @return string
244 | * Branch or detachment source.
245 | *
246 | * @throws \Exception
247 | * If neither branch nor detachment source is not found.
248 | */
249 | public function getOriginalBranch(): string {
250 | $branch = $this->getCurrentBranchName();
251 |
252 | // Repository could be in detached state. If this the case - we need to
253 | // capture the source of detachment, if it exists.
254 | if (str_contains($branch, 'HEAD detached')) {
255 | $branch = NULL;
256 | $branch_list = $this->getBranches();
257 | if ($branch_list) {
258 | $branch_list = array_filter($branch_list);
259 | foreach ($branch_list as $branch) {
260 | if (preg_match('/\(.*detached .* ([^)]+)\)/', $branch, $matches)) {
261 | $branch = $matches[1];
262 | break;
263 | }
264 | }
265 | }
266 |
267 | if (empty($branch)) {
268 | throw new \Exception('Unable to determine a detachment source');
269 | }
270 | }
271 |
272 | return $branch;
273 | }
274 |
275 | /**
276 | * Remove ignored files.
277 | */
278 | public function removeIgnoredFiles(): static {
279 | $files = [];
280 |
281 | if ($this->gitignore !== NULL && file_exists($this->gitignore)) {
282 | $files = $this->extractFromCommand(['ls-files', '-i', '-c', '--exclude-from=' . $this->gitignore]) ?: [];
283 | $files = array_merge($files, array_filter($files));
284 | }
285 |
286 | if ($this->gitignore !== NULL && file_exists($this->gitignore)) {
287 | $files = $this->extractFromCommand(['ls-files', '-i', '-c', '--exclude-from=' . $this->gitignore]) ?: [];
288 | $files = array_merge($files, array_filter($files));
289 | }
290 |
291 | // Symlinks are not returned by the command above. We need to find them
292 | // manually and check if they are ignored.
293 | $symlinks_iterator = (new Finder())
294 | ->ignoreDotFiles(FALSE)
295 | ->ignoreVCS(TRUE)
296 | ->in($this->getRepositoryPath())
297 | ->filter(fn($file) => $file->isLink())
298 | ->getIterator();
299 |
300 | foreach ($symlinks_iterator as $file) {
301 | $path = $file->getRelativePathname();
302 | if ($this->isFileIgnored($path)) {
303 | $files[] = $path;
304 | }
305 | }
306 |
307 | foreach ($files as $file) {
308 | $filename = $this->getRepositoryPath() . DIRECTORY_SEPARATOR . $file;
309 | if ($this->fs->exists($filename) || is_link($filename)) {
310 | $this->logger->debug(sprintf('Removing ignored file %s', $filename));
311 | $this->fs->remove($filename);
312 | }
313 | }
314 |
315 | return $this;
316 | }
317 |
318 | /**
319 | * Remove 'other' files.
320 | *
321 | * 'Other' files are files that are neither staged nor tracked in git.
322 | */
323 | public function removeOtherFiles(): static {
324 | $files = $this->extractFromCommand(['ls-files', '--other', '--exclude-standard']) ?: [];
325 | $files = array_filter($files);
326 |
327 | foreach ($files as $file) {
328 | $filename = $this->getRepositoryPath() . DIRECTORY_SEPARATOR . $file;
329 | if ($this->fs->exists($filename)) {
330 | $this->logger->debug(sprintf('Removing other file %s', $filename));
331 | $this->fs->remove($filename);
332 | }
333 | }
334 |
335 | return $this;
336 | }
337 |
338 | /**
339 | * Remove any repositories within current repository.
340 | */
341 | public function removeSubRepositories(): static {
342 | $finder = new Finder();
343 | $dirs = $finder
344 | ->directories()
345 | ->name('.git')
346 | ->ignoreDotFiles(FALSE)
347 | ->ignoreVCS(FALSE)
348 | ->depth('>0')
349 | ->in($this->getRepositoryPath());
350 |
351 | $dirs = iterator_to_array($dirs->directories());
352 |
353 | foreach ($dirs as $dir) {
354 | $dir = $dir->getPathname();
355 | $this->fs->remove($dir);
356 | $this->logger->debug(sprintf('Removing sub-repository "%s"', $this->fsGetAbsolutePath((string) $dir)));
357 | }
358 |
359 | // After removing sub-repositories, the files that were previously tracked
360 | // in those repositories are now become a part of the current repository.
361 | // We need to add them as changes.
362 | $this->addAllChanges();
363 |
364 | return $this;
365 | }
366 |
367 | /**
368 | * Check if provided branch name can be used in Git.
369 | *
370 | * @param string $name
371 | * Branch name to check.
372 | *
373 | * @return bool
374 | * TRUE if it is a valid Git branch, FALSE otherwise.
375 | */
376 | public static function isValidBranchName(string $name): bool {
377 | return preg_match('/^(?!\/|.*(?:[\/\.]\.|\/\/|\\|@\{))[^\040\177\s\~\^\:\?\*\[]+(?exists($uri);
400 | $is_external = (bool) preg_match('/^(?:git|ssh|https?|[\d\w\.\-_]+@[\w\.\-]+):(?:\/\/)?[\w\.@:\/~_-]+\.git(?:\/?|\#[\d\w\.\-_]+?)$/', $uri);
401 |
402 | return match ($type) {
403 | 'any' => $is_local || $is_external,
404 | 'local' => $is_local,
405 | 'external' => $is_external,
406 | default => throw new \InvalidArgumentException(sprintf('Invalid argument "%s" provided', $type)),
407 | };
408 | }
409 |
410 | /**
411 | * Reset to the previous commit.
412 | */
413 | public function resetToPreviousCommit(): static {
414 | $this->run('reset', '--soft', 'HEAD~1');
415 |
416 | return $this;
417 | }
418 |
419 | /**
420 | * Replace .gitignore with content from custom .gitignore file.
421 | */
422 | public function replaceGitignoreFromCustom(): static {
423 | if ($this->gitignoreCustom !== NULL && !empty($this->gitignoreCustom) && $this->gitignore !== NULL) {
424 | $this->logger->debug(sprintf('Copying custom .gitignore file from %s to %s', $this->gitignoreCustom, $this->gitignore));
425 | $this->fs->rename($this->gitignoreCustom, $this->gitignore, TRUE);
426 | }
427 |
428 | return $this;
429 | }
430 |
431 | /**
432 | * Restore .gitignore content to custom .gitignore file if it existed.
433 | */
434 | public function restoreGitignoreToCustom(): static {
435 | if ($this->gitignoreCustom !== NULL && $this->gitignore !== NULL && file_exists($this->gitignore)) {
436 | $this->logger->debug(sprintf('Restoring custom .gitignore file from %s to %s', $this->gitignore, $this->gitignoreCustom));
437 | $this->fs->rename($this->gitignore, $this->gitignoreCustom, TRUE);
438 | }
439 |
440 | return $this;
441 | }
442 |
443 | /**
444 | * Unstage all changes.
445 | */
446 | public function unstageAllChanges(): static {
447 | $this->run('reset', 'HEAD');
448 |
449 | return $this;
450 | }
451 |
452 | /**
453 | * Check if file is ignored.
454 | *
455 | * @param string $file
456 | * File to check.
457 | *
458 | * @return bool
459 | * TRUE if file is ignored, FALSE otherwise.
460 | */
461 | public function isFileIgnored(string $file): bool {
462 | try {
463 | $this->extractFromCommand(['check-ignore', '-v', $file]);
464 | }
465 | catch (GitException $exception) {
466 | if ($exception->getCode() === 1) {
467 | return FALSE;
468 | }
469 |
470 | throw $exception;
471 | }
472 |
473 | return TRUE;
474 | }
475 |
476 | }
477 |
--------------------------------------------------------------------------------
/src/Traits/FilesystemTrait.php:
--------------------------------------------------------------------------------
1 |
33 | */
34 | protected array $fsOriginalCwdStack = [];
35 |
36 | /**
37 | * Set root directory path.
38 | *
39 | * @param string|null $path
40 | * The path of the root directory.
41 | *
42 | * @return static
43 | * The called object.
44 | */
45 | protected function fsSetRootDir(?string $path = NULL): static {
46 | $path = empty($path) ? $this->fsGetRootDir() : $this->fsGetAbsolutePath($path);
47 | $this->fsAssertPathsExist($path);
48 | $this->fsRootDir = $path;
49 |
50 | return $this;
51 | }
52 |
53 | /**
54 | * Get root directory.
55 | *
56 | * @return string
57 | * Get value of the root directory, the directory where the
58 | * script was started from or current working directory.
59 | */
60 | protected function fsGetRootDir(): string {
61 | if (!isset($this->fsRootDir)) {
62 | if (isset($_SERVER['PWD']) && is_string($_SERVER['PWD']) && !empty($_SERVER['PWD'])) {
63 | $this->fsRootDir = $_SERVER['PWD'];
64 | }
65 | else {
66 | $this->fsRootDir = (string) getcwd();
67 | }
68 | }
69 |
70 | return $this->fsRootDir;
71 | }
72 |
73 | /**
74 | * Check that a command is available in current session.
75 | *
76 | * @param string $command
77 | * Command to check.
78 | *
79 | * @return bool
80 | * TRUE if command is available, FALSE otherwise.
81 | */
82 | protected function fsIsCommandAvailable(string $command): bool {
83 | $process = new Process(['which', $command]);
84 | $process->run();
85 |
86 | return $process->isSuccessful();
87 | }
88 |
89 | /**
90 | * Get absolute path for provided file.
91 | *
92 | * @param string $file
93 | * File to resolve. If absolute, no resolution will be performed.
94 | * @param string|null $root
95 | * Optional path to root dir. If not provided, internal root path is used.
96 | *
97 | * @return string
98 | * Absolute path for provided file.
99 | */
100 | protected function fsGetAbsolutePath(string $file, ?string $root = NULL): string {
101 | if ($this->fs->isAbsolutePath($file)) {
102 | return static::fsRealpath($file);
103 | }
104 |
105 | $root = $root ? $root : $this->fsGetRootDir();
106 | $root = static::fsRealpath($root);
107 | $file = $root . DIRECTORY_SEPARATOR . $file;
108 |
109 | return static::fsRealpath($file);
110 | }
111 |
112 | /**
113 | * Check that path exists.
114 | *
115 | * @param string|array $paths
116 | * File name or array of file names to check.
117 | * @param bool $strict
118 | * If TRUE and the file does not exist, an exception will be thrown.
119 | * Defaults to TRUE.
120 | *
121 | * @return bool
122 | * TRUE if file exists and FALSE if not, but only if $strict is FALSE.
123 | *
124 | * @throws \Exception
125 | * If at least one file does not exist.
126 | */
127 | protected function fsAssertPathsExist($paths, bool $strict = TRUE): bool {
128 | $paths = is_array($paths) ? $paths : [$paths];
129 |
130 | if (!$this->fs->exists($paths)) {
131 | if ($strict) {
132 | throw new \Exception(sprintf('One of the files or directories does not exist: %s', implode(', ', $paths)));
133 | }
134 |
135 | return FALSE;
136 | }
137 |
138 | return TRUE;
139 | }
140 |
141 | /**
142 | * Replacement for PHP's `realpath` resolves non-existing paths.
143 | *
144 | * The main deference is that it does not return FALSE on non-existing
145 | * paths.
146 | *
147 | * @param string $path
148 | * Path that needs to be resolved.
149 | *
150 | * @return string
151 | * Resolved path.
152 | *
153 | * @see https://stackoverflow.com/a/29372360/712666
154 | */
155 | protected static function fsRealpath(string $path): string {
156 | // Whether $path is unix or not.
157 | $is_unix_path = $path === '' || $path[0] !== '/';
158 | $unc = str_starts_with($path, '\\\\');
159 |
160 | // Attempt to detect if path is relative in which case, add cwd.
161 | if (!str_contains($path, ':') && $is_unix_path && !$unc) {
162 | $path = getcwd() . DIRECTORY_SEPARATOR . $path;
163 | if ($path[0] === '/') {
164 | $is_unix_path = FALSE;
165 | }
166 | }
167 |
168 | // Resolve path parts (single dot, double dot and double delimiters).
169 | $path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
170 | $parts = array_filter(explode(DIRECTORY_SEPARATOR, $path), static function ($part): bool {
171 | return strlen($part) > 0;
172 | });
173 |
174 | $absolutes = [];
175 | foreach ($parts as $part) {
176 | if ('.' === $part) {
177 | continue;
178 | }
179 | if ('..' === $part) {
180 | array_pop($absolutes);
181 | }
182 | else {
183 | $absolutes[] = $part;
184 | }
185 | }
186 |
187 | $path = implode(DIRECTORY_SEPARATOR, $absolutes);
188 | // Put initial separator that could have been lost.
189 | $path = $is_unix_path ? $path : '/' . $path;
190 | $path = $unc ? '\\\\' . $path : $path;
191 |
192 | // Resolve any symlinks.
193 | if (function_exists('readlink') && file_exists($path) && is_link($path) > 0) {
194 | $path = readlink($path);
195 |
196 | if (!$path) {
197 | // @codeCoverageIgnoreStart
198 | throw new \Exception(sprintf('Could not resolve symlink for path: %s', $path));
199 | // @codeCoverageIgnoreEnd
200 | }
201 | }
202 |
203 | if (str_starts_with($path, sys_get_temp_dir())) {
204 | $tmp_realpath = realpath(sys_get_temp_dir());
205 | if ($tmp_realpath) {
206 | $path = str_replace(sys_get_temp_dir(), $tmp_realpath, $path);
207 | }
208 | }
209 |
210 | return $path;
211 | }
212 |
213 | }
214 |
--------------------------------------------------------------------------------
/src/Traits/LoggerTrait.php:
--------------------------------------------------------------------------------
1 | getOption('log')) {
42 | $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG);
43 | }
44 |
45 | $this->loggerDumpFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . time() . '-artifact-log.log';
46 |
47 | $this->logger = new Logger($name);
48 |
49 | $console_handler = new ConsoleHandler($output);
50 | $this->logger->pushHandler($console_handler);
51 |
52 | if (!empty($this->loggerDumpFile)) {
53 | $map = [
54 | OutputInterface::VERBOSITY_QUIET => Level::Error,
55 | OutputInterface::VERBOSITY_NORMAL => Level::Warning,
56 | OutputInterface::VERBOSITY_VERBOSE => Level::Notice,
57 | OutputInterface::VERBOSITY_VERY_VERBOSE => Level::Info,
58 | OutputInterface::VERBOSITY_DEBUG => Level::Debug,
59 | ];
60 |
61 | $stream_handler = new StreamHandler($this->loggerDumpFile, $map[$output->getVerbosity()] ?? Level::Debug);
62 | $this->logger->pushHandler($stream_handler);
63 | }
64 |
65 | $this->logger->debug('Debug messages enabled');
66 | }
67 |
68 | /**
69 | * Dump logger to file.
70 | *
71 | * @param string $filename
72 | * Filename to dump the logger to.
73 | */
74 | protected function loggerDump(string $filename): void {
75 | if ($this->fs->exists($this->loggerDumpFile)) {
76 | $this->fs->copy($this->loggerDumpFile, $filename);
77 | $this->fs->remove($this->loggerDumpFile);
78 | }
79 | }
80 |
81 | }
82 |
--------------------------------------------------------------------------------
/src/Traits/TokenTrait.php:
--------------------------------------------------------------------------------
1 | $method($argument);
37 | }
38 | }
39 | }
40 |
41 | return $replacement;
42 | }, $string);
43 |
44 | return $processed ?? $string;
45 | }
46 |
47 | /**
48 | * Check if the string has at least one token.
49 | *
50 | * @param string $string
51 | * String to check.
52 | *
53 | * @return bool
54 | * TRUE if there is at least one token present, FALSE otherwise.
55 | */
56 | protected static function tokenExists(string $string): bool {
57 | return (bool) preg_match('/\[[^]]+]/', $string);
58 | }
59 |
60 | }
61 |
--------------------------------------------------------------------------------
/src/app.php:
--------------------------------------------------------------------------------
1 | add($command);
18 | $application->setDefaultCommand((string) $command->getName(), TRUE);
19 |
20 | $application->run();
21 | // @codeCoverageIgnoreEnd
22 |
--------------------------------------------------------------------------------