skilleter-thingy 0.0.88__py3-none-any.whl → 0.0.89__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.

Potentially problematic release.


This version of skilleter-thingy might be problematic. Click here for more details.

@@ -4,11 +4,10 @@
4
4
 
5
5
  import os
6
6
  import sys
7
- import argparse
8
7
  import fnmatch
9
8
  import configparser
10
- import shlex
11
- from collections import defaultdict
9
+
10
+ from dataclasses import dataclass, field
12
11
 
13
12
  import thingy.git2 as git
14
13
  import thingy.colour as colour
@@ -21,14 +20,14 @@ import thingy.colour as colour
21
20
  # DONE: Use the configuration file
22
21
  # DONE: init function
23
22
  # TODO: -j option to run in parallel?
24
- # TODO: Pull/fetch - only output after running command and only if something updated
25
- # TODO: Better error-handling - e.g. continue/abort option after failure in one repo
23
+ # NOPE: Pull/fetch - only output after running command and only if something updated
24
+ # DONE: Better error-handling - e.g. continue/abort option after failure in one repo
26
25
  # TODO: Consistent colours in output
27
26
  # TODO: Dry-run option
28
27
  # DONE: If the config file isn't in the current directory then search up the directory tree for it but run in the current directory
29
28
  # TODO: If run in a subdirectory, only process repos in that tree (or have an option to do so)
30
29
  # TODO: Is it going to be a problem if the same repo is checked out twice or more in the same workspace
31
- # TODO: Switch to tomlkit
30
+ # NOPE: Switch to tomlkit
32
31
  # TODO: Verbose option
33
32
  # TODO: When specifying list of repos, if repo name doesn't contain '/' prefix it with '*'?
34
33
 
@@ -43,6 +42,59 @@ DEFAULT_BRANCH = 'DEFAULT'
43
42
 
44
43
  ################################################################################
45
44
 
45
+ HELP_INFO = """usage: multigit [-h] [--dryrun] [--debug] [--verbose] [--quiet] [--config CONFIG] [--directory DIRECTORY] [--repos REPOS] [--modified] [--branched]
46
+ {+init, +config, +dir, GIT_COMMAND} ...
47
+
48
+ 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
49
+
50
+ options:
51
+ -h, --help show this help message and exit
52
+ --dryrun, --dry-run, -D
53
+ Dry-run comands
54
+ --debug, -d Debug
55
+ --verbose, -v Verbosity to the maximum
56
+ --quiet, -q Minimal console output
57
+ --config CONFIG, -c CONFIG
58
+ The configuration file (defaults to multigit.toml)
59
+ --directory DIRECTORY, --dir DIRECTORY
60
+ The top-level directory of the multigit tree (defaults to the current directory)
61
+ --repos REPOS, -r REPOS
62
+ 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)
63
+ --modified, -m Select repos that have local modifications
64
+ --branched, -b Select repos that do not have the default branch checked out
65
+ --continue, -C Continue if a git command returns an error (by default, executation terminates when a command fails)
66
+
67
+ Sub-commands:
68
+ {+init,+dir,+config,GIT_COMMAND}
69
+ +init Build or update the configuration file using the current branch in each repo as the default branch
70
+ +config Return the name and location of the configuration file
71
+ +dir Return the location of a working tree, given the repo name
72
+ GIT_COMMAND Any git command, including options and parameters - this is then run in all specified working trees
73
+
74
+ """
75
+
76
+ ################################################################################
77
+
78
+ @dataclass
79
+ class Arguments():
80
+ """Data class to contain command line options and parameters"""
81
+
82
+ dryrun: bool = False
83
+ debug: bool = False
84
+ quiet: bool = False
85
+ verbose: bool = False
86
+ configuration_file: str = DEFAULT_CONFIG_FILE
87
+ directory: str = '.'
88
+ repos: list[str] = field(default_factory=list)
89
+ modified: bool = False
90
+ branched: bool = False
91
+ command: str = None
92
+ error_continue: bool = False
93
+ parameters: list[str] = field(default_factory=list)
94
+ internal_command: bool = False
95
+
96
+ ################################################################################
97
+
46
98
  def error(msg, status=1):
47
99
  """Quit with an error"""
48
100
 
@@ -51,6 +103,26 @@ def error(msg, status=1):
51
103
 
52
104
  ################################################################################
53
105
 
106
+ def find_configuration(args):
107
+ """If the configuration file name has path elements, try and read it, otherwise
108
+ search up the directory tree looking for the configuration file.
109
+ Returns configuration file path or None if the configuration file
110
+ could not be found."""
111
+
112
+ if '/' in args.configuration_file:
113
+ config_file = args.configuration_file
114
+ else:
115
+ config_path = os.getcwd()
116
+ config_file = os.path.join(config_path, args.configuration_file)
117
+
118
+ while not os.path.isfile(config_file) and config_path != '/':
119
+ config_path = os.path.dirname(config_path)
120
+ config_file = os.path.join(config_path, args.configuration_file)
121
+
122
+ return config_file if os.path.isfile(config_file) else None
123
+
124
+ ################################################################################
125
+
54
126
  def show_progress(width, msg):
55
127
  """Show a single line progress message"""
56
128
 
@@ -76,7 +148,7 @@ def find_git_repos(args):
76
148
 
77
149
  repos = set()
78
150
 
79
- for root, dirs, _ in os.walk(args.directory):
151
+ for root, dirs, _ in os.walk(os.path.dirname(args.configuration_file)):
80
152
  if '.git' in dirs:
81
153
  if root.startswith('./'):
82
154
  root = root[2:]
@@ -147,21 +219,6 @@ def branch_name(name, default_branch):
147
219
 
148
220
  ################################################################################
149
221
 
150
- def run_git_status(cmd, path, cont=False, redirect=True):
151
- """Run a git command and exit if it fails"""
152
-
153
- output, status = git.git_run_status(cmd, path=path, redirect=redirect)
154
-
155
- if output:
156
- colour.write(f'[BOLD:{path}]')
157
- colour.write()
158
- colour.write(output, indent=4)
159
-
160
- if status and not cont:
161
- sys.exit(status)
162
-
163
- ################################################################################
164
-
165
222
  def mg_init(args, config, console):
166
223
  """Create or update the configuration
167
224
  By default, it scans the tree for git directories and adds or updates them
@@ -190,279 +247,6 @@ def mg_init(args, config, console):
190
247
 
191
248
  ################################################################################
192
249
 
193
- def mg_status(args, config, console):
194
- """Report Git status for any repo that has a non-empty status"""
195
-
196
- # TODO: More user-friendly output
197
-
198
- for repo in select_git_repos(args, config):
199
- if not args.quiet:
200
- show_progress(console.columns, repo.name)
201
-
202
- status = git.status(path=repo.name)
203
- branch = git.branch(path=repo.name)
204
-
205
- if status or branch != repo['default branch']:
206
- if branch == repo['default branch']:
207
- colour.write(f'[BOLD:{repo.name}]')
208
- else:
209
- colour.write(f'[BOLD:{repo.name}] - branch: [BLUE:{branch}]')
210
-
211
- staged = defaultdict(list)
212
- unstaged = defaultdict(list)
213
- untracked = []
214
-
215
- for entry in status:
216
- if entry[0] == '??':
217
- untracked.append(entry[1])
218
- elif entry[0][0] == 'M':
219
- staged['Updated'].append(entry[1])
220
- elif entry[0][0] == 'T':
221
- staged['Type changed'].append(entry[1])
222
- elif entry[0][0] == 'A':
223
- staged['Added'].append(entry[1])
224
- elif entry[0][0] == 'D':
225
- staged['Deleted'].append(entry[1])
226
- elif entry[0][0] == 'R':
227
- staged['Renamed'].append(entry[1])
228
- elif entry[0][0] == 'C':
229
- staged['Copied'].append(entry[1])
230
- elif entry[0][1] == 'M':
231
- colour.write(f' WT Updated: [BLUE:{entry[1]}]')
232
- elif entry[0][1] == 'T':
233
- colour.write(f' WT Type changed: [BLUE:{entry[1]}]')
234
- elif entry[0][1] == 'D':
235
- unstaged['Deleted'].append(entry[1])
236
- elif entry[0][1] == 'R':
237
- colour.write(f' WT Renamed: [BLUE:{entry[1]}]')
238
- elif entry[0][1] == 'C':
239
- colour.write(f' WT Copied: [BLUE:{entry[1]}]')
240
- else:
241
- staged['Other'].append(f' {entry[0]}: [BLUE:{entry[1]}]')
242
-
243
- if untracked:
244
- colour.write()
245
- colour.write('Untracked files:')
246
-
247
- for git_object in untracked:
248
- colour.write(f' [BLUE:{git_object}]')
249
-
250
- if staged:
251
- colour.write()
252
- colour.write('Changes staged for commit:')
253
-
254
- for item in staged:
255
- for git_object in staged[item]:
256
- colour.write(f' {item}: [BLUE:{git_object}]')
257
-
258
- if unstaged:
259
- colour.write()
260
- colour.write('Changes not staged for commit:')
261
-
262
- for item in unstaged:
263
- for git_object in unstaged[item]:
264
- colour.write(f' {item}: [BLUE:{git_object}]')
265
-
266
- colour.write()
267
-
268
- ################################################################################
269
-
270
- def mg_fetch(args, config, console):
271
- """Run git fetch everywhere"""
272
-
273
- _ = config
274
-
275
- for repo in select_git_repos(args, config):
276
- if not args.quiet:
277
- show_progress(console.columns, repo.name)
278
-
279
- colour.write(f'Fetching updates for [BLUE:{repo.name}]')
280
-
281
- result = git.fetch(path=repo.name)
282
-
283
- if result:
284
- colour.write(f'[BOLD:{repo.name}]')
285
- for item in result:
286
- if item.startswith('From '):
287
- colour.write(f' [BLUE:{item}]')
288
- else:
289
- colour.write(f' {item}')
290
-
291
- colour.write()
292
-
293
- ################################################################################
294
-
295
- def mg_pull(args, config, console):
296
- """Run git pull everywhere"""
297
-
298
- _ = config
299
-
300
- for repo in select_git_repos(args, config):
301
- if not args.quiet:
302
- show_progress(console.columns, repo.name)
303
-
304
- colour.write(f'Pulling updates for [BLUE:{repo.name}]')
305
-
306
- try:
307
- result = git.pull(path=repo.name)
308
- except git.GitError as exc:
309
- error(f'Error in {repo.name}: {exc}')
310
-
311
- if result and result[0] != 'Already up-to-date.':
312
- colour.write(f'[BOLD:{repo.name}]')
313
- for item in result:
314
- if item.startswith('Updating'):
315
- colour.write(f' [BLUE:{item}]')
316
- else:
317
- colour.write(f' {item}')
318
-
319
- colour.write()
320
-
321
- ################################################################################
322
-
323
- def mg_push(args, config, console):
324
- """Run git push everywhere where the current branch isn't one of the defaults
325
- and where the most recent commit was the current user and was on the branch
326
- """
327
-
328
- # DONE: Add option for force-push?
329
- # TODO: Add option for manual confirmation?
330
-
331
- for repo in select_git_repos(args, config):
332
- if not args.quiet:
333
- show_progress(console.columns, repo.name)
334
-
335
- branch = git.branch(path=repo.name)
336
-
337
- if branch != repo['default branch']:
338
- colour.write(f'Pushing changes to [BLUE:{branch}] in [BOLD:{repo.name}]')
339
-
340
- result = git.push(path=repo.name, force_with_lease=args.force)
341
-
342
- if result:
343
- colour.write(result, indent=4)
344
-
345
- colour.write()
346
-
347
- ################################################################################
348
-
349
- def mg_checkout(args, config, console):
350
- """Run git checkout everywhere.
351
- By default it just checks out the specified branch (or the default branch)
352
- if the branch exists in the repo.
353
- If the 'create' option is specified then branch is created"""
354
-
355
- # TODO: Add --create handling
356
- # TODO: Checkout remote branches
357
- # TODO: only try checkout if branch exists
358
- # TODO: option to fetch before checking out
359
-
360
- for repo in select_git_repos(args, config):
361
- if not args.quiet:
362
- show_progress(console.columns, repo.name)
363
-
364
- branch = branch_name(args.branch, repo['default branch'])
365
-
366
- if git.branch(path=repo.name) != branch:
367
- colour.write(f'Checking out [BLUE:{branch}] in [BOLD:{repo.name}]')
368
-
369
- git.checkout(branch, create=args.create, path=repo.name)
370
-
371
- ################################################################################
372
-
373
- def mg_commit(args, config, console):
374
- """For every repo that has a branch checked out and changes present,
375
- commit those changes onto the branch"""
376
-
377
- # DONE: Option to amend the commit if it is not the first one on the current branch
378
- # DONE: Prevent commits if current branch is the default branch
379
- # DONE: Option to specify wildcard for files to commit (default is all files)
380
-
381
- for repo in select_git_repos(args, config):
382
- if not args.quiet:
383
- show_progress(console.columns, repo.name)
384
-
385
- branch = git.branch(path=repo.name)
386
- modified = git.status(path=repo.name)
387
-
388
- if branch != repo['default branch'] and modified:
389
- colour.write(f'Committing [BOLD:{len(modified)}] changes onto [BLUE:{branch}] branch in [BOLD:{repo.name}]')
390
-
391
- git.commit(all=True, message=args.message, path=repo.name)
392
-
393
- ################################################################################
394
-
395
- def mg_update(args, config, console):
396
- """For every repo, pull the default branch and if the current branch
397
- is not the default branch, rebase it onto the default branch"""
398
-
399
- # TODO: Option to pull current branch
400
- # TODO: Use git-update
401
- # TODO: Option to delete current branch before pulling (to get updates without conflicts)
402
- # TODO: Option to stash changes on current branch before updating and unstash afterwards
403
-
404
- for repo in select_git_repos(args, config):
405
- if not args.quiet:
406
- show_progress(console.columns, repo.name)
407
-
408
- branch = git.branch(path=repo.name)
409
- default_branch = repo['default branch']
410
-
411
- colour.write(f'Updating branch [BLUE:{branch}] in [BOLD:{repo.name}]')
412
-
413
- if branch != default_branch:
414
- if not args.quiet:
415
- colour.write(f'Checking out [BLUE:{default_branch}]', indent=4)
416
-
417
- git.checkout(default_branch, path=repo.name)
418
-
419
- if not args.quiet:
420
- colour.write('Pulling updates from remote', indent=4)
421
-
422
- git.pull(path=repo.name)
423
-
424
- if branch != default_branch:
425
- if not args.quiet:
426
- colour.write(f'Checking out [BLUE:{branch}] and rebasing against [BLUE:{default_branch}]', indent=4)
427
-
428
- git.checkout(branch, path=repo.name)
429
- result = git.rebase(default_branch, path=repo.name)
430
- colour.write(result[0], indent=4)
431
-
432
- ################################################################################
433
-
434
- def mg_clean(args, config, console):
435
- """Clean the repos"""
436
-
437
- _ = config
438
-
439
- for repo in select_git_repos(args, config):
440
- if not args.quiet:
441
- show_progress(console.columns, repo.name)
442
-
443
- result = git.clean(recurse=args.recurse, force=args.force, dry_run=args.dry_run,
444
- quiet=args.quiet, exclude=args.exclude, ignore_rules=args.x,
445
- remove_only_ignored=args.X, path=repo.name)
446
-
447
- first_skip = True
448
-
449
- if result:
450
- colour.write(f'[BOLD:{repo.name}]')
451
-
452
- for item in result:
453
- skipping = item.startswith('Skipping repository ')
454
-
455
- if skipping and not args.verbose:
456
- if first_skip:
457
- colour.write('Skipping sub-repositories', indent=4)
458
- first_skip = False
459
- else:
460
- colour.write(item.strip(), indent=4)
461
-
462
- colour.write()
463
-
464
- ################################################################################
465
-
466
250
  def mg_dir(args, config, console):
467
251
  """Return the location of a working tree, given the name. Returns an
468
252
  error unless there is a unique match"""
@@ -472,11 +256,13 @@ def mg_dir(args, config, console):
472
256
  _ = console
473
257
  _ = config
474
258
 
259
+ if len(args.parameters) != 1:
260
+ error('The +dir command takes one parameter - the name of the working tree to search for')
261
+
475
262
  location = []
476
- search_dir = args.dir[0]
477
263
 
478
264
  for repo in select_git_repos(args, config):
479
- if fnmatch.fnmatch(repo['name'], search_dir):
265
+ if fnmatch.fnmatch(repo['name'], args.parameters[0]):
480
266
  location.append(repo.name)
481
267
 
482
268
  if len(location) == 0:
@@ -484,7 +270,7 @@ def mg_dir(args, config, console):
484
270
  elif len(location) > 1:
485
271
  error(f'Multiple matches with {search_dir}')
486
272
 
487
- colour.write(os.path.join(os.path.dirname(args.config), location[0]))
273
+ colour.write(os.path.join(os.path.dirname(args.configuration_file), location[0]))
488
274
 
489
275
  ################################################################################
490
276
 
@@ -494,181 +280,150 @@ def mg_config(args, config, console):
494
280
  _ = config
495
281
  _ = console
496
282
 
497
- colour.write(args.config)
283
+ if len(args.parameters):
284
+ error('The +config command does not take parameters')
285
+
286
+ colour.write(args.configuration_file)
498
287
 
499
288
  ################################################################################
500
289
 
501
- def mg_run(args, config, console):
290
+ def run_git_command(args, config, console):
502
291
  """Run a command in each of the working trees, optionally continuing if
503
292
  there's an error"""
504
293
 
505
294
  _ = config
506
-
507
- if len(args.cmd) == 0:
508
- error('No command specified')
509
- elif len(args.cmd) == 1:
510
- command = shlex.split(args.cmd[0])
511
- else:
512
- command = args.cmd
513
-
295
+ _ = console
514
296
 
515
297
  for repo in select_git_repos(args, config):
516
- if not args.quiet:
517
- show_progress(console.columns, repo.name)
518
-
519
- repo_command = []
520
- for cmd in command:
298
+ repo_command = [args.command]
299
+ for cmd in args.parameters:
521
300
  repo_command.append(branch_name(cmd, repo['default branch']))
522
301
 
523
- run_git_status(repo_command, repo.name, args.cont)
524
-
525
- ################################################################################
526
-
527
- def mg_review(args, config, console):
528
- """Run the git review command"""
529
-
530
- # TODO: Better parsing to replace DEFAULT with default branch only where appropriate
531
-
532
- for repo in select_git_repos(args, config):
533
- if not args.quiet:
534
- show_progress(console.columns, repo.name)
302
+ colour.write(f'\n[BOLD:{repo.name}]\n')
535
303
 
536
- params = []
537
- for p in args.parameters:
538
- params += shlex.split(p.replace(DEFAULT_BRANCH, repo['default branch']))
304
+ _, status = git.git_run_status(repo_command, path=repo.name, redirect=False)
539
305
 
540
- colour.write(f'Running review in [BOLD:{repo.name}]')
541
- run_git_status(['review'] + params, repo.name, cont=True, redirect=False)
306
+ if status and not args.error_continue:
307
+ sys.exit(status)
542
308
 
543
309
  ################################################################################
544
310
 
545
- def find_configuration(args):
546
- """If the configuration file name has path elements, try and read it, otherwise
547
- search up the directory tree looking for the configuration file.
548
- Returns configuration file path or None if the configuration file
549
- could not be found."""
311
+ def parse_command_line():
312
+ """Manually parse the command line as we want to be able to accept 'multigit <OPTIONS> <+MULTIGITCOMMAND | ANY_GIT_COMMAND_WITH_OPTIONS>
313
+ and I can't see a way to get ArgumentParser to accept arbitrary command+options"""
550
314
 
551
- if '/' in args.config:
552
- config_file = args.config
553
- else:
554
- config_path = os.getcwd()
555
- config_file = os.path.join(config_path, args.config)
315
+ args = Arguments()
556
316
 
557
- while not os.path.isfile(config_file) and config_path != '/':
558
- config_path = os.path.dirname(config_path)
559
- config_file = os.path.join(config_path, args.config)
317
+ # Expand arguments so that, for instance '-dv' is parsed as '-d -v'
560
318
 
561
- return config_file if os.path.isfile(config_file) else None
319
+ argv = []
562
320
 
563
- ################################################################################
321
+ for arg in sys.argv:
322
+ if arg[0] != '-' or arg.startswith('--'):
323
+ argv.append(arg)
324
+ else:
325
+ for c in arg[1:]:
326
+ argv.append('-' + c)
564
327
 
565
- def main():
566
- """Main function"""
328
+ # Currently doesn't handle single letter options in concatenated form - e.g. -dv
567
329
 
568
- commands = {
569
- 'init': mg_init,
570
- 'status': mg_status,
571
- 'fetch': mg_fetch,
572
- 'pull': mg_pull,
573
- 'push': mg_push,
574
- 'checkout': mg_checkout,
575
- 'commit': mg_commit,
576
- 'update': mg_update,
577
- 'clean': mg_clean,
578
- 'dir': mg_dir,
579
- 'config': mg_config,
580
- 'run': mg_run,
581
- 'review': mg_review,
582
- }
330
+ i = 1
331
+ while i < len(argv):
332
+ param = argv[i]
583
333
 
584
- # Parse args in the form COMMAND OPTIONS SUBCOMMAND SUBCOMMAND_OPTIONS PARAMETERS
334
+ if param in ('--dryrun', '--dry-run', '-D'):
335
+ args.dryrun = True
585
336
 
586
- parser = argparse.ArgumentParser(description='Run git commands in multiple Git repos. DISCLAIMER: This is beta-quality software, with missing features and liable to fail with stack dump, but shouldn\'t eat your data')
337
+ elif param in ('--debug', '-d'):
338
+ args.debug = True
587
339
 
588
- parser.add_argument('--dryrun', '--dry-run', '-D', action='store_true', help='Dry-run comands')
589
- parser.add_argument('--debug', '-d', action='store_true', help='Debug')
590
- parser.add_argument('--verbose', '-v', action='store_true', help='Verbosity to the maximum')
591
- parser.add_argument('--quiet', '-q', action='store_true', help='Minimal console output')
592
- parser.add_argument('--config', '-c', action='store', default=DEFAULT_CONFIG_FILE, help=f'The configuration file (defaults to {DEFAULT_CONFIG_FILE})')
593
- parser.add_argument('--directory', '--dir', action='store', default='.', help='The top-level directory of the multigit tree (defaults to the current directory)')
594
- parser.add_argument('--repos', '-r', action='append', default=None, help='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)')
595
- parser.add_argument('--modified', '-m', action='store_true', help='Select repos that have local modifications')
596
- parser.add_argument('--branched', '-b', action='store_true', help='Select repos that do not have the default branch checked out')
340
+ elif param in ('--verbose', '-v'):
341
+ args.verbose = True
597
342
 
598
- subparsers = parser.add_subparsers(dest='command')
343
+ elif param in ('--quiet', '-q'):
344
+ args.quiet = True
599
345
 
600
- # Subcommands - currently just init, status, fetch, pull, push, with more to come
346
+ elif param in ('--config', '-c'):
347
+ try:
348
+ i += 1
349
+ args.configuration_file = argv[i]
350
+ except IndexError:
351
+ error('--config - missing configuration file parameter')
601
352
 
602
- parser_init = subparsers.add_parser('init', help='Build or update the configuration file using the current branch in each repo as the default branch')
353
+ elif param in ('--repos', '-r'):
354
+ try:
355
+ i += 1
356
+ args.repos.append(argv[i])
357
+ except IndexError:
358
+ error('--repos - missing repo parameter')
603
359
 
604
- parser_status = subparsers.add_parser('status', help='Report git status in every repo that has something to report')
605
- parser_fetch = subparsers.add_parser('fetch', help='Run git fetch in every repo')
606
- parser_pull = subparsers.add_parser('pull', help='Run git pull in every repo')
360
+ elif param in ('--modified', '-m'):
361
+ args.modified = True
607
362
 
608
- parser_push = subparsers.add_parser('push', help='Run git push in every repo where the current branch isn\'t the default and the most recent commit was by the current user')
609
- parser_push.add_argument('--force', '-f', action='store_true', help='Use --force-push-with-least to update a remote branch if the local branch has been rebased')
363
+ elif param in ('--branched', '-b'):
364
+ args.branched = True
610
365
 
611
- parser_checkout = subparsers.add_parser('checkout', help='Checkout the specified branch')
612
- parser_checkout.add_argument('--create', '-b', action='store_true', help='Create the specified branch and check it out')
613
- parser_checkout.add_argument('branch', nargs='?', default=None, action='store', help='The branch name to check out (defaults to the default branch)')
366
+ elif param in ('--continue', '-C'):
367
+ args.error_continue = True
614
368
 
615
- parser_commit = subparsers.add_parser('commit', help='Commit changes')
616
- parser_commit.add_argument('--message', '-m', action='store', default=None, help='The commit message')
369
+ elif param in ('--help', '-h'):
370
+ print(HELP_INFO)
371
+ sys.exit(0)
617
372
 
618
- parser_update = subparsers.add_parser('update', help='Pull the default branch and if the current branch isn\'t the default branch, rebase it onto the default branch')
373
+ elif param[0] == '-':
374
+ error(f'Invalid option: "{param}"')
375
+ else:
376
+ break
619
377
 
620
- parser_clean = subparsers.add_parser('clean', help='Remove untracked files from the working tree')
378
+ i += 1
621
379
 
622
- parser_clean.add_argument('--recurse', '-d', action='store_true', help='Recurse into subdirectories')
623
- parser_clean.add_argument('--force', '-f', action='store_true', help='If the Git configuration variable clean.requireForce is not set to false, git clean will refuse to delete files or directories unless given -f or -i')
624
- # TODO: parser_clean.add_argument('--interactive', '-i', action='store_true', help='Show what would be done and clean files interactively.')
625
- parser_clean.add_argument('--dry-run', '-n', action='store_true', help='Don’t actually remove anything, just show what would be done.')
626
- # TODO: parser_clean.add_argument('--quiet', '-q', , action='store_true', help='Be quiet, only report errors, but not the files that are successfully removed.')
627
- parser_clean.add_argument('--exclude', '-e', action='store', help='Use the given exclude pattern in addition to the standard ignore rules.')
628
- parser_clean.add_argument('-x', action='store_true', help='Don’t use the standard ignore rules, but still use the ignore rules given with -e options from the command line.')
629
- parser_clean.add_argument('-X', action='store_true', help='Remove only files ignored by Git. This may be useful to rebuild everything from scratch, but keep manually created files.')
380
+ # After the options, we either have a multigit command (prefixed with '+') or a git command
381
+ # followed by parameter
630
382
 
631
- parser_dir = subparsers.add_parser('dir', help='Return the location of a working tree, given the repo name')
632
- parser_dir.add_argument('dir', nargs=1, action='store', help='The name of the working tree')
383
+ try:
384
+ if argv[i][0] == '+':
385
+ args.command = argv[i][1:]
386
+ args.internal_command = True
387
+ else:
388
+ args.command = argv[i]
389
+ args.internal_command = False
633
390
 
634
- parser_config = subparsers.add_parser('config', help='Return the name and location of the configuration file')
391
+ except IndexError:
392
+ error('Missing command')
635
393
 
636
- parser_run = subparsers.add_parser('run', help='Run any git command in each of the working trees')
637
- parser_run.add_argument('--cont', '-c', action='store_true', help='Continue if the command returns an error in any of the working trees')
638
- parser_run.add_argument('cmd', nargs='*', action='store', help='The command to run (the command may need to be quoted)')
394
+ args.parameters = argv[i+1:]
639
395
 
640
- parser_review = subparsers.add_parser('review', help='Review the changes in a working tree')
641
- parser_review.add_argument('parameters', nargs='*', action='store', help='Parameters passed to the "git review" command')
396
+ args.configuration_file = find_configuration(args)
642
397
 
643
- # Parse the command line
398
+ return args
644
399
 
645
- args = parser.parse_args()
400
+ ################################################################################
646
401
 
647
- # Basic error checking
402
+ def main():
403
+ """Main function"""
648
404
 
649
- if not args.command:
650
- error('No command specified')
405
+ commands = {
406
+ 'init': mg_init,
407
+ 'dir': mg_dir,
408
+ 'config': mg_config,
409
+ }
410
+
411
+ args = parse_command_line()
651
412
 
652
- if args.command not in commands:
653
- error(f'Unrecognized command "{args.command}"')
413
+ if args.internal_command and args.command not in commands:
414
+ error(f'Invalid command "{args.command}"')
654
415
 
655
416
  # If the configuration file exists, read it
656
417
 
657
418
  config = configparser.ConfigParser()
658
419
 
659
- args.config = find_configuration(args)
660
-
661
- if args.config:
662
- config.read(args.config)
663
- os.chdir(os.path.dirname(args.config))
420
+ if not (args.internal_command and args.command == 'init'):
421
+ if not os.path.isfile(args.configuration_file):
422
+ error(f'Cannot read configuration file {args.configuration_file}')
664
423
 
665
- # Command-specific validation
666
-
667
- if args.command == 'init':
668
- if args.modified or args.branched:
669
- error('The "--modified" and "--branched" options cannot be used with the "init" subcommand')
670
- elif not config:
671
- error(f'Unable to location configuration file "{args.config}"')
424
+ if os.path.isfile(args.configuration_file):
425
+ config.read(args.configuration_file)
426
+ os.chdir(os.path.dirname(args.configuration_file))
672
427
 
673
428
  # Get the console size
674
429
 
@@ -678,15 +433,27 @@ def main():
678
433
  console = None
679
434
  args.quiet = True
680
435
 
681
- # Run the subcommand
436
+ # Run an internal or external command-specific validation
437
+
438
+ if args.internal_command:
439
+ if args.command == 'init':
440
+ if args.modified or args.branched:
441
+ error('The "--modified" and "--branched" options cannot be used with the "init" subcommand')
442
+ elif not config:
443
+ error(f'Unable to location configuration file "{args.configuration_file}"')
444
+
445
+ # Run the subcommand
682
446
 
683
- commands[args.command](args, config, console)
447
+ commands[args.command](args, config, console)
684
448
 
685
- # Save the updated configuration file if it has changed (currently, only the init command will do this).
449
+ # Save the updated configuration file if it has changed (currently, only the init command will do this).
686
450
 
687
- if config and args.command == 'init':
688
- with open(args.config, 'w', encoding='utf8') as configfile:
689
- config.write(configfile)
451
+ if config and args.command == 'init':
452
+ with open(args.configuration_file, 'w', encoding='utf8') as configfile:
453
+ config.write(configfile)
454
+
455
+ else:
456
+ run_git_command(args, config, console)
690
457
 
691
458
  ################################################################################
692
459
 
@@ -64,10 +64,12 @@ def git(cmd, stdout=None, stderr=None, path=None):
64
64
  if path:
65
65
  git_cmd += ['-C', path]
66
66
 
67
+ git_cmd += cmd if isinstance(cmd, list) else [cmd]
68
+
67
69
  logging.debug('Running %s', ' '.join(git_cmd + cmd))
68
70
 
69
71
  try:
70
- return run.run(git_cmd + cmd, stdout=stdout, stderr=stderr)
72
+ return run.run(git_cmd, stdout=stdout, stderr=stderr)
71
73
  except run.RunError as exc:
72
74
  raise GitError(exc.msg, exc.status)
73
75
 
@@ -85,15 +87,17 @@ def git_run_status(cmd, stdout=None, stderr=None, path=None, redirect=True):
85
87
  if path:
86
88
  git_cmd += ['-C', path]
87
89
 
90
+ git_cmd += cmd if isinstance(cmd, list) else [cmd]
91
+
88
92
  if redirect:
89
- result = subprocess.run(git_cmd + cmd,
93
+ result = subprocess.run(git_cmd,
90
94
  stdout=stdout or subprocess.PIPE,
91
95
  stderr=stderr or subprocess.PIPE,
92
96
  text=True, check=False,
93
97
  errors='ignore',
94
98
  universal_newlines=True)
95
99
  else:
96
- result = subprocess.run(git_cmd + cmd)
100
+ result = subprocess.run(git_cmd)
97
101
 
98
102
  return (result.stdout or result.stderr), result.returncode
99
103
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: skilleter_thingy
3
- Version: 0.0.88
3
+ Version: 0.0.89
4
4
  Summary: A collection of useful utilities, mainly aimed at making Git more friendly
5
5
  Author-email: John Skilleter <john@skilleter.org.uk>
6
6
  Project-URL: Home, https://skilleter.org.uk
@@ -43,6 +43,12 @@ This is intended for use in a situation where you have a collection of related g
43
43
 
44
44
  Start by running ensuring that the default branch (e.g. `main`) is checked out in each of the working trees and, in the top-level directory, run `multigit init` to create the configuration file which, by default is called `multigit.toml` - this is just a text file that sets the configuration for each working tree in terms of name, origin, default branch and location.
45
45
 
46
+ The multigit command line format is:
47
+
48
+ multigit OPTIONS COMMAND
49
+
50
+ Where COMMAND is an internal multigit command if it starts with a '+' and is a git command otherwise.
51
+
46
52
  By default, when a multigit command, other than `init` is run, it runs a git command in each of the working trees. The command takes a number of options that can be used to select the list of working trees that each of the subcommands that it supports runs in:
47
53
 
48
54
  *--repos / -r* Allows a list of working trees to be specfied, either as the full or relative path, the name or a wildcard.
@@ -51,46 +57,27 @@ By default, when a multigit command, other than `init` is run, it runs a git com
51
57
 
52
58
  *--branched / -b* Run only working trees where the current branch that is checked out is NOT the default branch
53
59
 
54
- # TODO - Do we actually need any commands other than init, dir, config as everything else can be done via run?
55
- # TODO - Could just make the commands multigit +init/+dir/+config and then for anything else, just pass the whole of the command line to git once we've parsed the default branch?
56
-
57
- Multigit supports a (growing) list of subcommands:
58
-
59
- *init* - Create or update the configuration file
60
-
61
- *status* - Run `git status` in each of the working trees.
62
-
63
- *fetch* - Run `git fetch` in each of the working trees
64
-
65
- *pull* - Run `git pull` in each of the working trees
66
-
67
- *push* - Run `git push` in each of the working trees
68
-
69
- *co*
70
-
71
- *commit*
72
-
73
- *update*
60
+ Multigit supports a small list of subcommands:
74
61
 
75
- *clean*
62
+ *+init* - Create or update the configuration file
76
63
 
77
- *dir*
64
+ *+dir* - Given the name of a working tree, prin the location within the multigit tree
78
65
 
79
- *config*
66
+ *+config* - Print the name and location of the multigit configuration file.
80
67
 
81
- *review*
68
+ Any command not prefixed with '+' is run in each of the working trees (filtered by the various multigit options) as a git command.
82
69
 
83
- *run*
70
+ For example; `multigit -m commit -ab` would run `git commit -a` in each of the working trees that is branched and contains modified files.
84
71
 
85
72
  # Miscellaneous Git Utilities
86
73
 
87
74
  ## ggit
88
75
 
89
- Run a git command in all working trees under the current directory (note that this is not related to multigit (see below)).
76
+ Run a git command in all working trees under the current directory (note that this is not related to multigit).
90
77
 
91
78
  ## ggrep
92
79
 
93
- Run 'git grep' in all repos under the current directory (note that this is not related to multigit (see below)).
80
+ Run 'git grep' in all repos under the current directory (note that this is not related to multigit).
94
81
 
95
82
  ## gitprompt
96
83
 
@@ -25,7 +25,7 @@ skilleter_thingy/gl.py,sha256=9zbGpKxw6lX9RghLkdy-Q5sZlqtbB3uGFO04qTu1dH8,5954
25
25
  skilleter_thingy/gphotosync.py,sha256=Vb2zYTEFp26BYdkG810SRg9afyfDqvq4CLHTk-MFf60,22388
26
26
  skilleter_thingy/linecount.py,sha256=5voQtjJjDCVx4zjPwVRy620NpuLiwwFitzxjIsRGtxQ,4310
27
27
  skilleter_thingy/moviemover.py,sha256=j_Xb9_jFdgpFBAXcF4tEqbnKH_FonlnUU39LiCK980k,4470
28
- skilleter_thingy/multigit.py,sha256=E7Gsp4vnGEGicZXIt3GYFx6G1BOlPHAvftFSGuzQlas,27319
28
+ skilleter_thingy/multigit.py,sha256=hAMF5glefQ82_tMs-zGXj-5FGkXpytQZO5hjqjqA95M,16212
29
29
  skilleter_thingy/photodupe.py,sha256=l0hbzSLb2Vk2ceteg-x9fHXCEE1uUuFo84hz5rsZUPA,4184
30
30
  skilleter_thingy/phototidier.py,sha256=5gSjlINUxf3ZQl3NG0o7CsWwODvTbokIMIafLFvn8Hc,7818
31
31
  skilleter_thingy/py_audit.py,sha256=xJm5k5qyeA6ii8mODa4dOkmP8L1drv94UHuxR54RsIM,4384
@@ -52,7 +52,7 @@ skilleter_thingy/thingy/dircolors.py,sha256=5NbXMsGWdABLvvZfB70VPmN6N5HyyihfpgoQ
52
52
  skilleter_thingy/thingy/docker.py,sha256=9EFatudoVPfB1UbDEtzdJDB3o6ToHiNHv8-oLsUeqiQ,2449
53
53
  skilleter_thingy/thingy/files.py,sha256=oW6E6WWwVFSUPdrZnKMx7P_w_hh3etjoN7RrqvYHCHc,4705
54
54
  skilleter_thingy/thingy/git.py,sha256=qXWIduF4jbP5pKFYt_hW9Ex5iL9mSBBrcNKBkULhRTg,38834
55
- skilleter_thingy/thingy/git2.py,sha256=092CvXzdfYoinzB6QXD3zvJ3N2BwXDSd6ZXFgf8AmIg,37062
55
+ skilleter_thingy/thingy/git2.py,sha256=WfX85zWz-0h3FiP1ME5VJ-OUBVMX9Vr2JGo401jk9oc,37156
56
56
  skilleter_thingy/thingy/gitlab.py,sha256=uXAF918xnPk6qQyiwPQDbMZfqtJzhiRqDS7yEtJEIAg,6079
57
57
  skilleter_thingy/thingy/path.py,sha256=8uM2Q9zFRWv_SaVOX49PeecQXttl7J6lsmBuRXWsXKY,4732
58
58
  skilleter_thingy/thingy/popup.py,sha256=jW-nbpdeswqEMTli7OmBv1J8XQsvFoMI0J33O6dOeu8,2529
@@ -61,9 +61,9 @@ skilleter_thingy/thingy/run.py,sha256=6SNKWF01fSxzB10GMU9ajraXYZqAL1w0PXkqjJdr1U
61
61
  skilleter_thingy/thingy/tfm_pane.py,sha256=oqy5zBzKwfbjbGqetbbhpKi4x5He7sl4qkmhUeqtdZc,19789
62
62
  skilleter_thingy/thingy/tidy.py,sha256=71DCyj0VJrj52RmjQyj1eOiQJIfy5EIPHuThOrS6ZTA,5876
63
63
  skilleter_thingy/thingy/venv_template.py,sha256=SsVNvSwojd8NnFeQaZPCRQYTNdwJRplpZpygbUEXRnY,1015
64
- skilleter_thingy-0.0.88.dist-info/LICENSE,sha256=ljOS4DjXvqEo5VzGfdaRwgRZPbNScGBmfwyC8PChvmQ,32422
65
- skilleter_thingy-0.0.88.dist-info/METADATA,sha256=gXBQaiSXfgKjKq_tduHXU_lb0_4FKxjEBRMx87_JsSk,8239
66
- skilleter_thingy-0.0.88.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
67
- skilleter_thingy-0.0.88.dist-info/entry_points.txt,sha256=u5ymS-KPljIGTnprV5yJsAjz7qgeT2BZ-Qo_Con_PFM,2145
68
- skilleter_thingy-0.0.88.dist-info/top_level.txt,sha256=8-JhgToBBiWURunmvfpSxEvNkDHQQ7r25-aBXtZv61g,17
69
- skilleter_thingy-0.0.88.dist-info/RECORD,,
64
+ skilleter_thingy-0.0.89.dist-info/LICENSE,sha256=ljOS4DjXvqEo5VzGfdaRwgRZPbNScGBmfwyC8PChvmQ,32422
65
+ skilleter_thingy-0.0.89.dist-info/METADATA,sha256=m_ybn_cE_YK_cnxhqEmXKxyPm32Kx5cYWuRPwcqURUA,8236
66
+ skilleter_thingy-0.0.89.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
67
+ skilleter_thingy-0.0.89.dist-info/entry_points.txt,sha256=u5ymS-KPljIGTnprV5yJsAjz7qgeT2BZ-Qo_Con_PFM,2145
68
+ skilleter_thingy-0.0.89.dist-info/top_level.txt,sha256=8-JhgToBBiWURunmvfpSxEvNkDHQQ7r25-aBXtZv61g,17
69
+ skilleter_thingy-0.0.89.dist-info/RECORD,,