You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Two Windows bugs, one of which was hiding the other.
Junctions aren't traversed when scanning view paths
TemplatesController and the TemplateFolder fieldtype both walk view.paths with RecursiveDirectoryIterator and FOLLOW_SYMLINKS. A Windows junction reports an lstat mode that is neither a link nor a directory, so hasChildren() takes its "not a link" branch, returns false, and never reaches the FOLLOW_SYMLINKS code path at all.
The upshot for a Windows user with a junction under resources/views: the junction is offered as a selectable template or folder in its own right, and the templates inside it don't show up. Real symlinks were always fine — only junctions were affected, which is what non-elevated Windows tooling tends to produce, Filesystem::link() included.
Fixed with a RecursiveDirectoryIterator subclass that falls back to is_dir(). The clearstatcache() is load-bearing: parent::hasChildren() has just lstat'd the path, and is_dir() would otherwise be served that cached result and answer false. Verified on Windows CI — this is php/php-src#9674 and can be dropped if that's ever fixed upstream.
The tests were leaking into each other
TemplatesTest and TemplateFolderTest shared tests/Fieldtypes/templates-test-tmp, and both tore it down with deleteDirectory(), which reaches for unlink(). On Windows unlink() refuses anything carrying the directory attribute, so symlinked and junctioned directories survived teardown along with every directory above them.
TemplateFolderTest sorts immediately before TemplatesTest and creates identically named links. Its leftovers survived into TemplatesTest, whose own mklink /J then failed against the paths that already existed — silently, since Filesystem::link() discards the exit code. So TemplatesTest has been asserting against its sibling's leftover symlinks since #10031, and its symlink coverage never once exercised the code it appeared to.
That's why this only surfaced when #15140 sharded the suite and split the two classes apart. Each class now gets its own directory and a teardown that can actually remove reparse points.
Notes
TemplatesTest deliberately keeps using app('files')->link(), so on Windows it creates junctions and covers the fix above, while TemplateFolderTest uses symlink() and covers real symlinks. Between them both link types are exercised on Windows CI.
Statamic\Facades\File::deleteDirectory() has the same Windows blind spot as Laravel's and will leak reparse points anywhere else it's used on a tree containing them. Left alone here — it's a wider change than this PR wants to be.
The probe commits are kept deliberately. They're a throwaway workflow that ran each hypothesis on Windows CI, and they record why the obvious fix doesn't work.
Not for merging. Confirms two things on Windows CI:
1. PHP does not recurse into mklink /J junctions via RecursiveDirectoryIterator
with FOLLOW_SYMLINKS, but does recurse into real symlinks.
2. deleteDirectory() cannot remove directory symlinks, so TemplateFolderTest
leaks them into the directory TemplatesTest reuses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Overrides hasChildren() to fall back to is_dir(), which is true for junctions.
Checks that recursion continues from the unresolved pathname so template names
keep their virtual prefix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The override had no effect, so log whether it is invoked at all, and try a
plain recursive scan that leans on is_dir() instead of SPL recursion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parent::hasChildren() lstat()s the path first, and is_dir() appears to reuse
that cached result. Compare is_dir() before and after clearing the cache.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both classes shared tests/Fieldtypes/templates-test-tmp, and their teardown used
deleteDirectory(), which cannot remove a symlinked or junctioned directory on
Windows. TemplateFolderTest's symlinks therefore survived into TemplatesTest,
which reused them instead of the links it thought it had created.
Give each class its own directory and a teardown that can remove reparse points.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RecursiveDirectoryIterator treats a junction as a leaf, so a junctioned folder
under a view path was offered as a bogus template and the templates inside it
were invisible. Both the Templates and Template Folder fieldtypes were affected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
is_link() lstat's the path, and PHP caches an lstat result as the stat result
when it decides the path isn't a link. A junction isn't reported as a link, so
the following is_dir() was served that bogus mode and answered false, sending
junctions to unlink(), which cannot remove them on Windows.
Try unlink() then rmdir() and recurse into whatever survives both, so no stat
is involved. Cover it with a test, since a teardown leak is otherwise silent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes, all following DeletesDirectories from #15145.
The purge deletes whole trees, so it uses the trait directly. The restore deletes a
computed diff of individual paths rather than a tree, so it takes the technique
instead: unlink() then rmdir(), never asking what the path is, since a junction
reports an lstat mode that makes is_dir() and is_link() contradict each other.
The scan mattered most. It used the same ambiguous answer to decide whether to
recurse, so a junction that said "directory" would have had its target's contents
recorded as skeleton paths - and the restore would then have deleted files from
wherever that junction pointed. It now descends only into paths that resolve to
somewhere inside the skeleton.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two Windows bugs, one of which was hiding the other.
Junctions aren't traversed when scanning view paths
TemplatesControllerand theTemplateFolderfieldtype both walkview.pathswithRecursiveDirectoryIteratorandFOLLOW_SYMLINKS. A Windows junction reports anlstatmode that is neither a link nor a directory, sohasChildren()takes its "not a link" branch, returns false, and never reaches theFOLLOW_SYMLINKScode path at all.The upshot for a Windows user with a junction under
resources/views: the junction is offered as a selectable template or folder in its own right, and the templates inside it don't show up. Real symlinks were always fine — only junctions were affected, which is what non-elevated Windows tooling tends to produce,Filesystem::link()included.Fixed with a
RecursiveDirectoryIteratorsubclass that falls back tois_dir(). Theclearstatcache()is load-bearing:parent::hasChildren()has justlstat'd the path, andis_dir()would otherwise be served that cached result and answer false. Verified on Windows CI — this is php/php-src#9674 and can be dropped if that's ever fixed upstream.The tests were leaking into each other
TemplatesTestandTemplateFolderTestsharedtests/Fieldtypes/templates-test-tmp, and both tore it down withdeleteDirectory(), which reaches forunlink(). On Windowsunlink()refuses anything carrying the directory attribute, so symlinked and junctioned directories survived teardown along with every directory above them.TemplateFolderTestsorts immediately beforeTemplatesTestand creates identically named links. Its leftovers survived intoTemplatesTest, whose ownmklink /Jthen failed against the paths that already existed — silently, sinceFilesystem::link()discards the exit code. SoTemplatesTesthas been asserting against its sibling's leftover symlinks since #10031, and its symlink coverage never once exercised the code it appeared to.That's why this only surfaced when #15140 sharded the suite and split the two classes apart. Each class now gets its own directory and a teardown that can actually remove reparse points.
Notes
TemplatesTestdeliberately keeps usingapp('files')->link(), so on Windows it creates junctions and covers the fix above, whileTemplateFolderTestusessymlink()and covers real symlinks. Between them both link types are exercised on Windows CI.Statamic\Facades\File::deleteDirectory()has the same Windows blind spot as Laravel's and will leak reparse points anywhere else it's used on a tree containing them. Left alone here — it's a wider change than this PR wants to be.The probe commits are kept deliberately. They're a throwaway workflow that ran each hypothesis on Windows CI, and they record why the obvious fix doesn't work.