What happens
On a case-insensitive filesystem (macOS APFS, Windows), hugo --gc deletes image cache files under resources/_gen that the build actively uses, if the on-disk path case differs from the cache key. The build itself works fine (the case-insensitive filesystem resolves the read), but the pruner's usage check is a case-sensitive string comparison, so the just-used file is considered unused and removed.
The relevant code is in cache/filecache/filecache_pruner.go (unchanged on master):
if !shouldRemove && c.entryLocker.seen.Len() > 0 {
// Remove it if it's not been touched/used in the last build.
shouldRemove = !c.entryLocker.seen.Has(name)
}
seen contains the cache key (for images: the lowercased target path, via ImageCache.getOrCreate → c.fcache.ReadOrCreate(relTargetPath, …)), while name is the real on-disk name from the filesystem walk. If they differ only in case, seen.Has(name) is false and the file is deleted.
Why the case can differ in practice
Page paths have been lowercased since the v0.123 page-system rewrite, so cache keys for page-bundle images are lowercase. Caches created by older Hugo versions preserve the source directory case (e.g. resources/_gen/images/collections/PastFuture/…). Such caches are common in the wild: committed to git for CI cold-start speed, restored by CI cache plugins, or simply carried forward on a developer machine across Hugo upgrades.
The result is a permanent rebuild/evict cycle: --gc deletes the in-use entries → the next build silently re-processes all of them → the new files land inside the surviving mixed-case directory (the prune walk visits directories before deleting their files, so the directory is never seen empty and survives; on a case-insensitive filesystem the re-created file then lands in it) → the next --gc deletes them again. On the real site where we found this, every build re-scaled 267 images (~35 s per build, forever).
Minimal reproduction (macOS)
hugo new site repro && cd repro
mkdir -p content/MyBundle layouts/_default
printf 'baseURL = "https://example.org/"\ndisableKinds = ["taxonomy", "term", "rss", "sitemap"]\n' > hugo.toml
printf -- '---\ntitle: My Bundle\n---\n' > content/MyBundle/index.md
cp /path/to/any.jpeg content/MyBundle/cover.jpeg
printf '{{ with .Resources.GetMatch "cover.jpeg" }}{{ with .Resize "100x" }}<img src="{{ .RelPermalink }}">{{ end }}{{ end }}' > layouts/_default/single.html
printf 'home' > layouts/_default/home.html
hugo # creates resources/_gen/images/mybundle/cover_hu_<hash>.jpeg
# simulate a cache written by an older Hugo (or copied from elsewhere):
mv resources/_gen/images/mybundle resources/_gen/images/tmp
mv resources/_gen/images/tmp resources/_gen/images/MyBundle
hugo --gc # build succeeds (16 ms, cache hit), but reports "Cleaned 1"
find resources -type f # -> empty: the file the build just used is gone
hugo # re-processes the image (474 ms) — and writes it back
# into the surviving MyBundle/ dir, so the cycle repeats
Observed output of the --gc build:
Processed images │ 1
Cleaned │ 1
Total in 16 ms
A cache file that was read 16 ms earlier is deleted as "not been touched/used in the last build".
What I expected
--gc should never delete a cache entry that the same build read. On case-insensitive filesystems the usage check should match the way the filesystem resolved the read — e.g. compare case-insensitively (or normalize walk names against the seen set) when the cache filesystem is case-insensitive. Renaming the on-disk entry to the key's case would even self-heal old caches.
Version
hugo v0.164.0+extended+withdeploy darwin/arm64 BuildDate=2026-07-06T16:39:30Z VendorInfo=Homebrew
The pruner code is identical on current master.
What happens
On a case-insensitive filesystem (macOS APFS, Windows),
hugo --gcdeletes image cache files underresources/_genthat the build actively uses, if the on-disk path case differs from the cache key. The build itself works fine (the case-insensitive filesystem resolves the read), but the pruner's usage check is a case-sensitive string comparison, so the just-used file is considered unused and removed.The relevant code is in
cache/filecache/filecache_pruner.go(unchanged on master):seencontains the cache key (for images: the lowercased target path, viaImageCache.getOrCreate→c.fcache.ReadOrCreate(relTargetPath, …)), whilenameis the real on-disk name from the filesystem walk. If they differ only in case,seen.Has(name)is false and the file is deleted.Why the case can differ in practice
Page paths have been lowercased since the v0.123 page-system rewrite, so cache keys for page-bundle images are lowercase. Caches created by older Hugo versions preserve the source directory case (e.g.
resources/_gen/images/collections/PastFuture/…). Such caches are common in the wild: committed to git for CI cold-start speed, restored by CI cache plugins, or simply carried forward on a developer machine across Hugo upgrades.The result is a permanent rebuild/evict cycle:
--gcdeletes the in-use entries → the next build silently re-processes all of them → the new files land inside the surviving mixed-case directory (the prune walk visits directories before deleting their files, so the directory is never seen empty and survives; on a case-insensitive filesystem the re-created file then lands in it) → the next--gcdeletes them again. On the real site where we found this, every build re-scaled 267 images (~35 s per build, forever).Minimal reproduction (macOS)
Observed output of the
--gcbuild:A cache file that was read 16 ms earlier is deleted as "not been touched/used in the last build".
What I expected
--gcshould never delete a cache entry that the same build read. On case-insensitive filesystems the usage check should match the way the filesystem resolved the read — e.g. compare case-insensitively (or normalize walk names against theseenset) when the cache filesystem is case-insensitive. Renaming the on-disk entry to the key's case would even self-heal old caches.Version
The pruner code is identical on current master.