From 70938c35cac9cf86e67cda1225e12224f47696b0 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Mon, 31 Aug 2026 07:57:36 +0300 Subject: [PATCH 1/2] Fix the test-at-point path when the project root is a symlink `projectile-compilation-dir` resolves symlinks and `buffer-file-name` does not, so when a project is reached through a symlinked path the two disagree about where the root is and the relative name climbs out of the project: go test -run '^TestApplyDiscount$' ./../../../../../tmp/checkout/pricing directory /tmp/checkout/pricing outside main module Every rule gets the same relative name, so this was not Go-specific. Anything under /tmp on macOS hits it, as does a symlinked ~/src. The name is still taken as spelled first, which is what keeps a file under a symlinked subdirectory working - resolving that one is what would send it out of the project. Only when the spelled name escapes do both sides get resolved, and that result is used only if it stays inside. --- projectile.el | 30 +++++++++++++++++++---- test/projectile-test-at-point-test.el | 34 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/projectile.el b/projectile.el index 3641647f9..b2d3c11ed 100644 --- a/projectile.el +++ b/projectile.el @@ -13543,6 +13543,28 @@ enclosing node whose type is in RULE's `:node-types', call the rule's name)) ;;;###autoload +(defun projectile--test-at-point-file-name (file dir) + "Return FILE spelled relative to DIR for a test-at-point command. + +Prefer the name as spelled. A file under a symlinked subdirectory of +the project resolves to somewhere outside it, so running it through +`file-truename' would produce a `../..' path for a file that is really +sitting right there. + +The spelled name is the wrong one in the mirror-image case: the project +itself reached through a symlink that `projectile-compilation-dir' has +already resolved. Then the two spellings disagree about the root, the +relative name climbs out of the project, and the test runner is handed a +path it rejects - which is what every project under /tmp looks like on +macOS. Fall back to resolving both, and keep that only if it does stay +inside DIR." + (let ((spelled (file-relative-name file dir))) + (if (not (string-prefix-p ".." spelled)) + spelled + (let ((resolved (file-relative-name (file-truename file) + (file-truename dir)))) + (if (string-prefix-p ".." resolved) spelled resolved))))) + (defun projectile-run-test-at-point (arg) "Run the test around point, if any. @@ -13570,13 +13592,11 @@ a prefix ARG you can edit the command before it's run." (let ((test-name (projectile--test-at-point-name rule))) (unless test-name (user-error "No test found at point")) - ;; The command runs in the compilation directory, so the file - ;; name is made relative to it as spelled - not through - ;; `file-truename', which would escape the project for a file - ;; under a symlinked subdirectory and yield a useless `../..' path. + ;; The command runs in the compilation directory, so the file name + ;; is made relative to it. (let ((command (funcall (plist-get rule :command-fn) test-name - (file-relative-name + (projectile--test-at-point-file-name buffer-file-name (projectile-compilation-dir)))) ;; The command was derived from the test at point, so the diff --git a/test/projectile-test-at-point-test.el b/test/projectile-test-at-point-test.el index 7809756e4..e30f8b6ea 100644 --- a/test/projectile-test-at-point-test.el +++ b/test/projectile-test-at-point-test.el @@ -342,6 +342,40 @@ (expect (hash-table-count projectile-test-cmd-map) :to-equal 0) (expect (hash-table-count projectile-project-command-history) :to-equal 0)))) +(describe "projectile--test-at-point-file-name" + (it "spells the file relative to the compilation directory" + (expect (projectile--test-at-point-file-name + "/home/me/app/test/foo_test.go" "/home/me/app/") + :to-equal "test/foo_test.go")) + + (it "keeps the spelling as given when the file is under a symlinked subdirectory" + ;; `file-truename' would send this outside the project; the name as + ;; spelled is both correct and what the runner can open. + (spy-on 'file-truename :and-call-fake + (lambda (f) (if (string-prefix-p "/home/me/app/vendor/" f) + (replace-regexp-in-string + "\\`/home/me/app/vendor/" "/elsewhere/" f) + f))) + (expect (projectile--test-at-point-file-name + "/home/me/app/vendor/lib/foo_test.go" "/home/me/app/") + :to-equal "vendor/lib/foo_test.go")) + + (it "resolves both when the project root itself is reached through a symlink" + ;; `projectile-compilation-dir' resolves the root, `buffer-file-name' + ;; does not, so the spelled name climbs out of the project and the + ;; runner is handed a path it rejects. + (spy-on 'file-truename :and-call-fake + (lambda (f) (replace-regexp-in-string "\\`/tmp/" "/private/tmp/" f))) + (expect (projectile--test-at-point-file-name + "/tmp/app/cart_test.go" "/private/tmp/app/") + :to-equal "cart_test.go")) + + (it "keeps the spelled name when resolving does not help either" + (spy-on 'file-truename :and-call-fake #'identity) + (expect (projectile--test-at-point-file-name + "/somewhere/else/foo_test.go" "/home/me/app/") + :to-equal "../../../somewhere/else/foo_test.go"))) + (provide 'projectile-test-at-point-test) (describe "Ruby test-at-point" From f8238d39e527ae2e6a7544faac1d749239fd3334 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Mon, 31 Aug 2026 07:58:37 +0300 Subject: [PATCH 2/2] Add CHANGELOG entry for the test-at-point symlink fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da6a540e8..f461460f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ ### Bugs fixed +- [#2183](https://github.com/bbatsov/projectile/pull/2183): `projectile-run-test-at-point` now builds a usable file path when the project is reached through a symlink (anything under `/tmp` on macOS, or a symlinked `~/src`), instead of one that climbs out of the project and gets rejected by the test runner. - [#2178](https://github.com/bbatsov/projectile/pull/2178): The file-extension filter of `projectile-replace` and friends is now shell-quoted. ag received it unquoted, so a `*.el` filter reached it as the regexp `.el$` and also matched files ending in `model` or `panel`; on the other tools an extension containing a quote broke the command outright. - [#2177](https://github.com/bbatsov/projectile/pull/2177): `projectile-ripgrep` no longer dies under zsh with `no matches found`. The `ripgrep` package runs its command line through the shell, and the ignore exclusions went in unquoted - harmless until the generated-directory defaults introduced a pattern with a `*` in it. - [#2172](https://github.com/bbatsov/projectile/pull/2172): Fix subproject detection in a Go workspace. A `go.work` names its modules as `./api`, and the leading `./` survived into the subproject name, which then matched none of the project's file paths - so `projectile-find-file-in-subproject` offered nothing at all in a Go monorepo. Members are now spelled relative to the project root whatever the manifest wrote.