Subject: Re: bug#41572: 28.0.50; [PATCH] Support plain project marked with file .emacs-project

From: Dmitry Gutov <[email-masked]>
Date: Sun, 26 Sep 2021 03:22:33 +0300

Another issue is people working on monorepos (often backed by Git) 
sometimes want to split them into separate projects.

Either because they only work on a certain part of the whole project, or 
because they want to use Eglot, and that package has tied LSP roots 
detection to project roots (but the language server might expect some 
project configuration files in the root which reside in a subdirectory).

Here's a typical question/request for this functionality: 
https://emacs.stackexchange.com/questions/58463/project-el-override-project-root-with-dir-local-var/58468

Patch attached, it adds new user option project-vc-subprojects which 
alters the root-finding logic and cuts out the subproject contents from 
the parent project with the mechanism of "ignores".

The latter capability (excluding subproject's files) informed the choice 
of the approach. Which is altering the vc project backend's behavior, 
rather that offering this feature through another backend, like the one 
added in the previous patch, for example.

I don't know if all users of this feature will want them excluded, 
though. The attached implementation does, and maybe another option could 
be added to disable this.

Or we could drop this part of the behavior, insisting that users who 
want it could add the corresponding entries to project-vc-ignores. This 
way they would be listing the subprojects twice, however. And the 
project-vc-merge-submodules=nil behavior matches the other option 
(submodule files are excluded from the parent).

Again, thoughts welcome.
diff --git a/lisp/progmodes/project.el b/lisp/progmodes/project.el
index 57a961c260..e45667ed15 100644
--- a/lisp/progmodes/project.el
+++ b/lisp/progmodes/project.el
@@ -369,6 +369,23 @@ project-vc-merge-submodules
   :package-version '(project . "0.2.0")
   :safe #'booleanp)
 
+(defcustom project-vc-subprojects nil
+  "List of relative directory names to consider separate projects.
+Each entry should a string, name of a subproject root directory
+relative to the VC root.
+
+Every entry in this list will be considered a separate project
+for the purposes of files listings, searches, etc, and the parent
+project will exclude those files.
+
+One would usually set this variable through .dir-locals.el.
+
+If subprojects are Git submodules, you can use the variable
+`project-vc-merge-submodules' instead."
+  :type 'list
+  :version "28.1"
+  :safe (lambda (val) (and (listp val) (seq-every-p #'stringp val))))
+
 ;; FIXME: Using the current approach, major modes are supposed to set
 ;; this variable to a buffer-local value.  So we don't have access to
 ;; the "external roots" of language A from buffers of language B, which
@@ -428,7 +445,22 @@ project-try-vc
                       root)))))
             ('nil nil)
             (_ (ignore-errors (vc-call-backend backend 'root dir))))))
-    (and root (cons 'vc root))))
+    (when root
+      (let* ((relative-dir (file-relative-name dir root))
+             (subproject (seq-find
+                          (lambda (sub-dir)
+                            (string-prefix-p (file-name-as-directory sub-dir)
+                                             relative-dir))
+                          (project--value-in-dir
+                           'project-vc-subprojects
+                           dir))))
+        (if subproject
+            (cons 'vc (propertize
+                       (concat root subproject)
+                       ;; Side-channel so we don't change the value format.
+                       ;; But we could do that instead.
+                       'vc-subproject t))
+          (cons 'vc root))))))
 
 (defun project--submodule-p (root)
   ;; XXX: We only support Git submodules for now.
@@ -467,7 +499,9 @@ project-external-roots
 (cl-defmethod project-files ((project (head vc)) &optional dirs)
   (mapcan
    (lambda (dir)
-     (let ((ignores (project--value-in-dir 'project-vc-ignores dir))
+     (let ((ignores
+            (nconc (project--vc-subproject-ignores dir)
+                   (project--value-in-dir 'project-vc-ignores dir)))
            backend)
        (if (and (file-equal-p dir (cdr project))
                 (setq backend (vc-responsible-backend dir))
@@ -579,6 +613,12 @@ project--git-submodules
           (nreverse res)))
     (file-missing nil)))
 
+(defun project--vc-subproject-ignores (dir)
+  (unless (get-text-property 0 'vc-subproject dir)
+    (mapcar
+     (lambda (supb) (format "./%s" (file-name-as-directory supb)))
+     (project--value-in-dir 'project-vc-subprojects dir))))
+
 (cl-defmethod project-ignores ((project (head vc)) dir)
   (let* ((root (cdr project))
          backend)
@@ -608,6 +648,7 @@ project-ignores
              (vc-call-backend backend 'ignore-completion-table root)
            (vc-not-supported () nil)))))
      (project--value-in-dir 'project-vc-ignores root)
+     (project--vc-subproject-ignores root)
      (mapcar
       (lambda (dir)
         (concat dir "/"))