Skip to content

Commit 20d42ec

Browse files
committed
[sync] Preserve relative packaged symlink targets (#185)
1 parent 462235f commit 20d42ec

4 files changed

Lines changed: 68 additions & 10 deletions

File tree

src/Filesystem/Filesystem.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,11 @@ public function remove(string|iterable $files): void
119119
*/
120120
public function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false): void
121121
{
122-
$this->filesystem->symlink($this->getAbsolutePath($originDir), $this->getAbsolutePath($targetDir), $copyOnWindows);
122+
$origin = Path::isAbsolute($originDir)
123+
? $this->getAbsolutePath($originDir)
124+
: $originDir;
125+
126+
$this->filesystem->symlink($origin, $this->getAbsolutePath($targetDir), $copyOnWindows);
123127
}
124128

125129
/**

src/Sync/PackagedDirectorySynchronizer.php

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,9 @@ public function synchronize(string $targetDir, string $packagePath, string $dire
8585
$entryName = $packagedEntry->getFilename();
8686
$targetLink = Path::makeAbsolute($entryName, $targetDir);
8787
$sourcePath = $packagedEntry->getRealPath();
88+
$isDirectory = $packagedEntry->isDir();
8889

89-
$this->processLink($entryName, $targetLink, $sourcePath, $result);
90+
$this->processLink($entryName, $targetLink, $sourcePath, $isDirectory, $result);
9091
}
9192

9293
return $result;
@@ -104,10 +105,11 @@ private function processLink(
104105
string $entryName,
105106
string $targetLink,
106107
string $sourcePath,
108+
bool $isDirectory,
107109
SynchronizeResult $result,
108110
): void {
109111
if (! $this->filesystem->exists($targetLink)) {
110-
$this->createNewLink($entryName, $targetLink, $sourcePath, $result);
112+
$this->createNewLink($entryName, $targetLink, $sourcePath, $isDirectory, $result);
111113

112114
return;
113115
}
@@ -118,7 +120,7 @@ private function processLink(
118120
return;
119121
}
120122

121-
$this->processExistingSymlink($entryName, $targetLink, $sourcePath, $result);
123+
$this->processExistingSymlink($entryName, $targetLink, $sourcePath, $isDirectory, $result);
122124
}
123125

124126
/**
@@ -133,9 +135,13 @@ private function createNewLink(
133135
string $entryName,
134136
string $targetLink,
135137
string $sourcePath,
138+
bool $isDirectory,
136139
SynchronizeResult $result,
137140
): void {
138-
$relativeSourcePath = $this->filesystem->makePathRelative($sourcePath, $this->filesystem->dirname($targetLink));
141+
$relativeSourcePath = $this->normalizeRelativeSourcePath(
142+
$this->filesystem->makePathRelative($sourcePath, $this->filesystem->dirname($targetLink)),
143+
$isDirectory,
144+
);
139145

140146
$this->filesystem->symlink($relativeSourcePath, $targetLink);
141147
$this->logger->info('Created link: ' . $entryName . ' -> ' . $relativeSourcePath);
@@ -168,12 +174,13 @@ private function processExistingSymlink(
168174
string $entryName,
169175
string $targetLink,
170176
string $sourcePath,
177+
bool $isDirectory,
171178
SynchronizeResult $result,
172179
): void {
173180
$linkPath = $this->filesystem->readlink($targetLink, true);
174181

175182
if (! $linkPath || ! $this->filesystem->exists($linkPath)) {
176-
$this->repairBrokenLink($entryName, $targetLink, $sourcePath, $result);
183+
$this->repairBrokenLink($entryName, $targetLink, $sourcePath, $isDirectory, $result);
177184

178185
return;
179186
}
@@ -194,13 +201,34 @@ private function repairBrokenLink(
194201
string $entryName,
195202
string $targetLink,
196203
string $sourcePath,
204+
bool $isDirectory,
197205
SynchronizeResult $result,
198206
): void {
199207
$this->filesystem->remove($targetLink);
200208
$this->logger->notice('Existing link is broken: ' . $entryName . ' (removing and recreating)');
201209
$result->addRemovedBrokenLink($entryName);
202210

203-
$this->createNewLink($entryName, $targetLink, $sourcePath, $result);
211+
$this->createNewLink($entryName, $targetLink, $sourcePath, $isDirectory, $result);
212+
}
213+
214+
/**
215+
* Normalizes a relative symlink target emitted by Symfony path helpers.
216+
*
217+
* Files MUST NOT keep the trailing slash that directory-oriented path helpers
218+
* may append, otherwise link creation treats them as non-existent directories.
219+
*
220+
* @param string $relativeSourcePath Relative path from the consumer target directory to the packaged source
221+
* @param bool $isDirectory Whether the packaged source is a directory
222+
*
223+
* @return string Normalized relative symlink target
224+
*/
225+
private function normalizeRelativeSourcePath(string $relativeSourcePath, bool $isDirectory): string
226+
{
227+
if ($isDirectory) {
228+
return $relativeSourcePath;
229+
}
230+
231+
return rtrim($relativeSourcePath, '/');
204232
}
205233

206234
/**

tests/Filesystem/FilesystemTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,30 @@ public function symlinkAndReadlinkWillUseAbsolutePaths(): void
208208
self::assertSame(realpath($origin), $this->filesystem->readlink($target, true));
209209
}
210210

211+
/**
212+
* @return void
213+
*/
214+
#[Test]
215+
public function symlinkWillPreserveRelativeOrigins(): void
216+
{
217+
$currentWorkingDirectory = getcwd();
218+
$origin = $this->tempDir . '/origin';
219+
$target = $this->tempDir . '/target';
220+
$relativeOrigin = 'origin';
221+
222+
$this->filesystem->mkdir($origin);
223+
chdir($this->tempDir);
224+
225+
try {
226+
$this->filesystem->symlink($relativeOrigin, $target);
227+
} finally {
228+
chdir($currentWorkingDirectory);
229+
}
230+
231+
self::assertSame($relativeOrigin, $this->filesystem->readlink($target));
232+
self::assertSame(realpath($origin), $this->filesystem->readlink($target, true));
233+
}
234+
211235
/**
212236
* @return void
213237
*/

tests/Sync/PackagedDirectorySynchronizerTest.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ public function synchronizeWillCreateLinksForTopLevelFiles(): void
275275
$targetLink = '/consumer/.agents/agents/issue-editor.md';
276276
$relativeEntryPath = '../../../package/.agents/agents/issue-editor.md';
277277

278-
$this->mockFinder($this->createEntry('issue-editor.md', $entryPath));
278+
$this->mockFinder($this->createEntry('issue-editor.md', $entryPath, false));
279279

280280
$this->filesystem->exists('/package/.agents/agents')
281281
->willReturn(true);
@@ -287,7 +287,7 @@ public function synchronizeWillCreateLinksForTopLevelFiles(): void
287287
->willReturn('/consumer/.agents/agents')
288288
->shouldBeCalledOnce();
289289
$this->filesystem->makePathRelative($entryPath, '/consumer/.agents/agents')
290-
->willReturn($relativeEntryPath)
290+
->willReturn($relativeEntryPath . '/')
291291
->shouldBeCalledOnce();
292292
$this->filesystem->symlink($relativeEntryPath, $targetLink)
293293
->shouldBeCalledOnce();
@@ -329,13 +329,15 @@ private function mockFinder(SplFileInfo ...$entries): void
329329
*
330330
* @return SplFileInfo
331331
*/
332-
private function createEntry(string $entryName, string $sourcePath): SplFileInfo
332+
private function createEntry(string $entryName, string $sourcePath, bool $isDirectory = true): SplFileInfo
333333
{
334334
$entry = $this->prophesize(SplFileInfo::class);
335335
$entry->getFilename()
336336
->willReturn($entryName);
337337
$entry->getRealPath()
338338
->willReturn($sourcePath);
339+
$entry->isDir()
340+
->willReturn($isDirectory);
339341

340342
return $entry->reveal();
341343
}

0 commit comments

Comments
 (0)