Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,28 @@ jobs:
with:
name: coverage-${{ matrix.php-version }}
path: coverage.xml

test-windows:
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
php-version: ["7.4", "8.1", "8.2"]
steps:
- uses: actions/checkout@v3
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
coverage: none
- name: Cache Composer dependencies
uses: actions/cache@v3
with:
path: ~\AppData\Local\Composer\Cache\files
key: ${{ runner.os }}-composer-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock', '**/composer.json') }}
restore-keys: |
${{ runner.os }}-composer-${{ matrix.php-version }}-
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-suggest --ignore-platform-req=ext-dom --ignore-platform-req=ext-xml --ignore-platform-req=ext-xmlwriter
- name: Run PHPUnit
run: vendor\bin\phpunit
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@

## Unreleased
- Removed deprecated `SKIP_MISSED_TARGET` option. Use `SKIP_MISSING_TARGET` instead.
- Added Windows fallback strategies with configurable `windows-mode` (`symlink`, `junction`, `copy`).
- Improved error messages suggesting enabling Developer Mode or switching Windows strategies when symlinks fail.
- Documented Windows behaviours and added Windows CI coverage with end-to-end tests.
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ The behaviour of the plugin can be tuned via the following configuration keys:
| `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. |
| `windows-mode` | `junction` | Windows-only strategy: `symlink` (require Developer Mode/administrator), `junction` (fallback to junction/hardlink/copy), or `copy` (mirror the target without links). |

You can set personal configs for any symlink.
For personal configs `link` must be defined
Expand Down Expand Up @@ -130,13 +131,25 @@ removed.
| `The target path ... does not exists` | The target file or directory was not found. |
| `Link ... already exists` | A file/directory already occupies the link path. |
| `Cant unlink ...` | The plugin failed to remove a file when using `force-create`. |
| `Failed to create symlink ... Enable Windows Developer Mode ...` | Windows denied symlink creation. Enable Developer Mode or set `windows-mode` to `junction`/`copy`. |

### Windows compatibility

On Windows, creating symlinks requires either Administrator privileges or that
the system is running in Developer Mode. The plugin itself works, but the
underlying operating system may refuse to create a link if permissions are
insufficient. Additionally, relative symlinks use Unix-style `/` separators
the system is running in Developer Mode. When native symlinks are not
available, the plugin falls back automatically (default `windows-mode` value)
to NTFS junctions for directories and hardlinks/copies for files. You can opt
into different behaviours by setting `extra.somework/composer-symlinks.windows-mode`
to one of:

* `symlink` – always attempt a real symlink. Failures explicitly mention
enabling Developer Mode or switching to a fallback strategy.
* `junction` (default) – try a symlink first, then use junctions for directories
and hardlinks/copies for files when permissions prevent symlink creation.
* `copy` – skip symlinks altogether and mirror the target contents at the link
path.

Regardless of the chosen mode, relative symlinks use Unix-style `/` separators
internally which Windows resolves correctly.

License
Expand Down
69 changes: 69 additions & 0 deletions src/Symlinks/Processor/AbstractLinkProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);

namespace SomeWork\Symlinks\Processor;

use Composer\Util\Filesystem;
use SomeWork\Symlinks\Symlink;

abstract class AbstractLinkProcessor implements LinkProcessorInterface
{
protected Filesystem $filesystem;

public function __construct(Filesystem $filesystem)
{
$this->filesystem = $filesystem;
}

protected function createSymlink(Symlink $symlink): bool
{
if ($symlink->isAbsolutePath()) {
return symlink($symlink->getTarget(), $symlink->getLink());
}

return $this->filesystem->relativeSymlink($symlink->getTarget(), $symlink->getLink());
}

/**
* @param callable():bool $operation
* @return array{0: bool, 1: ?string}
*/
protected function callWithErrorCapture(callable $operation): array
{
$errorMessage = null;
set_error_handler(static function (int $severity, string $message) use (&$errorMessage): void {
$errorMessage = $message;
});

try {
$result = $operation();
} finally {
restore_error_handler();
}

return [$result, $errorMessage];
}

protected function formatGenericSymlinkError(Symlink $symlink, ?string $errorMessage): string
{
return sprintf(
'Failed to create symlink %s -> %s.%s',
$symlink->getLink(),
$symlink->getTarget(),
$this->formatDetails($errorMessage ? ['symlink: ' . $errorMessage] : [])
);
}

/**
* @param string[] $details
*/
protected function formatDetails(array $details): string
{
$filtered = array_filter($details);
if ($filtered === []) {
return '';
}

return ' Details: ' . implode('; ', $filtered) . '.';
}
}
11 changes: 11 additions & 0 deletions src/Symlinks/Processor/LinkProcessorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);

namespace SomeWork\Symlinks\Processor;

use SomeWork\Symlinks\Symlink;

interface LinkProcessorInterface
{
public function create(Symlink $symlink): bool;
}
23 changes: 23 additions & 0 deletions src/Symlinks/Processor/UnixLinkProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);

namespace SomeWork\Symlinks\Processor;

use SomeWork\Symlinks\RuntimeException;
use SomeWork\Symlinks\Symlink;

class UnixLinkProcessor extends AbstractLinkProcessor
{
public function create(Symlink $symlink): bool
{
[$result, $errorMessage] = $this->callWithErrorCapture(function () use ($symlink): bool {
return $this->createSymlink($symlink);
});

if (!$result) {
throw new RuntimeException($this->formatGenericSymlinkError($symlink, $errorMessage));
}

return true;
}
}
169 changes: 169 additions & 0 deletions src/Symlinks/Processor/WindowsLinkProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);

namespace SomeWork\Symlinks\Processor;

use SomeWork\Symlinks\RuntimeException;
use SomeWork\Symlinks\Symlink;

class WindowsLinkProcessor extends AbstractLinkProcessor
{
public function create(Symlink $symlink): bool
{
if ($symlink->getWindowsMode() === Symlink::WINDOWS_MODE_COPY) {
return $this->createCopy($symlink, Symlink::WINDOWS_MODE_COPY, null);
}

[$result, $errorMessage] = $this->callWithErrorCapture(function () use ($symlink): bool {
return $this->createSymlink($symlink);
});

if ($result) {
return true;
}

return $this->handleWindowsSymlinkFailure($symlink, $errorMessage);
}

private function handleWindowsSymlinkFailure(Symlink $symlink, ?string $errorMessage): bool
{
$mode = $symlink->getWindowsMode();

if ($mode === Symlink::WINDOWS_MODE_SYMLINK) {
throw new RuntimeException($this->formatWindowsSymlinkError($symlink, $errorMessage));
}

if ($mode === Symlink::WINDOWS_MODE_COPY) {
return $this->createCopy($symlink, $mode, $errorMessage);
}

if (is_dir($symlink->getTarget())) {
try {
$this->filesystem->junction($symlink->getTarget(), $symlink->getLink());

return true;
} catch (\Throwable $exception) {
throw new RuntimeException(
$this->formatWindowsJunctionError($symlink, $exception->getMessage(), $errorMessage),
(int) $exception->getCode(),
$exception
);
}
}

[$hardLinked, $hardLinkError] = $this->tryHardLink($symlink->getTarget(), $symlink->getLink());
if ($hardLinked) {
return true;
}

return $this->createCopy($symlink, $mode, $errorMessage, $hardLinkError);
}

/**
* @return array{0: bool, 1: ?string}
*/
private function tryHardLink(string $target, string $link): array
{
if (!function_exists('link')) {
return [false, 'link() function is not available'];
}

return $this->callWithErrorCapture(static function () use ($target, $link): bool {
return link($target, $link);
});
}

private function createCopy(
Symlink $symlink,
string $mode,
?string $symlinkError,
?string $hardLinkError = null
): bool {
try {
if ($this->filesystem->copy($symlink->getTarget(), $symlink->getLink())) {
return true;
}

$copyError = 'Filesystem::copy returned false';
} catch (\Throwable $exception) {
throw new RuntimeException(
$this->formatWindowsCopyError(
$symlink,
$mode,
$symlinkError,
$hardLinkError,
$exception->getMessage()
),
(int) $exception->getCode(),
$exception
);
}

throw new RuntimeException(
$this->formatWindowsCopyError($symlink, $mode, $symlinkError, $hardLinkError, $copyError)
);
}

private function formatWindowsSymlinkError(Symlink $symlink, ?string $errorMessage): string
{
return sprintf(
'Failed to create symlink %s -> %s. Enable Windows Developer Mode or configure extra.somework/composer-symlinks.windows-mode to "junction" or "copy".%s',
$symlink->getLink(),
$symlink->getTarget(),
$this->formatDetails($errorMessage ? ['symlink: ' . $errorMessage] : [])
);
}

private function formatWindowsJunctionError(Symlink $symlink, string $junctionError, ?string $symlinkError): string
{
$details = [
'junction: ' . $junctionError,
];

if ($symlinkError) {
$details[] = 'symlink: ' . $symlinkError;
}

return sprintf(
'Failed to create link %s -> %s using windows-mode "junction". Enable Windows Developer Mode or set windows-mode to "copy".%s',
$symlink->getLink(),
$symlink->getTarget(),
$this->formatDetails($details)
);
}

private function formatWindowsCopyError(
Symlink $symlink,
string $mode,
?string $symlinkError,
?string $hardLinkError,
?string $copyError
): string {
$details = [];

if ($symlinkError) {
$details[] = 'symlink: ' . $symlinkError;
}

if ($hardLinkError) {
$details[] = 'hardlink: ' . $hardLinkError;
}

if ($copyError) {
$details[] = 'copy: ' . $copyError;
}

$advice = $mode === Symlink::WINDOWS_MODE_COPY
? 'Enable Windows Developer Mode to allow native symlinks.'
: 'Enable Windows Developer Mode or set windows-mode to "copy".';

return sprintf(
'Failed to create link %s -> %s using windows-mode "%s". %s%s',
$symlink->getLink(),
$symlink->getTarget(),
$mode,
$advice,
$this->formatDetails($details)
);
}
}
17 changes: 17 additions & 0 deletions src/Symlinks/Symlink.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ class Symlink
{
protected string $target = '';
protected string $link = '';
public const WINDOWS_MODE_SYMLINK = 'symlink';
public const WINDOWS_MODE_JUNCTION = 'junction';
public const WINDOWS_MODE_COPY = 'copy';

protected bool $absolutePath = false;
protected bool $forceCreate = false;
protected string $windowsMode = self::WINDOWS_MODE_JUNCTION;

public function getTarget(): string
{
Expand Down Expand Up @@ -53,4 +58,16 @@ public function setForceCreate(bool $forceCreate): Symlink
$this->forceCreate = $forceCreate;
return $this;
}

public function getWindowsMode(): string
{
return $this->windowsMode;
}

public function setWindowsMode(string $windowsMode): self
{
$this->windowsMode = $windowsMode;

return $this;
}
}
Loading
Loading