2323use FastForward \DevTools \Console \Input \HasJsonOption ;
2424use FastForward \DevTools \Filesystem \FinderFactoryInterface ;
2525use FastForward \DevTools \Filesystem \FilesystemInterface ;
26+ use FastForward \DevTools \GitHooks \HookContentRenderer ;
27+ use FastForward \DevTools \Resource \FileDiff ;
2628use FastForward \DevTools \Resource \FileDiffer ;
2729use Psr \Log \LoggerInterface ;
2830use Symfony \Component \Config \FileLocatorInterface ;
3537use Symfony \Component \Console \Style \SymfonyStyle ;
3638use Symfony \Component \Filesystem \Exception \IOExceptionInterface ;
3739use Symfony \Component \Filesystem \Path ;
40+ use Throwable ;
3841
3942/**
4043 * Installs packaged Git hooks for the consumer repository.
@@ -55,6 +58,7 @@ final class GitHooksCommand extends Command
5558 * @param FilesystemInterface $filesystem the filesystem used to copy hooks
5659 * @param FileLocatorInterface $fileLocator the locator used to find packaged hooks
5760 * @param FinderFactoryInterface $finderFactory the factory used to create finders for hook files
61+ * @param HookContentRenderer $hookContentRenderer renders packaged hooks with runtime-specific placeholders
5862 * @param FileDiffer $fileDiffer the file differ used to summarize synchronization changes
5963 * @param LoggerInterface $logger the output-aware logger
6064 * @param SymfonyStyle $io the input/output service used to interact with the user
@@ -63,6 +67,7 @@ public function __construct(
6367 private readonly FilesystemInterface $ filesystem ,
6468 private readonly FileLocatorInterface $ fileLocator ,
6569 private readonly FinderFactoryInterface $ finderFactory ,
70+ private readonly HookContentRenderer $ hookContentRenderer ,
6671 private readonly FileDiffer $ fileDiffer ,
6772 private readonly LoggerInterface $ logger ,
6873 private readonly SymfonyStyle $ io ,
@@ -140,6 +145,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
140145 $ installFailure = false ;
141146
142147 foreach ($ files as $ file ) {
148+ $ sourcePath = $ file ->getRealPath ();
149+ $ sourceContents = $ this ->filesystem ->readFile ($ sourcePath );
150+ $ renderedSourceContents = $ this ->hookContentRenderer ->render ($ sourceContents );
143151 $ hookPath = Path::join ($ targetPath , $ file ->getRelativePathname ());
144152
145153 if (! $ overwrite && ! $ dryRun && ! $ check && ! $ interactive && $ this ->filesystem ->exists ($ hookPath )) {
@@ -156,7 +164,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
156164 }
157165
158166 if (($ overwrite || $ dryRun || $ check || $ interactive ) && $ this ->filesystem ->exists ($ hookPath )) {
159- $ comparison = $ this ->fileDiffer ->diff ($ file ->getRealPath (), $ hookPath );
167+ $ comparison = $ sourceContents === $ renderedSourceContents
168+ ? $ this ->fileDiffer ->diff ($ sourcePath , $ hookPath )
169+ : $ this ->compareRenderedHookContents ($ sourcePath , $ hookPath , $ renderedSourceContents );
160170
161171 $ this ->logger ->notice (
162172 $ comparison ->getSummary (),
@@ -211,7 +221,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
211221 }
212222 }
213223
214- if (! $ this ->installHook ($ file ->getRealPath (), $ hookPath , $ overwrite || $ interactive , $ input )) {
224+ if (! $ this ->installHook (
225+ $ sourcePath ,
226+ $ hookPath ,
227+ $ overwrite || $ interactive ,
228+ $ input ,
229+ $ sourceContents === $ renderedSourceContents ? null : $ renderedSourceContents ,
230+ )) {
215231 $ installFailure = true ;
216232
217233 continue ;
@@ -282,21 +298,28 @@ private function shouldReplaceHook(string $hookPath): bool
282298 * @param string $hookPath the target repository hook path
283299 * @param bool $replaceExisting whether an existing hook SHOULD be removed first
284300 * @param InputInterface $input the originating command input
301+ * @param string|null $renderedContents optional rendered hook contents that SHOULD be written instead of copied
285302 *
286303 * @return bool true when the hook was installed successfully
287304 */
288305 private function installHook (
289306 string $ sourcePath ,
290307 string $ hookPath ,
291308 bool $ replaceExisting ,
292- InputInterface $ input
309+ InputInterface $ input ,
310+ ?string $ renderedContents = null ,
293311 ): bool {
294312 try {
295313 if ($ replaceExisting && $ this ->filesystem ->exists ($ hookPath )) {
296314 $ this ->filesystem ->remove ($ hookPath );
297315 }
298316
299- $ this ->filesystem ->copy ($ sourcePath , $ hookPath , false );
317+ if (null === $ renderedContents ) {
318+ $ this ->filesystem ->copy ($ sourcePath , $ hookPath , false );
319+ } else {
320+ $ this ->filesystem ->dumpFile ($ hookPath , $ renderedContents );
321+ }
322+
300323 $ this ->filesystem ->chmod (files: $ hookPath , mode: 0o755 );
301324
302325 return true ;
@@ -316,4 +339,40 @@ private function installHook(
316339 return false ;
317340 }
318341 }
342+
343+ /**
344+ * Compares rendered hook contents with an existing installed hook.
345+ *
346+ * @param string $sourcePath the packaged hook source path
347+ * @param string $hookPath the target installed hook path
348+ * @param string $renderedContents the rendered hook contents
349+ *
350+ * @return FileDiff the rendered comparison result
351+ */
352+ private function compareRenderedHookContents (
353+ string $ sourcePath ,
354+ string $ hookPath ,
355+ string $ renderedContents
356+ ): FileDiff {
357+ try {
358+ $ targetContents = $ this ->filesystem ->readFile ($ hookPath );
359+ } catch (Throwable ) {
360+ return new FileDiff (
361+ FileDiff::STATUS_UNREADABLE ,
362+ \sprintf (
363+ 'Target %s will be overwritten from %s, but the existing or source content could not be read. ' ,
364+ $ hookPath ,
365+ $ sourcePath ,
366+ ),
367+ );
368+ }
369+
370+ return $ this ->fileDiffer ->diffContents (
371+ $ sourcePath ,
372+ $ hookPath ,
373+ $ renderedContents ,
374+ $ targetContents ,
375+ \sprintf ('Overwriting resource %s from %s. ' , $ hookPath , $ sourcePath ),
376+ );
377+ }
319378}
0 commit comments