Skip to content

hugo --gc deletes in-use image cache files when on-disk path case differs from the cache key (case-insensitive filesystems) #15101

Description

@cybernagl

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.getOrCreatec.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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions