git-rexec 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
#!python
|
|
2
|
+
#
|
|
3
|
+
# Optional dependencies:
|
|
4
|
+
# - colorama
|
|
5
|
+
# - setproctitle
|
|
6
|
+
#
|
|
7
|
+
# Author: James Cherti
|
|
8
|
+
# URL: https://github.com/jamescherti/git-rexec
|
|
9
|
+
#
|
|
10
|
+
# Copyright (C) 2019-2026 James Cherti
|
|
11
|
+
#
|
|
12
|
+
# This program is free software: you can redistribute it and/or modify it under
|
|
13
|
+
# the terms of the GNU General Public License as published by the Free Software
|
|
14
|
+
# Foundation, either version 3 of the License, or (at your option) any later
|
|
15
|
+
# version.
|
|
16
|
+
#
|
|
17
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
18
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
19
|
+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
|
20
|
+
# details.
|
|
21
|
+
#
|
|
22
|
+
# You should have received a copy of the GNU General Public License along with
|
|
23
|
+
# this program. If not, see <https://www.gnu.org/licenses/>.
|
|
24
|
+
#
|
|
25
|
+
"""Find Git repositories and execute commands against them in parallel.
|
|
26
|
+
|
|
27
|
+
This script allows for finding Git repositories within a directory structure
|
|
28
|
+
and executing commands against them. It supports conditional filtering,
|
|
29
|
+
background execution, and sequential foreground execution.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import os
|
|
34
|
+
import shlex
|
|
35
|
+
import shutil
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
import textwrap
|
|
39
|
+
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from multiprocessing import cpu_count
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Optional
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
from colorama import Fore
|
|
47
|
+
from colorama import init as colorama_init
|
|
48
|
+
HAS_COLORAMA: bool = True
|
|
49
|
+
except ImportError:
|
|
50
|
+
HAS_COLORAMA = False
|
|
51
|
+
|
|
52
|
+
class Fore: # type: ignore
|
|
53
|
+
YELLOW: str = ""
|
|
54
|
+
RED: str = ""
|
|
55
|
+
RESET: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class CommandResult:
|
|
60
|
+
"""Store the result of a subprocess command execution.
|
|
61
|
+
|
|
62
|
+
:param command: The command that was executed.
|
|
63
|
+
:param returncode: The exit status of the command.
|
|
64
|
+
:param stdout: Standard output content.
|
|
65
|
+
:param stderr: Standard error content.
|
|
66
|
+
"""
|
|
67
|
+
command: list[str]
|
|
68
|
+
returncode: int
|
|
69
|
+
stdout: str = ""
|
|
70
|
+
stderr: str = ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class RepoContext:
|
|
75
|
+
"""Represent a processed Git repository and its execution states.
|
|
76
|
+
|
|
77
|
+
:param path: The absolute path to the repository root.
|
|
78
|
+
:param parallel_result: The result of the parallel execution, if any.
|
|
79
|
+
"""
|
|
80
|
+
path: Path
|
|
81
|
+
parallel_result: Optional[CommandResult] = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def git_toplevel(repo_path: Path) -> Optional[Path]:
|
|
85
|
+
"""Return the absolute path to the top-level directory of a Git repository.
|
|
86
|
+
|
|
87
|
+
:param repo_path: Path inside the Git repository.
|
|
88
|
+
:return: Path to the repository's top-level directory, or None if not a
|
|
89
|
+
Git repo.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
proc: subprocess.CompletedProcess[str] = subprocess.run(
|
|
93
|
+
["git", "-C", str(repo_path), "rev-parse", "--show-toplevel"],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
check=True
|
|
97
|
+
)
|
|
98
|
+
return Path(proc.stdout.strip()).resolve()
|
|
99
|
+
except subprocess.CalledProcessError:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def git_fd_find_repo(path: Path, max_workers: int) -> set[Path]:
|
|
104
|
+
"""Find all Git repositories in 'path' using fd.
|
|
105
|
+
|
|
106
|
+
:param path: The root directory to search for Git repositories.
|
|
107
|
+
:param max_workers: Maximum number of threads for fd.
|
|
108
|
+
:return: set of paths to Git repositories.
|
|
109
|
+
"""
|
|
110
|
+
path_str: str = str(path)
|
|
111
|
+
try:
|
|
112
|
+
# Use fd to find .git directories efficiently
|
|
113
|
+
proc: subprocess.CompletedProcess[str] = subprocess.run(
|
|
114
|
+
[
|
|
115
|
+
'fd', '--type', 'd', '--hidden',
|
|
116
|
+
'--no-ignore', '^\\.git$',
|
|
117
|
+
'--absolute-path',
|
|
118
|
+
'--threads', str(max_workers),
|
|
119
|
+
path_str
|
|
120
|
+
],
|
|
121
|
+
capture_output=True,
|
|
122
|
+
text=True,
|
|
123
|
+
check=False
|
|
124
|
+
)
|
|
125
|
+
except FileNotFoundError as err:
|
|
126
|
+
print(f"Error: {err}", file=sys.stderr)
|
|
127
|
+
sys.exit(1)
|
|
128
|
+
|
|
129
|
+
# Use a set comprehension to remove duplicates and normalize paths
|
|
130
|
+
# efficiently
|
|
131
|
+
return {
|
|
132
|
+
Path(git_dir).parent
|
|
133
|
+
for git_dir in proc.stdout.splitlines()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def git_py_find_repo(root: Path) -> set[Path]:
|
|
138
|
+
"""Recursively find all Git repositories under the root path using Python.
|
|
139
|
+
|
|
140
|
+
:param root: Directory to search under.
|
|
141
|
+
:return: set of paths to Git repositories.
|
|
142
|
+
"""
|
|
143
|
+
return {
|
|
144
|
+
git_dir.absolute().parent
|
|
145
|
+
for git_dir in root.rglob(".git")
|
|
146
|
+
if git_dir.is_dir()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_command_get_output(repo_path: Path, cmd_list: list[str],
|
|
151
|
+
capture: bool = True) -> CommandResult:
|
|
152
|
+
"""Execute a shell command within a specific directory.
|
|
153
|
+
|
|
154
|
+
:param repo_path: The directory in which to execute the command.
|
|
155
|
+
:param cmd_list: The command list to execute.
|
|
156
|
+
:param capture: Whether to capture stdout/stderr.
|
|
157
|
+
:return: The result of the command execution.
|
|
158
|
+
"""
|
|
159
|
+
if not cmd_list:
|
|
160
|
+
return CommandResult(command=[], returncode=0)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
proc: subprocess.CompletedProcess[str] = subprocess.run(
|
|
164
|
+
cmd_list,
|
|
165
|
+
cwd=repo_path,
|
|
166
|
+
capture_output=capture,
|
|
167
|
+
text=True,
|
|
168
|
+
check=False
|
|
169
|
+
)
|
|
170
|
+
return CommandResult(
|
|
171
|
+
command=cmd_list,
|
|
172
|
+
returncode=proc.returncode,
|
|
173
|
+
stdout=proc.stdout if proc.stdout else "",
|
|
174
|
+
stderr=proc.stderr if proc.stderr else ""
|
|
175
|
+
)
|
|
176
|
+
except FileNotFoundError:
|
|
177
|
+
return CommandResult(
|
|
178
|
+
command=cmd_list,
|
|
179
|
+
returncode=127,
|
|
180
|
+
stderr=f"Error: Command not found: '{cmd_list[0]}'\n"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def format_parallel_output(
|
|
185
|
+
repo_path: Path, result: CommandResult, quiet: bool = False) -> str:
|
|
186
|
+
"""Format the output of a parallel command for display.
|
|
187
|
+
|
|
188
|
+
:param repo_path: The repository path.
|
|
189
|
+
:param result: The execution result.
|
|
190
|
+
:param quiet: Suppress informational headers.
|
|
191
|
+
:return: Formatted string ready for printing.
|
|
192
|
+
"""
|
|
193
|
+
raw_output: str = result.stdout + result.stderr
|
|
194
|
+
if not raw_output and result.returncode == 0:
|
|
195
|
+
return ""
|
|
196
|
+
|
|
197
|
+
formatted: str = raw_output.replace("\t", " ").rstrip()
|
|
198
|
+
if formatted:
|
|
199
|
+
formatted += "\n"
|
|
200
|
+
|
|
201
|
+
if quiet:
|
|
202
|
+
return formatted
|
|
203
|
+
|
|
204
|
+
header: str = f"{Fore.YELLOW}[EXEC-P] {repo_path}"
|
|
205
|
+
cmd_str: str = " ".join(result.command)
|
|
206
|
+
header += f": {cmd_str}{Fore.RESET}\n"
|
|
207
|
+
|
|
208
|
+
indent: str = " " * 4
|
|
209
|
+
indented_body: str = textwrap.indent(formatted, prefix=indent)
|
|
210
|
+
return header + indented_body
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def process_repo(repo_path: Path, exec_parallel_cmd: Optional[list[str]],
|
|
214
|
+
if_exec_cmd: Optional[list[str]]) -> Optional[RepoContext]:
|
|
215
|
+
"""Process a single repository: check conditions and run parallel commands.
|
|
216
|
+
|
|
217
|
+
:param repo_path: The repository path.
|
|
218
|
+
:param exec_parallel_cmd: Command list to execute in background/parallel.
|
|
219
|
+
:param if_exec_cmd: Command list to check before processing (filter).
|
|
220
|
+
:return: RepoContext if processed successfully, None if filtered out.
|
|
221
|
+
"""
|
|
222
|
+
# Filter: if-exec
|
|
223
|
+
if_exec_cmd_list: Optional[list[str]] = if_exec_cmd
|
|
224
|
+
if if_exec_cmd_list:
|
|
225
|
+
# We discard output for the filter check, only caring about exit code
|
|
226
|
+
filter_res: CommandResult = run_command_get_output(
|
|
227
|
+
repo_path, if_exec_cmd_list, capture=True
|
|
228
|
+
)
|
|
229
|
+
if filter_res.returncode != 0:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
# Action: exec-parallel
|
|
233
|
+
parallel_result: Optional[CommandResult] = None
|
|
234
|
+
exec_parallel_cmd_list: Optional[list[str]] = exec_parallel_cmd
|
|
235
|
+
if exec_parallel_cmd_list:
|
|
236
|
+
parallel_result = run_command_get_output(
|
|
237
|
+
repo_path, exec_parallel_cmd_list, capture=True
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return RepoContext(path=repo_path, parallel_result=parallel_result)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def discover_repos(directory: Path,
|
|
244
|
+
max_workers: int,
|
|
245
|
+
exclude_dirs: list[str]) -> set[Path]:
|
|
246
|
+
"""Discover Git repositories starting from 'directory' and apply exclusions.
|
|
247
|
+
|
|
248
|
+
:param directory: The root directory for search.
|
|
249
|
+
:param max_workers: Maximum number of threads/workers.
|
|
250
|
+
:param exclude_dirs: Directories to exclude.
|
|
251
|
+
:return: A set of repository root paths.
|
|
252
|
+
"""
|
|
253
|
+
repos: set[Path] = set()
|
|
254
|
+
|
|
255
|
+
# Discover all repositories
|
|
256
|
+
toplevel: Optional[Path] = git_toplevel(directory)
|
|
257
|
+
if toplevel:
|
|
258
|
+
repos = {toplevel}
|
|
259
|
+
elif shutil.which("fd"):
|
|
260
|
+
repos = git_fd_find_repo(directory, max_workers)
|
|
261
|
+
else:
|
|
262
|
+
repos = git_py_find_repo(directory)
|
|
263
|
+
|
|
264
|
+
# Filter out excluded directories
|
|
265
|
+
if not exclude_dirs:
|
|
266
|
+
return repos
|
|
267
|
+
|
|
268
|
+
list_ex_paths: list[Path] = [Path(ex_dir).resolve()
|
|
269
|
+
for ex_dir in exclude_dirs]
|
|
270
|
+
return {
|
|
271
|
+
repo for repo in repos
|
|
272
|
+
if not any(repo.resolve().is_relative_to(ex_path)
|
|
273
|
+
for ex_path in list_ex_paths)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def execute_parallel_tasks(
|
|
278
|
+
repos: set[Path],
|
|
279
|
+
exec_parallel: Optional[list[str]],
|
|
280
|
+
if_exec: Optional[list[str]],
|
|
281
|
+
max_workers: int
|
|
282
|
+
) -> list[RepoContext]:
|
|
283
|
+
"""Run discovery and parallel execution tasks.
|
|
284
|
+
|
|
285
|
+
:param repos: set of repositories to process.
|
|
286
|
+
:param exec_parallel: Command list for background execution.
|
|
287
|
+
:param if_exec: Command list for conditional filtering.
|
|
288
|
+
:param max_workers: Maximum number of threads for execution.
|
|
289
|
+
:return: list of processed repository contexts.
|
|
290
|
+
"""
|
|
291
|
+
results: list[RepoContext] = []
|
|
292
|
+
|
|
293
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
294
|
+
futures: dict[Future[Optional[RepoContext]], Path] = {
|
|
295
|
+
executor.submit(
|
|
296
|
+
process_repo, repo, exec_parallel, if_exec
|
|
297
|
+
): repo for repo in repos
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for future in as_completed(futures):
|
|
301
|
+
repo_path: Path = futures[future]
|
|
302
|
+
try:
|
|
303
|
+
result: Optional[RepoContext] = future.result()
|
|
304
|
+
if result:
|
|
305
|
+
results.append(result)
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
print(f"Error processing repository {repo_path}: {exc}",
|
|
308
|
+
file=sys.stderr)
|
|
309
|
+
|
|
310
|
+
return results
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def parse_args() -> argparse.Namespace:
|
|
314
|
+
"""Parse command-line arguments.
|
|
315
|
+
|
|
316
|
+
:return: Parsed arguments.
|
|
317
|
+
"""
|
|
318
|
+
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
|
319
|
+
description="Find git repos and execute commands.",
|
|
320
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
321
|
+
)
|
|
322
|
+
parser.add_argument(
|
|
323
|
+
"-C", "--directory",
|
|
324
|
+
type=Path,
|
|
325
|
+
default=Path("."),
|
|
326
|
+
help="Root directory to search (defaults to current directory)"
|
|
327
|
+
)
|
|
328
|
+
parser.add_argument(
|
|
329
|
+
"--exclude-dir",
|
|
330
|
+
action="append",
|
|
331
|
+
default=[],
|
|
332
|
+
help="Exclude a specific directory and all of its subdirectories"
|
|
333
|
+
)
|
|
334
|
+
parser.add_argument(
|
|
335
|
+
"-p", "--parallel",
|
|
336
|
+
action="store_true",
|
|
337
|
+
help="Execute the command in parallel using threads",
|
|
338
|
+
default=False
|
|
339
|
+
)
|
|
340
|
+
parser.add_argument(
|
|
341
|
+
"-i", "--if-exec",
|
|
342
|
+
type=str,
|
|
343
|
+
help="Execute commands only if this check returns exit code 0.",
|
|
344
|
+
default=None
|
|
345
|
+
)
|
|
346
|
+
parser.add_argument(
|
|
347
|
+
"-j", "--jobs",
|
|
348
|
+
type=int,
|
|
349
|
+
dest="max_workers",
|
|
350
|
+
help="Maximum number of processors/workers to use",
|
|
351
|
+
default=(cpu_count() or 1)
|
|
352
|
+
)
|
|
353
|
+
parser.add_argument(
|
|
354
|
+
"-q", "--quiet",
|
|
355
|
+
action="store_true",
|
|
356
|
+
help=("Quiet mode. Suppresses the informational tracking headers "
|
|
357
|
+
"([EXEC] and [EXEC-P]) that prefix execution output."),
|
|
358
|
+
default=False
|
|
359
|
+
)
|
|
360
|
+
parser.add_argument(
|
|
361
|
+
"exec_cmd",
|
|
362
|
+
type=str,
|
|
363
|
+
nargs="*",
|
|
364
|
+
help="The command to execute. You can use -- to pass options."
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
args: argparse.Namespace = parser.parse_args()
|
|
368
|
+
|
|
369
|
+
return args
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def print_error_summary(errors: list[tuple[Path, CommandResult]]) -> int:
|
|
373
|
+
"""Print a summary of execution errors.
|
|
374
|
+
|
|
375
|
+
:param errors: list of tuples containing path and result.
|
|
376
|
+
:return: The final exit code (1 if errors exist, else 0).
|
|
377
|
+
"""
|
|
378
|
+
if not errors:
|
|
379
|
+
return 0
|
|
380
|
+
|
|
381
|
+
print()
|
|
382
|
+
print(f"{Fore.RED}Errors:{Fore.RESET}")
|
|
383
|
+
|
|
384
|
+
final_errno: int = 0
|
|
385
|
+
for repo_path, result in errors:
|
|
386
|
+
cmd_display: str = " ".join(result.command)
|
|
387
|
+
if result.returncode != 0:
|
|
388
|
+
final_errno = 1
|
|
389
|
+
if result.returncode == 127:
|
|
390
|
+
msg: str = "Command not found"
|
|
391
|
+
else:
|
|
392
|
+
msg = f"errno {result.returncode}"
|
|
393
|
+
|
|
394
|
+
print(
|
|
395
|
+
f"{Fore.RED} - {repo_path}: {msg}: "
|
|
396
|
+
f"{cmd_display}{Fore.RESET}"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
return final_errno
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def main() -> None:
|
|
403
|
+
"""Execute the main command-line interface."""
|
|
404
|
+
# Optional: setproctitle
|
|
405
|
+
try:
|
|
406
|
+
# pylint: disable=import-outside-toplevel
|
|
407
|
+
from setproctitle import setproctitle
|
|
408
|
+
setproctitle(Path(sys.argv[0]).name) # type: ignore
|
|
409
|
+
except ImportError:
|
|
410
|
+
# Optional dependency 'setproctitle' is not installed.
|
|
411
|
+
pass
|
|
412
|
+
|
|
413
|
+
if HAS_COLORAMA:
|
|
414
|
+
colorama_init()
|
|
415
|
+
|
|
416
|
+
# Disable git prompting
|
|
417
|
+
os.environ["GIT_TERMINAL_PROMPT"] = "0"
|
|
418
|
+
|
|
419
|
+
args: argparse.Namespace = parse_args()
|
|
420
|
+
|
|
421
|
+
# Verify that the execution command exists
|
|
422
|
+
if args.exec_cmd and not shutil.which(args.exec_cmd[0]):
|
|
423
|
+
print(
|
|
424
|
+
f"Error: Command not found: '{args.exec_cmd[0]}'",
|
|
425
|
+
file=sys.stderr)
|
|
426
|
+
sys.exit(127)
|
|
427
|
+
|
|
428
|
+
# Discover Repositories
|
|
429
|
+
repos: set[Path] = discover_repos(args.directory.absolute(),
|
|
430
|
+
args.max_workers,
|
|
431
|
+
args.exclude_dir)
|
|
432
|
+
|
|
433
|
+
# setup background command list if needed
|
|
434
|
+
exec_parallel_cmd: Optional[list[str]] = None
|
|
435
|
+
if args.parallel and args.exec_cmd:
|
|
436
|
+
exec_parallel_cmd = args.exec_cmd
|
|
437
|
+
|
|
438
|
+
if_exec_cmd: Optional[list[str]] = None
|
|
439
|
+
if args.if_exec:
|
|
440
|
+
if_exec_cmd = shlex.split(args.if_exec)
|
|
441
|
+
|
|
442
|
+
# Parallel Processing (Filter + Background Exec)
|
|
443
|
+
processed_repos: list[RepoContext] = execute_parallel_tasks(
|
|
444
|
+
repos,
|
|
445
|
+
exec_parallel_cmd,
|
|
446
|
+
if_exec_cmd,
|
|
447
|
+
args.max_workers
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
execution_errors: list[tuple[Path, CommandResult]] = []
|
|
451
|
+
|
|
452
|
+
# Main Loop: Display results and run sequential commands
|
|
453
|
+
for context in processed_repos:
|
|
454
|
+
repo_path: Path = context.path
|
|
455
|
+
|
|
456
|
+
# Handle Background Execution Results
|
|
457
|
+
if context.parallel_result:
|
|
458
|
+
output_display: str = format_parallel_output(
|
|
459
|
+
repo_path, context.parallel_result, args.quiet)
|
|
460
|
+
if output_display:
|
|
461
|
+
print(output_display, end="")
|
|
462
|
+
|
|
463
|
+
if context.parallel_result.returncode != 0:
|
|
464
|
+
execution_errors.append((repo_path, context.parallel_result))
|
|
465
|
+
|
|
466
|
+
# Handle Foreground Execution
|
|
467
|
+
if args.exec_cmd and not args.parallel:
|
|
468
|
+
if not args.quiet:
|
|
469
|
+
print(
|
|
470
|
+
f"{Fore.YELLOW}[EXEC] {repo_path}: "
|
|
471
|
+
f"{subprocess.list2cmdline(args.exec_cmd)}"
|
|
472
|
+
f"{Fore.RESET}"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Run interactively/sequentially (capture=False allows interaction)
|
|
476
|
+
# However, for consistency with error tracking, we might want to
|
|
477
|
+
# capture. Usually --exec implies seeing output immediately.
|
|
478
|
+
try:
|
|
479
|
+
# We use subprocess.check_call to allow direct stdout/stderr
|
|
480
|
+
# flow unless we want to capture for error summary. To match
|
|
481
|
+
# previous logic, we let it flow to stdout.
|
|
482
|
+
subprocess.check_call(
|
|
483
|
+
args.exec_cmd,
|
|
484
|
+
cwd=repo_path
|
|
485
|
+
)
|
|
486
|
+
except subprocess.CalledProcessError as err:
|
|
487
|
+
# Construct a dummy result for the error summary
|
|
488
|
+
failed_res: CommandResult = CommandResult(
|
|
489
|
+
command=args.exec_cmd,
|
|
490
|
+
returncode=err.returncode
|
|
491
|
+
)
|
|
492
|
+
execution_errors.append((repo_path, failed_res))
|
|
493
|
+
except FileNotFoundError:
|
|
494
|
+
failed_res: CommandResult = CommandResult(
|
|
495
|
+
command=args.exec_cmd,
|
|
496
|
+
returncode=127
|
|
497
|
+
)
|
|
498
|
+
execution_errors.append((repo_path, failed_res))
|
|
499
|
+
|
|
500
|
+
# If no commands were run, just list the repo
|
|
501
|
+
if not args.exec_cmd:
|
|
502
|
+
print(repo_path)
|
|
503
|
+
|
|
504
|
+
# Final Error Summary
|
|
505
|
+
sys.exit(print_error_summary(execution_errors))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
if __name__ == "__main__":
|
|
509
|
+
try:
|
|
510
|
+
main()
|
|
511
|
+
except (KeyboardInterrupt, BrokenPipeError):
|
|
512
|
+
print("\nInterrupting...", file=sys.stderr)
|
|
513
|
+
sys.exit(1)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-rexec
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Locate Git repositories and execute commands against them in parallel.
|
|
5
|
+
Author: James Cherti
|
|
6
|
+
License: GPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/jamescherti/git-rexec
|
|
8
|
+
Keywords: git,parallel,executor,automation
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Provides-Extra: extras
|
|
17
|
+
Requires-Dist: colorama; extra == "extras"
|
|
18
|
+
Requires-Dist: setproctitle; extra == "extras"
|
|
19
|
+
|
|
20
|
+
# git-rexec: Find Git Repositories and Execute Commands Against Them, either Sequentially or in Parallel
|
|
21
|
+
|
|
22
|
+
The [git-rexec](https://github.com/jamescherti/git-rexec/) command-line tool that recursively locates Git repositories within a directory and executes commands against them, either sequentially or in parallel.
|
|
23
|
+
|
|
24
|
+
Here are examples demonstrating how to use `git-rexec`:
|
|
25
|
+
- Execute `git status -s` across all discovered Git repositories (found by searching recursively under the current working directory) in parallel (`-p` or `--parallel`):
|
|
26
|
+
```
|
|
27
|
+
git-rexec -p -- git status -s
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- Fetch updates across all discovered repositories, limiting the concurrency to 5 background jobs (`-j 5`), which helps avoid network congestion or server rate limits when communicating with upstream Git remotes:
|
|
31
|
+
```bash
|
|
32
|
+
git-rexec -j 5 --parallel -- git fetch
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- Target a specific base directory (`~/projects`) using the `-C` flag to recursively discover repositories within it, while explicitly excluding a specific subfolder (`~/projects/archive`). This example executes `git status -s` in parallel for all discovered repositories except those within the excluded path:
|
|
36
|
+
```bash
|
|
37
|
+
git-rexec -C ~/projects --exclude-dir ~/projects/archive --parallel -- git status -s
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- Evaluate whether a `README.md` file exists in the repository (`sh -c "test -f README.md"`). If the condition returns an exit status of 0 (success), it counts the number of lines in that file (`wc -l README.md`):
|
|
41
|
+
```bash
|
|
42
|
+
git-rexec --if-exec 'sh -c "test -f README.md"' --parallel -- wc -l README.md
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If this helps your workflow, please support the project by **⭐ starring git-rexec on GitHub** and sharing it on your website, blog, Mastodon, Reddit, X, LinkedIn, or other social media platforms to help more Git users discover its benefits.
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
- Recursively discover Git repositories starting from a specified root directory.
|
|
50
|
+
- Execute shell commands across multiple repositories in parallel using worker threads.
|
|
51
|
+
- Filter target repositories based on the exit code of a conditional check (`--if-exec`).
|
|
52
|
+
- Exclude specific directories from the search path.
|
|
53
|
+
- Optional: Can leverage `fd` for fast directory traversal if installed, falling back to standard Python path resolution otherwise.
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
### Method 1: Manual Installation (System-wide)
|
|
58
|
+
|
|
59
|
+
Download the `git-rexec` script, make it executable, and copy it to a directory in your system PATH (e.g., `/usr/local/bin`):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
sudo cp git-rexec /usr/local/bin/
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Method 2: Installation via pip
|
|
66
|
+
|
|
67
|
+
Install the package directly from the Git repository using `pip`:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install --user git+https://github.com/jamescherti/git-rexec
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Dependencies
|
|
74
|
+
|
|
75
|
+
### System Dependencies
|
|
76
|
+
|
|
77
|
+
- `git`: Required for repository validation and execution.
|
|
78
|
+
- `fd` (Optional): Highly recommended for faster repository discovery.
|
|
79
|
+
|
|
80
|
+
### Python Dependencies (Optional)
|
|
81
|
+
|
|
82
|
+
- `colorama`: Provides color-coded terminal output.
|
|
83
|
+
- `setproctitle`: Sets the process title for process monitoring tools.
|
|
84
|
+
|
|
85
|
+
You can install the optional Python dependencies via pip:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install colorama setproctitle
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Usage
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
git-rexec [OPTIONS] [exec_cmd ...]
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
*(Assuming the `git-rexec` script is executable and in your PATH.)*
|
|
100
|
+
|
|
101
|
+
### Positional Arguments
|
|
102
|
+
|
|
103
|
+
- `exec_cmd`: The shell command to execute within each discovered Git repository. You can use `--` to pass options directly to the command. If omitted, the script simply prints the paths of the discovered repositories.
|
|
104
|
+
|
|
105
|
+
### Options
|
|
106
|
+
|
|
107
|
+
- `-C, --directory <path>`: The root directory to start searching for Git repositories. Defaults to the current working directory (`.`).
|
|
108
|
+
- `--exclude-dir <path>`: Exclude a specific directory and all of its subdirectories from the search. This option can be provided multiple times.
|
|
109
|
+
- `-p, --parallel`: Execute the command in parallel using threads.
|
|
110
|
+
- `-i, --if-exec <command>`: Execute the main command only if this check command returns an exit code of `0`.
|
|
111
|
+
- `-j, --jobs <int>`: The maximum number of concurrent workers/processors to use for parallel execution. Defaults to the number of CPU cores available.
|
|
112
|
+
- `-h, --help`: Show the help message and exit.
|
|
113
|
+
- `-q, --quiet`: Quiet mode. Suppresses the informational tracking headers (`[EXEC]` and `[EXEC-P]`) that prefix execution output. In sequential mode, it hides the `[EXEC]` repository delimiter line entirely; in parallel mode (`-p`), it strips the yellow `[EXEC-P]` header track and removes the four-space indentation, printing only the raw, unindented stdout and stderr streams. This flag has no effect when no execution command is supplied, allowing discovered repository paths to print normally.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
118
|
+
|
|
119
|
+
Copyright (C) 2019-2026 [James Cherti](https://www.jamescherti.com).
|
|
120
|
+
|
|
121
|
+
## Links
|
|
122
|
+
|
|
123
|
+
- [git-rexec @GitHub](https://github.com/jamescherti/git-rexec)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
git_rexec-1.0.0.data/scripts/git-rexec,sha256=tH-t58Rd7-EwnmhfWFKcSHgjaspAzeliJapYOd-5jJM,16530
|
|
2
|
+
git_rexec-1.0.0.dist-info/METADATA,sha256=EazUM3ohURab0sywUaf0Lrf71N47ayE-gpYq6FLDgYo,5841
|
|
3
|
+
git_rexec-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
4
|
+
git_rexec-1.0.0.dist-info/top_level.txt,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
5
|
+
git_rexec-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|