Skip to content

Commit b30fc34

Browse files
[git-hooks] Handle drifted hook replacement safely (#190) (#196)
* [git-hooks] Handle drifted hook replacement safely (#190) * Update wiki submodule pointer for PR #196 * [tests] Remove invalid vendor coverage target (#190) --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 0e306ff commit b30fc34

6 files changed

Lines changed: 167 additions & 10 deletions

File tree

.github/wiki

Submodule wiki updated from 0e04846 to 546706f

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
### Fixed
1919

2020
- Keep packaged `.agents` payloads exportable and synchronize packaged skills and agents with repository-relative symlink targets so consumer repositories no longer receive broken absolute machine paths (#188)
21+
- Rewrite drifted Git hooks by removing the previous target first, restore the intended `0o755` executable mode, and report unwritable hook replacements cleanly when `.git/hooks` stays locked (#190)
2122

2223
## [1.20.0] - 2026-04-23
2324

docs/commands/git-hooks.rst

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ The ``git-hooks`` command installs the hook templates maintained in
1111

1212
1. Copies hook files from a source directory to the target hooks directory
1313
2. Sets executable permissions on copied hooks
14+
3. Replaces drifted hooks defensively by removing the previous target before
15+
recopying it
1416

1517
Usage
1618
-----
@@ -44,6 +46,11 @@ Options
4446
``--interactive``
4547
Prompt before replacing a drifted Git hook.
4648

49+
When a hook still cannot be rewritten because the target remains locked or
50+
unwritable, the command logs a clear error for that hook, continues processing
51+
the remaining hooks, and exits non-zero so ``dev-tools:sync`` reports the hook
52+
install problem clearly instead of aborting mid-copy.
53+
4754
``--json``
4855
Emit a structured machine-readable payload instead of the normal terminal
4956
output.
@@ -77,4 +84,5 @@ Exit Codes
7784
* - 0
7885
- Success. Hooks installed successfully.
7986
* - 1
80-
- Failure. Copy error.
87+
- Failure. Drift detected in ``--check`` mode or one or more hooks could
88+
not be rewritten automatically.

docs/running/specialized-commands.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,9 @@ Installs packaged Fast Forward Git hooks.
461461
Important details:
462462

463463
- copies hook files from source to target directory;
464-
- sets executable permissions on copied hooks;
464+
- sets executable permissions on copied hooks with ``0o755``;
465+
- removes an existing drifted hook before recopying it so stale target
466+
permissions do not block replacements;
465467
- ``--source`` defaults to ``resources/git-hooks``;
466468
- ``--target`` defaults to ``.git/hooks``;
467469
- ``--no-overwrite`` preserves existing hook files.

src/Console/Command/GitHooksCommand.php

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use Symfony\Component\Console\Input\InputInterface;
3232
use Symfony\Component\Console\Input\InputOption;
3333
use Symfony\Component\Console\Output\OutputInterface;
34+
use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
3435
use Symfony\Component\Filesystem\Path;
3536

3637
/**
@@ -129,7 +130,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
129130
->files()
130131
->in($sourcePath);
131132

132-
$status = self::SUCCESS;
133+
$checkFailure = false;
134+
$installFailure = false;
133135

134136
foreach ($files as $file) {
135137
$hookPath = Path::join($targetPath, $file->getRelativePathname());
@@ -180,7 +182,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
180182
}
181183

182184
if ($check) {
183-
$status = self::FAILURE;
185+
$checkFailure = true;
184186

185187
continue;
186188
}
@@ -203,8 +205,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
203205
}
204206
}
205207

206-
$this->filesystem->copy($file->getRealPath(), $hookPath, $overwrite || $interactive);
207-
$this->filesystem->chmod($hookPath, 755, 0o755);
208+
if (! $this->installHook($file->getRealPath(), $hookPath, $overwrite || $interactive, $input)) {
209+
$installFailure = true;
210+
211+
continue;
212+
}
208213

209214
$this->success(
210215
'Installed {hook_name} hook.',
@@ -216,7 +221,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
216221
);
217222
}
218223

219-
if (self::FAILURE === $status) {
224+
if ($checkFailure) {
220225
return $this->failure(
221226
'One or more Git hooks require synchronization updates.',
222227
$input,
@@ -227,6 +232,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int
227232
);
228233
}
229234

235+
if ($installFailure) {
236+
return $this->failure(
237+
'One or more Git hooks could not be installed automatically.',
238+
$input,
239+
[
240+
'target' => $targetPath,
241+
],
242+
$targetPath,
243+
);
244+
}
245+
230246
return $this->success(
231247
'Git hook synchronization completed successfully.',
232248
$input,
@@ -248,4 +264,42 @@ private function shouldReplaceHook(string $hookPath): bool
248264
return $this->getIO()
249265
->askConfirmation(\sprintf('Replace drifted Git hook %s? [y/N] ', $hookPath), false);
250266
}
267+
268+
/**
269+
* Installs a single hook and rewrites drifted targets defensively.
270+
*
271+
* @param string $sourcePath the packaged hook path
272+
* @param string $hookPath the target repository hook path
273+
* @param bool $replaceExisting whether an existing hook SHOULD be removed first
274+
* @param InputInterface $input the originating command input
275+
*
276+
* @return bool true when the hook was installed successfully
277+
*/
278+
private function installHook(string $sourcePath, string $hookPath, bool $replaceExisting, InputInterface $input): bool
279+
{
280+
try {
281+
if ($replaceExisting && $this->filesystem->exists($hookPath)) {
282+
$this->filesystem->remove($hookPath);
283+
}
284+
285+
$this->filesystem->copy($sourcePath, $hookPath, false);
286+
$this->filesystem->chmod(files: $hookPath, mode: 0o755);
287+
288+
return true;
289+
} catch (IOExceptionInterface $exception) {
290+
$this->logger->error(
291+
'Failed to install {hook_name} hook automatically. Remove or unlock {hook_path} and rerun git-hooks.',
292+
[
293+
'input' => $input,
294+
'hook_name' => $this->filesystem->basename($hookPath),
295+
'hook_path' => $hookPath,
296+
'error' => $exception->getMessage(),
297+
'file' => $exception->getPath() ?? $hookPath,
298+
'line' => null,
299+
],
300+
);
301+
302+
return false;
303+
}
304+
}
251305
}

tests/Console/Command/GitHooksCommandTest.php

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
use Symfony\Component\Config\FileLocatorInterface;
4040
use Symfony\Component\Console\Input\InputInterface;
4141
use Symfony\Component\Console\Output\OutputInterface;
42+
use Symfony\Component\Filesystem\Exception\IOException;
4243
use Symfony\Component\Finder\Finder;
4344

4445
use function Safe\mkdir;
@@ -164,9 +165,9 @@ public function executeWillCopyPackagedHooks(): void
164165
->willReturn('/app/.git/hooks');
165166
$this->filesystem->exists('/app/.git/hooks/post-merge')
166167
->willReturn(false);
167-
$this->filesystem->copy(Argument::containingString('/post-merge'), '/app/.git/hooks/post-merge', true)
168+
$this->filesystem->copy(Argument::containingString('/post-merge'), '/app/.git/hooks/post-merge', false)
168169
->shouldBeCalledOnce();
169-
$this->filesystem->chmod('/app/.git/hooks/post-merge', 755, 0o755)
170+
$this->filesystem->chmod('/app/.git/hooks/post-merge', 0o755)
170171
->shouldBeCalledOnce();
171172
$this->logger->log('info', 'Installed {hook_name} hook.', Argument::type('array'))
172173
->shouldBeCalledOnce();
@@ -327,6 +328,97 @@ public function executeWillSkipReplacingHookWhenInteractiveConfirmationIsDecline
327328
self::assertSame(GitHooksCommand::SUCCESS, $this->executeCommand());
328329
}
329330

331+
/**
332+
* @return void
333+
*/
334+
#[Test]
335+
public function executeWillRemoveDriftedHookBeforeReplacingIt(): void
336+
{
337+
$this->input->getOption('source')
338+
->willReturn('resources/git-hooks');
339+
$this->input->getOption('target')
340+
->willReturn('.git/hooks');
341+
$this->input->getOption('no-overwrite')
342+
->willReturn(false);
343+
344+
$this->fileLocator->locate('resources/git-hooks')
345+
->willReturn($this->sourceDirectory);
346+
$this->finderFactory->create()
347+
->willReturn(new Finder())
348+
->shouldBeCalledOnce();
349+
$this->filesystem->getAbsolutePath('.git/hooks')
350+
->willReturn('/app/.git/hooks');
351+
$this->filesystem->exists('/app/.git/hooks/post-merge')
352+
->willReturn(true);
353+
$this->fileDiffer->diff(Argument::containingString('/post-merge'), '/app/.git/hooks/post-merge')
354+
->willReturn(new FileDiff(FileDiff::STATUS_CHANGED, 'Changed summary', null))
355+
->shouldBeCalledOnce();
356+
$this->filesystem->remove('/app/.git/hooks/post-merge')
357+
->shouldBeCalledOnce();
358+
$this->filesystem->copy(Argument::containingString('/post-merge'), '/app/.git/hooks/post-merge', false)
359+
->shouldBeCalledOnce();
360+
$this->filesystem->chmod('/app/.git/hooks/post-merge', 0o755)
361+
->shouldBeCalledOnce();
362+
$this->logger->log('info', 'Installed {hook_name} hook.', Argument::type('array'))
363+
->shouldBeCalledOnce();
364+
$this->logger->log('info', 'Git hook synchronization completed successfully.', Argument::type('array'))
365+
->shouldBeCalledOnce();
366+
367+
self::assertSame(GitHooksCommand::SUCCESS, $this->executeCommand());
368+
}
369+
370+
/**
371+
* @return void
372+
*/
373+
#[Test]
374+
public function executeWillReportInstallFailureWhenReplacementStillCannotBeWritten(): void
375+
{
376+
$this->input->getOption('source')
377+
->willReturn('resources/git-hooks');
378+
$this->input->getOption('target')
379+
->willReturn('.git/hooks');
380+
$this->input->getOption('no-overwrite')
381+
->willReturn(false);
382+
383+
$this->fileLocator->locate('resources/git-hooks')
384+
->willReturn($this->sourceDirectory);
385+
$this->finderFactory->create()
386+
->willReturn(new Finder())
387+
->shouldBeCalledOnce();
388+
$this->filesystem->getAbsolutePath('.git/hooks')
389+
->willReturn('/app/.git/hooks');
390+
$this->filesystem->exists('/app/.git/hooks/post-merge')
391+
->willReturn(true);
392+
$this->fileDiffer->diff(Argument::containingString('/post-merge'), '/app/.git/hooks/post-merge')
393+
->willReturn(new FileDiff(FileDiff::STATUS_CHANGED, 'Changed summary', null))
394+
->shouldBeCalledOnce();
395+
$this->filesystem->remove('/app/.git/hooks/post-merge')
396+
->shouldBeCalledOnce();
397+
$this->filesystem->copy(Argument::containingString('/post-merge'), '/app/.git/hooks/post-merge', false)
398+
->willThrow(new IOException('Target file could not be opened for writing.', 0, null, '/app/.git/hooks/post-merge'))
399+
->shouldBeCalledOnce();
400+
$this->filesystem->basename('/app/.git/hooks/post-merge')
401+
->willReturn('post-merge')
402+
->shouldBeCalledOnce();
403+
$this->logger->error(
404+
'Failed to install {hook_name} hook automatically. Remove or unlock {hook_path} and rerun git-hooks.',
405+
Argument::that(
406+
static fn(array $context): bool => $context['input'] instanceof InputInterface
407+
&& 'post-merge' === $context['hook_name']
408+
&& '/app/.git/hooks/post-merge' === $context['hook_path']
409+
&& '/app/.git/hooks/post-merge' === $context['file']
410+
&& null === $context['line']
411+
&& str_contains($context['error'], 'Target file could not be opened for writing.')
412+
),
413+
)->shouldBeCalledOnce();
414+
$this->logger->error('One or more Git hooks could not be installed automatically.', Argument::type('array'))
415+
->shouldBeCalledOnce();
416+
$this->filesystem->chmod(Argument::cetera())
417+
->shouldNotBeCalled();
418+
419+
self::assertSame(GitHooksCommand::FAILURE, $this->executeCommand());
420+
}
421+
330422
/**
331423
* @return int
332424
*/

0 commit comments

Comments
 (0)