From 5273f06510ff5730002b28945fcaea3769368d26 Mon Sep 17 00:00:00 2001 From: Igor Pinchuk Date: Fri, 26 Sep 2025 03:00:11 +0800 Subject: [PATCH] Document symlink registry cleanup workflow --- README.md | 18 +- src/Symlinks/Plugin.php | 55 ++++- src/Symlinks/SymlinksExecutionTrait.php | 9 +- src/Symlinks/SymlinksFactory.php | 42 +++- src/Symlinks/SymlinksRegistry.php | 257 ++++++++++++++++++++++++ tests/ComposerIntegrationTest.php | 72 +++++++ tests/Symlinks/SymlinksRegistryTest.php | 146 ++++++++++++++ 7 files changed, 595 insertions(+), 4 deletions(-) create mode 100644 src/Symlinks/SymlinksRegistry.php create mode 100644 tests/Symlinks/SymlinksRegistryTest.php diff --git a/README.md b/README.md index 81d363b..686243b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ The behaviour of the plugin can be tuned via the following configuration keys: | `absolute-path` | `false` | Create symlinks using the real path to the target. | | `throw-exception` | `true` | Throw an exception on errors instead of just printing the message. | | `force-create` | `false` | Remove any existing file or directory at the link path before creating the symlink. | +| `cleanup` | `false` | Remove symlinks that were created previously but are no longer present in the configuration. | You can set personal configs for any symlink. For personal configs `link` must be defined @@ -47,12 +48,19 @@ For personal configs `link` must be defined "force-create": false, "skip-missing-target": false, "absolute-path": false, - "throw-exception": true + "throw-exception": true, + "cleanup": true } } } ``` +When `cleanup` is enabled the plugin keeps a registry of every symlink it +created in the file `vendor/composer-symlinks-state.json`. On the next run the +registry is compared with the current configuration: entries missing from the +configuration are deleted from both the registry and the filesystem. The file +is recreated automatically and can safely be ignored by VCS. + ### Placeholder syntax Symlink paths support the following placeholders which are expanded before @@ -103,6 +111,14 @@ $ SYMLINKS_DRY_RUN=1 composer install --no-interaction [DRY RUN] Symlinking /tmp/sample/linked.txt to /tmp/sample/source/file.txt ``` +### Uninstalling + +Running `composer remove somework/composer-symlinks` will trigger the plugin's +`uninstall()` hook. The hook reads the registry file and removes every stored +symlink from the filesystem before deleting the registry itself. This ensures +that no links created by the plugin are left behind when the package is +removed. + ### Typical error messages | Message | Meaning | diff --git a/src/Symlinks/Plugin.php b/src/Symlinks/Plugin.php index 1c99f71..1f5d318 100644 --- a/src/Symlinks/Plugin.php +++ b/src/Symlinks/Plugin.php @@ -36,6 +36,14 @@ public function deactivate(Composer $composer, IOInterface $io): void public function uninstall(Composer $composer, IOInterface $io): void { + $fileSystem = new Filesystem(); + $vendorDir = $this->resolveVendorDir($composer, $fileSystem); + if ($vendorDir === null) { + return; + } + + $registry = new SymlinksRegistry($fileSystem, $vendorDir); + $registry->removeAll(); } protected function createLinks(): callable @@ -46,7 +54,52 @@ protected function createLinks(): callable $dryRun = getenv('SYMLINKS_DRY_RUN') === '1' || getenv('SYMLINKS_DRY_RUN') === 'true'; $processor = new SymlinksProcessor($fileSystem, $dryRun); - $this->runSymlinks($factory, $processor, $event->getIO(), $dryRun); + $processedSymlinks = $this->runSymlinks($factory, $processor, $event->getIO(), $dryRun); + + if ($dryRun) { + return; + } + + $vendorDir = $factory->getVendorDirPath(); + if ($vendorDir === null) { + return; + } + + $registry = new SymlinksRegistry($fileSystem, $vendorDir); + $registry->sync($factory->getConfiguredSymlinks(), $processedSymlinks, $factory->isCleanupEnabled()); }; } + + private function resolveVendorDir(Composer $composer, Filesystem $filesystem): ?string + { + try { + $config = $composer->getConfig(); + } catch (\TypeError $exception) { + $config = null; + } + + $vendorDir = null; + if ($config !== null) { + $vendorDir = $config->get('vendor-dir'); + } + + if (!$vendorDir) { + $projectDir = getcwd(); + if ($projectDir === false) { + return null; + } + $vendorDir = $projectDir . DIRECTORY_SEPARATOR . 'vendor'; + } + + if (!$filesystem->isAbsolutePath($vendorDir)) { + $projectDir = getcwd(); + if ($projectDir === false) { + return null; + } + $combined = $projectDir . DIRECTORY_SEPARATOR . $vendorDir; + $vendorDir = realpath($combined) ?: $combined; + } + + return $vendorDir; + } } diff --git a/src/Symlinks/SymlinksExecutionTrait.php b/src/Symlinks/SymlinksExecutionTrait.php index ad3c75e..009fa70 100644 --- a/src/Symlinks/SymlinksExecutionTrait.php +++ b/src/Symlinks/SymlinksExecutionTrait.php @@ -7,19 +7,25 @@ trait SymlinksExecutionTrait { + /** + * @return Symlink[] + */ private function runSymlinks( SymlinksFactory $factory, SymlinksProcessor $processor, IOInterface $io, bool $dryRun - ): void { + ): array { $symlinks = $factory->process(); + $processed = []; foreach ($symlinks as $symlink) { try { if (!$processor->processSymlink($symlink)) { throw new RuntimeException('Unknown error'); } + $processed[] = $symlink; + $io->write(sprintf( ' %sSymlinking %s to %s', $dryRun ? '[DRY RUN] ' : '', @@ -42,5 +48,6 @@ private function runSymlinks( )); } } + return $processed; } } diff --git a/src/Symlinks/SymlinksFactory.php b/src/Symlinks/SymlinksFactory.php index 92b46d2..3d74ef7 100644 --- a/src/Symlinks/SymlinksFactory.php +++ b/src/Symlinks/SymlinksFactory.php @@ -22,6 +22,11 @@ class SymlinksFactory protected Filesystem $fileSystem; protected Event $event; + /** + * @var array + */ + private array $configuredSymlinks = []; + private ?string $vendorDir = null; public function __construct(Event $event, Filesystem $filesystem) { @@ -36,6 +41,7 @@ public function __construct(Event $event, Filesystem $filesystem) */ public function process(): array { + $this->configuredSymlinks = []; $symlinksData = $this->getSymlinksData(); $symlinks = []; @@ -142,6 +148,7 @@ protected function processSymlink(string $target, $linkData): ?Symlink is_link($linkPath) && realpath(dirname($linkPath) . DIRECTORY_SEPARATOR . readlink($linkPath)) === $targetPath ) { + $this->registerConfiguredSymlink($linkPath, $targetPath); $this->event->getIO()->write( sprintf( ' Symlink %s to %s already created', @@ -152,11 +159,15 @@ protected function processSymlink(string $target, $linkData): ?Symlink return null; } - return (new Symlink()) + $symlink = (new Symlink()) ->setTarget($targetPath) ->setLink($linkPath) ->setAbsolutePath($this->getConfig(static::ABSOLUTE_PATH, $linkData, false)) ->setForceCreate($this->getConfig(static::FORCE_CREATE, $linkData, false)); + + $this->registerConfiguredSymlink($linkPath, $targetPath); + + return $symlink; } /** @@ -245,7 +256,36 @@ private function getProjectDir(): ?string return $path === false ? $cwd : $path; } + public function getVendorDirPath(): ?string + { + if ($this->vendorDir === null) { + $this->vendorDir = $this->resolveVendorDir(); + } + + return $this->vendorDir; + } + + public function getConfiguredSymlinks(): array + { + return $this->configuredSymlinks; + } + + public function isCleanupEnabled(): bool + { + return $this->getConfig('cleanup', null, false); + } + + private function registerConfiguredSymlink(string $link, string $target): void + { + $this->configuredSymlinks[$link] = $target; + } + private function getVendorDir(): ?string + { + return $this->getVendorDirPath(); + } + + private function resolveVendorDir(): ?string { $composer = $this->event->getComposer(); $vendorDir = null; diff --git a/src/Symlinks/SymlinksRegistry.php b/src/Symlinks/SymlinksRegistry.php new file mode 100644 index 0000000..466eb91 --- /dev/null +++ b/src/Symlinks/SymlinksRegistry.php @@ -0,0 +1,257 @@ +filesystem = $filesystem; + $this->registryFile = rtrim($vendorDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . self::REGISTRY_FILENAME; + } + + /** + * @return array + */ + public function load(): array + { + if (!is_file($this->registryFile)) { + return []; + } + + $contents = file_get_contents($this->registryFile); + if ($contents === false || $contents === '') { + return []; + } + + $decoded = json_decode($contents, true); + if (!\is_array($decoded)) { + return []; + } + + $registry = []; + foreach ($decoded as $link => $target) { + if (\is_string($link) && \is_string($target)) { + $registry[$link] = $target; + } + } + + return $registry; + } + + /** + * @param array $configuredSymlinks + * @param Symlink[] $createdSymlinks + */ + public function sync(array $configuredSymlinks, array $createdSymlinks, bool $cleanup): void + { + $registry = $this->filterMissingPaths($this->load()); + + $registry = $this->recordProcessedSymlinks($registry, $createdSymlinks); + $registry = $this->recordConfiguredSymlinks($registry, $configuredSymlinks); + + if ($cleanup) { + $registry = $this->cleanupRemovedSymlinks($registry, array_keys($configuredSymlinks)); + } + + $this->save($registry); + } + + public function clear(): void + { + if (is_file($this->registryFile)) { + @unlink($this->registryFile); + } + } + + public function removeAll(): void + { + foreach ($this->load() as $link => $_target) { + $this->removePath($link); + } + + $this->clear(); + } + + public function getRegistryFile(): string + { + return $this->registryFile; + } + + /** + * @param array $registry + */ + private function save(array $registry): void + { + if ($registry === []) { + $this->clear(); + return; + } + + $directory = \dirname($this->registryFile); + if (!is_dir($directory)) { + $this->filesystem->ensureDirectoryExists($directory); + } + + file_put_contents( + $this->registryFile, + json_encode($registry, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ); + } + + private function resolveTarget(string $link, string $fallback): ?string + { + if (is_link($link)) { + $target = readlink($link); + if ($target === false) { + return $fallback; + } + + if ($this->filesystem->isAbsolutePath($target)) { + return realpath($target) ?: $target; + } + + $base = realpath(\dirname($link)); + if ($base === false) { + return $fallback; + } + + $combined = $base . DIRECTORY_SEPARATOR . $target; + return realpath($combined) ?: $combined; + } + + if (file_exists($link)) { + return realpath($link) ?: $fallback; + } + + return $fallback; + } + + /** + * @param array $registry + * + * @return array + */ + private function filterMissingPaths(array $registry): array + { + foreach ($registry as $link => $target) { + if (!$this->pathExists($link)) { + unset($registry[$link]); + } + } + + return $registry; + } + + private function pathExists(string $path): bool + { + return is_link($path) || file_exists($path); + } + + private function removePath(string $path): void + { + if (is_link($path)) { + @unlink($path); + return; + } + + if (is_dir($path)) { + $this->filesystem->removeDirectory($path); + return; + } + + if (file_exists($path)) { + $this->filesystem->remove($path); + } + } + + /** + * @param array $registry + * @param Symlink[] $createdSymlinks + * + * @return array + */ + private function recordProcessedSymlinks(array $registry, array $createdSymlinks): array + { + foreach ($createdSymlinks as $symlink) { + if (!$symlink instanceof Symlink) { + continue; + } + + $link = $symlink->getLink(); + if (!$this->pathExists($link)) { + continue; + } + + $resolvedTarget = $this->resolveTarget($link, $symlink->getTarget()); + if ($resolvedTarget === null) { + continue; + } + + $registry[$link] = $resolvedTarget; + } + + return $registry; + } + + /** + * @param array $registry + * @param array $configuredSymlinks + * + * @return array + */ + private function recordConfiguredSymlinks(array $registry, array $configuredSymlinks): array + { + foreach ($configuredSymlinks as $link => $target) { + if (!\is_string($link) || !\is_string($target)) { + continue; + } + + if (isset($registry[$link])) { + $registry[$link] = $target; + continue; + } + + if (!$this->pathExists($link)) { + continue; + } + + $resolvedTarget = $this->resolveTarget($link, $target); + if ($resolvedTarget !== null) { + $registry[$link] = $resolvedTarget; + } + } + + return $registry; + } + + /** + * @param array $registry + * @param array $configuredLinks + * + * @return array + */ + private function cleanupRemovedSymlinks(array $registry, array $configuredLinks): array + { + $allowed = array_fill_keys($configuredLinks, true); + + foreach (array_keys($registry) as $link) { + if (isset($allowed[$link])) { + continue; + } + + $this->removePath($link); + unset($registry[$link]); + } + + return $registry; + } +} diff --git a/tests/ComposerIntegrationTest.php b/tests/ComposerIntegrationTest.php index 93fb155..41b8f95 100644 --- a/tests/ComposerIntegrationTest.php +++ b/tests/ComposerIntegrationTest.php @@ -137,4 +137,76 @@ public function testDryRunViaComposerDoesNotCreateLinks(): void $this->assertFalse(file_exists($tmp . '/linkA.txt')); } + + public function testCleanupRemovesUnusedSymlinks(): void + { + $tmp = sys_get_temp_dir() . '/project_' . uniqid(); + mkdir($tmp); + + mkdir($tmp . '/sourceA', 0777, true); + file_put_contents($tmp . '/sourceA/fileA.txt', 'A'); + mkdir($tmp . '/sourceB', 0777, true); + file_put_contents($tmp . '/sourceB/fileB.txt', 'B'); + + $pluginPath = realpath(__DIR__ . '/..'); + + $composerData = [ + 'name' => 'test/project', + 'minimum-stability' => 'dev', + 'require' => [ + 'somework/composer-symlinks' => '*' + ], + 'repositories' => [ + ['type' => 'path', 'url' => $pluginPath, 'options' => ['symlink' => false]] + ], + 'config' => [ + 'allow-plugins' => [ + 'somework/composer-symlinks' => true + ] + ], + 'extra' => [ + 'somework/composer-symlinks' => [ + 'cleanup' => true, + 'symlinks' => [ + 'sourceA/fileA.txt' => 'linkA.txt', + 'sourceB/fileB.txt' => 'linkB.txt' + ] + ] + ] + ]; + file_put_contents($tmp . '/composer.json', json_encode($composerData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $cwd = getcwd(); + chdir($tmp); + exec('composer install --no-interaction --no-ansi 2>&1', $output, $code); + chdir($cwd); + + $this->assertSame(0, $code, implode("\n", $output)); + $this->assertTrue(is_link($tmp . '/linkA.txt')); + $this->assertTrue(is_link($tmp . '/linkB.txt')); + + $registryPath = $tmp . '/vendor/composer-symlinks-state.json'; + $this->assertFileExists($registryPath); + $registry = json_decode((string)file_get_contents($registryPath), true); + $this->assertIsArray($registry); + $this->assertArrayHasKey($tmp . '/linkA.txt', $registry); + $this->assertArrayHasKey($tmp . '/linkB.txt', $registry); + + unset($composerData['extra']['somework/composer-symlinks']['symlinks']['sourceB/fileB.txt']); + file_put_contents($tmp . '/composer.json', json_encode($composerData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + chdir($tmp); + $output = []; + exec('composer update --no-interaction --no-ansi 2>&1', $output, $code); + chdir($cwd); + + $this->assertSame(0, $code, implode("\n", $output)); + $this->assertTrue(is_link($tmp . '/linkA.txt')); + $this->assertFalse(file_exists($tmp . '/linkB.txt')); + + $registry = json_decode((string)file_get_contents($registryPath), true); + $this->assertIsArray($registry); + $this->assertArrayHasKey($tmp . '/linkA.txt', $registry); + $this->assertArrayNotHasKey($tmp . '/linkB.txt', $registry); + } } diff --git a/tests/Symlinks/SymlinksRegistryTest.php b/tests/Symlinks/SymlinksRegistryTest.php new file mode 100644 index 0000000..202bf24 --- /dev/null +++ b/tests/Symlinks/SymlinksRegistryTest.php @@ -0,0 +1,146 @@ +filesystem = new Filesystem(); + } + + public function testSyncRecordsProcessedAndConfiguredSymlinks(): void + { + $workspace = $this->createWorkspace(); + $vendorDir = $workspace . '/vendor'; + mkdir($vendorDir, 0777, true); + + $registry = new SymlinksRegistry($this->filesystem, $vendorDir); + + // Seed the registry with an entry referencing a missing link to ensure cleanup. + file_put_contents( + $registry->getRegistryFile(), + json_encode([ + $workspace . '/stale-link' => $workspace . '/stale-target', + ['invalid'], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ); + + $targetFile = $workspace . '/target.txt'; + file_put_contents($targetFile, 'content'); + + $processedLink = $workspace . '/processed-link.txt'; + symlink($targetFile, $processedLink); + + $createdSymlink = (new Symlink()) + ->setLink($processedLink) + ->setTarget($targetFile); + + $configuredLink = $workspace . '/configured-link.txt'; + symlink($targetFile, $configuredLink); + + $registry->sync([ + $configuredLink => $targetFile, + ], [$createdSymlink], false); + + $state = $registry->load(); + + $this->assertArrayHasKey($processedLink, $state); + $this->assertSame(realpath($targetFile), $state[$processedLink]); + $this->assertArrayHasKey($configuredLink, $state); + $this->assertSame(realpath($targetFile), $state[$configuredLink]); + $this->assertArrayNotHasKey($workspace . '/stale-link', $state); + + $this->filesystem->removeDirectory($workspace); + } + + public function testSyncCleanupRemovesStaleEntriesAndPaths(): void + { + $workspace = $this->createWorkspace(); + $vendorDir = $workspace . '/vendor'; + mkdir($vendorDir, 0777, true); + + $registry = new SymlinksRegistry($this->filesystem, $vendorDir); + + $targetA = $workspace . '/target-a.txt'; + $targetB = $workspace . '/target-b.txt'; + file_put_contents($targetA, 'A'); + file_put_contents($targetB, 'B'); + + $linkA = $workspace . '/link-a.txt'; + $linkB = $workspace . '/link-b.txt'; + symlink($targetA, $linkA); + symlink($targetB, $linkB); + + $registry->sync([ + $linkA => $targetA, + $linkB => $targetB, + ], [ + (new Symlink())->setLink($linkA)->setTarget($targetA), + (new Symlink())->setLink($linkB)->setTarget($targetB), + ], false); + + $this->assertFileExists($registry->getRegistryFile()); + + $registry->sync([ + $linkA => $targetA, + ], [], true); + + $state = $registry->load(); + + $this->assertArrayHasKey($linkA, $state); + $this->assertArrayNotHasKey($linkB, $state); + $this->assertFileDoesNotExist($linkB); + + $this->filesystem->removeDirectory($workspace); + } + + public function testRemoveAllDeletesRegisteredLinksAndStateFile(): void + { + $workspace = $this->createWorkspace(); + $vendorDir = $workspace . '/vendor'; + mkdir($vendorDir, 0777, true); + + $registry = new SymlinksRegistry($this->filesystem, $vendorDir); + + $target = $workspace . '/target.txt'; + file_put_contents($target, 'content'); + + $link = $workspace . '/link.txt'; + symlink($target, $link); + + $registry->sync([ + $link => $target, + ], [ + (new Symlink())->setLink($link)->setTarget($target), + ], false); + + $this->assertFileExists($link); + $this->assertFileExists($registry->getRegistryFile()); + + $registry->removeAll(); + + $this->assertFileDoesNotExist($link); + $this->assertFileDoesNotExist($registry->getRegistryFile()); + + $this->filesystem->removeDirectory($workspace); + } + + private function createWorkspace(): string + { + $workspace = sys_get_temp_dir() . '/symlinks_registry_' . uniqid('', true); + mkdir($workspace, 0777, true); + + return $workspace; + } +}