skilleter-thingy 0.0.88__py3-none-any.whl → 0.0.90__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,14 +42,87 @@ 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, or if no parameter specified, the root directory of the multigit tree
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
 
49
- sys.stderr.write(f'{msg}\n')
101
+ colour.write(f'[RED:ERROR:] {msg}\n', stream=sys.stderr)
50
102
  sys.exit(status)
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,375 +247,160 @@ 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
- ################################################################################
250
+ def mg_dir(args, config, console):
251
+ """Return the location of a working tree, given the name, or the root directory
252
+ of the tree if not
253
+ Returns an error unless there is a unique match"""
269
254
 
270
- def mg_fetch(args, config, console):
271
- """Run git fetch everywhere"""
255
+ # DONE: Should return location relative to the current directory or as absolute path
272
256
 
257
+ _ = console
273
258
  _ = config
274
259
 
275
- for repo in select_git_repos(args, config):
276
- if not args.quiet:
277
- show_progress(console.columns, repo.name)
278
260
 
279
- colour.write(f'Fetching updates for [BLUE:{repo.name}]')
261
+ if len(args.parameters) > 1:
262
+ error('The +dir command takes no more than one parameter - the name of the working tree to search for')
263
+ elif args.parameters:
264
+ location = []
265
+ search_dir = args.parameters[0]
280
266
 
281
- result = git.fetch(path=repo.name)
267
+ for repo in select_git_repos(args, config):
268
+ if fnmatch.fnmatch(repo['name'], search_dir):
269
+ location.append(repo.name)
282
270
 
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}')
271
+ if len(location) == 0:
272
+ error(f'No matches with [BLUE:{search_dir}]')
273
+ elif len(location) > 1:
274
+ error(f'Multiple matches with [BLUE:{search_dir}] - {" ".join(location)}')
290
275
 
291
- colour.write()
276
+ colour.write(os.path.join(os.path.dirname(args.configuration_file), location[0]))
277
+ else:
278
+ colour.write(os.path.dirname(args.configuration_file))
292
279
 
293
280
  ################################################################################
294
281
 
295
- def mg_pull(args, config, console):
296
- """Run git pull everywhere"""
282
+ def mg_config(args, config, console):
283
+ """Output the path to the configuration file"""
297
284
 
298
285
  _ = config
286
+ _ = console
299
287
 
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)
288
+ if len(args.parameters):
289
+ error('The +config command does not take parameters')
427
290
 
428
- git.checkout(branch, path=repo.name)
429
- result = git.rebase(default_branch, path=repo.name)
430
- colour.write(result[0], indent=4)
291
+ colour.write(args.configuration_file)
431
292
 
432
293
  ################################################################################
433
294
 
434
- def mg_clean(args, config, console):
435
- """Clean the repos"""
295
+ def run_git_command(args, config, console):
296
+ """Run a command in each of the working trees, optionally continuing if
297
+ there's an error"""
436
298
 
437
299
  _ = config
300
+ _ = console
438
301
 
439
302
  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}]')
303
+ repo_command = [args.command]
304
+ for cmd in args.parameters:
305
+ repo_command.append(branch_name(cmd, repo['default branch']))
451
306
 
452
- for item in result:
453
- skipping = item.startswith('Skipping repository ')
307
+ colour.write(f'\n[BOLD:{repo.name}]\n')
454
308
 
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)
309
+ _, status = git.git_run_status(repo_command, path=repo.name, redirect=False)
461
310
 
462
- colour.write()
311
+ if status and not args.error_continue:
312
+ sys.exit(status)
463
313
 
464
314
  ################################################################################
465
315
 
466
- def mg_dir(args, config, console):
467
- """Return the location of a working tree, given the name. Returns an
468
- error unless there is a unique match"""
469
-
470
- # DONE: Should return location relative to the current directory or as absolute path
471
-
472
- _ = console
473
- _ = config
316
+ def parse_command_line():
317
+ """Manually parse the command line as we want to be able to accept 'multigit <OPTIONS> <+MULTIGITCOMMAND | ANY_GIT_COMMAND_WITH_OPTIONS>
318
+ and I can't see a way to get ArgumentParser to accept arbitrary command+options"""
474
319
 
475
- location = []
476
- search_dir = args.dir[0]
320
+ args = Arguments()
477
321
 
478
- for repo in select_git_repos(args, config):
479
- if fnmatch.fnmatch(repo['name'], search_dir):
480
- location.append(repo.name)
481
-
482
- if len(location) == 0:
483
- error(f'No matches with {search_dir}')
484
- elif len(location) > 1:
485
- error(f'Multiple matches with {search_dir}')
486
-
487
- colour.write(os.path.join(os.path.dirname(args.config), location[0]))
322
+ # Expand arguments so that, for instance '-dv' is parsed as '-d -v'
488
323
 
489
- ################################################################################
324
+ argv = []
490
325
 
491
- def mg_config(args, config, console):
492
- """Output the path to the configuration file"""
326
+ for arg in sys.argv:
327
+ if arg[0] != '-' or arg.startswith('--'):
328
+ argv.append(arg)
329
+ else:
330
+ for c in arg[1:]:
331
+ argv.append('-' + c)
493
332
 
494
- _ = config
495
- _ = console
333
+ # Currently doesn't handle single letter options in concatenated form - e.g. -dv
496
334
 
497
- colour.write(args.config)
335
+ i = 1
336
+ while i < len(argv):
337
+ param = argv[i]
498
338
 
499
- ################################################################################
339
+ if param in ('--dryrun', '--dry-run', '-D'):
340
+ args.dryrun = True
500
341
 
501
- def mg_run(args, config, console):
502
- """Run a command in each of the working trees, optionally continuing if
503
- there's an error"""
342
+ elif param in ('--debug', '-d'):
343
+ args.debug = True
504
344
 
505
- _ = 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
345
+ elif param in ('--verbose', '-v'):
346
+ args.verbose = True
513
347
 
348
+ elif param in ('--quiet', '-q'):
349
+ args.quiet = True
514
350
 
515
- for repo in select_git_repos(args, config):
516
- if not args.quiet:
517
- show_progress(console.columns, repo.name)
351
+ elif param in ('--config', '-c'):
352
+ try:
353
+ i += 1
354
+ args.configuration_file = argv[i]
355
+ except IndexError:
356
+ error('--config - missing configuration file parameter')
518
357
 
519
- repo_command = []
520
- for cmd in command:
521
- repo_command.append(branch_name(cmd, repo['default branch']))
358
+ elif param in ('--repos', '-r'):
359
+ try:
360
+ i += 1
361
+ args.repos.append(argv[i])
362
+ except IndexError:
363
+ error('--repos - missing repo parameter')
522
364
 
523
- run_git_status(repo_command, repo.name, args.cont)
365
+ elif param in ('--modified', '-m'):
366
+ args.modified = True
524
367
 
525
- ################################################################################
368
+ elif param in ('--branched', '-b'):
369
+ args.branched = True
526
370
 
527
- def mg_review(args, config, console):
528
- """Run the git review command"""
371
+ elif param in ('--continue', '-C'):
372
+ args.error_continue = True
529
373
 
530
- # TODO: Better parsing to replace DEFAULT with default branch only where appropriate
374
+ elif param in ('--help', '-h'):
375
+ print(HELP_INFO)
376
+ sys.exit(0)
531
377
 
532
- for repo in select_git_repos(args, config):
533
- if not args.quiet:
534
- show_progress(console.columns, repo.name)
378
+ elif param[0] == '-':
379
+ error(f'Invalid option: "{param}"')
380
+ else:
381
+ break
535
382
 
536
- params = []
537
- for p in args.parameters:
538
- params += shlex.split(p.replace(DEFAULT_BRANCH, repo['default branch']))
383
+ i += 1
539
384
 
540
- colour.write(f'Running review in [BOLD:{repo.name}]')
541
- run_git_status(['review'] + params, repo.name, cont=True, redirect=False)
385
+ # After the options, we either have a multigit command (prefixed with '+') or a git command
386
+ # followed by parameter
542
387
 
543
- ################################################################################
388
+ try:
389
+ if argv[i][0] == '+':
390
+ args.command = argv[i][1:]
391
+ args.internal_command = True
392
+ else:
393
+ args.command = argv[i]
394
+ args.internal_command = False
544
395
 
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."""
396
+ except IndexError:
397
+ error('Missing command')
550
398
 
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)
399
+ args.parameters = argv[i+1:]
556
400
 
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)
401
+ args.configuration_file = find_configuration(args)
560
402
 
561
- return config_file if os.path.isfile(config_file) else None
403
+ return args
562
404
 
563
405
  ################################################################################
564
406
 
@@ -567,108 +409,26 @@ def main():
567
409
 
568
410
  commands = {
569
411
  '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
412
  'dir': mg_dir,
579
413
  'config': mg_config,
580
- 'run': mg_run,
581
- 'review': mg_review,
582
414
  }
583
415
 
584
- # Parse args in the form COMMAND OPTIONS SUBCOMMAND SUBCOMMAND_OPTIONS PARAMETERS
585
-
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')
587
-
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')
597
-
598
- subparsers = parser.add_subparsers(dest='command')
416
+ args = parse_command_line()
599
417
 
600
- # Subcommands - currently just init, status, fetch, pull, push, with more to come
601
-
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')
603
-
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')
607
-
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')
610
-
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)')
614
-
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')
617
-
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')
619
-
620
- parser_clean = subparsers.add_parser('clean', help='Remove untracked files from the working tree')
621
-
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.')
630
-
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')
633
-
634
- parser_config = subparsers.add_parser('config', help='Return the name and location of the configuration file')
635
-
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)')
639
-
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')
642
-
643
- # Parse the command line
644
-
645
- args = parser.parse_args()
646
-
647
- # Basic error checking
648
-
649
- if not args.command:
650
- error('No command specified')
651
-
652
- if args.command not in commands:
653
- error(f'Unrecognized command "{args.command}"')
418
+ if args.internal_command and args.command not in commands:
419
+ error(f'Invalid command "{args.command}"')
654
420
 
655
421
  # If the configuration file exists, read it
656
422
 
657
423
  config = configparser.ConfigParser()
658
424
 
659
- args.config = find_configuration(args)
660
-
661
- if args.config:
662
- config.read(args.config)
663
- os.chdir(os.path.dirname(args.config))
425
+ if not (args.internal_command and args.command == 'init'):
426
+ if not os.path.isfile(args.configuration_file):
427
+ error(f'Cannot read configuration file {args.configuration_file}')
664
428
 
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}"')
429
+ if os.path.isfile(args.configuration_file):
430
+ config.read(args.configuration_file)
431
+ os.chdir(os.path.dirname(args.configuration_file))
672
432
 
673
433
  # Get the console size
674
434
 
@@ -678,15 +438,27 @@ def main():
678
438
  console = None
679
439
  args.quiet = True
680
440
 
681
- # Run the subcommand
441
+ # Run an internal or external command-specific validation
442
+
443
+ if args.internal_command:
444
+ if args.command == 'init':
445
+ if args.modified or args.branched:
446
+ error('The "--modified" and "--branched" options cannot be used with the "init" subcommand')
447
+ elif not config:
448
+ error(f'Unable to location configuration file "{args.configuration_file}"')
449
+
450
+ # Run the subcommand
682
451
 
683
- commands[args.command](args, config, console)
452
+ commands[args.command](args, config, console)
684
453
 
685
- # Save the updated configuration file if it has changed (currently, only the init command will do this).
454
+ # Save the updated configuration file if it has changed (currently, only the init command will do this).
686
455
 
687
- if config and args.command == 'init':
688
- with open(args.config, 'w', encoding='utf8') as configfile:
689
- config.write(configfile)
456
+ if config and args.command == 'init':
457
+ with open(args.configuration_file, 'w', encoding='utf8') as configfile:
458
+ config.write(configfile)
459
+
460
+ else:
461
+ run_git_command(args, config, console)
690
462
 
691
463
  ################################################################################
692
464
 
@@ -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.90
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=-UzmnCHXTq3Wlsem1zm4QsEp8wIB1HZnFvpTo3R3h_o,16575
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.90.dist-info/LICENSE,sha256=ljOS4DjXvqEo5VzGfdaRwgRZPbNScGBmfwyC8PChvmQ,32422
65
+ skilleter_thingy-0.0.90.dist-info/METADATA,sha256=JqhKf6MsYFBPaHNvtsPxEv0kbzzd9kVyWYdRmwDEVS8,8236
66
+ skilleter_thingy-0.0.90.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
67
+ skilleter_thingy-0.0.90.dist-info/entry_points.txt,sha256=u5ymS-KPljIGTnprV5yJsAjz7qgeT2BZ-Qo_Con_PFM,2145
68
+ skilleter_thingy-0.0.90.dist-info/top_level.txt,sha256=8-JhgToBBiWURunmvfpSxEvNkDHQQ7r25-aBXtZv61g,17
69
+ skilleter_thingy-0.0.90.dist-info/RECORD,,