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.