taler-deployment

Deployment scripts and configuration files
Log | Files | Refs | README

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:
Mtools/README.md | 25++++++++++++++++++++++---
Mtools/taler-repos | 33+++++++++++++++++++++++++++++++--
Mtools/test_taler_repos.py | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 = [