create --paths-from-shell-command, fixes #5968

This adds the `--paths-from-shell-command` option to the `create` command, enabling the use of shell-specific features like pipes and redirection when specifying input paths. Includes related test coverage.
This commit is contained in:
Thomas Waldmann
2026-03-07 01:37:32 +01:00
parent bcce0c13f7
commit 4f2f2255c3
3 changed files with 41 additions and 7 deletions
+3
View File
@@ -89,6 +89,9 @@ Examples
$ find ~ -size -1000k | borg create --paths-from-stdin small-files-only
# Use --paths-from-command with find to back up files from only a given user
$ borg create --paths-from-command joes-files -- find /srv/samba/shared -user joe
# Use --paths-from-shell-command with find to back up a few files from only a given user -
# BE VERY CAREFUL AND ONLY USE TRUSTED INPUT FOR THE SHELL COMMAND!
$ borg create --paths-from-shell-command some-of-joes-files -- "find /srv/samba/shared -user joe | head"
# Use --paths-from-stdin with --paths-delimiter (for example, for filenames with newlines in them)
$ find ~ -size -1000k -print0 | borg create \
--paths-from-stdin \
+23 -7
View File
@@ -91,13 +91,24 @@ class CreateMixIn:
else:
status = "+" # included
self.print_file_status(status, path)
elif args.paths_from_command or args.paths_from_stdin:
elif args.paths_from_command or args.paths_from_shell_command or args.paths_from_stdin:
paths_sep = eval_escapes(args.paths_delimiter) if args.paths_delimiter is not None else "\n"
if args.paths_from_command:
if args.paths_from_command or args.paths_from_shell_command:
try:
env = prepare_subprocess_env(system=True)
proc = subprocess.Popen( # nosec B603
args.paths, stdout=subprocess.PIPE, env=env, preexec_fn=None if is_win32 else ignore_sigint
if args.paths_from_shell_command:
# Use shell=True to support pipes, redirection, etc.
shell = True
cmd = " ".join(args.paths)
else:
shell = False
cmd = args.paths
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
env=env,
shell=shell, # nosec B602
preexec_fn=None if is_win32 else ignore_sigint,
)
except (FileNotFoundError, PermissionError) as e:
raise CommandError(f"Failed to execute command: {e}")
@@ -131,7 +142,7 @@ class CreateMixIn:
self.print_file_status(status, path)
if not dry_run and status is not None:
fso.stats.files_stats[status] += 1
if args.paths_from_command:
if args.paths_from_command or args.paths_from_shell_command:
rc = proc.wait()
if rc != 0:
raise CommandError(f"Command {args.paths[0]!r} exited with status {rc}")
@@ -762,8 +773,8 @@ class CreateMixIn:
If you need more control and you want to give every single fs object path
to borg (maybe implementing your own recursion or your own rules), you can use
``--paths-from-stdin`` or ``--paths-from-command`` (with the latter, borg will
fail to create an archive should the command fail).
``--paths-from-stdin``, ``--paths-from-command`` or ``--paths-from-shell-command``
(with the latter two, borg will fail to create an archive should the command fail).
Borg supports paths with the slashdot hack to strip path prefixes here also.
So, be careful not to unintentionally trigger that.
@@ -842,6 +853,11 @@ class CreateMixIn:
action="store_true",
help="interpret PATH as command and treat its output as ``--paths-from-stdin``",
)
subparser.add_argument(
"--paths-from-shell-command",
action="store_true",
help="interpret PATH as shell command and treat its output as ``--paths-from-stdin``",
)
subparser.add_argument(
"--paths-delimiter",
action=Highlander,
@@ -418,6 +418,21 @@ def test_create_paths_from_command_missing_command(archivers, request):
assert output.endswith("No command given." + os.linesep)
@pytest.mark.skipif(is_win32, reason="shell patterns not supported on Windows")
def test_create_paths_from_shell_command(archivers, request):
archiver = request.getfixturevalue(archivers)
cmd(archiver, "repo-create", RK_ENCRYPTION)
create_regular_file(archiver.input_path, "file1", size=1024 * 80)
create_regular_file(archiver.input_path, "file2", size=1024 * 80)
create_regular_file(archiver.input_path, "file3", size=1024 * 80)
input_data = "input/file1\ninput/file2\ninput/file3"
# Use a shell pipe to test that shell=True works correctly.
cmd(archiver, "create", "--paths-from-shell-command", "test", "--", f"echo '{input_data}' | head -n 2")
archive_list = cmd(archiver, "list", "test", "--json-lines")
paths = [json.loads(line)["path"] for line in archive_list.split("\n") if line]
assert paths == ["input/file1", "input/file2"]
def test_create_without_root(archivers, request):
"""test create without a root"""
archiver = request.getfixturevalue(archivers)