commit d511f7b69f67f7292c668f3298bd50ef32e3f6fe
parent 9d789578143ad101528fcda656b6f9583dc3430d
Author: Florian Dold <dold@taler.net>
Date: Sun, 6 Sep 2026 17:29:28 +0200
taler-repos: add foreach command
Run a program with each configured repository as its working directory.
Validate all repository paths before starting and stop at the first
command failure. Preserve program arguments and allow dirty worktrees.
Diffstat:
3 files changed, 182 insertions(+), 5 deletions(-)
diff --git a/tools/README.md b/tools/README.md
@@ -1,8 +1,9 @@
# Local workspace tools
-`taler-repos` updates, builds, installs, tags, and reports versions of the local
-Taler stack. It is a standalone Python script using only the standard library;
-it can be copied elsewhere and does not read deployment configuration files.
+`taler-repos` updates, builds, installs, tags, reports versions, and runs commands
+across the local Taler stack. It is a standalone Python script using only the
+standard library; it can be copied elsewhere and does not read deployment
+configuration files.
Run it from the directory containing the component repositories, or pass
`--root` before the command:
@@ -12,9 +13,27 @@ Run it from the directory containing the component repositories, or pass
./taler-deployment/tools/taler-repos build
./taler-deployment/tools/taler-repos bump --dry-run
./taler-deployment/tools/taler-repos bump --dev --dry-run
+./taler-deployment/tools/taler-repos foreach git status --short
/path/to/taler-repos --root ~/taler versions
```
+`foreach PROGRAM [ARG ...]` runs the given program and arguments sequentially
+in each repository from the script's embedded `REPOSITORIES` list, in list
+order. Each repository is the command's working directory. All repository
+paths are validated before any command runs; dirty worktrees and repositories
+without upstream branches are allowed. The repository and command are printed
+before each invocation, which inherits standard input, output, and error.
+The first command failure stops execution and makes `taler-repos` exit with
+status 1.
+
+Arguments are passed directly to the program. An optional `--` may precede
+the program. For pipelines, redirection, or other shell expressions, invoke a
+shell explicitly:
+
+```sh
+/path/to/taler-repos --root ~/taler foreach -- sh -c 'pwd && git status --short'
+```
+
`build` requires clean worktrees with upstream branches, pulls all components
with `--ff-only`, then runs bootstrap, configure, make, and make install in
dependency order. The installation prefix is `~/local`. Dependencies are
diff --git a/tools/taler-repos b/tools/taler-repos
@@ -2,7 +2,7 @@
# This file is in the public domain.
-"""Update, build, install, tag, and report versions of the local Taler stack."""
+"""Manage the local Taler stack and run commands in its repositories."""
from __future__ import annotations
@@ -169,6 +169,12 @@ def build() -> None:
run(repo, "make", "install")
+def foreach(command: list[str]) -> None:
+ repos = [repository(name) for name in REPOSITORIES]
+ for repo in repos:
+ run(repo, *command)
+
+
def versions() -> None:
for name in REPOSITORIES:
repo = repository(name)
@@ -266,6 +272,21 @@ def parse_args() -> argparse.Namespace:
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("build", help="pull, build, and install the Taler stack")
subparsers.add_parser("versions", help="show each repository's tag or commit")
+ foreach_parser = subparsers.add_parser(
+ "foreach",
+ help="run a command in each repository",
+ description=(
+ "Run PROGRAM with each configured repository as its working directory, "
+ "in repository list order. Validate all repository paths first and "
+ "stop at the first command failure. Use sh -c for shell expressions."
+ ),
+ )
+ foreach_parser.add_argument(
+ "argv",
+ nargs=argparse.REMAINDER,
+ metavar="PROGRAM [ARG ...]",
+ help="program and arguments to execute; an optional -- may precede PROGRAM",
+ )
bump_parser = subparsers.add_parser(
"bump",
help="tag the next version of each untagged component HEAD",
@@ -286,7 +307,13 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="validate and show changes without tagging",
)
- return parser.parse_args()
+ args = parser.parse_args()
+ if args.command == "foreach":
+ if args.argv[:1] == ["--"]:
+ args.argv = args.argv[1:]
+ if not args.argv:
+ foreach_parser.error("a program is required")
+ return args
def main() -> int:
@@ -298,6 +325,8 @@ def main() -> int:
build()
elif args.command == "bump":
bump(dev=args.dev, dry_run=args.dry_run)
+ elif args.command == "foreach":
+ foreach(args.argv)
else:
versions()
except BuildError as exc:
diff --git a/tools/test_taler_repos.py b/tools/test_taler_repos.py
@@ -5,8 +5,10 @@
import importlib.machinery
import importlib.util
import io
+import json
import os
import subprocess
+import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
@@ -142,6 +144,133 @@ class WorkspaceTests(unittest.TestCase):
self.assertEqual(taler_repos.ROOT, Path(temporary).resolve())
+class ForeachTests(unittest.TestCase):
+ def setUp(self):
+ temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(temporary.cleanup)
+ self.root = Path(temporary.name) / "workspace with spaces"
+ self.root.mkdir()
+ self.enterContext(
+ patch.dict(
+ os.environ,
+ {
+ "GIT_CONFIG_NOSYSTEM": "1",
+ "GIT_CONFIG_GLOBAL": os.devnull,
+ },
+ )
+ )
+ for name in taler_repos.REPOSITORIES:
+ repo = self.root / name
+ repo.mkdir()
+ subprocess.run(
+ ["git", "init", "--quiet", "--initial-branch=main", "--template="],
+ cwd=repo,
+ check=True,
+ capture_output=True,
+ )
+ # These repositories have no upstream and contain untracked files.
+ (repo / "dirty").write_text("uncommitted")
+
+ def invoke(self, *args, explicit_root=True):
+ command = [sys.executable, "-B", str(SCRIPT.resolve())]
+ if explicit_root:
+ command.extend(["--root", str(self.root)])
+ return subprocess.run(
+ [*command, "foreach", *args],
+ cwd=self.root.parent if explicit_root else self.root,
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+ def records(self, result):
+ return [
+ json.loads(line)
+ for line in result.stdout.splitlines()
+ if line.startswith("{")
+ ]
+
+ def test_cwd_order_and_literal_arguments_with_optional_separator(self):
+ code = (
+ "import json, os, sys; "
+ "print(json.dumps({'cwd': os.getcwd(), 'args': sys.argv[1:]}))"
+ )
+ arguments = [
+ "with spaces", "", "'quoted'", "$HOME", "$(pwd)", "*", ";",
+ "--help", "--root", "elsewhere", "--",
+ ]
+ expected = [
+ {"cwd": str((self.root / name).resolve()), "args": arguments}
+ for name in taler_repos.REPOSITORIES
+ ]
+ for separator in ((), ("--",)):
+ with self.subTest(separator=separator):
+ result = self.invoke(*separator, sys.executable, "-c", code, *arguments)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(self.records(result), expected)
+ for name in taler_repos.REPOSITORIES:
+ self.assertIn(f"[{name}] $ ", result.stdout)
+
+ def test_default_root_and_explicit_shell_command(self):
+ result = self.invoke("sh", "-c", "pwd && printf 'done\\n'", explicit_root=False)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ lines = [
+ line for line in result.stdout.splitlines()
+ if line and not line.startswith("[")
+ ]
+ expected = []
+ for name in taler_repos.REPOSITORIES:
+ expected.extend([str((self.root / name).resolve()), "done"])
+ self.assertEqual(lines, expected)
+
+ def test_command_failure_stops_at_the_failed_repository(self):
+ code = (
+ "import json, os, sys; "
+ "name = os.path.basename(os.getcwd()); "
+ "print(json.dumps({'repo': name})); "
+ "sys.exit(7 if name == sys.argv[1] else 0)"
+ )
+ for index in (0, 2):
+ name = taler_repos.REPOSITORIES[index]
+ with self.subTest(repository=name):
+ result = self.invoke(sys.executable, "-c", code, name)
+ self.assertEqual(result.returncode, 1)
+ self.assertEqual(
+ self.records(result),
+ [{"repo": n} for n in taler_repos.REPOSITORIES[:index + 1]],
+ )
+ self.assertTrue(result.stderr.startswith(f"taler-repos: {name}:"))
+ self.assertIn("exited with status 7", result.stderr)
+
+ def test_missing_program_stops_after_first_repository(self):
+ result = self.invoke(str(self.root / "missing-program"))
+ self.assertEqual(result.returncode, 1)
+ self.assertIn("could not run", result.stderr)
+ self.assertIn(f"[{taler_repos.REPOSITORIES[0]}] $ ", result.stdout)
+ self.assertNotIn(f"[{taler_repos.REPOSITORIES[1]}] $ ", result.stdout)
+
+ def test_invalid_last_repository_prevents_all_execution(self):
+ name = taler_repos.REPOSITORIES[-1]
+ repo = self.root / name
+ repo.rename(self.root / "unused")
+ for kind in ("missing", "not a Git repository"):
+ with self.subTest(kind=kind):
+ if kind == "not a Git repository":
+ repo.mkdir()
+ result = self.invoke(sys.executable, "-c", "print('executed')")
+ self.assertEqual(result.returncode, 1)
+ self.assertTrue(result.stderr.startswith(f"taler-repos: {name}:"))
+ self.assertEqual(result.stdout, "")
+
+ def test_empty_command_is_a_usage_error(self):
+ for args in ((), ("--",)):
+ with self.subTest(args=args):
+ result = self.invoke(*args)
+ self.assertEqual(result.returncode, 2)
+ self.assertIn("a program is required", result.stderr)
+ self.assertEqual(result.stdout, "")
+
+
class NextTagTests(unittest.TestCase):
def test_semver_transitions(self):
cases = [