From 039291a8782d8924651e512c60b1bc6436a6bbce Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 29 Sep 2026 18:49:39 +0300 Subject: [PATCH 1/2] [Fix #2193] Judge dirconfig staleness by when the cache was filled projectile-maybe-invalidate-cache compared .projectile against the persistent cache file, and file-newer-than-file-p is true when that file doesn't exist. Since 2.9.0 made the cache in-memory by default no such file is written, so every find-file in a project with a .projectile threw the cache away and re-indexed. The in-memory cache already records when it was filled, so compare against that, fall back to the cache file only when nothing is cached in memory, and treat no cache at all as nothing to invalidate. --- projectile.el | 22 ++++++++++++++-- test/projectile-root-test.el | 50 ++++++++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/projectile.el b/projectile.el index 1c4d2509c..11abeb772 100644 --- a/projectile.el +++ b/projectile.el @@ -2588,10 +2588,28 @@ is tracked in `projectile--current-project'." (run-hook-with-args 'projectile-project-changed-functions project-root previous)))))) +(defun projectile--dirconfig-newer-than-cache-p (project-root) + "Return non-nil if PROJECT-ROOT's dirconfig changed after its files were cached. +When the files are cached in memory, what counts is when that cache was +filled; otherwise it's the persistent cache file, if there is one. With +no cache at all there is nothing to be stale." + (let ((dirconfig (projectile-dirconfig-file project-root)) + (cache-time (gethash project-root projectile-projects-cache-time))) + (if cache-time + ;; The cache time is in whole seconds, so an edit in the second the + ;; cache was filled counts as newer: re-indexing once too often + ;; beats serving a file list that ignores the edit. + (when-let* ((attributes (file-attributes dirconfig))) + (time-less-p cache-time (file-attribute-modification-time attributes))) + (let ((cache-file (projectile-project-cache-file project-root))) + (and (file-exists-p cache-file) + (file-newer-than-file-p dirconfig cache-file)))))) + (defun projectile-maybe-invalidate-cache (force) "Invalidate if FORCE or project's dirconfig newer than cache." - (when (or force (file-newer-than-file-p (projectile-dirconfig-file) - (projectile-project-cache-file))) + (when (or force + (when-let* ((project-root (projectile-project-root))) + (projectile--dirconfig-newer-than-cache-p project-root))) (projectile-invalidate-cache nil))) diff --git a/test/projectile-root-test.el b/test/projectile-root-test.el index 1b1687c15..e80d9a777 100644 --- a/test/projectile-root-test.el +++ b/test/projectile-root-test.el @@ -56,8 +56,54 @@ (expect (projectile-maybe-invalidate-cache t) :to-be-truthy)) (it "should invalidate cache if dirconfig is newer than cache" (spy-on 'projectile-invalidate-cache :and-return-value t) - (spy-on 'file-newer-than-file-p :and-return-value t) - (expect (projectile-maybe-invalidate-cache nil) :to-be-truthy))) + (spy-on 'projectile--dirconfig-newer-than-cache-p :and-return-value t) + (expect (projectile-maybe-invalidate-cache nil) :to-be-truthy)) + (it "leaves an in-memory cache alone when the dirconfig predates it (#2193)" + (projectile-test-with-stub-root "project" (".projectile") + (let ((root (projectile-project-root))) + (set-file-times (projectile-dirconfig-file root) (seconds-to-time 1000)) + (puthash root 2000 projectile-projects-cache-time) + (spy-on 'projectile-invalidate-cache) + (projectile-maybe-invalidate-cache nil) + (expect 'projectile-invalidate-cache :not :to-have-been-called))))) + +(describe "projectile--dirconfig-newer-than-cache-p" + :var (root) + (it "keeps an in-memory cache filled after the dirconfig was written" + (projectile-test-with-stub-root "project" (".projectile") + (setq root (projectile-project-root)) + (set-file-times (projectile-dirconfig-file root) (seconds-to-time 1000)) + (puthash root 2000 projectile-projects-cache-time) + ;; No cache file: the in-memory cache never writes one. + (expect (file-exists-p (projectile-project-cache-file root)) :to-be nil) + (expect (projectile--dirconfig-newer-than-cache-p root) :to-be nil))) + + (it "detects a dirconfig edited after the in-memory cache was filled" + (projectile-test-with-stub-root "project" (".projectile") + (setq root (projectile-project-root)) + (set-file-times (projectile-dirconfig-file root) (seconds-to-time 3000)) + (puthash root 2000 projectile-projects-cache-time) + (expect (projectile--dirconfig-newer-than-cache-p root) :to-be-truthy))) + + (it "falls back to the cache file when nothing is cached in memory" + (projectile-test-with-stub-root "project" (".projectile" ".projectile-cache.eld") + (setq root (projectile-project-root)) + (set-file-times (projectile-project-cache-file root) (seconds-to-time 2000)) + (set-file-times (projectile-dirconfig-file root) (seconds-to-time 3000)) + (expect (projectile--dirconfig-newer-than-cache-p root) :to-be-truthy) + (set-file-times (projectile-dirconfig-file root) (seconds-to-time 1000)) + (expect (projectile--dirconfig-newer-than-cache-p root) :to-be nil))) + + (it "has nothing to invalidate when there is no cache at all" + (projectile-test-with-stub-root "project" (".projectile") + (setq root (projectile-project-root)) + (expect (projectile--dirconfig-newer-than-cache-p root) :to-be nil))) + + (it "has nothing to invalidate when there is no dirconfig" + (projectile-test-with-stub-root "project" ("a.el") + (setq root (projectile-project-root)) + (puthash root 2000 projectile-projects-cache-time) + (expect (projectile--dirconfig-newer-than-cache-p root) :to-be nil)))) (describe "projectile-root-top-down" (it "identifies the root directory of a project by top-down search" From eb643baa8726ab7ee25fc739aabfbab0bb007139 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 29 Sep 2026 18:50:07 +0300 Subject: [PATCH 2/2] Add CHANGELOG entry for the dirconfig cache fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33df6becf..851f13971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ ### Bugs fixed +- [#2196](https://github.com/bbatsov/projectile/pull/2196): With the default in-memory cache, a project with a `.projectile` no longer throws its cache away and re-indexes on every `projectile-find-file` ([#2193](https://github.com/bbatsov/projectile/issues/2193)). - [#2195](https://github.com/bbatsov/projectile/pull/2195): `projectile-dispatch` can be extended with `transient-insert-suffix`/`transient-append-suffix` as soon as `transient` is loaded, instead of failing with "Not a transient prefix command" until the menu had been opened once ([#2194](https://github.com/bbatsov/projectile/discussions/2194)). - [#2191](https://github.com/bbatsov/projectile/pull/2191): The test suite passes under native compilation with an empty eln cache, as in distro package builds, instead of failing two dirconfig specs with `native-lisp-load-failed` ([#2100](https://github.com/bbatsov/projectile/issues/2100)). - [#2190](https://github.com/bbatsov/projectile/pull/2190): Alien indexing no longer hands git exclude pathspecs to an `fd` whose `projectile-git-fd-args` lacks `--strip-cwd-prefix`, which made it fail with `No valid search paths given` ([#2187](https://github.com/bbatsov/projectile/issues/2187)).