From 69adda8f55aaeedcee77948318c3e389665019db Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sun, 26 Apr 2026 07:14:41 +0100 Subject: Memoize per-function nil results in the root cache (#1836) Previously a root function returning nil stored nil in the cache, which is indistinguishable from a missing entry, so on the next call the function was re-run. When a project's root is found by the third function in projectile-project-root-functions, the first two re-walked the tree on every call - and projectile-project-root is hot in mode-line / company / spaceline updates. Store the symbol 'none for unsuccessful entries and treat it as a cache hit on lookup. The overall-failure marker already used 'none in a separate slot, so this just extends the same convention to the per-function entries. --- CHANGELOG.md | 1 + projectile.el | 15 ++++++++++----- test/projectile-test.el | 26 +++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e7a67..a906264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### Bugs fixed * [#1211](https://github.com/bbatsov/projectile/issues/1211): Fix file-local `projectile-project-root` overrides being ignored after the first buffer in a directory was visited. The cache used `(default-directory)` as part of the key but `projectile-root-local` reads a buffer-local variable, so two buffers in the same directory with different overrides got the first buffer's answer. Results from `projectile-root-local` are no longer cached. +* [#1836](https://github.com/bbatsov/projectile/issues/1836): Memoize per-function `nil` results in the project root cache. Previously, when an early entry in `projectile-project-root-functions` returned nil, the nil was indistinguishable from a missing cache entry, so the function was re-run on every `projectile-project-root` call. With many entries this could cost several full directory walks per call. A `'none` sentinel is now stored for unsuccessful entries. * [#1508](https://github.com/bbatsov/projectile/issues/1508): Fix dirconfig parser silently treating lines as ignore patterns when the `+`/`-`/`!` prefix or the comment character is preceded by whitespace; leading spaces and tabs are now skipped before prefix dispatch. * Fix `projectile-files-via-ext-command` executing empty string as shell command for non-git VCS sub-projects. * Fix `projectile-select-files` crashing on filenames with regexp metacharacters by using `string-search` instead of `string-match`. diff --git a/projectile.el b/projectile.el index 0530925..d88819f 100644 --- a/projectile.el +++ b/projectile.el @@ -1482,17 +1482,22 @@ If DIR is not supplied it's set to the current directory by default." ;; `projectile-root-local' reads a buffer-local variable rather ;; than inspecting DIR, so its result must not be cached - two ;; buffers in the same directory can legitimately disagree. + ;; For other functions, both successes and per-function failures + ;; (stored as the 'none sentinel) are memoized, so functions + ;; earlier in the list that returned nil aren't re-walked on + ;; every call. (seq-some (lambda (func) (if (eq func 'projectile-root-local) (funcall func dir) (let* ((cache-key (cons func dir)) (cache-value (gethash cache-key projectile-project-root-cache))) - (if (and cache-value (file-exists-p cache-value)) - cache-value - (let ((value (funcall func (file-truename dir)))) - (puthash cache-key value projectile-project-root-cache) - value))))) + (cond + ((eq cache-value 'none) nil) + ((and cache-value (file-exists-p cache-value)) cache-value) + (t (let ((value (funcall func (file-truename dir)))) + (puthash cache-key (or value 'none) projectile-project-root-cache) + value)))))) projectile-project-root-functions) ;; if we get here, we have failed to find a root by all ;; conventional means, and we assume the failure isn't transient diff --git a/test/projectile-test.el b/test/projectile-test.el index 52f24b1..88d1da7 100644 --- a/test/projectile-test.el +++ b/test/projectile-test.el @@ -1283,7 +1283,31 @@ Just delegates OPERATION and ARGS for all operations except for`shell-command`'. ;; Second "buffer": same default-directory, different override. ;; The cache must not return the previous buffer's answer. (let ((projectile-project-root beta-root)) - (expect (projectile-project-root) :to-equal beta-root))))))) + (expect (projectile-project-root) :to-equal beta-root)))))) + + (it "caches per-function nil results so earlier misses aren't re-walked (#1836)" + (projectile-test-with-sandbox + (projectile-test-with-files + ("projectA/.git/" + "projectA/src/") + (let* ((calls 0) + (always-nil (lambda (_dir) (cl-incf calls) nil)) + (projectile-project-root-functions + (list always-nil 'projectile-root-bottom-up)) + (projectile-project-root-files-bottom-up '(".git")) + (dir (expand-file-name "projectA/src/")) + (projectile-project-root-cache (make-hash-table :test 'equal))) + ;; first call: always-nil is invoked, then bottom-up wins + (expect (file-truename (projectile-project-root dir)) + :to-equal (file-truename (expand-file-name "projectA/"))) + (expect calls :to-equal 1) + ;; second call: the cached 'none for always-nil should be honoured + (expect (file-truename (projectile-project-root dir)) + :to-equal (file-truename (expand-file-name "projectA/"))) + (expect calls :to-equal 1) + ;; and the cache holds the 'none sentinel for the failing function + (expect (gethash (cons always-nil dir) projectile-project-root-cache) + :to-be 'none)))))) (describe "projectile-file-exists-p" (it "returns t if file exists" -- cgit v1.0