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;
+ }
+}