Passed
Push — master ( 79a62c...33662e )
by Sebastian
01:59
created

Installer::shouldHookBeMoved()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
ccs 0
cts 0
cp 0
rs 10
cc 1
nc 1
nop 0
crap 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\Runner;
15
16
use CaptainHook\App\Config;
17
use CaptainHook\App\Console\IO;
18
use CaptainHook\App\Console\IOUtil;
19
use CaptainHook\App\Exception;
20
use CaptainHook\App\Hook\Template;
21
use CaptainHook\App\Hook\Util as HookUtil;
22
use CaptainHook\App\Hooks;
23
use CaptainHook\App\Storage\File;
24
use RuntimeException;
25
use SebastianFeldmann\Camino\Check;
26
use SebastianFeldmann\Camino\Path;
27
use SebastianFeldmann\Git\Repository;
28
29
/**
30
 * Class Installer
31
 *
32
 * @package CaptainHook
33
 * @author  Sebastian Feldmann <[email protected]>
34
 * @link    https://github.com/captainhookphp/captainhook
35
 * @since   Class available since Release 0.9.0
36
 */
37
class Installer extends RepositoryAware
38
{
39
    /**
40
     * Install hooks brute force
41
     *
42
     * @var bool
43
     */
44
    private $force = false;
45
46
    /**
47
     * Don't overwrite existing hooks
48
     *
49
     * @var bool
50
     */
51
    private $skipExisting = false;
52
53 3
    /**
54
     * Path where the existing hooks should be moved to
55 3
     *
56 3
     * @var string
57
     */
58
    private $moveExistingTo = '';
59
60
    /**
61
     * Hook that should be handled.
62
     *
63
     * @var string
64
     */
65
    protected $hookToHandle;
66 4
67
    /**
68 4
     * Hook template
69 1
     *
70
     * @var Template
71 3
     */
72 3
    private $template;
73
74
    /**
75
     * Git repository.
76
     *
77
     * @var \SebastianFeldmann\Git\Repository
78
     */
79
    protected $repository;
80 3
81
    /**
82 3
     * HookHandler constructor.
83
     *
84 3
     * @param \CaptainHook\App\Console\IO       $io
85 3
     * @param \CaptainHook\App\Config           $config
86
     * @param \SebastianFeldmann\Git\Repository $repository
87 3
     * @param \CaptainHook\App\Hook\Template    $template
88
     */
89
    public function __construct(IO $io, Config $config, Repository $repository, Template $template)
90
    {
91
        $this->template = $template;
92
        parent::__construct($io, $config, $repository);
93
    }
94 3
95
    /**
96 3
     * @param  bool $force
97
     * @return \CaptainHook\App\Runner\Installer
98
     */
99
    public function setForce(bool $force): Installer
100
    {
101
        $this->force = $force;
102
        return $this;
103
    }
104
105 4
    /**
106
     * @param  bool $skip
107 4
     * @return \CaptainHook\App\Runner\Installer
108 4
     */
109 1
    public function setSkipExisting(bool $skip): Installer
110 1
    {
111
        if ($skip && !empty($this->moveExistingTo)) {
112
            throw new RuntimeException('choose --move-existing-to or --skip-existing');
113 4
        }
114 3
        $this->skipExisting = $skip;
115
        return $this;
116 4
    }
117
118
    /**
119
     * Set the path where the current hooks should be moved to
120
     *
121
     * @param  string $backup
122
     * @return \CaptainHook\App\Runner\Installer
123
     */
124 5
    public function setMoveExistingTo(string $backup): Installer
125
    {
126 5
        if (!empty($backup) && $this->skipExisting) {
127 5
            throw new RuntimeException('choose --skip-existing or --move-existing-to');
128 5
        }
129
        $this->moveExistingTo = $backup;
130
        return $this;
131
    }
132 5
133 1
    /**
134 1
     * Hook setter
135
     *
136
     * @param  string $hook
137 5
     * @return \CaptainHook\App\Runner\Installer
138 4
     * @throws \CaptainHook\App\Exception\InvalidHookName
139 4
     */
140 4
    public function setHook(string $hook): Installer
141 4
    {
142 4
        if (!empty($hook) && !HookUtil::isValid($hook)) {
143
            throw new Exception\InvalidHookName('Invalid hook name \'' . $hook . '\'');
144 5
        }
145
        $this->hookToHandle = $hook;
146
        return $this;
147
    }
148
149
    /**
150
     * Execute installation
151
     *
152 4
     * @return void
153
     */
154 4
    public function run(): void
155
    {
156
        $hooks = $this->getHooksToInstall();
157
158
        foreach ($hooks as $hook => $ask) {
159
            $this->installHook($hook, ($ask && !$this->force));
160
        }
161
    }
162
163 5
    /**
164
     * Return list of hooks to install
165 5
     *
166
     * [
167
     *   string    => bool
168
     *   HOOK_NAME => ASK_USER_TO_CONFIRM_INSTALL
169
     * ]
170
     *
171
     * @return array<string, bool>
172
     */
173
    public function getHooksToInstall(): array
174
    {
175 4
        // callback to write bool true to all array entries
176
        // to make sure the user will be asked to confirm every hook installation
177 4
        // unless the user provided the force or skip option
178 4
        $callback = function () {
179
            return true;
180
        };
181
        // if a specific hook is set the user chose it so don't ask for permission anymore
182
        return empty($this->hookToHandle)
183
            ? array_map($callback, HookUtil::getValidHooks())
184
            : [$this->hookToHandle => false];
185
    }
186
187
    /**
188
     * Install given hook
189
     *
190
     * @param string $hook
191
     * @param bool   $ask
192
     */
193
    private function installHook(string $hook, bool $ask): void
194
    {
195
        if ($this->shouldHookBeSkipped($hook)) {
196
            $hint = $this->io->isVerbose() ? ', remove the --skip-existing option to overwrite.' : '';
197
            $this->io->write('  <comment>' . $hook . '</comment> is already installed' . $hint);
198
            return;
199
        }
200
201
        $doIt = true;
202
        if ($ask) {
203
            $answer = $this->io->ask('  <info>Install \'' . $hook . '\' hook?</info> <comment>[y,n]</comment> ', 'y');
204
            $doIt   = IOUtil::answerToBool($answer);
205
        }
206
207
        if ($doIt) {
208
            if ($this->shouldHookBeMoved()) {
209
                $this->backupHook($hook);
210
            }
211
            $this->writeHookFile($hook);
212
        }
213
    }
214
215
    /**
216
     * Check if the hook is installed and should be skipped
217
     *
218
     * @param  string $hook
219
     * @return bool
220
     */
221
    private function shouldHookBeSkipped(string $hook): bool
222
    {
223
        return $this->skipExisting && $this->repository->hookExists($hook);
224
    }
225
226
    /**
227
     * If a path to incorporate the existing hook is set we should incorporate existing hooks
228
     *
229
     * @return bool
230
     */
231
    private function shouldHookBeMoved(): bool
232
    {
233
        return !empty($this->moveExistingTo);
234
    }
235
236
    /**
237
     * Move the existing hook to the configured location
238
     *
239
     * @param string $hook
240
     */
241
    private function backupHook(string $hook)
242
    {
243
        // no hook to move just leave
244
        if (!$this->repository->hookExists($hook)) {
245
            return;
246
        }
247
248
        $hookFileOrig   = $this->repository->getHooksDir() . DIRECTORY_SEPARATOR . $hook;
249
        $hookCmd        = rtrim($this->moveExistingTo, '/\\') . DIRECTORY_SEPARATOR . $hook;
250
        $hookCmdArgs    = $hookCmd . Hooks::getOriginalHookArguments($hook);
251
        $hookFileTarget = !Check::isAbsolutePath($this->moveExistingTo)
252
                        ? dirname($this->config->getPath()) . DIRECTORY_SEPARATOR . $hookCmd
253
                        : $hookCmd;
254
255
        $this->moveExistingHook($hookFileOrig, $hookFileTarget);
256
257
        $this->io->write(
258
            [
259
                '  Moved existing ' . $hook . ' hook to ' . $hookCmd,
260
                '  Add <comment>\'' . $hookCmdArgs . '\'</comment> to your '
261
                . $hook . ' configuration to execute it.'
262
            ]
263
        );
264
    }
265
266
    /**
267
     * Write given hook to .git/hooks directory
268
     *
269
     * @param  string $hook
270
     * @return void
271
     */
272
    private function writeHookFile(string $hook): void
273
    {
274
        $hooksDir = $this->repository->getHooksDir();
275
        $hookFile = $hooksDir . DIRECTORY_SEPARATOR . $hook;
276
        $doIt     = true;
277
278
        // if hook is configured and no force option is set
279
        // ask the user if overwriting the hook is ok
280
        if ($this->needInstallConfirmation($hook)) {
281
            $ans  = $this->io->ask('  <comment>The \'' . $hook . '\' hook exists! Overwrite? [y,n]</comment> ', 'n');
282
            $doIt = IOUtil::answerToBool($ans);
283
        }
284
285
        if ($doIt) {
286
            $code = $this->getHookSourceCode($hook);
287
            $file = new File($hookFile);
288
            $file->write($code);
289
            chmod($hookFile, 0755);
290
            $this->io->write('  <info>\'' . $hook . '\' hook installed successfully</info>');
291
        }
292
    }
293
294
    /**
295
     * Return the source code for a given hook script
296
     *
297
     * @param  string $hook
298
     * @return string
299
     */
300
    private function getHookSourceCode(string $hook): string
301
    {
302
        return $this->template->getCode($hook);
303
    }
304
305
    /**
306
     * If the hook already exists the user has to confirm the installation
307
     *
308
     * @param  string $hook The name of the hook to check
309
     * @return bool
310
     */
311
    private function needInstallConfirmation(string $hook): bool
312
    {
313
        return $this->repository->hookExists($hook) && !$this->force;
314
    }
315
316
    /**
317
     * Move the existing hook script to the new location
318
     *
319
     * @param  string $originalLocation
320
     * @param  string $newLocation
321
     * @return void
322
     * @throws \RuntimeException
323
     */
324
    private function moveExistingHook(string $originalLocation, string $newLocation): void
325
    {
326
        $dir = dirname($newLocation);
327
        // make sure the target directory isn't a file
328
        if (file_exists($dir) && !is_dir($dir)) {
329
            throw new RuntimeException($dir . ' is not a directory');
330
        }
331
        // create the directory if it does not exist
332
        if (!is_dir($dir)) {
333
            mkdir($dir, 0755, true);
334
        }
335
336
        // move the hook into the target directory
337
        rename($originalLocation, $newLocation);
338
    }
339
}
340