skilleter-thingy 0.0.88__tar.gz → 0.0.90__tar.gz

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.

Files changed (75) hide show
  1. {skilleter_thingy-0.0.88/skilleter_thingy.egg-info → skilleter_thingy-0.0.90}/PKG-INFO +15 -28
  2. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/README.md +14 -27
  3. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/pyproject.toml +1 -1
  4. skilleter_thingy-0.0.90/skilleter_thingy/multigit.py +490 -0
  5. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/git2.py +7 -3
  6. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90/skilleter_thingy.egg-info}/PKG-INFO +15 -28
  7. skilleter_thingy-0.0.88/skilleter_thingy/multigit.py +0 -718
  8. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/LICENSE +0 -0
  9. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/setup.cfg +0 -0
  10. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/__init__.py +0 -0
  11. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/addpath.py +0 -0
  12. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/borger.py +0 -0
  13. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/box.py +0 -0
  14. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/console_colours.py +0 -0
  15. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/diskspacecheck.py +0 -0
  16. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/docker_purge.py +0 -0
  17. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/ffind.py +0 -0
  18. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/ggit.py +0 -0
  19. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/ggrep.py +0 -0
  20. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/git_br.py +0 -0
  21. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/git_ca.py +0 -0
  22. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/git_cleanup.py +0 -0
  23. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/git_co.py +0 -0
  24. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/git_common.py +0 -0
  25. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/git_hold.py +0 -0
  26. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/git_mr.py +0 -0
  27. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/git_parent.py +0 -0
  28. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/git_review.py +0 -0
  29. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/git_update.py +0 -0
  30. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/git_wt.py +0 -0
  31. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/gitcmp_helper.py +0 -0
  32. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/gitprompt.py +0 -0
  33. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/gl.py +0 -0
  34. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/gphotosync.py +0 -0
  35. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/linecount.py +0 -0
  36. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/moviemover.py +0 -0
  37. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/photodupe.py +0 -0
  38. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/phototidier.py +0 -0
  39. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/py_audit.py +0 -0
  40. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/readable.py +0 -0
  41. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/remdir.py +0 -0
  42. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/rmdupe.py +0 -0
  43. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/rpylint.py +0 -0
  44. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/splitpics.py +0 -0
  45. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/strreplace.py +0 -0
  46. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/sysmon.py +0 -0
  47. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/tfm.py +0 -0
  48. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/tfparse.py +0 -0
  49. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/__init__.py +0 -0
  50. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/colour.py +0 -0
  51. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/dc_curses.py +0 -0
  52. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/dc_defaults.py +0 -0
  53. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/dc_util.py +0 -0
  54. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/dircolors.py +0 -0
  55. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/docker.py +0 -0
  56. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/files.py +0 -0
  57. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/git.py +0 -0
  58. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/gitlab.py +0 -0
  59. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/path.py +0 -0
  60. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/popup.py +0 -0
  61. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/process.py +0 -0
  62. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/run.py +0 -0
  63. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/tfm_pane.py +0 -0
  64. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/tidy.py +0 -0
  65. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/thingy/venv_template.py +0 -0
  66. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/trimpath.py +0 -0
  67. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/venv_create.py +0 -0
  68. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/window_rename.py +0 -0
  69. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/xchmod.py +0 -0
  70. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy/yamlcheck.py +0 -0
  71. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy.egg-info/SOURCES.txt +0 -0
  72. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy.egg-info/dependency_links.txt +0 -0
  73. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy.egg-info/entry_points.txt +0 -0
  74. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy.egg-info/requires.txt +0 -0
  75. {skilleter_thingy-0.0.88 → skilleter_thingy-0.0.90}/skilleter_thingy.egg-info/top_level.txt +0 -0
@@ -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
 
@@ -22,6 +22,12 @@ This is intended for use in a situation where you have a collection of related g
22
22
 
23
23
  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.
24
24
 
25
+ The multigit command line format is:
26
+
27
+ multigit OPTIONS COMMAND
28
+
29
+ Where COMMAND is an internal multigit command if it starts with a '+' and is a git command otherwise.
30
+
25
31
  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:
26
32
 
27
33
  *--repos / -r* Allows a list of working trees to be specfied, either as the full or relative path, the name or a wildcard.
@@ -30,46 +36,27 @@ By default, when a multigit command, other than `init` is run, it runs a git com
30
36
 
31
37
  *--branched / -b* Run only working trees where the current branch that is checked out is NOT the default branch
32
38
 
33
- # TODO - Do we actually need any commands other than init, dir, config as everything else can be done via run?
34
- # 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?
35
-
36
- Multigit supports a (growing) list of subcommands:
37
-
38
- *init* - Create or update the configuration file
39
-
40
- *status* - Run `git status` in each of the working trees.
41
-
42
- *fetch* - Run `git fetch` in each of the working trees
43
-
44
- *pull* - Run `git pull` in each of the working trees
45
-
46
- *push* - Run `git push` in each of the working trees
47
-
48
- *co*
49
-
50
- *commit*
51
-
52
- *update*
39
+ Multigit supports a small list of subcommands:
53
40
 
54
- *clean*
41
+ *+init* - Create or update the configuration file
55
42
 
56
- *dir*
43
+ *+dir* - Given the name of a working tree, prin the location within the multigit tree
57
44
 
58
- *config*
45
+ *+config* - Print the name and location of the multigit configuration file.
59
46
 
60
- *review*
47
+ Any command not prefixed with '+' is run in each of the working trees (filtered by the various multigit options) as a git command.
61
48
 
62
- *run*
49
+ For example; `multigit -m commit -ab` would run `git commit -a` in each of the working trees that is branched and contains modified files.
63
50
 
64
51
  # Miscellaneous Git Utilities
65
52
 
66
53
  ## ggit
67
54
 
68
- Run a git command in all working trees under the current directory (note that this is not related to multigit (see below)).
55
+ Run a git command in all working trees under the current directory (note that this is not related to multigit).
69
56
 
70
57
  ## ggrep
71
58
 
72
- Run 'git grep' in all repos under the current directory (note that this is not related to multigit (see below)).
59
+ Run 'git grep' in all repos under the current directory (note that this is not related to multigit).
73
60
 
74
61
  ## gitprompt
75
62
 
@@ -7,7 +7,7 @@ name = "skilleter_thingy"
7
7
 
8
8
  # Version must be incremented to install updated Thingy
9
9
 
10
- version = "0.0.88"
10
+ version = "0.0.90"
11
11
 
12
12
  authors = [
13
13
  {name="John Skilleter", email="john@skilleter.org.uk"},
@@ -0,0 +1,490 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """mg - MultiGit - utility for managing multiple Git repos in a hierarchical directory tree"""
4
+
5
+ import os
6
+ import sys
7
+ import fnmatch
8
+ import configparser
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ import thingy.git2 as git
13
+ import thingy.colour as colour
14
+
15
+ ################################################################################
16
+
17
+ # DONE: / Output name of each git repo as it is processed as command sits there seeming to do nothing otherwise.
18
+ # DONE: Don't save the configuration on exit if it hasn't changed
19
+ # DONE: Don't use a fixed list of default branch names
20
+ # DONE: Use the configuration file
21
+ # DONE: init function
22
+ # TODO: -j option to run in parallel?
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
25
+ # TODO: Consistent colours in output
26
+ # TODO: Dry-run option
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
28
+ # TODO: If run in a subdirectory, only process repos in that tree (or have an option to do so)
29
+ # TODO: Is it going to be a problem if the same repo is checked out twice or more in the same workspace
30
+ # NOPE: Switch to tomlkit
31
+ # TODO: Verbose option
32
+ # TODO: When specifying list of repos, if repo name doesn't contain '/' prefix it with '*'?
33
+
34
+ ################################################################################
35
+
36
+ DEFAULT_CONFIG_FILE = 'multigit.toml'
37
+
38
+ # If a branch name is specified as 'DEFAULT' then the default branch for the
39
+ # repo is used instead.
40
+
41
+ DEFAULT_BRANCH = 'DEFAULT'
42
+
43
+ ################################################################################
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
+
98
+ def error(msg, status=1):
99
+ """Quit with an error"""
100
+
101
+ colour.write(f'[RED:ERROR:] {msg}\n', stream=sys.stderr)
102
+ sys.exit(status)
103
+
104
+ ################################################################################
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
+
126
+ def show_progress(width, msg):
127
+ """Show a single line progress message"""
128
+
129
+ name = msg[:width-1]
130
+
131
+ colour.write(f'{name}', newline=False)
132
+
133
+ if len(name) < width-1:
134
+ colour.write(' '*(width-len(name)), newline=False)
135
+
136
+ colour.write('\r', newline=False)
137
+
138
+ ################################################################################
139
+
140
+ def find_git_repos(args):
141
+ """Locate and return a list of '.git' directory parent directories in the
142
+ specified path.
143
+
144
+ If wildcard is not None then it is treated as a list of wildcards and
145
+ only repos matching at least one of the wildcards are returned.
146
+
147
+ If the same repo matches multiple times it will only be returned once. """
148
+
149
+ repos = set()
150
+
151
+ for root, dirs, _ in os.walk(os.path.dirname(args.configuration_file)):
152
+ if '.git' in dirs:
153
+ if root.startswith('./'):
154
+ root = root[2:]
155
+
156
+ if args.repos:
157
+ for card in args.repos:
158
+ if fnmatch.fnmatch(root, card):
159
+ if root not in repos:
160
+ yield root
161
+ repos.add(root)
162
+ break
163
+ else:
164
+ if root not in repos:
165
+ yield root
166
+ repos.add(root)
167
+
168
+ ################################################################################
169
+
170
+ def select_git_repos(args, config):
171
+ """Return git repos from the configuration that match the criteria on the
172
+ multigit command line (the --repos, --modified and --branched options)
173
+ or, return them all if no relevant options specified"""
174
+
175
+ for repo in config.sections():
176
+ # If repos are specified, then only match according to wildcards, full
177
+ # path or just basename.
178
+
179
+ if args.repos:
180
+ for entry in args.repos:
181
+ if '?' in entry or '*' in entry:
182
+ if fnmatch.fnmatch(repo, entry):
183
+ matching = True
184
+ break
185
+ elif '/' in entry:
186
+ if repo == entry:
187
+ matching = True
188
+ break
189
+ elif os.path.basename(repo) == entry:
190
+ matching = True
191
+ break
192
+
193
+ else:
194
+ matching = False
195
+ else:
196
+ matching = True
197
+
198
+ # If branched specified, only match if the repo is matched _and_ branched
199
+
200
+ if matching and args.branched:
201
+ if git.branch(path=repo) == config[repo]['default branch']:
202
+ matching = False
203
+
204
+ # If modified specified, only match if the repo is matched _and_ modified
205
+
206
+ if matching and args.modified:
207
+ if not git.status(path=repo):
208
+ matching = False
209
+
210
+ if matching:
211
+ yield config[repo]
212
+
213
+ ################################################################################
214
+
215
+ def branch_name(name, default_branch):
216
+ """If name is None or DEFAULT_BRANCH return default_branch, otherwise return name"""
217
+
218
+ return default_branch if not name or name == DEFAULT_BRANCH else name
219
+
220
+ ################################################################################
221
+
222
+ def mg_init(args, config, console):
223
+ """Create or update the configuration
224
+ By default, it scans the tree for git directories and adds or updates them
225
+ in the configuration, using the current branch as the default branch. """
226
+
227
+ # TODO: Update should remove or warn about repos that are no longer present
228
+
229
+ # Search for .git directories
230
+
231
+ for repo in find_git_repos(args):
232
+ if not args.quiet:
233
+ show_progress(console.columns, repo.name)
234
+
235
+ if not repo in config:
236
+ config[repo] = {
237
+ 'default branch': git.branch(path=repo)
238
+ }
239
+
240
+ remote = git.remotes(path=repo)
241
+
242
+ if 'origin' in remote:
243
+ config[repo]['origin'] = remote['origin']
244
+ config[repo]['name']= os.path.basename(remote['origin']).removesuffix('.git')
245
+ else:
246
+ config[repo]['name'] = os.path.basename(repo)
247
+
248
+ ################################################################################
249
+
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"""
254
+
255
+ # DONE: Should return location relative to the current directory or as absolute path
256
+
257
+ _ = console
258
+ _ = config
259
+
260
+
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]
266
+
267
+ for repo in select_git_repos(args, config):
268
+ if fnmatch.fnmatch(repo['name'], search_dir):
269
+ location.append(repo.name)
270
+
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)}')
275
+
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))
279
+
280
+ ################################################################################
281
+
282
+ def mg_config(args, config, console):
283
+ """Output the path to the configuration file"""
284
+
285
+ _ = config
286
+ _ = console
287
+
288
+ if len(args.parameters):
289
+ error('The +config command does not take parameters')
290
+
291
+ colour.write(args.configuration_file)
292
+
293
+ ################################################################################
294
+
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"""
298
+
299
+ _ = config
300
+ _ = console
301
+
302
+ for repo in select_git_repos(args, config):
303
+ repo_command = [args.command]
304
+ for cmd in args.parameters:
305
+ repo_command.append(branch_name(cmd, repo['default branch']))
306
+
307
+ colour.write(f'\n[BOLD:{repo.name}]\n')
308
+
309
+ _, status = git.git_run_status(repo_command, path=repo.name, redirect=False)
310
+
311
+ if status and not args.error_continue:
312
+ sys.exit(status)
313
+
314
+ ################################################################################
315
+
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"""
319
+
320
+ args = Arguments()
321
+
322
+ # Expand arguments so that, for instance '-dv' is parsed as '-d -v'
323
+
324
+ argv = []
325
+
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)
332
+
333
+ # Currently doesn't handle single letter options in concatenated form - e.g. -dv
334
+
335
+ i = 1
336
+ while i < len(argv):
337
+ param = argv[i]
338
+
339
+ if param in ('--dryrun', '--dry-run', '-D'):
340
+ args.dryrun = True
341
+
342
+ elif param in ('--debug', '-d'):
343
+ args.debug = True
344
+
345
+ elif param in ('--verbose', '-v'):
346
+ args.verbose = True
347
+
348
+ elif param in ('--quiet', '-q'):
349
+ args.quiet = True
350
+
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')
357
+
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')
364
+
365
+ elif param in ('--modified', '-m'):
366
+ args.modified = True
367
+
368
+ elif param in ('--branched', '-b'):
369
+ args.branched = True
370
+
371
+ elif param in ('--continue', '-C'):
372
+ args.error_continue = True
373
+
374
+ elif param in ('--help', '-h'):
375
+ print(HELP_INFO)
376
+ sys.exit(0)
377
+
378
+ elif param[0] == '-':
379
+ error(f'Invalid option: "{param}"')
380
+ else:
381
+ break
382
+
383
+ i += 1
384
+
385
+ # After the options, we either have a multigit command (prefixed with '+') or a git command
386
+ # followed by parameter
387
+
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
395
+
396
+ except IndexError:
397
+ error('Missing command')
398
+
399
+ args.parameters = argv[i+1:]
400
+
401
+ args.configuration_file = find_configuration(args)
402
+
403
+ return args
404
+
405
+ ################################################################################
406
+
407
+ def main():
408
+ """Main function"""
409
+
410
+ commands = {
411
+ 'init': mg_init,
412
+ 'dir': mg_dir,
413
+ 'config': mg_config,
414
+ }
415
+
416
+ args = parse_command_line()
417
+
418
+ if args.internal_command and args.command not in commands:
419
+ error(f'Invalid command "{args.command}"')
420
+
421
+ # If the configuration file exists, read it
422
+
423
+ config = configparser.ConfigParser()
424
+
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}')
428
+
429
+ if os.path.isfile(args.configuration_file):
430
+ config.read(args.configuration_file)
431
+ os.chdir(os.path.dirname(args.configuration_file))
432
+
433
+ # Get the console size
434
+
435
+ try:
436
+ console = os.get_terminal_size()
437
+ except OSError:
438
+ console = None
439
+ args.quiet = True
440
+
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
451
+
452
+ commands[args.command](args, config, console)
453
+
454
+ # Save the updated configuration file if it has changed (currently, only the init command will do this).
455
+
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)
462
+
463
+ ################################################################################
464
+
465
+ def multigit():
466
+ """Entry point"""
467
+
468
+ try:
469
+ main()
470
+
471
+ # Catch keyboard aborts
472
+
473
+ except KeyboardInterrupt:
474
+ sys.exit(1)
475
+
476
+ # Quietly fail if output was being piped and the pipe broke
477
+
478
+ except BrokenPipeError:
479
+ sys.exit(2)
480
+
481
+ # Catch-all failure for Git errors
482
+
483
+ except git.GitError as exc:
484
+ sys.stderr.write(exc.msg)
485
+ sys.exit(exc.status)
486
+
487
+ ################################################################################
488
+
489
+ if __name__ == '__main__':
490
+ multigit()
@@ -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