Repository: sharkdp/fd
Pull request #960 (2022-02-18 08:22:27)

Multi exec

sharkdp: Oh - very cool! Thank you for working on this. Let us know when it's ready for review.
tmccombs: One issue here is if you run something like:

```
fd --exec echo directory  '{//}' \; --exec  echo basename '{/}' \;
```
then you can end up printing baches of directories then batches of basenames instead of writing the directory and base name together, because the commands are run in parallel. 

There are a few things we can do about this:

1. Do nothing, and warn users that if they care about the order of output they need to use `--threads=1`
2. Hold the lock for stdout while running all the --exec commands. Which is not great, since it means commands can't be run in parallel.
3. Concatenate the output from all commands into a string before writing to stdout. Would require some refactoring.
4. Create a Vec of the outputs from the commands, then write ouch each entry with the stdout lock held. Would require some refactoring.

I'm leaning towards 3 or 4, what are your thoughts?
tmccombs: My latest commit implements 4. All that remains is writing some tests.
tmccombs: Biggest downsides to using 3 or 4 are that it can result in higher memory usage, and potentially delay output from the first exec command(s). There will also be slightly higher overhead for allocating the buffers. That could probably be mitigated somewhat by using a `smallvec` instead of a `Vec`. Or possibly using a thread-local arena.
sharkdp: Thank you, I'm planning to take a look soon. 
sharkdp: I found the following problematic case:
```
> touch foo
> fd -x echo \; -x
[fd error]: Problem while executing command: Permission denied (os error 13)
```
(maybe we could improve the error messages here)

Using `strace -f -e execve` we can see that it's trying to execute:
```
[pid 107001] execve("./foo", ["./foo"], 0x7ffc494d0718 /* 66 vars */) = -1 EACCES (Permission denied)
```

If `foo` is executable, it's actually executed.
sharkdp: Benchmarks look good (no change when running a single command) :+1: 

### Command execution

| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `./fd-8.3.2 'ab' '/home/shark/Informatik/' --exec echo` | 357.7 ± 4.7 | 353.5 | 370.4 | 1.00 ± 0.01 |
| `./fd-960 'ab' '/home/shark/Informatik/' --exec echo` | 356.0 ± 1.7 | 354.3 | 359.3 | 1.00 |

### Command execution (large output)

| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `./fd-8.3.2 -tf 'ab' '/home/shark/Informatik/' --exec cat` | 325.7 ± 1.7 | 323.1 | 327.9 | 1.00 |
| `./fd-960 -tf 'ab' '/home/shark/Informatik/' --exec cat` | 326.8 ± 2.6 | 324.2 | 333.8 | 1.00 ± 0.01 |


Patch:
diff --git a/Cargo.toml b/Cargo.toml
index c939dc5..6502428 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -53,7 +53,7 @@ once_cell = "1.9.0"
 
 [dependencies.clap]
 version = "3.1"
-features = ["suggestions", "color", "wrap_help", "cargo"]
+features = ["suggestions", "color", "wrap_help", "cargo", "unstable-grouped"]
 
 [target.'cfg(unix)'.dependencies]
 users = "0.11.0"
diff --git a/README.md b/README.md
index d7b3266..ff114d1 100644
--- a/README.md
+++ b/README.md
@@ -557,12 +557,6 @@ Starting with Fedora 28, you can install `fd` from the official package sources:
 dnf install fd-find
 ```
 
-For older versions, you can use this [Fedora copr](https://copr.fedorainfracloud.org/coprs/keefle/fd/) to install `fd`:
-``` bash
-dnf copr enable keefle/fd
-dnf install fd
-```
-
 ### On Alpine Linux
 
 You can install [the fd package](https://pkgs.alpinelinux.org/packages?name=fd)
diff --git a/doc/fd.1 b/doc/fd.1
index f917b12..620af8b 100644
--- a/doc/fd.1
+++ b/doc/fd.1
@@ -341,6 +341,9 @@ Note that all subsequent positional arguments are considered to be arguments to
 It is therefore recommended to place the \-x/\-\-exec option last. Alternatively, you can supply
 a ';' argument to end the argument list and continue with more fd options.
 Most shells require ';' to be escaped: '\\;'.
+This option can be specified multiple times, in which case all commands are run for each
+file found, in the order they are provided. In that case, you must supply a ';' argument for
+all but the last commands.
 
 The following placeholders are substituted before the command is executed:
 .RS
@@ -394,6 +397,9 @@ basename without file extension
 
 If no placeholder is present, an implicit "{}" at the end is assumed.
 
+Like \-\-exec, this can be used multiple times, in which case each command will be run in
+the order given.
+
 Examples:
 
   - Find all test_*.py files and open them in your favorite editor:
diff --git a/src/app.rs b/src/app.rs
index 4ceebcd..01ddc1d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -386,6 +386,7 @@ pub fn build_app() -> Command<'static> {
                 .long("exec")
                 .short('x')
                 .min_values(1)
+                .multiple_occurrences(true)
                 .allow_hyphen_values(true)
                 .value_terminator(";")
                 .value_name("cmd")
@@ -417,6 +418,7 @@ pub fn build_app() -> Command<'static> {
                 .long("exec-batch")
                 .short('X')
                 .min_values(1)
+                .multiple_occurrences(true)
                 .allow_hyphen_values(true)
                 .value_terminator(";")
                 .value_name("cmd")
diff --git a/src/config.rs b/src/config.rs
index 5a71714..697744b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -3,7 +3,7 @@ use std::{path::PathBuf, sync::Arc, time::Duration};
 use lscolors::LsColors;
 use regex::bytes::RegexSet;
 
-use crate::exec::CommandTemplate;
+use crate::exec::CommandSet;
 use crate::filetypes::FileTypes;
 #[cfg(unix)]
 use crate::filter::OwnerFilter;
@@ -83,7 +83,7 @@ pub struct Config {
     pub extensions: Option<RegexSet>,
 
     /// If a value is supplied, each item found will be used to generate and execute commands.
-    pub command: Option<Arc<CommandTemplate>>,
+    pub command: Option<Arc<CommandSet>>,
 
     /// Maximum number of search results to pass to each `command`. If zero, the number is
     /// unlimited.
diff --git a/src/exec/command.rs b/src/exec/command.rs
index 9c2c5e2..935b6f4 100644
--- a/src/exec/command.rs
+++ b/src/exec/command.rs
@@ -6,47 +6,93 @@ use std::sync::Mutex;
 use crate::error::print_error;
 use crate::exit_codes::ExitCode;
 
+struct Outputs {
+    stdout: Vec<u8>,
+    stderr: Vec<u8>,
+}
+struct OutputBuffer<'a> {
+    output_permission: &'a Mutex<()>,
+    outputs: Vec<Outputs>,
+}
+
+impl<'a> OutputBuffer<'a> {
+    fn new(output_permission: &'a Mutex<()>) -> Self {
+        Self {
+            output_permission,
+            outputs: Vec::new(),
+        }
+    }
+
+    fn push(&mut self, stdout: Vec<u8>, stderr: Vec<u8>) {
+        self.outputs.push(Outputs { stdout, stderr });
+    }
+
+    fn write(self) {
+        // avoid taking the lock if there is nothing to do
+        if self.outputs.is_empty() {
+            return;
+        }
+        // While this lock is active, this thread will be the only thread allowed
+        // to write its outputs.
+        let _lock = self.output_permission.lock().unwrap();
+
+        let stdout = io::stdout();
+        let stderr = io::stderr();
+
+        let mut stdout = stdout.lock();
+        let mut stderr = stderr.lock();
+
+        for output in self.outputs.iter() {
+            let _ = stdout.write_all(&output.stdout);
+            let _ = stderr.write_all(&output.stderr);
+        }
+    }
+}
+
 /// Executes a command.
-pub fn execute_command(
-    mut cmd: Command,
+pub fn execute_commands<I: Iterator<Item = Command>>(
+    cmds: I,
     out_perm: &Mutex<()>,
     enable_output_buffering: bool,
 ) -> ExitCode {
-    // Spawn the supplied command.
-    let output = if enable_output_buffering {
-        cmd.output()
-    } else {
-        // If running on only one thread, don't buffer output
-        // Allows for viewing and interacting with intermediate command output
-        cmd.spawn().and_then(|c| c.wait_with_output())
-    };
-
-    // Then wait for the command to exit, if it was spawned.
-    match output {
-        Ok(output) => {
-            // While this lock is active, this thread will be the only thread allowed
-            // to write its outputs.
-            let _lock = out_perm.lock().unwrap();
-
-            let stdout = io::stdout();
-            let stderr = io::stderr();
-
-            let _ = stdout.lock().write_all(&output.stdout);
-            let _ = stderr.lock().write_all(&output.stderr);
-
-            if output.status.code() == Some(0) {
-                ExitCode::Success
-            } else {
-                ExitCode::GeneralError
+    let mut output_buffer = OutputBuffer::new(out_perm);
+    for mut cmd in cmds {
+        // Spawn the supplied command.
+        let output = if enable_output_buffering {
+            cmd.output()
+        } else {
+            // If running on only one thread, don't buffer output
+            // Allows for viewing and interacting with intermediate command output
+            cmd.spawn().and_then(|c| c.wait_with_output())
+        };
+
+        // Then wait for the command to exit, if it was spawned.
+        match output {
+            Ok(output) => {
+                if enable_output_buffering {
+                    output_buffer.push(output.stdout, output.stderr);
+                }
+                if output.status.code() != Some(0) {
+                    output_buffer.write();
+                    return ExitCode::GeneralError;
+                }
+            }
+            Err(why) => {
+                output_buffer.write();
+                return handle_cmd_error(&cmd, why);
             }
         }
-        Err(ref why) if why.kind() == io::ErrorKind::NotFound => {
-            print_error(format!("Command not found: {:?}", cmd));
-            ExitCode::GeneralError
-        }
-        Err(why) => {
-            print_error(format!("Problem while executing command: {}", why));
-            ExitCode::GeneralError
-        }
+    }
+    output_buffer.write();
+    ExitCode::Success
+}
+
+pub fn handle_cmd_error(cmd: &Command, err: io::Error) -> ExitCode {
+    if err.kind() == io::ErrorKind::NotFound {
+        print_error(format!("Command not found: {:?}", cmd));
+        ExitCode::GeneralError
+    } else {
+        print_error(format!("Problem while executing command: {}", err));
+        ExitCode::GeneralError
     }
 }
diff --git a/src/exec/job.rs b/src/exec/job.rs
index 85b30f1..f192543 100644
--- a/src/exec/job.rs
+++ b/src/exec/job.rs
@@ -6,14 +6,14 @@ use crate::error::print_error;
 use crate::exit_codes::{merge_exitcodes, ExitCode};
 use crate::walk::WorkerResult;
 
-use super::CommandTemplate;
+use super::CommandSet;
 
 /// An event loop that listens for inputs from the `rx` receiver. Each received input will
 /// generate a command with the supplied command template. The generated command will then
 /// be executed, and this process will continue until the receiver's sender has closed.
 pub fn job(
     rx: Arc<Mutex<Receiver<WorkerResult>>>,
-    cmd: Arc<CommandTemplate>,
+    cmd: Arc<CommandSet>,
     out_perm: Arc<Mutex<()>>,
     show_filesystem_errors: bool,
     buffer_output: bool,
@@ -39,7 +39,7 @@ pub fn job(
         // Drop the lock so that other threads can read from the receiver.
         drop(lock);
         // Generate a command, execute it and store its exit code.
-        results.push(cmd.generate_and_execute(&value, Arc::clone(&out_perm), buffer_output))
+        results.push(cmd.execute(&value, Arc::clone(&out_perm), buffer_output))
     }
     // Returns error in case of any error.
     merge_exitcodes(results)
@@ -47,9 +47,8 @@ pub fn job(
 
 pub fn batch(
     rx: Receiver<WorkerResult>,
-    cmd: &CommandTemplate,
+    cmd: &CommandSet,
     show_filesystem_errors: bool,
-    buffer_output: bool,
     limit: usize,
 ) -> ExitCode {
     let paths = rx.iter().filter_map(|value| match value {
@@ -63,14 +62,14 @@ pub fn batch(
     });
     if limit == 0 {
         // no limit
-        return cmd.generate_and_execute_batch(paths, buffer_output);
+        return cmd.execute_batch(paths);
     }
 
     let mut exit_codes = Vec::new();
     let mut peekable = paths.peekable();
     while peekable.peek().is_some() {
         let limited = peekable.by_ref().take(limit);
-        let exit_code = cmd.generate_and_execute_batch(limited, buffer_output);
+        let exit_code = cmd.execute_batch(limited);
         exit_codes.push(exit_code);
     }
     merge_exitcodes(exit_codes)
diff --git a/src/exec/mod.rs b/src/exec/mod.rs
index 3f93acd..d140528 100644
--- a/src/exec/mod.rs
+++ b/src/exec/mod.rs
@@ -9,13 +9,13 @@ use std::path::{Component, Path, PathBuf, Prefix};
 use std::process::{Command, Stdio};
 use std::sync::{Arc, Mutex};
 
-use anyhow::{anyhow, Result};
+use anyhow::{bail, Result};
 use once_cell::sync::Lazy;
 use regex::Regex;
 
 use crate::exit_codes::ExitCode;
 
-use self::command::execute_command;
+use self::command::{execute_commands, handle_cmd_error};
 use self::input::{basename, dirname, remove_extension};
 pub use self::job::{batch, job};
 use self::token::Token;
@@ -29,44 +29,94 @@ pub enum ExecutionMode {
     Batch,
 }
 
-/// Represents a template that is utilized to generate command strings.
-///
-/// The template is meant to be coupled with an input in order to generate a command. The
-/// `generate_and_execute()` method will be used to generate a command and execute it.
 #[derive(Debug, Clone, PartialEq)]
-pub struct CommandTemplate {
-    args: Vec<ArgumentTemplate>,
+pub struct CommandSet {
     mode: ExecutionMode,
     path_separator: Option<String>,
+    commands: Vec<CommandTemplate>,
 }
 
-impl CommandTemplate {
-    pub fn new<I, S>(input: I, path_separator: Option<String>) -> CommandTemplate
+impl CommandSet {
+    pub fn new<I, S>(input: I, path_separator: Option<String>) -> Result<CommandSet>
     where
-        I: IntoIterator<Item = S>,
+        I: IntoIterator<Item = Vec<S>>,
         S: AsRef<str>,
     {
-        Self::build(input, ExecutionMode::OneByOne, path_separator)
+        Ok(CommandSet {
+            mode: ExecutionMode::OneByOne,
+            path_separator,
+            commands: input
+                .into_iter()
+                .map(CommandTemplate::new)
+                .collect::<Result<_>>()?,
+        })
     }
 
-    pub fn new_batch<I, S>(input: I, path_separator: Option<String>) -> Result<CommandTemplate>
+    pub fn new_batch<I, S>(input: I, path_separator: Option<String>) -> Result<CommandSet>
     where
-        I: IntoIterator<Item = S>,
+        I: IntoIterator<Item = Vec<S>>,
         S: AsRef<str>,
     {
-        let cmd = Self::build(input, ExecutionMode::Batch, path_separator);
-        if cmd.number_of_tokens() > 1 {
-            return Err(anyhow!("Only one placeholder allowed for batch commands"));
-        }
-        if cmd.args[0].has_tokens() {
-            return Err(anyhow!(
-                "First argument of exec-batch is expected to be a fixed executable"
-            ));
+        Ok(CommandSet {
+            mode: ExecutionMode::Batch,
+            path_separator,
+            commands: input
+                .into_iter()
+                .map(|args| {
+                    let cmd = CommandTemplate::new(args)?;
+                    if cmd.number_of_tokens() > 1 {
+                        bail!("Only one placeholder allowed for batch commands");
+                    }
+                    if cmd.args[0].has_tokens() {
+                        bail!("First argument of exec-batch is expected to be a fixed executable");
+                    }
+                    Ok(cmd)
+                })
+                .collect::<Result<Vec<_>>>()?,
+        })
+    }
+
+    pub fn in_batch_mode(&self) -> bool {
+        self.mode == ExecutionMode::Batch
+    }
+
+    pub fn execute(&self, input: &Path, out_perm: Arc<Mutex<()>>, buffer_output: bool) -> ExitCode {
+        let path_separator = self.path_separator.as_deref();
+        let commands = self
+            .commands
+            .iter()
+            .map(|c| c.generate(input, path_separator));
+        execute_commands(commands, &out_perm, buffer_output)
+    }
+
+    pub fn execute_batch<I>(&self, paths: I) -> ExitCode
+    where
+        I: Iterator<Item = PathBuf>,
+    {
+        let path_separator = self.path_separator.as_deref();
+        let mut paths = paths.collect::<Vec<_>>();
+        paths.sort();
+        for cmd in &self.commands {
+            let exit = cmd.generate_and_execute_batch(&paths, path_separator);
+            if exit != ExitCode::Success {
+                return exit;
+            }
         }
-        Ok(cmd)
+        ExitCode::Success
     }
+}
 
-    fn build<I, S>(input: I, mode: ExecutionMode, path_separator: Option<String>) -> CommandTemplate
+/// Represents a template that is utilized to generate command strings.
+///
+/// The template is meant to be coupled with an input in order to generate a command. The
+/// `generate_and_execute()` method will be used to generate a command and execute it.
+#[derive(Debug, Clone, PartialEq)]
+struct CommandTemplate {
+    args: Vec<ArgumentTemplate>,
+}
+
+impl CommandTemplate {
+    fn new<I, S>(input: I) -> Result<CommandTemplate>
     where
         I: IntoIterator<Item = S>,
         S: AsRef<str>,
@@ -117,16 +167,21 @@ impl CommandTemplate {
             args.push(ArgumentTemplate::Tokens(tokens));
         }
 
+        // We need to check that we have at least one argument, because if not
+        // it will try to execute each file and directory it finds.
+        //
+        // Sadly, clap can't currently handle this for us, see
+        // https://github.com/clap-rs/clap/issues/3542
+        if args.is_empty() {
+            bail!("No executable provided for --exec or --exec-batch");
+        }
+
         // If a placeholder token was not supplied, append one at the end of the command.
         if !has_placeholder {
             args.push(ArgumentTemplate::Tokens(vec![Token::Placeholder]));
         }
 
-        CommandTemplate {
-            args,
-            mode,
-            path_separator,
-        }
+        Ok(CommandTemplate { args })
     }
 
     fn number_of_tokens(&self) -> usize {
@@ -136,45 +191,31 @@ impl CommandTemplate {
     /// Generates and executes a command.
     ///
     /// Using the internal `args` field, and a supplied `input` variable, a `Command` will be
-    /// build. Once all arguments have been processed, the command is executed.
-    pub fn generate_and_execute(
-        &self,
-        input: &Path,
-        out_perm: Arc<Mutex<()>>,
-        buffer_output: bool,
-    ) -> ExitCode {
-        let mut cmd = Command::new(self.args[0].generate(&input, self.path_separator.as_deref()));
+    /// build.
+    fn generate(&self, input: &Path, path_separator: Option<&str>) -> Command {
+        let mut cmd = Command::new(self.args[0].generate(&input, path_separator));
         for arg in &self.args[1..] {
-            cmd.arg(arg.generate(&input, self.path_separator.as_deref()));
+            cmd.arg(arg.generate(&input, path_separator));
         }
-
-        execute_command(cmd, &out_perm, buffer_output)
+        cmd
     }
 
-    pub fn in_batch_mode(&self) -> bool {
-        self.mode == ExecutionMode::Batch
-    }
-
-    pub fn generate_and_execute_batch<I>(&self, paths: I, buffer_output: bool) -> ExitCode
-    where
-        I: Iterator<Item = PathBuf>,
-    {
+    fn generate_and_execute_batch(
+        &self,
+        paths: &[PathBuf],
+        path_separator: Option<&str>,
+    ) -> ExitCode {
         let mut cmd = Command::new(self.args[0].generate("", None));
         cmd.stdin(Stdio::inherit());
         cmd.stdout(Stdio::inherit());
         cmd.stderr(Stdio::inherit());
 
-        let mut paths: Vec<_> = paths.collect();
         let mut has_path = false;
 
         for arg in &self.args[1..] {
             if arg.has_tokens() {
-                paths.sort();
-
-                // A single `Tokens` is expected
-                // So we can directly consume the iterator once and for all
-                for path in &mut paths {
-                    cmd.arg(arg.generate(path, self.path_separator.as_deref()));
+                for path in paths {
+                    cmd.arg(arg.generate(path, path_separator));
                     has_path = true;
                 }
             } else {
@@ -183,7 +224,10 @@ impl CommandTemplate {
         }
 
         if has_path {
-            execute_command(cmd, &Mutex::new(()), buffer_output)
+            match cmd.spawn().and_then(|mut c| c.wait()) {
+                Ok(_) => ExitCode::Success,
+                Err(e) => handle_cmd_error(&cmd, e),
+            }
         } else {
             ExitCode::Success
         }
@@ -302,13 +346,15 @@ mod tests {
     #[test]
     fn tokens_with_placeholder() {
         assert_eq!(
-            CommandTemplate::new(&[&"echo", &"${SHELL}:"], None),
-            CommandTemplate {
-                args: vec![
-                    ArgumentTemplate::Text("echo".into()),
-                    ArgumentTemplate::Text("${SHELL}:".into()),
-                    ArgumentTemplate::Tokens(vec![Token::Placeholder]),
-                ],
+            CommandSet::new(vec![vec![&"echo", &"${SHELL}:"]], None).unwrap(),
+            CommandSet {
+                commands: vec![CommandTemplate {
+                    args: vec![
+                        ArgumentTemplate::Text("echo".into()),
+                        ArgumentTemplate::Text("${SHELL}:".into()),
+                        ArgumentTemplate::Tokens(vec![Token::Placeholder]),
+                    ]
+                }],
                 mode: ExecutionMode::OneByOne,
                 path_separator: None,
             }
@@ -318,12 +364,14 @@ mod tests {
     #[test]
     fn tokens_with_no_extension() {
         assert_eq!(
-            CommandTemplate::new(&["echo", "{.}"], None),
-            CommandTemplate {
-                args: vec![
-                    ArgumentTemplate::Text("echo".into()),
-                    ArgumentTemplate::Tokens(vec![Token::NoExt]),
-                ],
+            CommandSet::new(vec![vec!["echo", "{.}"]], None).unwrap(),
+            CommandSet {
+                commands: vec![CommandTemplate {
+                    args: vec![
+                        ArgumentTemplate::Text("echo".into()),
+                        ArgumentTemplate::Tokens(vec![Token::NoExt]),
+                    ],
+                }],
                 mode: ExecutionMode::OneByOne,
                 path_separator: None,
             }
@@ -333,12 +381,14 @@ mod tests {
     #[test]
     fn tokens_with_basename() {
         assert_eq!(
-            CommandTemplate::new(&["echo", "{/}"], None),
-            CommandTemplate {
-                args: vec![
-                    ArgumentTemplate::Text("echo".into()),
-                    ArgumentTemplate::Tokens(vec![Token::Basename]),
-                ],
+            CommandSet::new(vec![vec!["echo", "{/}"]], None).unwrap(),
+            CommandSet {
+                commands: vec![CommandTemplate {
+                    args: vec![
+                        ArgumentTemplate::Text("echo".into()),
+                        ArgumentTemplate::Tokens(vec![Token::Basename]),
+                    ],
+                }],
                 mode: ExecutionMode::OneByOne,
                 path_separator: None,
             }
@@ -348,12 +398,14 @@ mod tests {
     #[test]
     fn tokens_with_parent() {
         assert_eq!(
-            CommandTemplate::new(&["echo", "{//}"], None),
-            CommandTemplate {
-                args: vec![
-                    ArgumentTemplate::Text("echo".into()),
-                    ArgumentTemplate::Tokens(vec![Token::Parent]),
-                ],
+            CommandSet::new(vec![vec!["echo", "{//}"]], None).unwrap(),
+            CommandSet {
+                commands: vec![CommandTemplate {
+                    args: vec![
+                        ArgumentTemplate::Text("echo".into()),
+                        ArgumentTemplate::Tokens(vec![Token::Parent]),
+                    ],
+                }],
                 mode: ExecutionMode::OneByOne,
                 path_separator: None,
             }
@@ -363,12 +415,14 @@ mod tests {
     #[test]
     fn tokens_with_basename_no_extension() {
         assert_eq!(
-            CommandTemplate::new(&["echo", "{/.}"], None),
-            CommandTemplate {
-                args: vec![
-                    ArgumentTemplate::Text("echo".into()),
-                    ArgumentTemplate::Tokens(vec![Token::BasenameNoExt]),
-                ],
+            CommandSet::new(vec![vec!["echo", "{/.}"]], None).unwrap(),
+            CommandSet {
+                commands: vec![CommandTemplate {
+                    args: vec![
+                        ArgumentTemplate::Text("echo".into()),
+                        ArgumentTemplate::Tokens(vec![Token::BasenameNoExt]),
+                    ],
+                }],
                 mode: ExecutionMode::OneByOne,
                 path_separator: None,
             }
@@ -378,16 +432,18 @@ mod tests {
     #[test]
     fn tokens_multiple() {
         assert_eq!(
-            CommandTemplate::new(&["cp", "{}", "{/.}.ext"], None),
-            CommandTemplate {
-                args: vec![
-                    ArgumentTemplate::Text("cp".into()),
-                    ArgumentTemplate::Tokens(vec![Token::Placeholder]),
-                    ArgumentTemplate::Tokens(vec![
-                        Token::BasenameNoExt,
-                        Token::Text(".ext".into())
-                    ]),
-                ],
+            CommandSet::new(vec![vec!["cp", "{}", "{/.}.ext"]], None).unwrap(),
+            CommandSet {
+                commands: vec![CommandTemplate {
+                    args: vec![
+                        ArgumentTemplate::Text("cp".into()),
+                        ArgumentTemplate::Tokens(vec![Token::Placeholder]),
+                        ArgumentTemplate::Tokens(vec![
+                            Token::BasenameNoExt,
+                            Token::Text(".ext".into())
+                        ]),
+                    ],
+                }],
                 mode: ExecutionMode::OneByOne,
                 path_separator: None,
             }
@@ -397,12 +453,14 @@ mod tests {
     #[test]
     fn tokens_single_batch() {
         assert_eq!(
-            CommandTemplate::new_batch(&["echo", "{.}"], None).unwrap(),
-            CommandTemplate {
-                args: vec![
-                    ArgumentTemplate::Text("echo".into()),
-                    ArgumentTemplate::Tokens(vec![Token::NoExt]),
-                ],
+            CommandSet::new_batch(vec![vec!["echo", "{.}"]], None).unwrap(),
+            CommandSet {
+                commands: vec![CommandTemplate {
+                    args: vec![
+                        ArgumentTemplate::Text("echo".into()),
+                        ArgumentTemplate::Tokens(vec![Token::NoExt]),
+                    ],
+                }],
                 mode: ExecutionMode::Batch,
                 path_separator: None,
             }
@@ -411,7 +469,17 @@ mod tests {
 
     #[test]
     fn tokens_multiple_batch() {
-        assert!(CommandTemplate::new_batch(&["echo", "{.}", "{}"], None).is_err());
+        assert!(CommandSet::new_batch(vec![vec!["echo", "{.}", "{}"]], None).is_err());
+    }
+
+    #[test]
+    fn template_no_args() {
+        assert!(CommandTemplate::new::<Vec<_>, &'static str>(vec![]).is_err());
+    }
+
+    #[test]
+    fn command_set_no_args() {
+        assert!(CommandSet::new(vec![vec!["echo"], vec![]], None).is_err());
     }
 
     #[test]
diff --git a/src/main.rs b/src/main.rs
index 7e55da1..8bcd05e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -24,7 +24,7 @@ use regex::bytes::{RegexBuilder, RegexSetBuilder};
 
 use crate::config::Config;
 use crate::error::print_error;
-use crate::exec::CommandTemplate;
+use crate::exec::CommandSet;
 use crate::exit_codes::ExitCode;
 use crate::filetypes::FileTypes;
 #[cfg(unix)]
@@ -390,19 +390,16 @@ fn extract_command(
     matches: &clap::ArgMatches,
     path_separator: Option<&str>,
     colored_output: bool,
-) -> Result<Option<CommandTemplate>> {
+) -> Result<Option<CommandSet>> {
     None.or_else(|| {
-        matches.values_of("exec").map(|args| {
-            Ok(CommandTemplate::new(
-                args,
-                path_separator.map(str::to_string),
-            ))
-        })
+        matches
+            .grouped_values_of("exec")
+            .map(|args| CommandSet::new(args, path_separator.map(str::to_string)))
     })
     .or_else(|| {
         matches
-            .values_of("exec-batch")
-            .map(|args| CommandTemplate::new_batch(args, path_separator.map(str::to_string)))
+            .grouped_values_of("exec-batch")
+            .map(|args| CommandSet::new_batch(args, path_separator.map(str::to_string)))
     })
     .or_else(|| {
         if !matches.is_present("list-details") {
@@ -412,9 +409,8 @@ fn extract_command(
         let color = matches.value_of("color").unwrap_or("auto");
         let color_arg = format!("--color={}", color);
 
-        let res = determine_ls_command(&color_arg, colored_output).map(|cmd| {
-            CommandTemplate::new_batch(cmd, path_separator.map(str::to_string)).unwrap()
-        });
+        let res = determine_ls_command(&color_arg, colored_output)
+            .map(|cmd| CommandSet::new_batch([cmd], path_separator.map(str::to_string)).unwrap());
 
         Some(res)
     })
diff --git a/src/walk.rs b/src/walk.rs
index 4343bc3..d947ade 100644
--- a/src/walk.rs
+++ b/src/walk.rs
@@ -350,13 +350,7 @@ fn spawn_receiver(
         // This will be set to `Some` if the `--exec` argument was supplied.
         if let Some(ref cmd) = config.command {
             if cmd.in_batch_mode() {
-                exec::batch(
-                    rx,
-                    cmd,
-                    show_filesystem_errors,
-                    enable_output_buffering,
-                    config.batch_size,
-                )
+                exec::batch(rx, cmd, show_filesystem_errors, config.batch_size)
             } else {
                 let shared_rx = Arc::new(Mutex::new(rx));
 


Test patch:
diff --git a/tests/tests.rs b/tests/tests.rs
index ec95a9c..3436437 100644
--- a/tests/tests.rs
+++ b/tests/tests.rs
@@ -1383,6 +1383,67 @@ fn test_exec() {
     }
 }
 
+#[test]
+fn test_exec_multi() {
+    // TODO test for windows
+    if cfg!(windows) {
+        return;
+    }
+    let (te, abs_path) = get_test_env_with_abs_path(DEFAULT_DIRS, DEFAULT_FILES);
+
+    te.assert_output(
+        &[
+            "--absolute-path",
+            "foo",
+            "--exec",
+            "echo",
+            ";",
+            "--exec",
+            "echo",
+            "test",
+            "{/}",
+        ],
+        &format!(
+            "{abs_path}/a.foo
+                {abs_path}/one/b.foo
+                {abs_path}/one/two/C.Foo2
+                {abs_path}/one/two/c.foo
+                {abs_path}/one/two/three/d.foo
+                {abs_path}/one/two/three/directory_foo
+                test a.foo
+                test b.foo
+                test C.Foo2
+                test c.foo
+                test d.foo
+                test directory_foo",
+            abs_path = &abs_path
+        ),
+    );
+
+    te.assert_output(
+        &[
+            "e1", "--exec", "echo", "{.}", ";", "--exec", "echo", "{/}", ";", "--exec", "echo",
+            "{//}", ";", "--exec", "echo", "{/.}",
+        ],
+        "e1 e2
+        e1 e2
+        .
+        e1 e2",
+    );
+
+    te.assert_output(
+        &[
+            "foo", "--exec", "echo", "-n", "{/}: ", ";", "--exec", "echo", "{//}",
+        ],
+        "a.foo: .
+        b.foo: ./one
+        C.Foo2: ./one/two
+        c.foo: ./one/two
+        d.foo: ./one/two/three
+        directory_foo: ./one/two/three",
+    );
+}
+
 #[test]
 fn test_exec_batch() {
     let (te, abs_path) = get_test_env_with_abs_path(DEFAULT_DIRS, DEFAULT_FILES);
@@ -1435,6 +1496,21 @@ fn test_exec_batch() {
     }
 }
 
+#[test]
+fn test_exec_batch_multi() {
+    // TODO test for windows
+    if cfg!(windows) {
+        return;
+    }
+    let te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
+
+    te.assert_output(
+        &["foo",  "--exec-batch", "echo", "{}", ";", "--exec-batch", "echo", "{/}"],
+        "./a.foo ./one/b.foo ./one/two/C.Foo2 ./one/two/c.foo ./one/two/three/d.foo ./one/two/three/directory_foo
+        a.foo b.foo C.Foo2 c.foo d.foo directory_foo",
+    );
+}
+
 #[test]
 fn test_exec_batch_with_limit() {
     // TODO Test for windows