mud-git 1.0.0__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.
mud/__about__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
mud/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import sys
4
+
5
+ from mud import utils, settings
6
+ from mud.app import App
7
+
8
+ def run():
9
+ try:
10
+ utils.settings = settings.Settings(utils.SETTINGS_FILE_NAME)
11
+
12
+ app = App()
13
+ app.run()
14
+ except KeyboardInterrupt:
15
+ utils.print_error('Stopped by user.', 0)
16
+
17
+
18
+ if __name__ == '__main__':
19
+ run()
mud/app.py ADDED
@@ -0,0 +1,275 @@
1
+ import os
2
+ import sys
3
+ import asyncio
4
+ import argparse
5
+ import subprocess
6
+
7
+ from argparse import ArgumentParser
8
+
9
+ from mud import config
10
+
11
+ from mud import utils
12
+ from mud.utils import glyphs
13
+
14
+ from mud.styles import *
15
+ from mud.commands import *
16
+ from mud.runner import Runner
17
+
18
+
19
+ class App:
20
+ def __init__(self):
21
+ self.cmd_runner = None
22
+ self.command = None
23
+ self.config = None
24
+ self.parser = self._create_parser()
25
+
26
+ @staticmethod
27
+ def _create_parser() -> ArgumentParser:
28
+ parser = argparse.ArgumentParser(description=f'{BOLD}mud{RESET} allows you to run commands in multiple repositories.')
29
+ subparsers = parser.add_subparsers(dest='command')
30
+
31
+ subparsers.add_parser(LOG[0], aliases=LOG[1:], help='Displays log of latest commit messages for all repositories in a table view.')
32
+ subparsers.add_parser(INFO[0], aliases=INFO[1:], help='Displays branch divergence and working directory changes')
33
+ subparsers.add_parser(INIT[0], aliases=INIT[1:], help=f'Initializes the {BOLD}.mudconfig{RESET} and adds all repositories in this directory to {BOLD}.mudconfig{RESET}.')
34
+ subparsers.add_parser(TAGS[0], aliases=TAGS[1:], help='Displays git tags in repositories.')
35
+ subparsers.add_parser(LABELS[0], aliases=LABELS[1:], help='Displays mud labels across repositories.')
36
+ subparsers.add_parser(STATUS[0], aliases=STATUS[1:], help='Displays working directory changes.')
37
+ subparsers.add_parser(BRANCHES[0], aliases=BRANCHES[1:], help='Displays all branches in repositories.')
38
+ subparsers.add_parser(REMOTE_BRANCHES[0], aliases=REMOTE_BRANCHES[1:], help='Displays all remote branches in repositories.')
39
+ subparsers.add_parser(CONFIGURE[0], aliases=CONFIGURE[1:], help='Runs the interactive configuration wizard.')
40
+
41
+ add_parser = subparsers.add_parser(ADD[0], aliases=ADD[1:], help='Adds repository or labels an existing repository.')
42
+ add_parser.add_argument('label', help='The label to add (optional).', nargs='?', default='', type=str)
43
+ add_parser.add_argument('path', help='Repository to add (optional).', nargs='?', type=str)
44
+
45
+ remove_parser = subparsers.add_parser(REMOVE[0], aliases=REMOVE[1:], help='Removes repository or removes the label from an existing repository.')
46
+ remove_parser.add_argument('label', help='Label to remove from repository (optional).', nargs='?', default='', type=str)
47
+ remove_parser.add_argument('path', help='Repository to remove (optional).', nargs='?', type=str)
48
+
49
+ parser.add_argument(*COMMAND_ATTR, metavar='COMMAND', nargs='?', default='', type=str, help=f'Explicit command argument. Use this when you want to run a command that has a special characters.')
50
+ parser.add_argument(*TABLE_ATTR, metavar='TABLE', nargs='?', default='', type=str, help=f'Switches table view, runs in table view it is disabled in {BOLD}.mudsettings{RESET}.')
51
+ parser.add_argument(*LABEL_PREFIX, metavar='LABEL', nargs='?', default='', type=str, help='Includes repositories with provided label.')
52
+ parser.add_argument(*NOT_LABEL_PREFIX, metavar='NOT_LABEL', nargs='?', default='', type=str, help=f'Excludes repositories with provided label..')
53
+ parser.add_argument(*BRANCH_PREFIX, metavar='BRANCH', nargs='?', default='', type=str, help='Includes repositories on a provided branch.')
54
+ parser.add_argument(*NOT_BRANCH_PREFIX, metavar='NOT_BRANCH', nargs='?', default='', type=str, help='Excludes repositories on a provided branch.')
55
+ parser.add_argument(*MODIFIED_ATTR, action='store_true', help='Filters modified repositories.')
56
+ parser.add_argument(*DIVERGED_ATTR, action='store_true', help='Filters repositories with diverged branches.')
57
+ parser.add_argument(*ASYNC_ATTR, action='store_true', help='Switches asynchronous run feature.')
58
+ parser.add_argument(SET_GLOBAL[0], help=f'Sets {BOLD}.mudconfig{RESET} in the current repository as your fallback {BOLD}.mudconfig{RESET}.', action='store_true')
59
+ parser.add_argument(VERSION[0], help='Displays the current version of mud.', action='store_true')
60
+ parser.add_argument('catch_all', nargs='*', help='Type any commands to execute among repositories.')
61
+ return parser
62
+
63
+ def run(self) -> None:
64
+ # Displays default help message
65
+ if len(sys.argv) == 1 or sys.argv[1] in HELP:
66
+ utils.version()
67
+ self.parser.print_help()
68
+ return
69
+ # Sets global repository in .mudsettings
70
+ if sys.argv[1] in SET_GLOBAL:
71
+ config_path = os.path.join(os.getcwd(), utils.CONFIG_FILE_NAME)
72
+ if os.path.exists(config_path):
73
+ utils.settings.config.set('mud', 'config_path', config_path)
74
+ utils.settings.save()
75
+ print(f'Current {BOLD}.mudconfig{RESET} set as a global configuration.')
76
+ return
77
+ # Prints version
78
+ elif sys.argv[1] in VERSION:
79
+ utils.version()
80
+ return
81
+ # Runs configuration wizard
82
+ elif sys.argv[1] in CONFIGURE:
83
+ utils.configure()
84
+ return
85
+
86
+ current_directory = os.getcwd()
87
+ self.config = config.Config()
88
+
89
+ # Discovers repositories in current directory
90
+ if sys.argv[1] in INIT:
91
+ self.init(self.parser.parse_args())
92
+ return
93
+
94
+ self.config.find()
95
+ self._filter_with_arguments()
96
+
97
+ self.cmd_runner = Runner(self.config)
98
+ # Handling commands
99
+ if len(sys.argv) > 1 and sys.argv[1] in [cmd for group in COMMANDS for cmd in group]:
100
+ args = self.parser.parse_args()
101
+ if args.command in INIT:
102
+ os.chdir(current_directory)
103
+ self.init(args)
104
+ elif args.command in ADD:
105
+ self.add(args)
106
+ elif args.command in REMOVE:
107
+ self.remove(args)
108
+ else:
109
+ if len(self.repos) == 0:
110
+ utils.print_error('No repositories are matching this filter.', 1)
111
+ return
112
+ if args.command in INFO:
113
+ self.cmd_runner.info(self.repos)
114
+ elif args.command in LOG:
115
+ self.cmd_runner.log(self.repos)
116
+ elif args.command in REMOTE_BRANCHES:
117
+ self.cmd_runner.remote_branches(self.repos)
118
+ elif args.command in BRANCHES:
119
+ self.cmd_runner.branches(self.repos)
120
+ elif args.command in LABELS:
121
+ self.cmd_runner.labels(self.repos)
122
+ elif args.command in TAGS:
123
+ self.cmd_runner.tags(self.repos)
124
+ elif args.command in STATUS:
125
+ self.cmd_runner.status(self.repos)
126
+ # Handling subcommands
127
+ else:
128
+ del sys.argv[0]
129
+ if self.command is None:
130
+ if len(sys.argv) == 0:
131
+ self.parser.print_help()
132
+ return
133
+ self.command = ' '.join(sys.argv)
134
+ self._parse_aliases()
135
+ try:
136
+ if self.run_async:
137
+ if self.table:
138
+ asyncio.run(self.cmd_runner.run_async_table_view(self.repos.keys(), self.command))
139
+ else:
140
+ asyncio.run(self.cmd_runner.run_async(self.repos.keys(), self.command))
141
+ else:
142
+ self.cmd_runner.run_ordered(self.repos.keys(), self.command)
143
+ except Exception as exception:
144
+ utils.print_error(f'Invalid command. {exception}', 2)
145
+
146
+ def init(self, args) -> None:
147
+ table = utils.get_table(['Path', 'Status'])
148
+ self.config.data = {}
149
+ index = 0
150
+ directories = [d for d in os.listdir('.') if os.path.isdir(d) and os.path.isdir(os.path.join(d, '.git'))]
151
+ for directory in directories:
152
+ if directory in self.config.paths():
153
+ continue
154
+ self.config.add_label(directory, getattr(args, 'label', ''))
155
+ index += 1
156
+ table.add_row([f'{DIM}{directory}{RESET}', f'{GREEN}{glyphs("added")}{RESET}'])
157
+ if index == 0:
158
+ utils.print_error('No git repositories were found in this directory.', 3)
159
+ return
160
+ self.config.save(utils.CONFIG_FILE_NAME)
161
+ utils.print_table(table)
162
+
163
+ def add(self, args) -> None:
164
+ self.config.add_label(args.path, args.label)
165
+ self.config.save(utils.CONFIG_FILE_NAME)
166
+
167
+ def remove(self, args) -> None:
168
+ if args.path:
169
+ self.config.remove_label(args.path, args.label)
170
+ elif args.label:
171
+ self.config.remove_path(args.label)
172
+ else:
173
+ utils.print_error(f'Invalid input. Please provide a value to remove.', 4)
174
+ self.config.save(utils.CONFIG_FILE_NAME)
175
+
176
+ # Filter out repositories if user provided filters
177
+ def _filter_with_arguments(self) -> None:
178
+ self.repos = self.config.data
179
+ self.table = utils.settings.config['mud'].getboolean('run_table', fallback=True)
180
+ self.run_async = utils.settings.config['mud'].getboolean('run_async', fallback=True)
181
+
182
+ for path, labels in self.config.filter_label('ignore', self.config.data).items():
183
+ del self.repos[path]
184
+ include_labels = []
185
+ exclude_labels = []
186
+ include_branches = []
187
+ exclude_branches = []
188
+ modified = False
189
+ diverged = False
190
+ index = 1
191
+ while index < len(sys.argv):
192
+ arg = sys.argv[index]
193
+ if not arg.startswith('-'):
194
+ break
195
+ if any(arg.startswith(prefix) for prefix in LABEL_PREFIX):
196
+ include_labels.append(arg.split('=', 1)[1])
197
+ elif any(arg.startswith(prefix) for prefix in NOT_LABEL_PREFIX):
198
+ exclude_labels.append(arg.split('=', 1)[1])
199
+ elif any(arg.startswith(prefix) for prefix in BRANCH_PREFIX):
200
+ include_branches.append(arg.split('=', 1)[1])
201
+ elif any(arg.startswith(prefix) for prefix in NOT_BRANCH_PREFIX):
202
+ exclude_branches.append(arg.split('=', 1)[1])
203
+ elif arg in MODIFIED_ATTR:
204
+ modified = True
205
+ elif arg in DIVERGED_ATTR:
206
+ diverged = True
207
+ elif arg in TABLE_ATTR:
208
+ self.table = not self.table
209
+ elif arg in ASYNC_ATTR:
210
+ self.run_async = not self.run_async
211
+ elif any(arg.startswith(prefix) for prefix in COMMAND_ATTR):
212
+ self.command = arg.split('=', 1)[1]
213
+ else:
214
+ index += 1
215
+ continue
216
+ del sys.argv[index]
217
+ directory = os.getcwd()
218
+ to_delete = []
219
+
220
+ for repo, labels in self.repos.items():
221
+ abs_path = os.path.join(directory, repo)
222
+
223
+ if not os.path.isdir(abs_path):
224
+ utils.print_error(f'Invalid path {BOLD}{repo}{RESET}.', 12, False)
225
+ to_delete.append(repo)
226
+ continue
227
+ elif not os.path.isdir(os.path.join(abs_path, '.git')):
228
+ utils.print_error(f'{BOLD}.git{RESET} directory not found at target "{repo}".', 13, False)
229
+ to_delete.append(repo)
230
+ continue
231
+
232
+ os.chdir(abs_path)
233
+ delete = False
234
+
235
+ if any(include_labels) and not any(item in include_labels for item in labels):
236
+ delete = True
237
+ if any(exclude_labels) and any(item in exclude_labels for item in labels):
238
+ delete = True
239
+
240
+ if not delete:
241
+ try:
242
+ branch = subprocess.check_output('git rev-parse --abbrev-ref HEAD', shell=True, text=True).splitlines()[0]
243
+ except subprocess.CalledProcessError:
244
+ branch = 'NA'
245
+ if any(include_branches) and branch not in include_branches:
246
+ delete = True
247
+ if any(exclude_branches) and branch in exclude_branches:
248
+ delete = True
249
+
250
+ if not delete and modified:
251
+ status_output = subprocess.check_output('git status --porcelain', shell=True, stderr=subprocess.DEVNULL)
252
+ if not status_output:
253
+ delete = True
254
+
255
+ if not delete and diverged:
256
+ branch_status = subprocess.check_output('git status --branch --porcelain', shell=True, text=True).splitlines()
257
+ if not any('ahead' in line or 'behind' in line for line in branch_status if line.startswith('##')):
258
+ delete = True
259
+
260
+ if delete:
261
+ to_delete.append(repo)
262
+
263
+ for repo in to_delete:
264
+ del self.repos[repo]
265
+
266
+ os.chdir(directory)
267
+
268
+ def _parse_aliases(self) -> None:
269
+ if utils.settings.alias_settings is None:
270
+ return
271
+ for alias, command in dict(utils.settings.alias_settings).items():
272
+ args = self.command.split(' ')
273
+ if args[0] == alias:
274
+ del args[0]
275
+ self.command = ' '.join(command.split(' ') + args)
mud/commands.py ADDED
@@ -0,0 +1,28 @@
1
+ # Commands
2
+ ADD = ['add', 'a']
3
+ REMOVE = ['remove', 'rm']
4
+ LOG = ['log', 'l']
5
+ INFO = ['info', 'i']
6
+ INIT = ['init']
7
+ TAGS = ['tags', 'tag', 't']
8
+ LABELS = ['labels', 'lb']
9
+ STATUS = ['status', 'st']
10
+ BRANCHES = ['branch', 'branches', 'br']
11
+ REMOTE_BRANCHES = ['remote-branch', 'remote-branches', 'rbr']
12
+ HELP = ['help', '--help', '-h']
13
+ VERSION = ['--version', '-v', 'version']
14
+ CONFIGURE = ['configure', 'config']
15
+ SET_GLOBAL = ['--set-global']
16
+
17
+ COMMANDS = [ADD, REMOVE, LOG, INFO, INIT, TAGS, LABELS, STATUS, BRANCHES, REMOTE_BRANCHES, HELP, VERSION, CONFIGURE, SET_GLOBAL]
18
+
19
+ # Filters
20
+ ASYNC_ATTR = '-a', '--async'
21
+ TABLE_ATTR = '-t', '--table'
22
+ COMMAND_ATTR = '-c', '--command'
23
+ MODIFIED_ATTR = '-m', '--modified'
24
+ DIVERGED_ATTR = '-d', '--diverged'
25
+ LABEL_PREFIX = '-l=', '--label='
26
+ NOT_LABEL_PREFIX = '-nl=', '--not-label='
27
+ BRANCH_PREFIX = '-b=', '--branch='
28
+ NOT_BRANCH_PREFIX = '-nb=', '--not-branch='
mud/config.py ADDED
@@ -0,0 +1,99 @@
1
+ import os
2
+ import re
3
+ import csv
4
+
5
+ from typing import List, Dict
6
+
7
+ from mud import utils
8
+ from mud.styles import *
9
+
10
+
11
+ class Config:
12
+ def __init__(self):
13
+ self.data = {}
14
+
15
+ def save(self, file_path: str) -> None:
16
+ def _filter_labels(label: str):
17
+ return bool(re.match(r'^\w+$', label))
18
+
19
+ with open(file_path, 'w', newline='') as tsvfile:
20
+ writer = csv.writer(tsvfile, delimiter='\t')
21
+
22
+ for path, labels in self.data.items():
23
+ valid_labels = [label for label in labels if _filter_labels(label)]
24
+ formatted_labels = ','.join(valid_labels) if valid_labels else ''
25
+ writer.writerow([path, formatted_labels])
26
+
27
+ def find(self) -> None:
28
+ if os.path.exists(utils.CONFIG_FILE_NAME):
29
+ self.load(utils.CONFIG_FILE_NAME)
30
+ return
31
+
32
+ directory = os.getcwd()
33
+ current_path = directory
34
+ while os.path.dirname(current_path) != current_path:
35
+ os.chdir(current_path)
36
+ if os.path.exists(utils.CONFIG_FILE_NAME):
37
+ self.load(utils.CONFIG_FILE_NAME)
38
+ return utils.CONFIG_FILE_NAME
39
+ current_path = os.path.dirname(current_path)
40
+
41
+ if utils.settings.mud_settings['config_path'] != '' and os.path.exists(
42
+ utils.settings.mud_settings['config_path']):
43
+ directory = os.path.dirname(utils.settings.mud_settings['config_path'])
44
+ os.chdir(directory)
45
+ os.environ['PWD'] = directory
46
+ self.load(utils.CONFIG_FILE_NAME)
47
+ return
48
+
49
+ utils.print_error(f'{BOLD}.mudconfig{RESET} file was not found. Type `mud init` to create configuration file.', 11)
50
+ return
51
+
52
+ def load(self, file_path: str) -> None:
53
+ self.data = {}
54
+ with open(file_path, 'r') as tsvfile:
55
+ reader = csv.reader(tsvfile, delimiter='\t')
56
+ for row in reader:
57
+ path = row[0]
58
+
59
+ if path.startswith('~'):
60
+ path = os.path.expanduser(path)
61
+
62
+ labels = [label.strip() for label in row[1].split(',') if len(row) > 1 and label.strip()] if len(row) > 1 else []
63
+ self.data[path] = labels
64
+
65
+ def paths(self) -> List[str]:
66
+ return list(self.data.keys())
67
+
68
+ def filter_label(self, label: str, repos: Dict[str, List[str]] = None) -> Dict[str, List[str]]:
69
+ if repos is None:
70
+ repos = self.data
71
+ if label == '':
72
+ return repos
73
+ result = {}
74
+ for path, labels in repos.items():
75
+ if label in labels:
76
+ result[path] = labels
77
+ return result
78
+
79
+ def add_label(self, path: str, label: str) -> None:
80
+ if path is None:
81
+ path = label
82
+ label = None
83
+ if not os.path.isdir(path):
84
+ utils.print_error(f'Invalid path {BOLD}{path}{RESET}. Remember that path should be relative.', 14)
85
+ return
86
+ if path not in self.data:
87
+ self.data[path] = []
88
+ if label is not None and label not in self.data[path]:
89
+ self.data[path].append(label)
90
+
91
+ def remove_path(self, path: str) -> None:
92
+ if path in self.data:
93
+ del self.data[path]
94
+
95
+ def remove_label(self, path: str, label: str) -> None:
96
+ if path in self.data and label in self.data[path]:
97
+ self.data[path].remove(label)
98
+ if not self.data[path]:
99
+ del self.data[path]