skilleter-thingy 0.3.14__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.
Files changed (39) hide show
  1. skilleter_thingy/__init__.py +0 -0
  2. skilleter_thingy/addpath.py +107 -0
  3. skilleter_thingy/console_colours.py +63 -0
  4. skilleter_thingy/ffind.py +535 -0
  5. skilleter_thingy/ggit.py +88 -0
  6. skilleter_thingy/ggrep.py +155 -0
  7. skilleter_thingy/git_br.py +186 -0
  8. skilleter_thingy/git_ca.py +147 -0
  9. skilleter_thingy/git_cleanup.py +297 -0
  10. skilleter_thingy/git_co.py +227 -0
  11. skilleter_thingy/git_common.py +68 -0
  12. skilleter_thingy/git_hold.py +162 -0
  13. skilleter_thingy/git_parent.py +84 -0
  14. skilleter_thingy/git_retag.py +67 -0
  15. skilleter_thingy/git_review.py +1450 -0
  16. skilleter_thingy/git_update.py +398 -0
  17. skilleter_thingy/git_wt.py +72 -0
  18. skilleter_thingy/gitcmp_helper.py +328 -0
  19. skilleter_thingy/gitprompt.py +293 -0
  20. skilleter_thingy/linecount.py +154 -0
  21. skilleter_thingy/multigit.py +915 -0
  22. skilleter_thingy/py_audit.py +133 -0
  23. skilleter_thingy/remdir.py +127 -0
  24. skilleter_thingy/rpylint.py +98 -0
  25. skilleter_thingy/strreplace.py +82 -0
  26. skilleter_thingy/test.py +34 -0
  27. skilleter_thingy/tfm.py +948 -0
  28. skilleter_thingy/tfparse.py +101 -0
  29. skilleter_thingy/trimpath.py +82 -0
  30. skilleter_thingy/venv_create.py +47 -0
  31. skilleter_thingy/venv_template.py +47 -0
  32. skilleter_thingy/xchmod.py +124 -0
  33. skilleter_thingy/yamlcheck.py +89 -0
  34. skilleter_thingy-0.3.14.dist-info/METADATA +606 -0
  35. skilleter_thingy-0.3.14.dist-info/RECORD +39 -0
  36. skilleter_thingy-0.3.14.dist-info/WHEEL +5 -0
  37. skilleter_thingy-0.3.14.dist-info/entry_points.txt +31 -0
  38. skilleter_thingy-0.3.14.dist-info/licenses/LICENSE +619 -0
  39. skilleter_thingy-0.3.14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,915 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """mg - MultiGit - utility for managing multiple Git working trees in a hierarchical directory tree"""
4
+
5
+ import os
6
+ import sys
7
+ import fnmatch
8
+ import configparser
9
+ import subprocess
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from skilleter_modules import git
14
+ from skilleter_modules import colour
15
+
16
+ ################################################################################
17
+ # TODO: 2. If run in a subdirectory, only process working trees in that tree (or have an option to do so, or an option _not_ to do so; --all)
18
+ # TODO: 2. select_git_repos() and +dir should use consist way of selecting repos if possible
19
+ # TODO: 3 .init option '--set-default' to update the default branch to the current one for specified working trees
20
+ # TODO: 3. Verbose option
21
+ # TODO: 3. When filtering by tag or by repo name, if name starts with '!' only match if tag isn't present or repo name doesn't match (and don't allow '!' at start of tag otherwise)
22
+ # TODO: 3. (alternative to above) A '--not' option that inverts all the matching criteria, so '--not --branched --modified' selects all unmodified repos which aren't branched
23
+ # TODO: 3. (alternative to both above) Additional options; --!modified --!branched --!tag which act as the inverse of each option, so --!branched selects all unbranched repos.
24
+ # TODO: 3. (another alternative) - Use 'not-' instead of '!', so have --not-branched, --not-modified, --not-tag (single-letter options -B, -M, -T).
25
+ # TODO: 4. Option to +dir to return all matches so that caller can select one they want
26
+ # TODO: 4. Shell autocomplete for +dir
27
+ # TODO: 5. -j option to run in parallel - yes, but it will only work with non-interactive Git commands
28
+ ################################################################################
29
+
30
+ DEFAULT_CONFIG_FILE = 'multigit.toml'
31
+
32
+ # If a branch name is specified as 'DEFAULT' then the default branch for the
33
+ # repo is used instead.
34
+
35
+ DEFAULT_BRANCH = 'DEFAULT'
36
+
37
+ ################################################################################
38
+ # Command line help - we aren't using argparse since it isn't flexible enough to handle arbtirary git
39
+ # commands are parameters so we have to manually create the help and parse the command line
40
+
41
+ HELP_INFO = """usage: multigit [--help|-h] [--verbose|-v] [--quiet|-q] [--config|-c CONFIG] [--repos|-r REPOS] [--modified|-m] [--branched|-b] [--sub|-s] [--tag|-t TAGS] [--continue|-o] [--path|-C PATH]
42
+ {+clone, +init, +config, +dir, +list, +run, +add, GIT_COMMAND} ...
43
+
44
+ Run git commands in multiple Git repos. DISCLAIMER: This is beta-quality software, with missing features and liable to fail with a stack trace, but shouldn't eat your data
45
+
46
+ options:
47
+ -h, --help show this help message and exit
48
+ --verbose, -v Verbosity to the maximum
49
+ --quiet, -q Minimal console output
50
+ --config CONFIG, -c CONFIG
51
+ The configuration file (defaults to multigit.toml)
52
+ --repos REPOS, -r REPOS
53
+ The repo names to work on (defaults to all repos and can contain shell wildcards and can be issued multiple times on the command line)
54
+ --modified, -m Select repos that have local modifications
55
+ --branched, -b Select repos that do not have the default branch checked out
56
+ --tag TAG, -t TAG Select repos that have the specified tag (can be issued multiple times on the command line)
57
+ --sub, -s Select only the repos in the current directory and subdirectories
58
+ --continue, -o Continue if a git command returns an error (by default, executation terminates when a command fails)
59
+ --path, -C PATH Run as if the command was started in PATH instead of the current working directory
60
+
61
+ Sub-commands:
62
+ {+clone, +init, +dir, +config, +list, +run, +add, +update, GIT_COMMAND}
63
+ +clone REPO {BRANCH} Clone a repo containing a multigit configuration file, then clone all the child repos and check out the default branch in each
64
+ +init Build or update the configuration file using the current branch in each repo as the default branch
65
+ +config Output the name and location of the configuration file
66
+ +dir Output the location of a working tree, given the repo name, or if no parameter specified, the root directory of the multigit tree
67
+ +list Output a list of the top level directories of each of the Git repos
68
+ +tag TAG Apply a configuration tag to repos filtered by the command line options (list configuration tags if no parameter specified)
69
+ +untag TAG Remove a configuration tag to repos filtered by the command line options
70
+ +run COMMAND Run the specified command in repos filtered by the command line options
71
+ +add REPO DIR Clone REPO into the DIR directory and add it to the multigit configuration
72
+ +update Clone any repos in the current configuration that do not have working trees
73
+ GIT_COMMAND Any git command, including options and parameters - this is then run in all specified working trees
74
+
75
+ """
76
+
77
+ ################################################################################
78
+
79
+ @dataclass
80
+ class Arguments():
81
+ """Data class to contain command line options and parameters"""
82
+
83
+ # Command line options for output noise
84
+
85
+ quiet: bool = False
86
+ verbose: bool = False
87
+
88
+ # True if we continue after a git command returns an error
89
+
90
+ error_continue: bool = False
91
+
92
+ # Default and current configuration file
93
+
94
+ default_configuration_file: str = DEFAULT_CONFIG_FILE
95
+ configuration_file: str = None
96
+
97
+ # Command line filter options
98
+
99
+ repos: list[str] = field(default_factory=list)
100
+ tag: list[str] = field(default_factory=list)
101
+ modified: bool = False
102
+ branched: bool = False
103
+ subdirectories: bool = False
104
+
105
+ # Command to run with parameters
106
+
107
+ command: str = None
108
+ parameters: list[str] = field(default_factory=list)
109
+
110
+ # True if running an internal command
111
+
112
+ internal_command: bool = False
113
+
114
+ # True if the configuration data needs to be written back on completion
115
+
116
+ config_modified: bool = False
117
+
118
+ ################################################################################
119
+
120
+ def verbose(args, msg):
121
+ """Output a message to stderr if running verbosely"""
122
+
123
+ if args.verbose:
124
+ colour.write(f'>>>{msg}', stream=sys.stderr)
125
+
126
+ ################################################################################
127
+
128
+ def absolute_repo_path(args, relative_path=''):
129
+ """Given a path relative to the multigit configuration file, return
130
+ the absolute path thereto"""
131
+
132
+ return os.path.join(os.path.dirname(args.configuration_file), relative_path)
133
+
134
+ ################################################################################
135
+
136
+ def relative_repo_path(args, relative_path=''):
137
+ """Given a path relative to the multigit configuration file, return
138
+ the relative path from the current directory"""
139
+
140
+ return os.path.relpath(absolute_repo_path(args, relative_path))
141
+
142
+ ################################################################################
143
+
144
+ def safe_clone(args, location, origin):
145
+ """If location exists then fail with an error if it isn't a directory, or
146
+ or is a directory, but isn't a working tree, or is a working tree for a
147
+ different Git repo than remote otherwise do nothing.
148
+ If it doesn't exist, then clone the specified remote there."""
149
+
150
+ if os.path.exists(location):
151
+ if not os.path.isdir(os.path.join(location, '.git')):
152
+ colour.error(f'"[BLUE:{location}]" already exists and is not a Git working tree', prefix=True)
153
+
154
+ remotes = git.remotes(path=location)
155
+
156
+ for remote in remotes:
157
+ if origin == remotes[remote]:
158
+ break
159
+ else:
160
+ colour.error(f'"[BLUE:{location}]" already exists and was not cloned from [BLUE:{origin}]', prefix=True)
161
+
162
+ else:
163
+ if not args.quiet:
164
+ colour.write(f'Cloning [BOLD:{origin}] into [BLUE:{location}]')
165
+
166
+ git.clone(origin, working_tree=location)
167
+
168
+ ################################################################################
169
+
170
+ def find_configuration(default_config_file):
171
+ """If the configuration file name has path elements, try and read it, otherwise
172
+ search up the directory tree looking for the configuration file.
173
+ Returns configuration file path or None if the configuration file
174
+ could not be found."""
175
+
176
+ if '/' in default_config_file:
177
+ config_file = default_config_file
178
+ else:
179
+ try:
180
+ config_path = os.getcwd()
181
+ except FileNotFoundError:
182
+ colour.error('Unable to determine current directory', prefix=True)
183
+
184
+ config_file = os.path.join(config_path, default_config_file)
185
+
186
+ while not os.path.isfile(config_file) and config_path != '/':
187
+ config_path = os.path.dirname(config_path)
188
+ config_file = os.path.join(config_path, default_config_file)
189
+
190
+ return config_file if os.path.isfile(config_file) else None
191
+
192
+ ################################################################################
193
+
194
+ def show_progress(width, msg):
195
+ """Show a single line progress message without moving the cursor to the next
196
+ line."""
197
+
198
+ colour.write(msg[:width-1], newline=False, cleareol=True, cr=True)
199
+
200
+ ################################################################################
201
+
202
+ def find_working_trees(args):
203
+ """Locate and return a list of '.git' directory parent directories in the
204
+ specified path.
205
+
206
+ If wildcard is not None then it is treated as a list of wildcards and
207
+ only repos matching at least one of the wildcards are returned.
208
+
209
+ If the same repo matches multiple times it will only be returned once. """
210
+
211
+ repos = set()
212
+
213
+ for root, dirs, _ in os.walk(os.path.dirname(args.configuration_file), topdown=True):
214
+ if '.git' in dirs:
215
+ relative_path = os.path.relpath(root)
216
+
217
+ if args.repos:
218
+ for card in args.repos:
219
+ if fnmatch.fnmatch(relative_path, card):
220
+ if relative_path not in repos:
221
+ yield relative_path
222
+ repos.add(relative_path)
223
+ break
224
+ else:
225
+ if relative_path not in repos:
226
+ yield relative_path
227
+ repos.add(relative_path)
228
+
229
+ # Don't recurse down into hidden directories
230
+
231
+ dirs[:] = [d for d in dirs if d[0] != '.']
232
+
233
+ ################################################################################
234
+
235
+ def select_git_repos(args, config):
236
+ """Return git repos from the configuration that match the criteria on the
237
+ multigit command line (the --repos, --tag, --modified, --sub and --branched options)
238
+ or, return them all if no relevant options specified"""
239
+
240
+ for repo_path in config.sections():
241
+ # If repos are specified, then only match according to exact name match,
242
+ # exact path match or wildcard match
243
+
244
+ repo_abs_path = absolute_repo_path(args, repo_path)
245
+
246
+ if args.repos:
247
+ for entry in args.repos:
248
+ if config[repo_path]['repo name'] == entry:
249
+ matching = True
250
+ break
251
+
252
+ if repo_path == entry:
253
+ matching = True
254
+ break
255
+
256
+ if '?' in entry or '*' in entry:
257
+ if fnmatch.fnmatch(repo_path, entry) or fnmatch.fnmatch(config[repo_path]['repo name'], entry):
258
+ matching = True
259
+ break
260
+
261
+ else:
262
+ matching = False
263
+ else:
264
+ matching = True
265
+
266
+ # If branched specified, only match if the repo is matched _and_ branched
267
+
268
+ if matching and args.branched:
269
+ if git.branch(path=repo_abs_path) == config[repo_path]['default branch']:
270
+ matching = False
271
+
272
+ # If modified specified, only match if the repo is matched _and_ modified
273
+
274
+ if matching and args.modified:
275
+ if not git.status(path=repo_abs_path):
276
+ matching = False
277
+
278
+ # If tag filtering specified, only match if the repo is tagged with one of the specified tags
279
+
280
+ if matching and args.tag:
281
+ for entry in args.tag:
282
+ try:
283
+ tags = config[repo_path]['tags'].split(',')
284
+ if entry in tags:
285
+ break
286
+ except KeyError:
287
+ pass
288
+ else:
289
+ matching = False
290
+
291
+ # If subdirectories specified, only match if the repo is in the current directory tree
292
+
293
+ if matching and args.subdirectories:
294
+ repo_path_rel = os.path.relpath(absolute_repo_path(args, repo_path))
295
+
296
+ if repo_path_rel == '..' or repo_path_rel.startswith('../'):
297
+ matching = False
298
+
299
+ # If we have a match, yield the config entry to the caller
300
+
301
+ if matching:
302
+ yield config[repo_path]
303
+
304
+ ################################################################################
305
+
306
+ def branch_name(name, default_branch):
307
+ """If name is None or DEFAULT_BRANCH return default_branch, otherwise return name"""
308
+
309
+ return default_branch if not name or name == DEFAULT_BRANCH else name
310
+
311
+ ################################################################################
312
+
313
+ def add_new_repo(args, config, repo_path, default_branch=None):
314
+ """Add a new configuration entry containing the default branch, remote origin
315
+ (if there is one), name and default branch"""
316
+
317
+ abs_repo_path = absolute_repo_path(args, repo_path)
318
+
319
+ added = repo_path not in config
320
+
321
+ config[repo_path] = {}
322
+
323
+ if not default_branch:
324
+ default_branch = git.branch(path=abs_repo_path)
325
+
326
+ if not default_branch:
327
+ colour.error(f'Unable to determine default branch in [BLUE:{abs_repo_path}]', prefix=True)
328
+
329
+ config[repo_path]['default branch'] = default_branch
330
+
331
+ remote = git.remotes(path=abs_repo_path)
332
+
333
+ if 'origin' in remote:
334
+ config[repo_path]['origin'] = remote['origin']
335
+ config[repo_path]['repo name'] = os.path.basename(remote['origin']).removesuffix('.git')
336
+ else:
337
+ config[repo_path]['repo name'] = os.path.basename(repo_path)
338
+
339
+ if not args.quiet:
340
+ if added:
341
+ colour.write(f'Added [BLUE:{repo_path}] with default branch [BLUE:{default_branch}]')
342
+ else:
343
+ colour.write(f'Reset [BLUE:{repo_path}] with default branch [BLUE:{default_branch}]')
344
+
345
+ ################################################################################
346
+
347
+ def mg_clone(args, config, console):
348
+ """Clone a repo, optionally check out a branch and attempt to read the
349
+ multigit configuration file and clone all the repos listed therein, checkouting
350
+ the default branch in each one"""
351
+
352
+ _ = console
353
+
354
+ # Sanity checks
355
+
356
+ if not args.parameters:
357
+ colour.error('The "[BOLD:clone]" subcommand takes 1 or 2 parameters - the repo to clone and, optionally, the branch to check out', prefix=True)
358
+
359
+ if args.branched or args.modified:
360
+ colour.error('The "[BOLD:modified]" and "[BOLD:branched]" options cannot be used with the "[BOLD:clone]" subcommand', prefix=True)
361
+
362
+ # Destination directory is the last portion of the repo URL with the extension removed
363
+
364
+ directory = os.path.splitext(os.path.basename(args.parameters[0]))[0]
365
+
366
+ if os.path.exists(directory):
367
+ if os.path.isdir(directory):
368
+ colour.error(f'The "[BLUE:{directory}]" directory already exists', prefix=True)
369
+ else:
370
+ colour.error(f'"[BLUE:{directory}]" already exists', prefix=True)
371
+
372
+ # Clone the repo and chdir into it
373
+
374
+ if not args.quiet:
375
+ colour.write(f'Cloning [BOLD:{args.parameters[0]}] into [BLUE:{directory}]')
376
+
377
+ git.clone(args.parameters[0], working_tree=directory)
378
+
379
+ os.chdir(directory)
380
+
381
+ # Optionally checkout a branch, if specified
382
+
383
+ if len(args.parameters) > 1:
384
+ git.checkout(args.parameters[1])
385
+
386
+ # Open the configuration file in the repo (if no configuration file has been specified, use the default)
387
+
388
+ if not args.configuration_file:
389
+ args.configuration_file = args.default_configuration_file
390
+
391
+ if not os.path.isfile(args.configuration_file):
392
+ colour.error(f'Cannot find the configuration file: [BLUE:{args.default_configuration_file}]', prefix=True)
393
+
394
+ config.read(args.configuration_file)
395
+
396
+ # Now iterate through the repos, creating directories and cloning them and checking
397
+ # out the default branch
398
+
399
+ for repo in select_git_repos(args, config):
400
+ if repo.name != '.':
401
+ directory = os.path.dirname(repo.name)
402
+
403
+ if directory:
404
+ os.makedirs(directory, exist_ok=True)
405
+
406
+ if not args.quiet:
407
+ colour.write(f'Cloning [BLUE:{repo["origin"]}] into [BLUE:{directory}]')
408
+
409
+ git.clone(repo['origin'], working_tree=repo.name)
410
+
411
+ if not args.quiet:
412
+ colour.write(f' Checking out [BLUE:{repo["default branch"]}]')
413
+
414
+ git.checkout(repo['default branch'], path=repo.name)
415
+
416
+ ################################################################################
417
+
418
+ def mg_init(args, config, console):
419
+ """Create or update the configuration
420
+ By default, it scans the tree for git directories and adds or updates them
421
+ in the configuration, using the current branch as the default branch. """
422
+
423
+ # Sanity checks
424
+
425
+ if args.modified or args.branched or args.tag or args.subdirectories:
426
+ colour.error('The "[BOLD:--tag]", "[BOLD:--modified]" "[BOLD:--sub]", and "[BOLD:--branched]" options cannot be used with the "[BOLD:init]" subcommand', prefix=True)
427
+
428
+ # Search for .git directories and add any that aren't already in the configuration
429
+
430
+ repo_list = []
431
+ for repo_dir in find_working_trees(args):
432
+ if not args.quiet:
433
+ show_progress(console.columns, repo_dir)
434
+
435
+ repo_list.append(repo_dir)
436
+
437
+ if repo_dir not in config:
438
+ add_new_repo(args, config, repo_dir)
439
+
440
+ if not args.quiet:
441
+ colour.write(cleareol=True)
442
+
443
+ # Look for configuration entries that are no longer present and delete them
444
+
445
+ removals = []
446
+
447
+ for repo in config:
448
+ if repo != 'DEFAULT' and repo not in repo_list:
449
+ removals.append(repo)
450
+
451
+ for entry in removals:
452
+ del config[entry]
453
+ colour.write(f'Removed [BLUE:{entry}] as it no longer exists')
454
+
455
+ # The configuration file needs to be updated
456
+
457
+ args.config_modified = True
458
+
459
+ ################################################################################
460
+
461
+ def mg_add(args, config, console):
462
+ """Add a new repo - takes 2 parameters; the repo to clone and the directory
463
+ to clone it into. If successful, adds the repo to the configuration"""
464
+
465
+ _ = console
466
+ _ = config
467
+
468
+ verbose(args, f'add: Parameters: {", ".join(args.parameters)}')
469
+
470
+ if len(args.parameters) != 2:
471
+ colour.error('The "[BOLD:+add]" command takes two parameters; the repo to clone the location to clone it into', prefix=True)
472
+
473
+ if args.modified or args.branched or args.tag or args.subdirectories:
474
+ colour.error('The "[BOLD:--tag]", "[BOLD:--modified]" "[BOLD:--sub]", and "[BOLD:--branched]" options cannot be used with the "[BOLD:+add]" subcommand', prefix=True)
475
+
476
+ repo = args.parameters[0]
477
+ location = args.parameters[1]
478
+
479
+ # Attempt to clone it
480
+
481
+ safe_clone(args, repo, location)
482
+
483
+ # Add to the configuration
484
+
485
+ add_new_repo(args, config, location)
486
+
487
+ # The configuration file needs to be updated
488
+
489
+ args.config_modified = True
490
+
491
+ ################################################################################
492
+
493
+ def mg_update(args, config, console):
494
+ """Clone any repos in the current configuration that do not have working trees
495
+ Similar to the '+init' command except that it updates an existing multigit
496
+ tree rather than creating one from scratch."""
497
+
498
+ _ = console
499
+
500
+ # Don't allow pointless options
501
+
502
+ if args.modified or args.branched or args.tag:
503
+ colour.error('The "[BOLD:--tag]", "[BOLD:--modified]" and "[BOLD:--branched]" options cannot be used with the "[BOLD:+update]" subcommand', prefix=True)
504
+
505
+ # Now iterate through the repos, cloning any that don't already have working trees
506
+
507
+ for repo in select_git_repos(args, config):
508
+ if repo.name != '.':
509
+ safe_clone(args, repo.name, repo['origin'])
510
+
511
+ ################################################################################
512
+
513
+ def mg_dir(args, config, console):
514
+ """Return the location of a working tree, given the name, or the root directory
515
+ of the tree if not
516
+ Returns an error unless there is a unique match"""
517
+
518
+ _ = console
519
+ _ = config
520
+
521
+ verbose(args, f'dir: Parameters: {", ".join(args.parameters)}')
522
+
523
+ if len(args.parameters) > 1:
524
+ colour.error('The "[BOLD:+dir]" command takes no more than one parameter - the name of the working tree to search for', prefix=True)
525
+
526
+ # TODO: mg_dir _should_ use these options
527
+
528
+ if args.modified or args.branched or args.tag or args.subdirectories:
529
+ colour.error('The "[BOLD:--tag]", "[BOLD:--modified]" "[BOLD:--sub]", and "[BOLD:--branched]" options cannot be used with the "[BOLD:+dir]" subcommand', prefix=True)
530
+
531
+ # If a parameter is specified, look for matches, otherwise just return the location of the
532
+ # configuration file
533
+
534
+ if not args.parameters:
535
+ colour.write(os.path.dirname(args.configuration_file))
536
+ return
537
+
538
+ locations = []
539
+
540
+ search_name = args.parameters[0]
541
+
542
+ # Search for wildcard matches, or matches that contain the search term if it
543
+ # doesn't already contain a wildcard
544
+
545
+ if '*' in search_name or '?' in search_name:
546
+ search_name = f'*{search_name}*'
547
+ for repo in select_git_repos(args, config):
548
+ if fnmatch.fnmatch(repo['repo name'], search_name):
549
+ locations.append(repo.name)
550
+ else:
551
+ for repo in select_git_repos(args, config):
552
+ if search_name in repo['repo name']:
553
+ locations.append(repo.name)
554
+
555
+ if not locations:
556
+ colour.error(f'No matches with [BLUE:{search_name}]', prefix=True)
557
+
558
+ colour.write("\n".join([relative_repo_path(args, loc) for loc in locations]))
559
+
560
+
561
+ ################################################################################
562
+
563
+ def mg_tag(args, config, console):
564
+ """Apply a configuration tag"""
565
+
566
+ _ = console
567
+
568
+ if len(args.parameters) > 1:
569
+ colour.error('The "[BOLD:+tag]" command takes no more than one parameter', prefix=True)
570
+
571
+ for repo in select_git_repos(args, config):
572
+ try:
573
+ tags = repo.get('tags').split(',')
574
+ except AttributeError:
575
+ tags = []
576
+
577
+ if args.parameters:
578
+ if args.parameters[0] not in tags:
579
+ tags.append(args.parameters[0])
580
+ repo['tags'] = ','.join(tags)
581
+ args.config_modified = True
582
+ elif tags:
583
+ colour.write(f'[BLUE:{repo["repo name"]}] - {", ".join(tags)}')
584
+
585
+ ################################################################################
586
+
587
+ def mg_untag(args, config, console):
588
+ """Remove a configuration tag"""
589
+
590
+ _ = console
591
+
592
+ if len(args.parameters) > 1:
593
+ colour.error('The "[BOLD:+tag]" command takes no more than one parameter', prefix=True)
594
+
595
+ for repo in select_git_repos(args, config):
596
+ try:
597
+ tags = repo.get('tags', '').split(',')
598
+ except AttributeError:
599
+ tags = []
600
+
601
+ if args.parameters[0] in tags:
602
+ tags.remove(args.parameters[0])
603
+ repo['tags'] = ','.join(tags)
604
+ args.config_modified = True
605
+
606
+ ################################################################################
607
+
608
+ def mg_config(args, config, console):
609
+ """Output the path to the configuration file"""
610
+
611
+ _ = config
612
+ _ = console
613
+
614
+ if len(args.parameters):
615
+ colour.error('The "[BOLD:+config]" command does not take parameters', prefix=True)
616
+
617
+ colour.write(os.path.relpath(args.configuration_file))
618
+
619
+ ################################################################################
620
+
621
+ def mg_list(args, config, console):
622
+ """List the top-level directories of the Git repos in the configuration"""
623
+
624
+ _ = console
625
+
626
+ for repo in select_git_repos(args, config):
627
+ print(repo.name)
628
+
629
+ ################################################################################
630
+
631
+ def mg_run(args, config, console):
632
+ """Run a command in each of the working trees, optionally continuing if
633
+ there's an error"""
634
+
635
+ _ = console
636
+
637
+ if not args.parameters:
638
+ colour.error('[BOLD:+run] command - missing parameter(s)', prefix=True)
639
+
640
+ # Run the command in each of the working trees
641
+
642
+ for repo in select_git_repos(args, config):
643
+ if not args.quiet:
644
+ colour.write(f'\n[BLUE:{os.path.relpath(repo.name)}]\n')
645
+
646
+ repo_path = absolute_repo_path(args, repo.name)
647
+
648
+ try:
649
+ status = subprocess.run(args.parameters, cwd=repo_path, check=True)
650
+
651
+ except FileNotFoundError:
652
+ err_msg = f'"[BLUE:{args.parameters[0]}]" - Command not found'
653
+ if args.error_continue:
654
+ colour.write(f'[RED:WARNING]: {err_msg}')
655
+ else:
656
+ colour.error(f'[RED:ERROR]: {err_msg}')
657
+
658
+ except subprocess.CalledProcessError as exc:
659
+ if not args.error_continue:
660
+ sys.exit(exc.returncode)
661
+
662
+ ################################################################################
663
+
664
+ def run_git_command(args, config, console):
665
+ """Run a Git command in each of the working trees, optionally continuing if
666
+ there's an error"""
667
+
668
+ _ = console
669
+
670
+ for repo in select_git_repos(args, config):
671
+ repo_command = [args.command]
672
+
673
+ # Replace 'DEFAULT' in the command with the default branch in the repo
674
+
675
+ for cmd in args.parameters:
676
+ repo_command.append(branch_name(cmd, repo['default branch']))
677
+
678
+ colour.write(f'\n[BLUE:{os.path.relpath(repo.name)}]\n')
679
+
680
+ # Run the command in the working tree
681
+
682
+ repo_path = absolute_repo_path(args, repo.name)
683
+
684
+ _, status = git.git_run_status(repo_command, path=repo_path, redirect=False)
685
+
686
+ if status and not args.error_continue:
687
+ sys.exit(status)
688
+
689
+ ################################################################################
690
+
691
+ def parse_command_line():
692
+ """Manually parse the command line as we want to be able to accept 'multigit <OPTIONS> <+MULTIGITCOMMAND | ANY_GIT_COMMAND_WITH_OPTIONS>
693
+ and I can't see a way to get ArgumentParser to accept arbitrary command+options"""
694
+
695
+ args = Arguments()
696
+
697
+ # Parse the command line, setting options in the args dataclass appropriately
698
+
699
+ arg_list = sys.argv[1:]
700
+
701
+ # Iterate through each lump of options (e.g. '-xyz' is a lump of 3 options and
702
+ # '--config' is a lump containing a single option, as is just '-x')
703
+
704
+ while arg_list and arg_list[0].startswith('-'):
705
+ # Split a parameter into a list, so --x becomes [x] but -xyz becomes [x, y, z]
706
+ # Note that this means that an option with a parameter must always have a space
707
+ # between the option and the parameter (e.g. '-C path' not '-Cpath'
708
+ # Also note that we don't support '--option=VALUE' - it must be '--option VALUE'
709
+
710
+ arg_entry = arg_list.pop(0)
711
+ if arg_entry.startswith('--'):
712
+ option_list = [arg_entry[2:]]
713
+ else:
714
+ option_list = list(arg_entry[1:])
715
+
716
+ # Process each option in the current option lump.
717
+ # For short options that take a parameter (e.g. '-C PATH') we check that the option list
718
+ # is empty as '-Cx PATH' expands to '-C', '-x PATH', not '-C PATH', '-x'
719
+
720
+ while option_list:
721
+ option = option_list.pop(0)
722
+
723
+ if option in ('verbose', 'v'):
724
+ args.verbose = True
725
+
726
+ elif option in ('quiet', 'q'):
727
+ args.quiet = True
728
+
729
+ elif option in ('config', 'c'):
730
+ if option_list:
731
+ colour.error('The "[BLUE:--config]" option takes a configuration file parameter', prefix=True)
732
+
733
+ try:
734
+ args.default_configuration_file = arg_list.pop(0)
735
+ except IndexError:
736
+ colour.error('"The [BLUE:--config]" option takes a configuration file parameter', prefix=True)
737
+
738
+ elif option in ('repos', 'r'):
739
+ if option_list:
740
+ colour.error('The "[BLUE:--repos]" option takes a repo parameter', prefix=True)
741
+
742
+ try:
743
+ args.repos.append(arg_list.pop(0))
744
+ except IndexError:
745
+ colour.error('The "[BLUE:--repos]" option takes a repo parameter', prefix=True)
746
+
747
+ elif option in ('tag', 't'):
748
+ if option_list:
749
+ colour.error('The "[BLUE:--tag]" option takes a tag parameter', prefix=True)
750
+
751
+ try:
752
+ args.tag.append(arg_list.pop(0))
753
+ except IndexError:
754
+ colour.error('The "[BLUE:--tag]" option takes a tag parameter', prefix=True)
755
+
756
+ elif option in ('modified', 'm'):
757
+ args.modified = True
758
+
759
+ elif option in ('branched', 'b'):
760
+ args.branched = True
761
+
762
+ elif option in ('sub', 's'):
763
+ args.subdirectories = True
764
+
765
+ elif option in ('continue', 'o'):
766
+ args.error_continue = True
767
+
768
+ elif option in ('path', 'C'):
769
+ if option_list:
770
+ colour.error('The "[BOLD:--path]" option takes a path parameter')
771
+
772
+ try:
773
+ workingdir = arg_list.pop(0)
774
+ os.chdir(workingdir)
775
+ except IndexError:
776
+ colour.error('The "[BOLD:-C]" option takes a path parameter', prefix=True)
777
+ except FileNotFoundError:
778
+ colour.error(f'"[BOLD:--path]" - path "[BLUE:{workingdir}]" not found', prefix=True)
779
+
780
+ elif option in ('help', 'h'):
781
+ colour.write(HELP_INFO)
782
+ sys.exit(0)
783
+
784
+ else:
785
+ colour.error(f'Invalid option: "[BOLD:{option}]"', prefix=True)
786
+
787
+ # After the options, we either have a multigit command (prefixed with '+') or a git command
788
+ # followed by parameter
789
+
790
+ try:
791
+ command = arg_list.pop(0)
792
+
793
+ if command[0] == '+':
794
+ args.command = command[1:]
795
+ args.internal_command = True
796
+ else:
797
+ args.command = command
798
+ args.internal_command = False
799
+
800
+ except IndexError:
801
+ colour.error('Missing command', prefix=True)
802
+
803
+ # Save the command parameters
804
+
805
+ args.parameters = arg_list
806
+
807
+ # Locate the configuration file
808
+
809
+ args.configuration_file = find_configuration(args.default_configuration_file)
810
+
811
+ return args
812
+
813
+ ################################################################################
814
+
815
+ COMMANDS = {
816
+ 'clone': mg_clone,
817
+ 'init': mg_init,
818
+ 'dir': mg_dir,
819
+ 'config': mg_config,
820
+ 'tag': mg_tag,
821
+ 'untag': mg_untag,
822
+ 'list': mg_list,
823
+ 'run': mg_run,
824
+ 'add': mg_add,
825
+ 'update': mg_update,
826
+ }
827
+
828
+ def main():
829
+ """Main function"""
830
+
831
+ # Parse the command line and santity check the command to run
832
+ # (if it is an external command we let git worry about it)
833
+
834
+ args = parse_command_line()
835
+
836
+ if args.internal_command and args.command not in COMMANDS:
837
+ colour.error(f'Invalid command "{args.command}"', prefix=True)
838
+
839
+ # If the configuration file exists, read it
840
+
841
+ config = configparser.ConfigParser()
842
+
843
+ # If running the '+init' command without an existing configuration file
844
+ # use the default one (which may have been overridden on the command line)
845
+ # Otherwise, fail if we can't find the configuration file.
846
+
847
+ if not args.configuration_file:
848
+ if args.internal_command:
849
+ if args.command == 'init':
850
+ args.configuration_file = os.path.abspath(args.default_configuration_file)
851
+ else:
852
+ colour.error('Cannot locate configuration file', prefix=True)
853
+
854
+ if args.configuration_file and os.path.isfile(args.configuration_file):
855
+ config.read(args.configuration_file)
856
+
857
+ # Get the console size
858
+
859
+ try:
860
+ console = os.get_terminal_size()
861
+ except OSError:
862
+ console = None
863
+ args.quiet = True
864
+
865
+ # Run an internal or external command-specific validation
866
+
867
+ if args.internal_command:
868
+ # Everything except '+init' and '+clone' requires the configuration file
869
+
870
+ if args.command not in ('init', 'clone') and args.configuration_file is None:
871
+ colour.error('Configuration file not found', prefix=True)
872
+
873
+ COMMANDS[args.command](args, config, console)
874
+
875
+ # Save the updated configuration file if it has changed (currently, only the init command will do this).
876
+
877
+ if config and args.config_modified:
878
+ with open(args.configuration_file, 'w', encoding='utf8') as configfile:
879
+ config.write(configfile)
880
+
881
+ else:
882
+ # Run the external command, no need to update the config as it can't change here
883
+
884
+ run_git_command(args, config, console)
885
+
886
+ ################################################################################
887
+
888
+ def multigit():
889
+ """Entry point"""
890
+
891
+ try:
892
+ main()
893
+
894
+ # Catch keyboard aborts
895
+
896
+ except KeyboardInterrupt:
897
+ sys.exit(1)
898
+
899
+ # Quietly fail if output was being piped and the pipe broke
900
+
901
+ except BrokenPipeError:
902
+ sys.exit(2)
903
+
904
+ # Catch-all failure for Git errors
905
+
906
+ except git.GitError as exc:
907
+ sys.stderr.write(exc.msg)
908
+ sys.stderr.write('\n')
909
+
910
+ sys.exit(exc.status)
911
+
912
+ ################################################################################
913
+
914
+ if __name__ == '__main__':
915
+ multigit()