Passed
Push — main ( 9a05b8...0143ab )
by Sebastian
03:43
created

Docker   A

Complexity

Total Complexity 20

Size/Duplication

Total Lines 223
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 58
c 3
b 0
f 0
dl 0
loc 223
rs 10
ccs 68
cts 68
cp 1
wmc 20

11 Methods

Rating   Name   Duplication   Size   Complexity  
A dockerIsNotInteractive() 0 3 1
A hasGitPathMappingConfigured() 0 3 1
A createInteractiveOptions() 0 3 2
A resolveBinaryPath() 0 20 2
A createEnvOptions() 0 9 4
A dockerHasNoEnvSettings() 0 3 1
A createTTYOptions() 0 6 1
A getCode() 0 27 3
A __construct() 0 4 1
A getOptimizeDockerCommand() 0 17 2
A endOfExecPositionInCommand() 0 9 2
1
<?php
2
3
/**
4
 * This file is part of CaptainHook
5
 *
6
 * (c) Sebastian Feldmann <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace CaptainHook\App\Hook\Template;
15
16
use CaptainHook\App\CH;
17
use CaptainHook\App\Config;
18
use CaptainHook\App\Hook\Template;
19
use CaptainHook\App\Hooks;
20
use CaptainHook\App\Runner\Bootstrap\Util;
21
22
/**
23
 * Docker class
24
 *
25
 * Generates the bash scripts placed in .git/hooks/* for every hook
26
 * to execute CaptainHook inside a Docker container.
27
 *
28
 * @package CaptainHook
29
 * @author  Sebastian Feldmann <[email protected]>
30
 * @link    https://github.com/captainhook-git/captainhook
31
 * @since   Class available since Release 4.3.0
32
 */
33
class Docker implements Template
34
{
35
    /**
36
     * All path required for template creation
37
     *
38
     * @var \CaptainHook\App\Hook\Template\PathInfo
39
     */
40
    private PathInfo $pathInfo;
41
42
    /**
43
     * CaptainHook configuration
44
     *
45
     * @var \CaptainHook\App\Config
46
     */
47
    private Config $config;
48
49
    /**
50
     * Docker constructor
51
     *
52
     * @param \CaptainHook\App\Hook\Template\PathInfo $pathInfo
53
     * @param \CaptainHook\App\Config                 $config
54
     */
55 25
    public function __construct(PathInfo $pathInfo, Config $config)
56
    {
57 25
        $this->pathInfo = $pathInfo;
58 25
        $this->config   = $config;
59
    }
60
61
    /**
62
     * Return the code for the git hook scripts
63
     *
64
     * @param  string $hook Name of the hook to generate the sourcecode for
65
     * @return string
66
     */
67 25
    public function getCode(string $hook): string
68
    {
69 25
        $path2Config = $this->pathInfo->getConfigPath();
70 25
        $config      = $path2Config !== CH::CONFIG ? ' --configuration=' . escapeshellarg($path2Config) : '';
71 25
        $bootstrap   = Util::bootstrapCmdOption($this->pathInfo->isPhar(), $this->config);
72
73 25
        $lines = [
74 25
            '#!/bin/sh',
75 25
            '',
76 25
            '# installed by CaptainHook ' . CH::VERSION,
77 25
            '',
78 25
            '# if necessary read original hook stdIn to pass it in as --input option',
79 25
            Hooks::receivesStdIn($hook) ? 'input=$(cat)' : 'input=""',
80 25
            '',
81 25
            'if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then',
82 25
            '    exec < /dev/tty',
83 25
            'fi',
84 25
            '',
85 25
            $this->getOptimizeDockerCommand($hook) . ' '
86 25
            . $this->resolveBinaryPath()
87 25
            . $config
88 25
            . $bootstrap
89 25
            . ' --input=\""$input"\"'
90 25
            . ' hook:' . $hook
91 25
            . ' "$@"'
92 25
        ];
93 25
        return implode(PHP_EOL, $lines) . PHP_EOL;
94
    }
95
96
97
    /**
98
     * Returns the optimized docker exec command
99
     *
100
     * This tries to optimize the `docker exec` commands. Docker exec should always run in --interactive mode.
101
     * During hooks that could need user input it should use --tty.
102
     * In case of `commit -a` we have to pass the GIT_INDEX_FILE env variable so `git` inside the container
103
     * can recognize the temp index.
104
     *
105
     * @param  string $hook
106
     * @return string
107
     */
108 25
    private function getOptimizeDockerCommand(string $hook): string
109
    {
110 25
        $command = $this->config->getRunConfig()->getDockerCommand();
111 25
        $endExec = $this->endOfExecPositionInCommand($command);
112
113
        // if the docker command can be identified, add env vars and tty flags
114 25
        if ($endExec !== -1) {
115 24
            $executable = substr($command, 0, $endExec);
116 24
            $options    = substr($command, $endExec);
117
118 24
            $command     = trim($executable)
119 24
                         . $this->createInteractiveOptions($options)
120 24
                         . $this->createTTYOptions($options)
121 24
                         . $this->createEnvOptions($options, $hook)
122 24
                         . ' ' . trim($options);
123
        }
124 25
        return $command;
125
    }
126
127
    /**
128
     * Creates the TTY options if needed
129
     *
130
     * @param  string $options
131
     * @return string
132
     */
133 24
    private function createTTYOptions(string $options): string
134
    {
135
        // Because this currently breaks working with Jetbrains IDEs it is deactivated for now
136
        // $tty        = Hooks::allowsUserInput($hook) ? ' -t' : '';
137
        // $useTTY      = !preg_match('# -[a-z]*t| --tty#', $options) ? $tty : '';
138 24
        return '';
139
    }
140
141
    /**
142
     * Create the env settings if needed
143
     *
144
     * Only force the -e option for pre-commit hooks because with `commit -a` needs
145
     * the GIT_INDEX_FILE environment variable.
146
     *
147
     * @param  string $options
148
     * @param  string $hook
149
     * @return string
150
     */
151 24
    private function createEnvOptions(string $options, string $hook): string
152
    {
153 24
        return (
154 24
            $this->hasGitPathMappingConfigured()
155 24
            && $this->dockerHasNoEnvSettings($options)
156 24
            && in_array($hook, [Hooks::PRE_COMMIT, Hooks::PREPARE_COMMIT_MSG])
157 24
        )
158 9
            ? ' -e GIT_INDEX_FILE="' . $this->config->getRunConfig()->getGitPath() . '/$(basename $GIT_INDEX_FILE)"'
159 24
            : '';
160
    }
161
162
    /**
163
     * Checks if the ENV settings are present
164
     *
165
     * @param string $options
166
     * @return bool
167
     */
168 9
    private function dockerHasNoEnvSettings(string $options): bool
169
    {
170 9
        return !preg_match('# (-[a-z]*e|--env)[= ]+GIT_INDEX_FILE#', $options);
171
    }
172
173
    /**
174
     * Creates the interactive option if needed, returns ' -i' or ''
175
     *
176
     * @param  string $options
177
     * @return string
178
     */
179 24
    private function createInteractiveOptions(string $options): string
180
    {
181 24
        return $this->dockerIsNotInteractive($options) ? ' -i' : '';
182
    }
183
184
    /**
185
     * Checks if the interactive flag is set
186
     *
187
     * @param  string $options
188
     * @return bool
189
     */
190 24
    private function dockerIsNotInteractive(string $options): bool
191
    {
192 24
        return !preg_match('# -[a-z]*i| --interactive#', $options);
193
    }
194
195
    /**
196
     * Check if a git path is configured
197
     *
198
     * @return bool
199
     */
200 24
    private function hasGitPathMappingConfigured(): bool
201
    {
202 24
        return !empty($this->config->getRunConfig()->getGitPath());
203
    }
204
205
    /**
206
     * Resolves the path to the captainhook binary and returns it
207
     *
208
     * @return string
209
     */
210 25
    private function resolveBinaryPath(): string
211
    {
212
        // if a specific executable is configured use just that
213 25
        if (!empty($this->config->getRunConfig()->getCaptainsPath())) {
214 20
            return $this->config->getRunConfig()->getCaptainsPath();
215
        }
216
217
        // For Docker, we need to strip down the current working directory.
218
        // This is caused because docker will always connect to a specific working directory
219
        // where the absolute path will not be recognized.
220
        // E.g.:
221
        //   cwd    => /project/
222
        //   path   => /project/vendor/bin/captainhook
223
        //   docker => ./vendor/bin/captainhook
224
        // if the executable is located inside the repository we can use a relative path
225
        // by default this should return something like ./vendor/bin/captainhook
226
        // if the executable is not located in your git repository it will return the absolute path
227
        // which will most likely not work from within the docker container
228
        // you have to use the 'run' 'path' config then
229 5
        return $this->pathInfo->getExecutablePath();
230
    }
231
232
    /**
233
     * Look for the docker exec position in the command
234
     *
235
     * The position is necessary to optimize the interactive and env variable settings.
236
     * Returns -1 if nothing can be detected.
237
     *
238
     * This detection works with:
239
     *  - docker
240
     *  - docker-compose
241
     *  - podman
242
     *  - sail
243
     *
244
     * @param string $command
245
     * @return int
246
     */
247 25
    private function endOfExecPositionInCommand(string $command): int
248
    {
249
        // find the exec command to extract the options later on and improve them
250 25
        $regex   = '~(?:^|[\s;|&])(?:[\w./-]*/)?(docker|docker-compose|podman|sail)\s+exec\b~i';
251 25
        $matches = [];
252 25
        if (preg_match($regex, $command, $matches)) {
253 24
            return strlen($matches[0]);
254
        }
255 1
        return -1;
256
    }
257
}
258