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.
- skilleter_thingy/__init__.py +0 -0
- skilleter_thingy/addpath.py +107 -0
- skilleter_thingy/console_colours.py +63 -0
- skilleter_thingy/ffind.py +535 -0
- skilleter_thingy/ggit.py +88 -0
- skilleter_thingy/ggrep.py +155 -0
- skilleter_thingy/git_br.py +186 -0
- skilleter_thingy/git_ca.py +147 -0
- skilleter_thingy/git_cleanup.py +297 -0
- skilleter_thingy/git_co.py +227 -0
- skilleter_thingy/git_common.py +68 -0
- skilleter_thingy/git_hold.py +162 -0
- skilleter_thingy/git_parent.py +84 -0
- skilleter_thingy/git_retag.py +67 -0
- skilleter_thingy/git_review.py +1450 -0
- skilleter_thingy/git_update.py +398 -0
- skilleter_thingy/git_wt.py +72 -0
- skilleter_thingy/gitcmp_helper.py +328 -0
- skilleter_thingy/gitprompt.py +293 -0
- skilleter_thingy/linecount.py +154 -0
- skilleter_thingy/multigit.py +915 -0
- skilleter_thingy/py_audit.py +133 -0
- skilleter_thingy/remdir.py +127 -0
- skilleter_thingy/rpylint.py +98 -0
- skilleter_thingy/strreplace.py +82 -0
- skilleter_thingy/test.py +34 -0
- skilleter_thingy/tfm.py +948 -0
- skilleter_thingy/tfparse.py +101 -0
- skilleter_thingy/trimpath.py +82 -0
- skilleter_thingy/venv_create.py +47 -0
- skilleter_thingy/venv_template.py +47 -0
- skilleter_thingy/xchmod.py +124 -0
- skilleter_thingy/yamlcheck.py +89 -0
- skilleter_thingy-0.3.14.dist-info/METADATA +606 -0
- skilleter_thingy-0.3.14.dist-info/RECORD +39 -0
- skilleter_thingy-0.3.14.dist-info/WHEEL +5 -0
- skilleter_thingy-0.3.14.dist-info/entry_points.txt +31 -0
- skilleter_thingy-0.3.14.dist-info/licenses/LICENSE +619 -0
- 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()
|