>Looks good, except that it does not apply onto the latest main.
I rebase pulled the main and created the patch by `git format-patch
-1`. Please check the patch to see if the issue is gone.

>Could you also change it to your name for correct attribution while you
>are on it?
Done

Lei Zhe


On Thu, Mar 5, 2026 at 3:26 AM Ihor Radchenko <[email protected]> wrote:
>
> Ihor Radchenko <[email protected]> writes:
>
> > Lei Zhe <[email protected]> writes:
> >
> >> Sorry for the late reply.
> >> Please check the updated patch.
>
> Thanks!
> Looks good, except that it does not apply onto the latest main. Could
> you please rebase?
>
> Also, I see
>
>   From: llcc <[email protected]>
>
> Could you also change it to your name for correct attribution while you
> are on it?
>
> --
> Ihor Radchenko // yantar92,
> Org mode maintainer,
> Learn more about Org mode at <https://orgmode.org/>.
> Support Org development at <https://liberapay.com/org-mode>,
> or support my work at <https://liberapay.com/yantar92>
From d4cd5eefcfc49673dae0b0b8cfb3457125751048 Mon Sep 17 00:00:00 2001
From: Lei Zhe <[email protected]>
Date: Fri, 4 Apr 2025 20:47:11 +0800
Subject: [PATCH] ob-tangle.el: Support tangling a source block to multiple
 targets

* lisp/ob-tangle.el (org-babel-tangle--compute-targets): New function
to compute multiple target file paths for a source block, supporting
the new :tangle-directory header argument.
(org-babel-tangle-collect-blocks): Refactor to handle the new nested
list structure that supports multiple tangle targets per block.
(org-babel-tangle-single-block): Modify to return a list of file-block
pairs instead of a single pair, enabling multiple target support.
(org-babel-tangle--unbracketed-link): Add guard for stringp check to
handle list vaulues in :tangle parameter.

* testing/lisp/test-ob-tangle.el (ob-tangle/collect-blocks): New test
helper function and variable.
(ob-tangle/collect-blocks): Extend tests to cover :tangle-directory
header and multiple tangle targets.
---
 doc/org-manual.org             | 46 ++++++++++++++++--
 etc/ORG-NEWS                   | 54 +++++++++++++++++++++
 lisp/ob-tangle.el              | 87 ++++++++++++++++++++++++----------
 testing/lisp/test-ob-tangle.el | 57 ++++++++++++++++++----
 4 files changed, 206 insertions(+), 38 deletions(-)

diff --git a/doc/org-manual.org b/doc/org-manual.org
index 9c4c27877..601eabdc1 100644
--- a/doc/org-manual.org
+++ b/doc/org-manual.org
@@ -19653,10 +19653,48 @@ to source file(s).
 
 - {{{var(FILENAME)}}} ::
 
-  Export the code block to source file whose file name is derived from
-  any string passed to the =tangle= header argument.  Org derives the
-  file name as being relative to the directory of the Org file's
-  location.  Example: =:tangle FILENAME=.
+  Export the code block to source file(s) whose file name(s) are
+  derived from the value passed to the =tangle= header argument.  Org
+  derives the file names as being relative to the directory of the Org
+  file's location, or relative to the directory specified by the
+  =tangle-directory= header argument if provided.
+
+  The FILENAME can take several forms:
+  - A single file name (string) ::
+
+    Example: =:tangle script.py=.
+    
+  - A list of file names ::
+
+    Tangle the block to multiple files.  Example: =:tangle
+    '("src/main.py" "src/utils.py")=.
+    
+  - A variable or function call ::
+
+    Evaluate to get the target file name(s).  Example: =:tangle
+    'tangle-targets= or =:tangle (get-tangle-targets)=.
+
+  When both =:tangle-directory= and multiple =:tangle= files are
+  specified, the block is tangled to all combinations of directories
+  and files.
+
+  Example:
+  #+begin_example
+  ,#+begin_src emacs-lisp :tangle '("config.el" "backup.el") :tangle-directory '("~/.config" "/backup")
+  (message "Tangling to multiple targets specified by :tangle and :tangle-directory")
+  ,#+end_src
+  #+end_example
+
+  This tangles the block to four files:
+  - ~/.config/config.el
+  - ~/.config/backup.el
+  - /backup/config.el
+  - /backup/backup.el
+  
+#+cindex: @samp{tangle-directory}, header argument
+The =tangle-directory= header argument specifies one or more base
+directories for relative tangle file paths. This works similarly to
+=tangle= and accepts the same types of values.
 
 #+cindex: comment trees
 Additionally, code blocks inside commented subtrees (see [[*Comment
diff --git a/etc/ORG-NEWS b/etc/ORG-NEWS
index 40fa1e6aa..b1e464dc4 100644
--- a/etc/ORG-NEWS
+++ b/etc/ORG-NEWS
@@ -30,6 +30,18 @@ Please send Org bug reports to mailto:[email protected].
 The "Tags and Priorities" section of the global context menu is split
 into a "Tags" section and a "Priorities" section.
 
+*** ~org-babel-tangle-single-block~ may return multiple target files
+
+When called with non-nil ONLY-THIS-BLOCK,
+~org-babel-tangle-single-block~ can now return multiple target file
+entries if a source block tangles to more than one file.
+
+Previously, the returned list always contained exactly one element.
+The return value structure is unchanged, but callers should no longer
+assume that the outer list has length one, which means ~(car
+(org-babel-tangle-single-block ...))~ is not guaranteed to return
+the correct ~(FILE . ...)~ cons.
+
 ** New features
 
 # We list the most important features, and the features that may
@@ -40,6 +52,40 @@ into a "Tags" section and a "Priorities" section.
 Priorities can now be increased, decreased, set to the default, and
 set interactively from the priority context menus.
 
+*** ob-tangle.el now supports tangling to multiple targets
+
+Source blocks can now be tangled to multiple target files using
+the ~:tangle~ header argument.  The following forms are now supported:
+
+- =:tangle "file1.el"= (existing behavior)
+- =:tangle '("file1.el" "file2.el")= (new: list of files)
+- =:tangle (get-tangle-targets)= (new: function returning paths)
+- =:tangle 'tangle-targets= (new: variable returning paths)
+
+Additionally, the new ~:tangle-directory~ header argument specifies
+base directories for relative tangle paths:
+
+- =:tangle-directory "/tmp"= (single directory)
+- =:tangle-directory '("/tmp/a" "/tmp/b")= (multiple directories)
+- =:tangle-directory (get-tangle-dirs)= (function returning directories)
+- =:tangle-directory 'tangle-dirs= (variable returning directories)
+
+When both ~:tangle-directory~ and multiple ~:tangle~ files are specified,
+the block is tangled to all combinations of directories and files.
+
+Example:
+#+begin_example
+,#+begin_src emacs-lisp :tangle '("config.el" "backup.el") :tangle-directory '("~/.config" "/backup")
+(message "Tangling to multiple targets specified by :tangle and :tangle-directory")
+,#+end_src
+#+end_example
+
+This tangles the block to four files:
+- ~/.config/config.el
+- ~/.config/backup.el
+- /backup/config.el
+- /backup/backup.el
+
 ** New and changed options
 
 # Changes dealing with changing default values of customizations,
@@ -76,6 +122,14 @@ showed =0%= when a non-zero amount have been completed, e.g. 1/101.
 Those cases now show =1%= instead.  The same is true for the checkbox
 status summary type =X%= when defining columns for column view.
 
+*** ~org-babel-tangle-use-relative-file-links~ is ignored during tangling when =:tangle= specifies multiple targets.
+
+Previously, when ~org-babel-tangle-use-relative-file-links~ was
+non-nil, Org Babel attempted to generate relative commented links in
+tangled files.  This behavior no longer applies when =:tangle=
+specifies multiple targets; in that case, absolute links are always
+used and the variable is ignored.
+
 * Version 9.8
 ** Important announcements and breaking changes
 
diff --git a/lisp/ob-tangle.el b/lisp/ob-tangle.el
index 6fe553469..6e4ae07da 100644
--- a/lisp/ob-tangle.el
+++ b/lisp/ob-tangle.el
@@ -72,8 +72,12 @@ then the name of the language is used."
   :safe t)
 
 (defcustom org-babel-tangle-use-relative-file-links t
-  "Use relative path names in links from tangled source back the Org file."
+  "Use relative path names in links from tangled source back the Org file.
+
+Note that relative links are not used when a code block is tangled into
+multiple target files."
   :group 'org-babel-tangle
+  :package-version '(Org . "10.0")
   :type 'boolean)
 
 (defcustom org-babel-post-tangle-hook nil
@@ -490,7 +494,6 @@ source code blocks by languages matching a regular expression.
 Optional argument TANGLE-FILE can be used to limit the collected
 code blocks by target file."
   (let ((counter 0)
-        (buffer-fn (buffer-file-name (buffer-base-buffer)))
         last-heading-pos blocks)
     (org-babel-map-src-blocks (buffer-file-name)
       (let ((current-heading-pos
@@ -504,26 +507,22 @@ code blocks by target file."
 	  (setq last-heading-pos current-heading-pos)))
       (unless (or (org-in-commented-heading-p)
 		  (org-in-archived-heading-p))
-	(let* ((info (org-babel-get-src-block-info 'no-eval))
-	       (src-lang (nth 0 info))
-	       (src-tfile (cdr (assq :tangle (nth 2 info)))))
-	  (unless (or (string= src-tfile "no")
-                      ;; src block without lang
-                      (and (not src-lang) (string= src-tfile "yes"))
-		      (and tangle-file (not (equal tangle-file src-tfile)))
-                      ;; lang-re but either no lang or lang doesn't match
-		      (and lang-re
-                           (or (not src-lang)
-                               (not (string-match-p lang-re src-lang)))))
-	    ;; Add the spec for this block to blocks under its tangled
-	    ;; file name.
-	    (let* ((block (org-babel-tangle-single-block counter))
-                   (src-tfile (cdr (assq :tangle (nth 4 block))))
-		   (file-name (org-babel-effective-tangled-filename
-                               buffer-fn src-lang src-tfile))
-		   (by-fn (assoc file-name blocks)))
-	      (if by-fn (setcdr by-fn (cons (cons src-lang block) (cdr by-fn)))
-		(push (cons file-name (list (cons src-lang block))) blocks)))))))
+        (dolist (block (org-babel-tangle-single-block counter t))
+          (let ((src-file (car block))
+                (src-lang (caadr block)))
+            (unless (or (not src-file)
+                        ;; src block without lang
+                        (and (not src-lang) src-file)
+                        (and tangle-file (not (equal tangle-file src-file)))
+                        ;; lang-re but either no lang or lang doesn't match
+                        (and lang-re
+                             (or (not src-lang)
+                                 (not (string-match-p lang-re src-lang)))))
+              (setq blocks
+                    (mapcar (lambda (group)
+                              (cons (car group)
+                                    (apply #'append (mapcar #'cdr (cdr group)))))
+                            (seq-group-by #'car (push block blocks)))))))))
     ;; Ensure blocks are in the correct order.
     (mapcar (lambda (b) (cons (car b) (nreverse (cdr b))))
 	    (nreverse blocks))))
@@ -540,6 +539,7 @@ The PARAMS are the 3rd element of the info for the same src block."
                         (match-string 1 l))))
         (when bare
           (if (and org-babel-tangle-use-relative-file-links
+                   (stringp (cdr (assq :tangle params)))
                    (string-match org-link-types-re bare)
                    (string= (match-string 1 bare) "file"))
               (concat "file:"
@@ -549,6 +549,41 @@ The PARAMS are the 3rd element of the info for the same src block."
             bare))))))
 
 (defvar org-outline-regexp) ; defined in lisp/org.el
+
+(defun org-babel-tangle--compute-targets (buffer-fn info)
+  "Compute the list of target file paths for tangling a source block.
+
+BUFFER-FN is the absolute file name of the source buffer.  INFO is the
+source block information, as returned by `org-babel-get-src-block-info'."
+  (let* ((params         (nth 2 info))
+         (lang           (nth 0 info))
+         (tangle-dir-raw (cdr (assq :tangle-directory params)))
+         (tangle-targets (cdr (assq :tangle params)))
+         (tangle-dirs    (ensure-list tangle-dir-raw))
+         (tangle-files
+          (cond
+           ((and (stringp tangle-targets) (string= tangle-targets "yes"))
+            ;; Default to buffer name if :tangle yes
+            (list (file-name-nondirectory
+                   (org-babel-effective-tangled-filename buffer-fn lang tangle-targets))))
+           ((and (stringp tangle-targets) (string= tangle-targets "no")) nil)
+           (t (ensure-list tangle-targets)))))
+
+    (when tangle-files
+      (setq tangle-files
+            (cl-loop for file in tangle-files append
+                     (if (file-name-absolute-p file)
+                         (list file) ;; absolute paths stay as is
+                       (if tangle-dirs
+                           (mapcar (lambda (dir) (expand-file-name file dir)) tangle-dirs)
+                         (list file))))))
+
+    ;; Normalize final paths
+    (cl-remove-duplicates
+     (mapcar (lambda (file)
+               (org-babel-effective-tangled-filename buffer-fn lang file))
+             tangle-files))))
+
 (defun org-babel-tangle-single-block (block-counter &optional only-this-block)
   "Collect the tangled source for current block.
 Return the list of block attributes needed by
@@ -616,7 +651,6 @@ non-nil, return the full association list to be used by
 			 (match-end 0)
 		       (point-min))))
 	      (point)))))
-         (src-tfile (cdr (assq :tangle params)))
 	 (result
 	  (list start-line
 		(if org-babel-tangle-use-relative-file-links
@@ -629,9 +663,10 @@ non-nil, return the full association list to be used by
 		  (org-trim (org-remove-indentation body)))
 		comment)))
     (if only-this-block
-        (let* ((file-name (org-babel-effective-tangled-filename
-                           file src-lang src-tfile)))
-          (list (cons file-name (list (cons src-lang result)))))
+        (let* ((file-names (org-babel-tangle--compute-targets file info)))
+          (mapcar (lambda (file-name)
+                    (cons file-name (list (cons src-lang result))))
+                  file-names))
       result)))
 
 (defun org-babel-tangle-comment-links (&optional info)
diff --git a/testing/lisp/test-ob-tangle.el b/testing/lisp/test-ob-tangle.el
index 640d910b5..a65b1f758 100644
--- a/testing/lisp/test-ob-tangle.el
+++ b/testing/lisp/test-ob-tangle.el
@@ -579,6 +579,14 @@ another block
         (set-buffer-modified-p nil))
       (kill-buffer buffer))))
 
+(defun ob-tangle/tangle-targets ()
+  "Return tangle targets for testing."
+  '("relative.el" "/tmp/absolute.el"))
+
+(defvar ob-tangle/tangle-targets
+  '("relative.el" "/tmp/absolute.el")
+  "Tangle targets variable for testing.")
+
 (ert-deftest ob-tangle/collect-blocks ()
   "Test block collection into groups for tangling."
   (org-test-with-temp-text-in-file "" ; filled below, it depends on temp file name
@@ -628,6 +636,18 @@ another block
 \"H1: no language and inherited :tangle relative.el in properties\"
 #+end_src
 
+#+begin_src emacs-lisp :tangle '(\"relative.el\" \"/tmp/absolute.el\")
+\"H1: :tangle relative.el and /tmp/absolute.el\"
+#+end_src
+
+#+begin_src emacs-lisp :tangle 'ob-tangle/tangle-targets
+\"H1: :tangle relative.el and /tmp/absolute.el\"
+#+end_src
+
+#+begin_src emacs-lisp :tangle (ob-tangle/tangle-targets)
+\"H1: :tangle relative.el and /tmp/absolute.el\"
+#+end_src
+
 * H2 without :tangle in properties
 
 #+begin_src emacs-lisp
@@ -664,7 +684,19 @@ another block
 
 #+begin_src
 \"H2: without language and thus without :tangle\"
-#+end_src"
+#+end_src
+
+* H3 with :tangle-directory
+
+#+begin_src emacs-lisp :tangle-directory /tmp/a :tangle '(\"foo.el\" \"bar.el\") :mkdirp yes
+\"H3: :tangle /tmp/foo.el and /tmp/bar.el\"
+#+end_src
+
+#+begin_src emacs-lisp :tangle-directory '(\"/tmp/a\" \"/tmp/b\") :tangle '(\"foo.el\" \"bar.el\") :mkdirp yes
+\"H3: :tangle /tmp/a/foo.el, /tmp/a/bar.el, /tmp/b/foo.el and /tmp/b/bar.el\"
+#+end_src
+
+"
                     `((?a . ,el-file-abs)
                       (?r . ,el-file-rel))))
       ;; We check the collected blocks to tangle by counting equal
@@ -687,10 +719,15 @@ another block
                                               ;; From `org-babel-tangle-collect-blocks'.
                                               collected-blocks)))))
         (should (equal (funcall normalize-expected-targets-alist
-                                `(("/tmp/absolute.el" . 4)
-                                  ("relative.el" . 6)
+                                `(("/tmp/absolute.el" . 7)
+                                  ("/tmp/a/foo.el" . 2)
+                                  ("/tmp/a/bar.el" . 2)
+                                  ("/tmp/b/foo.el" . 1)
+                                  ("/tmp/b/bar.el" . 1)
+                                  ("relative.el" . 8)
                                   ;; file name differs between tests
-                                  (,el-file-abs . 4)))
+                                  (,el-file-abs . 4)
+                                  ))
                        (funcall count-blocks-in-target-files
                                 (org-babel-tangle-collect-blocks))))
         ;; Simulate TARGET-FILE to test as `org-babel-tangle' and
@@ -702,12 +739,16 @@ another block
                 (list (cons :tangle el-file-abs)))))
           (should (equal
                    (funcall normalize-expected-targets-alist
-                            `(("/tmp/absolute.el" . 4)
-                              ("relative.el" . 6)
+                            `(("/tmp/absolute.el" . 7)
+                              ("/tmp/a/foo.el" . 2)
+                              ("/tmp/a/bar.el" . 2)
+                              ("/tmp/b/foo.el" . 1)
+                              ("/tmp/b/bar.el" . 1)
+                              ("relative.el" . 8)
                               ;; Default :tangle header now also
                               ;; points to the file name derived from the name of
-                              ;; the Org file, so 6 blocks should go there.
-                              (,el-file-abs . 6)))
+                              ;; the Org file, so 5 blocks should go there.
+                              (,el-file-abs . 5)))
                    (funcall count-blocks-in-target-files
                             (org-babel-tangle-collect-blocks)))))))))
 
-- 
2.50.1 (Apple Git-155)

Reply via email to