branch: elpa/projectile
commit 6db2b0383efd21a233de2ed4bdd3512ea2ea902c
Author: Bozhidar Batsov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>

    Re-run function-valued lifecycle commands instead of caching them 
[#1549][#1676]
    
    A project type can register a lifecycle command as a function - CMake's
    preset pickers do this, and users can too via :run/:test. The function was
    resolved once and its returned string frozen in the per-project command
    cache, so a picker only prompted on the first run and reused the stale
    choice forever after.
    
    Skip caching for function-derived commands (a new :no-cache path in
    projectile--run-project-cmd, driven by projectile--phase-command-dynamic-p)
    so they re-resolve on every run and can prompt again. Their executed result
    still feeds the command history, so projectile-repeat-last-command keeps
    working. String commands are unaffected and still cached as before.
---
 CHANGELOG.md                     |  4 +++
 projectile.el                    | 26 +++++++++++++--
 test/projectile-commands-test.el | 70 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 98 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33722fe824..cd989ff4d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -44,6 +44,10 @@
 * Add `projectile-frecency-max-projects` (default 100), which caps how many 
projects' frecency history is kept so the store can't grow without bound.
 * `projectile-replace-scan-chunk-size` is now a user-facing `defcustom` (was 
an internal variable).
 
+### Bugs fixed
+
+* [#1549](https://github.com/bbatsov/projectile/issues/1549), 
[#1676](https://github.com/bbatsov/projectile/issues/1676): Re-invoke a 
function-valued lifecycle command (e.g. a CMake preset picker or a project 
type's `:run`/`:test` function) on every run instead of freezing its first 
result in the command cache, so it can prompt again.
+
 ## 3.1.0 (2026-07-04)
 
 ### New features
diff --git a/projectile.el b/projectile.el
index 1bdb51dc82..77ce578503 100644
--- a/projectile.el
+++ b/projectile.el
@@ -10232,10 +10232,13 @@ Duplicates are handled according to 
`projectile-cmd-hist-ignoredups'."
    (t (ring-insert history command))))
 
 (cl-defun projectile--run-project-cmd
-    (command command-map &key command-type show-prompt prompt-prefix 
save-buffers use-comint-mode buffer-name-function)
+    (command command-map &key command-type show-prompt prompt-prefix 
save-buffers use-comint-mode buffer-name-function no-cache)
   "Run a project COMMAND, typically a test- or compile command.
 
 Cache the COMMAND for later use inside the hash-table COMMAND-MAP.
+With NO-CACHE non-nil the command is not stored in COMMAND-MAP (though
+its result still enters the command history); this is for
+function-derived commands that must be re-resolved on every run.
 
 COMMAND-TYPE, when non-nil, is the lifecycle command type symbol
 \(e.g. `compile' or `test') and is used to keep a per-type command
@@ -10265,7 +10268,11 @@ The command actually run is returned."
          (compilation-buffer-name-function compilation-buffer-name-function)
          (compilation-save-buffers-predicate 
compilation-save-buffers-predicate))
     (when command-map
-      (puthash default-directory command command-map)
+      ;; A function-derived command (NO-CACHE) is re-resolved on every run so
+      ;; it can prompt again (e.g. a CMake preset picker); don't freeze its
+      ;; result in the cache, only feed it to the history below.
+      (unless no-cache
+        (puthash default-directory command command-map))
       ;; Record into the combined per-project history (read by
       ;; `projectile-repeat-last-command') and, when known, into the
       ;; per-type history used for this command's prompt.
@@ -10331,6 +10338,20 @@ The command actually run is returned."
   :type 'boolean
   :package-version '(projectile . "2.5.0"))
 
+(defun projectile--phase-command-dynamic-p (phase)
+  "Non-nil when PHASE's command comes from a function for the current project.
+A project type can register a lifecycle command as a function (e.g. the
+CMake preset pickers, or a user's own `:test'/`:run' function).  Such a
+command is meant to be re-invoked - and may prompt - on every run, so its
+result must not be frozen in the command cache after the first run.  A
+`.dir-locals.el' override always wins and is a plain string, so it is
+never treated as dynamic."
+  (let ((descriptor (projectile--phase-descriptor phase)))
+    (and (not (symbol-value (plist-get descriptor :dir-local-var)))
+         (functionp
+          (plist-get (alist-get (projectile-project-type) 
projectile-project-types)
+                     (intern (format "%s-command" phase)))))))
+
 (defun projectile--run-lifecycle-phase (phase show-prompt)
   "Run the current project's command for lifecycle PHASE.
 PHASE is a symbol naming an entry of `projectile--lifecycle-phases'.
@@ -10346,6 +10367,7 @@ With SHOW-PROMPT non-nil force prompting for the 
command, as in
                                  :show-prompt show-prompt
                                  :prompt-prefix (plist-get descriptor :prompt)
                                  :save-buffers (plist-get descriptor 
:save-buffers)
+                                 :no-cache 
(projectile--phase-command-dynamic-p phase)
                                  :use-comint-mode (symbol-value (plist-get 
descriptor :use-comint-var)))))
 
 ;;;###autoload
diff --git a/test/projectile-commands-test.el b/test/projectile-commands-test.el
index b9c2253aa7..9ed1c65199 100644
--- a/test/projectile-commands-test.el
+++ b/test/projectile-commands-test.el
@@ -178,6 +178,30 @@
     ;; the compilation dir
     (spy-on 'file-directory-p :and-return-value t))
 
+  (it "with :no-cache does not cache the command but still records history"
+    (let ((command-map (make-hash-table :test 'equal))
+          (projectile-project-command-history (make-hash-table :test 'equal))
+          (projectile-project-root "/a/random/path"))
+      (projectile--run-project-cmd "cmake . --preset debug" command-map
+                                   :command-type 'configure :no-cache t)
+      ;; nothing frozen in the cache...
+      (expect (hash-table-count command-map) :to-equal 0)
+      ;; ...but the executed command still feeds both histories, so
+      ;; projectile-repeat-last-command keeps working
+      (expect (ring-elements
+               (projectile--get-command-history projectile-project-root 
'configure))
+              :to-equal '("cmake . --preset debug"))
+      (expect (ring-elements
+               (projectile--get-command-history projectile-project-root))
+              :to-equal '("cmake . --preset debug"))))
+
+  (it "caches the command by default"
+    (let ((command-map (make-hash-table :test 'equal))
+          (projectile-project-command-history (make-hash-table :test 'equal))
+          (projectile-project-root "/a/random/path"))
+      (projectile--run-project-cmd "make" command-map :command-type 'compile)
+      (expect (hash-table-count command-map) :to-equal 1)))
+
   (it "projectile-cmd-hist-ignoredups set to t"
 
     (let ((command-map (make-hash-table :test 'equal))
@@ -257,6 +281,52 @@
     (let ((projectile-project-types '((test-type))))
       (expect (projectile-default-generic-command 'test-type 'compile-command) 
:to-equal nil))))
 
+(describe "function-valued lifecycle commands"
+  ;; #1549 / #1676: a project type can register a lifecycle command as a
+  ;; function (e.g. CMake's preset pickers, or a user's own :run/:test
+  ;; function).  It must be re-invoked - and may prompt - on every run rather
+  ;; than being frozen once its first result lands in the command cache.
+  (before-each
+    (spy-on 'projectile-run-compilation)
+    (spy-on 'file-directory-p :and-return-value t)
+    (spy-on 'projectile-acquire-root :and-return-value "/proj/")
+    (spy-on 'projectile-project-root :and-return-value "/proj/")
+    (spy-on 'projectile-compilation-dir :and-return-value "/proj/")
+    (spy-on 'projectile-project-name :and-return-value "proj"))
+
+  (it "re-invokes a function :run command each run and never caches it"
+    (let ((projectile-project-types projectile-project-types)
+          (projectile-run-cmd-map (make-hash-table :test 'equal))
+          (projectile-project-run-cmd nil)
+          (projectile-per-project-compilation-buffer nil)
+          (compilation-read-command nil))
+      (defun projectile-test--dyn-run () "run-it")
+      (projectile-register-project-type 'dyn-run-project '("dyn.marker")
+                                        :run 'projectile-test--dyn-run)
+      (spy-on 'projectile-project-type :and-return-value 'dyn-run-project)
+      (spy-on 'projectile-test--dyn-run :and-call-through)
+      (projectile-run-project nil)
+      (projectile-run-project nil)
+      ;; the function is consulted on BOTH runs...
+      (expect 'projectile-test--dyn-run :to-have-been-called-times 2)
+      ;; ...and nothing is frozen in the run-command cache
+      (expect (hash-table-count projectile-run-cmd-map) :to-equal 0)
+      (expect 'projectile-run-compilation :to-have-been-called-with "run-it" 
nil)))
+
+  (it "re-runs CMake preset selection on every configure"
+    (let ((projectile-configure-cmd-map (make-hash-table :test 'equal))
+          (projectile-project-configure-cmd nil)
+          (projectile-per-project-compilation-buffer nil)
+          (compilation-read-command nil))
+      (spy-on 'projectile-project-type :and-return-value 'cmake)
+      (spy-on 'projectile--cmake-select-command
+              :and-return-value projectile--cmake-no-preset)
+      (projectile-configure-project nil)
+      (projectile-configure-project nil)
+      ;; the preset picker runs on each configure, not just the first
+      (expect 'projectile--cmake-select-command :to-have-been-called-times 2)
+      (expect (hash-table-count projectile-configure-cmd-map) :to-equal 0))))
+
 (describe "display-variant commands"
   ;; The other-window/-frame variants share a helper and differ only in the
   ;; display function they hand off to.

Reply via email to