mud-git 0.0.post1.dev1__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/__init__.py +18 -0
- mud/app.py +269 -0
- mud/commands.py +27 -0
- mud/config.py +99 -0
- mud/runner.py +500 -0
- mud/settings.py +51 -0
- mud/styles.py +96 -0
- mud/utils.py +126 -0
- mud_git-0.0.post1.dev1.dist-info/LICENSE +21 -0
- mud_git-0.0.post1.dev1.dist-info/METADATA +139 -0
- mud_git-0.0.post1.dev1.dist-info/RECORD +14 -0
- mud_git-0.0.post1.dev1.dist-info/WHEEL +5 -0
- mud_git-0.0.post1.dev1.dist-info/entry_points.txt +2 -0
- mud_git-0.0.post1.dev1.dist-info/top_level.txt +1 -0
mud/runner.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import asyncio
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from typing import List, Dict
|
|
6
|
+
from collections import Counter
|
|
7
|
+
|
|
8
|
+
from mud import utils
|
|
9
|
+
from mud.utils import glyphs
|
|
10
|
+
from mud.styles import *
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Runner:
|
|
14
|
+
_force_color_env = {"GIT_PAGER": "cat", "TERM": "xterm-256color", "GIT_CONFIG_PARAMETERS": "'color.ui=always'"}
|
|
15
|
+
_label_color_cache = {}
|
|
16
|
+
_current_color_index = 0
|
|
17
|
+
|
|
18
|
+
def __init__(self, repos):
|
|
19
|
+
self._force_color_env = self._force_color_env | os.environ.copy()
|
|
20
|
+
self._last_printed_lines = 0
|
|
21
|
+
self.repos = repos
|
|
22
|
+
|
|
23
|
+
# `mud info` command implementation
|
|
24
|
+
def info(self, repos: Dict[str, List[str]]) -> None:
|
|
25
|
+
def get_directory_size(directory):
|
|
26
|
+
total_size = 0
|
|
27
|
+
for directory_path, directory_names, file_names in os.walk(directory):
|
|
28
|
+
for f in file_names:
|
|
29
|
+
fp = os.path.join(directory_path, f)
|
|
30
|
+
if os.path.isfile(fp):
|
|
31
|
+
total_size += os.path.getsize(fp)
|
|
32
|
+
return total_size
|
|
33
|
+
|
|
34
|
+
def format_size(size_in_bytes):
|
|
35
|
+
if size_in_bytes >= 1024 ** 3:
|
|
36
|
+
return f'{BOLD}{size_in_bytes / (1024 ** 3):.2f}{RESET} GB{glyphs("space")}{RED}{glyphs("weight")}{RESET}'
|
|
37
|
+
elif size_in_bytes >= 1024 ** 2:
|
|
38
|
+
return f'{BOLD}{size_in_bytes / (1024 ** 2):.2f}{RESET} MB{glyphs("space")}{YELLOW}{glyphs("weight")}{RESET}'
|
|
39
|
+
elif size_in_bytes >= 1024:
|
|
40
|
+
return f'{BOLD}{size_in_bytes / 1024:.2f}{RESET} KB{glyphs("space")}{GREEN}{glyphs("weight")}{RESET}'
|
|
41
|
+
else:
|
|
42
|
+
return f'{BOLD}{size_in_bytes}{RESET} Bytes{glyphs("space")}{BLUE}{glyphs("weight")}{RESET}'
|
|
43
|
+
|
|
44
|
+
def get_git_origin_host_icon(url: str):
|
|
45
|
+
icon = YELLOW + glyphs('git')
|
|
46
|
+
|
|
47
|
+
if 'azure' in url or 'visualstudio' in url:
|
|
48
|
+
icon = BLUE + glyphs('azure')
|
|
49
|
+
elif 'github' in url:
|
|
50
|
+
icon = GRAY + glyphs('github')
|
|
51
|
+
elif 'gitlab' in url:
|
|
52
|
+
icon = YELLOW + glyphs('gitlab')
|
|
53
|
+
elif 'bitbucket' in url:
|
|
54
|
+
icon = CYAN + glyphs('bitbucket')
|
|
55
|
+
|
|
56
|
+
icon += RESET + glyphs('space')
|
|
57
|
+
return icon
|
|
58
|
+
|
|
59
|
+
table = utils.get_table(['Path', 'Commits', 'User Commits', 'Size', 'Labels'])
|
|
60
|
+
table.align['Commits'] = 'r'
|
|
61
|
+
table.align['User Commits'] = 'r'
|
|
62
|
+
table.align['Size'] = 'r'
|
|
63
|
+
|
|
64
|
+
for path, labels in repos.items():
|
|
65
|
+
try:
|
|
66
|
+
origin_url = subprocess.check_output('git remote get-url origin', shell=True, text=True, cwd=path).strip()
|
|
67
|
+
except Exception:
|
|
68
|
+
origin_url = ''
|
|
69
|
+
|
|
70
|
+
formatted_path = f'{get_git_origin_host_icon(origin_url)}{self._get_formatted_path(path)}'
|
|
71
|
+
size = format_size(get_directory_size(path))
|
|
72
|
+
commits = f'{BOLD}{subprocess.check_output("git rev-list --count HEAD", shell=True, text=True, cwd=path).strip()}{RESET} {DIM}commits{RESET}'
|
|
73
|
+
user_commits = f'{GREEN}{BOLD}{subprocess.check_output("git rev-list --count --author=\"$(git config user.name)\" HEAD", shell=True, text=True, cwd=path).strip()}{RESET} {DIM}by you{RESET}'
|
|
74
|
+
colored_labels = self._get_formatted_labels(labels)
|
|
75
|
+
|
|
76
|
+
table.add_row([formatted_path, commits, user_commits, size, colored_labels])
|
|
77
|
+
|
|
78
|
+
utils.print_table(table)
|
|
79
|
+
|
|
80
|
+
# `mud status` command implementation
|
|
81
|
+
def status(self, repos: Dict[str, List[str]]) -> None:
|
|
82
|
+
table = utils.get_table(['Path', 'Branch', 'Origin Sync', 'Status', 'Edits'])
|
|
83
|
+
|
|
84
|
+
for path, labels in repos.items():
|
|
85
|
+
output = self._get_status_porcelain(path)
|
|
86
|
+
files = output.splitlines()
|
|
87
|
+
|
|
88
|
+
formatted_path = self._get_formatted_path(path)
|
|
89
|
+
branch = self._get_branch_status(path)
|
|
90
|
+
origin_sync = self._get_origin_sync(path)
|
|
91
|
+
status = self._get_status_string(files)
|
|
92
|
+
|
|
93
|
+
colored_output = []
|
|
94
|
+
|
|
95
|
+
for file in files:
|
|
96
|
+
file_status = file[:2].strip()
|
|
97
|
+
if file_status.startswith('M') or file_status.startswith('U'):
|
|
98
|
+
color = YELLOW
|
|
99
|
+
elif file_status.startswith('A') or file_status.startswith('C') or file_status.startswith('??') or file_status.startswith('!!'):
|
|
100
|
+
color = BRIGHT_GREEN
|
|
101
|
+
elif file_status.startswith('D'):
|
|
102
|
+
color = RED
|
|
103
|
+
elif file_status.startswith('R'):
|
|
104
|
+
color = BLUE
|
|
105
|
+
else:
|
|
106
|
+
color = CYAN
|
|
107
|
+
|
|
108
|
+
colored_output.append(self._get_formatted_path(file[3:].strip(), color))
|
|
109
|
+
|
|
110
|
+
table.add_row([formatted_path, branch, origin_sync, status, ', '.join(colored_output)])
|
|
111
|
+
|
|
112
|
+
utils.print_table(table)
|
|
113
|
+
|
|
114
|
+
# `mud labels` command implementation
|
|
115
|
+
def labels(self, repos: Dict[str, List[str]]) -> None:
|
|
116
|
+
table = utils.get_table(['Path', 'Labels'])
|
|
117
|
+
|
|
118
|
+
for path, labels in repos.items():
|
|
119
|
+
formatted_path = self._get_formatted_path(path)
|
|
120
|
+
colored_labels = self._get_formatted_labels(labels)
|
|
121
|
+
table.add_row([formatted_path, colored_labels])
|
|
122
|
+
|
|
123
|
+
utils.print_table(table)
|
|
124
|
+
|
|
125
|
+
# `mud log` command implementation
|
|
126
|
+
def log(self, repos: Dict[str, List[str]]) -> None:
|
|
127
|
+
table = utils.get_table(['Path', 'Branch', 'Author', 'Time', 'Message'])
|
|
128
|
+
|
|
129
|
+
for path in repos.keys():
|
|
130
|
+
formatted_path = self._get_formatted_path(path)
|
|
131
|
+
branch = self._get_branch_status(path)
|
|
132
|
+
author = self._get_authors_name(path)
|
|
133
|
+
commit = self._get_commit_message(path)
|
|
134
|
+
|
|
135
|
+
# Commit time
|
|
136
|
+
commit_time_cmd = subprocess.run('git log -1 --pretty=format:%cd --date=relative', shell=True, text=True, cwd=path, capture_output=True)
|
|
137
|
+
commit_time = commit_time_cmd.stdout.strip()
|
|
138
|
+
|
|
139
|
+
table.add_row([formatted_path, branch, author, commit_time, commit])
|
|
140
|
+
|
|
141
|
+
utils.print_table(table)
|
|
142
|
+
|
|
143
|
+
# `mud branch` command implementation
|
|
144
|
+
def branches(self, repos: Dict[str, List[str]]) -> None:
|
|
145
|
+
table = utils.get_table(['Path', 'Branches'])
|
|
146
|
+
all_branches = {}
|
|
147
|
+
|
|
148
|
+
# Preparing branches for sorting to display them in the right order.
|
|
149
|
+
for path in repos.keys():
|
|
150
|
+
raw_branches = [line.strip() for line in subprocess.check_output('git branch', shell=True, text=True, cwd=path).split('\n') if line.strip()]
|
|
151
|
+
for branch in raw_branches:
|
|
152
|
+
branch = branch.replace(' ', '').replace('*', '')
|
|
153
|
+
if branch not in all_branches:
|
|
154
|
+
all_branches[branch] = 0
|
|
155
|
+
all_branches[branch] += 1
|
|
156
|
+
branch_counter = Counter(all_branches)
|
|
157
|
+
|
|
158
|
+
for path, labels in repos.items():
|
|
159
|
+
formatted_path = self._get_formatted_path(path)
|
|
160
|
+
branches = subprocess.check_output('git branch --color=never', shell=True, text=True, cwd=path).splitlines()
|
|
161
|
+
current_branch = next((branch.lstrip('* ') for branch in branches if branch.startswith('*')), None)
|
|
162
|
+
branches = [branch.lstrip('* ') for branch in branches]
|
|
163
|
+
sorted_branches = sorted(branches, key=lambda x: branch_counter.get(x, 0), reverse=True)
|
|
164
|
+
|
|
165
|
+
if current_branch and current_branch in sorted_branches:
|
|
166
|
+
sorted_branches.remove(current_branch)
|
|
167
|
+
sorted_branches.insert(0, current_branch)
|
|
168
|
+
|
|
169
|
+
formatted_branches = self._get_formatted_branches(sorted_branches, current_branch)
|
|
170
|
+
table.add_row([formatted_path, formatted_branches])
|
|
171
|
+
|
|
172
|
+
utils.print_table(table)
|
|
173
|
+
|
|
174
|
+
# `mud branch` command implementation
|
|
175
|
+
def remote_branches(self, repos: Dict[str, List[str]]) -> None:
|
|
176
|
+
# TODO: merge with branches() function
|
|
177
|
+
table = utils.get_table(['Path', 'Branches'])
|
|
178
|
+
all_branches = {}
|
|
179
|
+
|
|
180
|
+
# Preparing branches for sorting to display them in the right order.
|
|
181
|
+
for path in repos.keys():
|
|
182
|
+
raw_branches = [
|
|
183
|
+
line.lstrip('* ').removeprefix('origin/')
|
|
184
|
+
for line in subprocess.check_output('git branch -r', shell=True, text=True, cwd=path).split('\n')
|
|
185
|
+
if line.strip() and '->' not in line
|
|
186
|
+
]
|
|
187
|
+
for branch in raw_branches:
|
|
188
|
+
branch = branch.replace(' ', '').replace('*', '')
|
|
189
|
+
if branch not in all_branches:
|
|
190
|
+
all_branches[branch] = 0
|
|
191
|
+
all_branches[branch] += 1
|
|
192
|
+
branch_counter = Counter(all_branches)
|
|
193
|
+
|
|
194
|
+
for path, labels in repos.items():
|
|
195
|
+
formatted_path = self._get_formatted_path(path)
|
|
196
|
+
branches = subprocess.check_output('git branch -r --color=never', shell=True, text=True, cwd=path).splitlines()
|
|
197
|
+
current_branch = next((branch.lstrip('* ') for branch in branches if branch.startswith('*')), None)
|
|
198
|
+
branches = [branch.lstrip('* ').removeprefix('origin/') for branch in branches if '->' not in branch]
|
|
199
|
+
sorted_branches = sorted(branches, key=lambda x: branch_counter.get(x, 0), reverse=True)
|
|
200
|
+
|
|
201
|
+
if current_branch and current_branch in sorted_branches:
|
|
202
|
+
sorted_branches.remove(current_branch)
|
|
203
|
+
sorted_branches.insert(0, current_branch)
|
|
204
|
+
|
|
205
|
+
formatted_branches = self._get_formatted_branches(sorted_branches, current_branch)
|
|
206
|
+
table.add_row([formatted_path, formatted_branches])
|
|
207
|
+
|
|
208
|
+
utils.print_table(table)
|
|
209
|
+
|
|
210
|
+
# `mud tags` command implementation
|
|
211
|
+
def tags(self, repos: Dict[str, List[str]]) -> None:
|
|
212
|
+
COLORS = [196, 202, 208, 214, 220, 226, 118, 154, 190, 33, 39, 45, 51, 87, 93, 99, 105, 111, 27, 63, 69, 75, 81, 87, 123, 129, 135, 141, 147, 183, 189, 225]
|
|
213
|
+
|
|
214
|
+
tag_colors = {}
|
|
215
|
+
|
|
216
|
+
def assign_color(tag: str) -> str:
|
|
217
|
+
if tag not in tag_colors:
|
|
218
|
+
color_code = COLORS[len(tag_colors) % len(COLORS)]
|
|
219
|
+
tag_colors[tag] = f'\033[38;5;{color_code}m'
|
|
220
|
+
return tag_colors[tag]
|
|
221
|
+
|
|
222
|
+
table = utils.get_table(['Path', 'Tags'])
|
|
223
|
+
|
|
224
|
+
for path, labels in repos.items():
|
|
225
|
+
formatted_path = self._get_formatted_path(path)
|
|
226
|
+
tags = sorted([line.strip() for line in subprocess.check_output('git tag', shell=True, text=True, cwd=path).splitlines() if line.strip()], reverse=True)
|
|
227
|
+
tags = [f'{assign_color(tag)}{glyphs("tag")} {RESET}{tag}' for tag in tags]
|
|
228
|
+
tags = ' '.join(tags)
|
|
229
|
+
table.add_row([formatted_path, tags])
|
|
230
|
+
|
|
231
|
+
utils.print_table(table)
|
|
232
|
+
|
|
233
|
+
# `mud <COMMAND>` when run_async = 0 and run_table = 0
|
|
234
|
+
def run_ordered(self, repos: List[str], command: str) -> None:
|
|
235
|
+
for path in repos:
|
|
236
|
+
process = subprocess.run(command, cwd=path, universal_newlines=True, shell=True, capture_output=True, text=True, env=self._force_color_env)
|
|
237
|
+
self._print_process_header(path, command, process.returncode != 0, process.returncode)
|
|
238
|
+
if process.stdout and not process.stdout.isspace():
|
|
239
|
+
print(process.stdout)
|
|
240
|
+
if process.stderr and not process.stderr.isspace():
|
|
241
|
+
print(process.stderr)
|
|
242
|
+
|
|
243
|
+
# `mud <COMMAND>` when run_async = 1 and run_table = 0
|
|
244
|
+
async def run_async(self, repos: List[str], command: str) -> None:
|
|
245
|
+
sem = asyncio.Semaphore(len(repos))
|
|
246
|
+
|
|
247
|
+
async def run_process(path: str) -> None:
|
|
248
|
+
async with sem:
|
|
249
|
+
process = await asyncio.create_subprocess_shell(command, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=self._force_color_env)
|
|
250
|
+
stdout, stderr = await process.communicate()
|
|
251
|
+
self._print_process_header(path, command, process.returncode != 0, process.returncode)
|
|
252
|
+
if stderr:
|
|
253
|
+
print(stderr.decode())
|
|
254
|
+
if stdout and not stdout.isspace():
|
|
255
|
+
print(stdout.decode())
|
|
256
|
+
|
|
257
|
+
await asyncio.gather(*(run_process(path) for path in repos))
|
|
258
|
+
|
|
259
|
+
# `mud <COMMAND>` when run_async = 1 and run_table = 1
|
|
260
|
+
async def run_async_table_view(self, repos: List[str], command: str) -> None:
|
|
261
|
+
sem = asyncio.Semaphore(len(repos))
|
|
262
|
+
table = {repo: ['', ''] for repo in repos}
|
|
263
|
+
|
|
264
|
+
async def task(repo: str) -> None:
|
|
265
|
+
async with sem:
|
|
266
|
+
await self._run_process(repo, table, command)
|
|
267
|
+
|
|
268
|
+
tasks = [asyncio.create_task(task(repo)) for repo in repos]
|
|
269
|
+
await asyncio.gather(*tasks)
|
|
270
|
+
|
|
271
|
+
async def _run_process(self, path: str, table: Dict[str, List[str]], command: str) -> None:
|
|
272
|
+
process = await asyncio.create_subprocess_shell(command, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=self._force_color_env)
|
|
273
|
+
table[path] = ['', f'{YELLOW}{glyphs("running")}{RESET}']
|
|
274
|
+
|
|
275
|
+
while True:
|
|
276
|
+
line = await process.stdout.readline()
|
|
277
|
+
if not line:
|
|
278
|
+
line = await process.stderr.readline()
|
|
279
|
+
if not line:
|
|
280
|
+
break
|
|
281
|
+
line = line.decode().strip()
|
|
282
|
+
line = table[path][0] if not line.strip() else line
|
|
283
|
+
table[path] = [line, f'{YELLOW}{glyphs("running")}{RESET}']
|
|
284
|
+
self._print_process(table)
|
|
285
|
+
|
|
286
|
+
return_code = await process.wait()
|
|
287
|
+
|
|
288
|
+
if return_code == 0:
|
|
289
|
+
status = f'{GREEN}{glyphs("finished")}{RESET}'
|
|
290
|
+
else:
|
|
291
|
+
status = f'{RED}{glyphs("failed")} Code: {return_code}{RESET}'
|
|
292
|
+
|
|
293
|
+
table[path] = [table[path][0], status]
|
|
294
|
+
self._print_process(table)
|
|
295
|
+
|
|
296
|
+
def _print_process(self, info: Dict[str, List[str]]) -> None:
|
|
297
|
+
table = utils.get_table(['Path', 'Status', 'Output'])
|
|
298
|
+
for path, (line, status) in info.items():
|
|
299
|
+
formatted_path = self._get_formatted_path(path)
|
|
300
|
+
table.add_row([formatted_path, status, line])
|
|
301
|
+
|
|
302
|
+
table_str = utils.table_to_str(table)
|
|
303
|
+
num_lines = table_str.count('\n') + 1
|
|
304
|
+
self._clear_printed_lines()
|
|
305
|
+
utils.print_table(table)
|
|
306
|
+
self._last_printed_lines = num_lines
|
|
307
|
+
|
|
308
|
+
def _clear_printed_lines(self) -> None:
|
|
309
|
+
if self._last_printed_lines > 0:
|
|
310
|
+
for _ in range(self._last_printed_lines):
|
|
311
|
+
# Clear previous line
|
|
312
|
+
print('\033[A\033[K', end='')
|
|
313
|
+
self._last_printed_lines = 0
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def _get_status_porcelain(path: str) -> str:
|
|
317
|
+
try:
|
|
318
|
+
output = subprocess.check_output('git status --porcelain', shell=True, text=True, cwd=path)
|
|
319
|
+
return output
|
|
320
|
+
except Exception as e:
|
|
321
|
+
return str(e)
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def _get_status_string(files: List[str]) -> str:
|
|
325
|
+
modified, added, removed, moved = 0, 0, 0, 0
|
|
326
|
+
|
|
327
|
+
for file in files:
|
|
328
|
+
file = file.lstrip()
|
|
329
|
+
if file.startswith('M') or file.startswith('U'):
|
|
330
|
+
modified += 1
|
|
331
|
+
elif file.startswith('A') or file.startswith('C') or file.startswith('??') or file.startswith('!!'):
|
|
332
|
+
added += 1
|
|
333
|
+
elif file.startswith('D'):
|
|
334
|
+
removed += 1
|
|
335
|
+
elif file.startswith('R'):
|
|
336
|
+
moved += 1
|
|
337
|
+
status = ''
|
|
338
|
+
if added:
|
|
339
|
+
status += f'{BRIGHT_GREEN}{added} {glyphs("added")}{RESET} '
|
|
340
|
+
if modified:
|
|
341
|
+
status += f'{YELLOW}{modified} {glyphs("modified")}{RESET} '
|
|
342
|
+
if moved:
|
|
343
|
+
status += f'{BLUE}{moved} {glyphs("moved")}{RESET} '
|
|
344
|
+
if removed:
|
|
345
|
+
status += f'{RED}{removed} {glyphs("removed")}{RESET} '
|
|
346
|
+
if not files:
|
|
347
|
+
status = f'{GREEN}{glyphs("clear")}{RESET}'
|
|
348
|
+
return status
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _get_branch_status(path: str) -> str:
|
|
352
|
+
try:
|
|
353
|
+
branch_cmd = subprocess.run('git rev-parse --abbrev-ref HEAD', shell=True, text=True, cwd=path, capture_output=True)
|
|
354
|
+
branch_stdout = branch_cmd.stdout.strip()
|
|
355
|
+
except subprocess.CalledProcessError:
|
|
356
|
+
branch_stdout = 'NA'
|
|
357
|
+
if '/' in branch_stdout:
|
|
358
|
+
branch_path = branch_stdout.split('/')
|
|
359
|
+
icon = Runner._get_branch_icon(branch_path[0])
|
|
360
|
+
return f'{icon}{RESET}{glyphs("space")}{branch_path[0]}{RESET}/{BOLD}{("/".join(branch_path[1:]))}{RESET}'
|
|
361
|
+
elif branch_stdout == 'HEAD':
|
|
362
|
+
# check if we are on tag
|
|
363
|
+
glyph = glyphs('tag')
|
|
364
|
+
color = BRIGHT_MAGENTA
|
|
365
|
+
info_cmd = subprocess.run('git describe --tags --exact-match', shell=True, text=True, cwd=path, capture_output=True)
|
|
366
|
+
info_cmd = info_cmd.stdout.strip()
|
|
367
|
+
|
|
368
|
+
if not info_cmd.strip():
|
|
369
|
+
glyph = glyphs("branch")
|
|
370
|
+
color = CYAN
|
|
371
|
+
info_cmd = subprocess.run('git rev-parse --short HEAD', shell=True, text=True, cwd=path, capture_output=True)
|
|
372
|
+
info_cmd = info_cmd.stdout.strip()
|
|
373
|
+
|
|
374
|
+
return f'{color}{glyph}{RESET}{glyphs("space")}{DIM}{branch_stdout}{RESET}:{info_cmd}'
|
|
375
|
+
else:
|
|
376
|
+
return f'{Runner._get_branch_icon(branch_stdout)}{RESET}{glyphs("space")}{branch_stdout}'
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _get_origin_sync(path: str) -> str:
|
|
380
|
+
try:
|
|
381
|
+
ahead_behind_cmd = subprocess.run('git rev-list --left-right --count HEAD...@{upstream}', shell=True, text=True, cwd=path, capture_output=True)
|
|
382
|
+
stdout = ahead_behind_cmd.stdout.strip().split()
|
|
383
|
+
except subprocess.CalledProcessError:
|
|
384
|
+
stdout = ['0', '0']
|
|
385
|
+
|
|
386
|
+
origin_sync = ''
|
|
387
|
+
if len(stdout) >= 2:
|
|
388
|
+
ahead, behind = stdout[0], stdout[1]
|
|
389
|
+
if ahead and ahead != '0':
|
|
390
|
+
origin_sync += f'{BRIGHT_GREEN}{glyphs("ahead")} {ahead}{RESET}'
|
|
391
|
+
if behind and behind != '0':
|
|
392
|
+
if origin_sync:
|
|
393
|
+
origin_sync += ' '
|
|
394
|
+
origin_sync += f'{BRIGHT_BLUE}{glyphs("behind")} {behind}{RESET}'
|
|
395
|
+
|
|
396
|
+
if not origin_sync.strip():
|
|
397
|
+
origin_sync = f'{BLUE}{glyphs("synced")}{RESET}'
|
|
398
|
+
|
|
399
|
+
return origin_sync
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def _print_process_header(path: str, command: str, failed: bool, code: int) -> None:
|
|
403
|
+
command = f'{BKG_WHITE}{BLACK}{glyphs("space")}{glyphs("terminal")}{glyphs("space")}{BOLD}{command} {END_BOLD}{WHITE}{RESET}{WHITE}{BKG_BLACK}{glyphs(")")}{RESET}'
|
|
404
|
+
path = f'{BKG_BLACK}{glyphs("space")}{DIM}{glyphs("directory")}{END_DIM}{glyphs("space")}{Runner._get_formatted_path(path)}{glyphs("space")}{RESET}'
|
|
405
|
+
code = f'{BLACK}{BKG_RED if failed else BKG_GREEN}{glyphs(")")}{BRIGHT_WHITE}{glyphs("space")}{glyphs("failed") if failed else glyphs("finished")} {f"{BOLD}{code}" if failed else ""}{glyphs("space")}{RESET}{RED if failed else GREEN}{glyphs(")")}{RESET}'
|
|
406
|
+
print(f'{command}{path}{code}')
|
|
407
|
+
|
|
408
|
+
@staticmethod
|
|
409
|
+
def _get_formatted_path(path: str, color: str = None) -> str:
|
|
410
|
+
collapse_paths = utils.settings.config['mud'].getboolean('collapse_paths', fallback=False)
|
|
411
|
+
|
|
412
|
+
if color is None:
|
|
413
|
+
color = WHITE
|
|
414
|
+
|
|
415
|
+
in_quotes = path.startswith('\"') and path.endswith('\"')
|
|
416
|
+
quote = '\"' if in_quotes else ''
|
|
417
|
+
|
|
418
|
+
if in_quotes:
|
|
419
|
+
path = path.replace('\"', '')
|
|
420
|
+
|
|
421
|
+
def apply_styles(text: str) -> str:
|
|
422
|
+
return color + quote + text + quote + RESET
|
|
423
|
+
|
|
424
|
+
if os.path.isabs(path):
|
|
425
|
+
home = os.path.expanduser('~')
|
|
426
|
+
if path.startswith(home):
|
|
427
|
+
path = path.replace(home, '~', 1)
|
|
428
|
+
if path.startswith('/'):
|
|
429
|
+
path = path[1:]
|
|
430
|
+
parts = path.split('/')
|
|
431
|
+
if collapse_paths:
|
|
432
|
+
return apply_styles((DIM + '/'.join([p[0] for p in parts[:-1]] + [END_DIM + parts[-1]])))
|
|
433
|
+
else:
|
|
434
|
+
return apply_styles((DIM + '/'.join(parts[:-1]) + '/' + END_DIM + parts[-1]))
|
|
435
|
+
if '/' not in path:
|
|
436
|
+
return apply_styles(path)
|
|
437
|
+
|
|
438
|
+
parts = path.split('/')
|
|
439
|
+
if collapse_paths:
|
|
440
|
+
return apply_styles((DIM + '/'.join([p[0] for p in parts[:-1]] + [END_DIM + parts[-1]])))
|
|
441
|
+
else:
|
|
442
|
+
return apply_styles((DIM + '/'.join(parts[:-1]) + '/' + END_DIM + parts[-1]))
|
|
443
|
+
|
|
444
|
+
@staticmethod
|
|
445
|
+
def _get_authors_name(path: str) -> str:
|
|
446
|
+
cmd = subprocess.run('git log -1 --pretty=format:%an', shell=True, text=True, cwd=path, capture_output=True)
|
|
447
|
+
git_config_user_cmd = subprocess.run(['git', 'config', 'user.name'], text=True, capture_output=True)
|
|
448
|
+
committer_color = '' if cmd.stdout.strip() == git_config_user_cmd.stdout.strip() else DIM
|
|
449
|
+
return f'{committer_color}{cmd.stdout.strip()}{RESET}'
|
|
450
|
+
|
|
451
|
+
@staticmethod
|
|
452
|
+
def _get_commit_message(path: str) -> str:
|
|
453
|
+
cmd = subprocess.run('git log -1 --pretty=format:%s', shell=True, text=True, cwd=path, capture_output=True)
|
|
454
|
+
return cmd.stdout.strip()
|
|
455
|
+
|
|
456
|
+
@staticmethod
|
|
457
|
+
def _get_formatted_labels(labels: List[str]) -> str:
|
|
458
|
+
if len(labels) == 0:
|
|
459
|
+
return ''
|
|
460
|
+
colored_labels = ''
|
|
461
|
+
for label in labels:
|
|
462
|
+
color_index = Runner._get_color_index(label) % len(TEXT)
|
|
463
|
+
colored_labels += f'{TEXT[(color_index + 3) % len(TEXT)]}{glyphs("label")}{glyphs("space")}{label}{RESET} '
|
|
464
|
+
|
|
465
|
+
return colored_labels.rstrip()
|
|
466
|
+
|
|
467
|
+
@staticmethod
|
|
468
|
+
def _get_formatted_branches(branches: List[str], current_branch: str) -> str:
|
|
469
|
+
if len(branches) == 0:
|
|
470
|
+
return ''
|
|
471
|
+
|
|
472
|
+
output = ''
|
|
473
|
+
for branch in branches:
|
|
474
|
+
prefix = f'{BOLD}{RED}*{RESET}' if current_branch == branch else ''
|
|
475
|
+
icon = Runner._get_branch_icon(branch.split('/')[0])
|
|
476
|
+
branch = Runner._get_formatted_path(branch, BRIGHT_WHITE)
|
|
477
|
+
output += f'{icon}{glyphs("space")}{prefix}{BRIGHT_WHITE}{branch}{RESET} '
|
|
478
|
+
return output
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def _get_branch_icon(branch_prefix: str) -> str:
|
|
482
|
+
if branch_prefix in ['bugfix', 'bug', 'hotfix']:
|
|
483
|
+
return RED + glyphs('bugfix') + RESET
|
|
484
|
+
elif branch_prefix in ['feature', 'feat', 'develop']:
|
|
485
|
+
return GREEN + glyphs('feature') + RESET
|
|
486
|
+
elif branch_prefix in ['release']:
|
|
487
|
+
return BLUE + glyphs('release') + RESET
|
|
488
|
+
elif branch_prefix in ['master', 'main']:
|
|
489
|
+
return YELLOW + glyphs('master') + RESET
|
|
490
|
+
elif branch_prefix in ['test']:
|
|
491
|
+
return MAGENTA + glyphs('test') + RESET
|
|
492
|
+
else:
|
|
493
|
+
return CYAN + glyphs('branch') + RESET
|
|
494
|
+
|
|
495
|
+
@staticmethod
|
|
496
|
+
def _get_color_index(label: str) -> (str, str):
|
|
497
|
+
if label not in Runner._label_color_cache:
|
|
498
|
+
Runner._label_color_cache[label] = Runner._current_color_index
|
|
499
|
+
Runner._current_color_index = (Runner._current_color_index + 1) % len(BKG)
|
|
500
|
+
return Runner._label_color_cache[label]
|
mud/settings.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import configparser
|
|
3
|
+
|
|
4
|
+
MAIN_SCOPE = 'mud'
|
|
5
|
+
ALIAS_SCOPE = 'alias'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Settings:
|
|
9
|
+
def __init__(self, file_name: str) -> None:
|
|
10
|
+
self.file_name = file_name
|
|
11
|
+
self.mud_settings = None
|
|
12
|
+
self.alias_settings = None
|
|
13
|
+
self.config = configparser.ConfigParser()
|
|
14
|
+
self.settings_file = os.path.join(os.path.expanduser('~'), self.file_name)
|
|
15
|
+
self.defaults = {
|
|
16
|
+
'mud': {
|
|
17
|
+
'config_path': '',
|
|
18
|
+
'nerd_fonts': True,
|
|
19
|
+
'run_async': True,
|
|
20
|
+
'run_table': True,
|
|
21
|
+
'simplify_branches': True
|
|
22
|
+
},
|
|
23
|
+
'alias': {
|
|
24
|
+
'to': 'git checkout',
|
|
25
|
+
'fetch': 'git fetch',
|
|
26
|
+
'pull': 'git pull',
|
|
27
|
+
'push': 'git push'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
self.load_settings()
|
|
31
|
+
|
|
32
|
+
def load_settings(self) -> None:
|
|
33
|
+
if not os.path.exists(self.settings_file):
|
|
34
|
+
self.config.read_dict(self.defaults)
|
|
35
|
+
self.save()
|
|
36
|
+
else:
|
|
37
|
+
self.config.read(self.settings_file)
|
|
38
|
+
|
|
39
|
+
self.mud_settings = {}
|
|
40
|
+
for key in self.defaults[MAIN_SCOPE]:
|
|
41
|
+
if isinstance(self.defaults[MAIN_SCOPE][key], bool):
|
|
42
|
+
self.mud_settings[key] = self.config.getboolean(MAIN_SCOPE, key, fallback=self.defaults[MAIN_SCOPE][key])
|
|
43
|
+
else:
|
|
44
|
+
self.mud_settings[key] = self.config.get(MAIN_SCOPE, key, fallback=self.defaults[MAIN_SCOPE][key])
|
|
45
|
+
|
|
46
|
+
if ALIAS_SCOPE in self.config:
|
|
47
|
+
self.alias_settings = self.config[ALIAS_SCOPE]
|
|
48
|
+
|
|
49
|
+
def save(self) -> None:
|
|
50
|
+
with open(self.settings_file, 'w') as configfile:
|
|
51
|
+
self.config.write(configfile)
|
mud/styles.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
BOLD = '\033[1m'
|
|
2
|
+
DIM = '\033[2m'
|
|
3
|
+
ITALIC = '\033[3m'
|
|
4
|
+
UNDERLINE = '\033[4m'
|
|
5
|
+
BLINK = '\033[5m'
|
|
6
|
+
|
|
7
|
+
STYLES = [BOLD, DIM, ITALIC, UNDERLINE, BLINK]
|
|
8
|
+
|
|
9
|
+
END_BOLD = '\033[22m'
|
|
10
|
+
END_DIM = '\033[22m'
|
|
11
|
+
END_ITALIC = '\033[23m'
|
|
12
|
+
END_UNDERLINE = '\033[24m'
|
|
13
|
+
END_BLINK = '\033[25m'
|
|
14
|
+
|
|
15
|
+
END = [END_BOLD, END_DIM, END_ITALIC, END_UNDERLINE, END_BLINK]
|
|
16
|
+
|
|
17
|
+
# Text colors
|
|
18
|
+
WHITE = '\033[37m'
|
|
19
|
+
GRAY = '\033[90m'
|
|
20
|
+
BLACK = '\033[30m'
|
|
21
|
+
RED = '\033[31m'
|
|
22
|
+
GREEN = '\033[32m'
|
|
23
|
+
YELLOW = '\033[33m'
|
|
24
|
+
BLUE = '\033[34m'
|
|
25
|
+
MAGENTA = '\033[35m'
|
|
26
|
+
CYAN = '\033[36m'
|
|
27
|
+
BRIGHT_WHITE = '\033[97m'
|
|
28
|
+
BRIGHT_RED = '\033[91m'
|
|
29
|
+
BRIGHT_GREEN = '\033[92m'
|
|
30
|
+
BRIGHT_YELLOW = '\033[93m'
|
|
31
|
+
BRIGHT_BLUE = '\033[94m'
|
|
32
|
+
BRIGHT_MAGENTA = '\033[95m'
|
|
33
|
+
BRIGHT_CYAN = '\033[96m'
|
|
34
|
+
|
|
35
|
+
TEXT = [WHITE, GRAY, BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, BRIGHT_WHITE, BRIGHT_RED, BRIGHT_GREEN, BRIGHT_YELLOW, BRIGHT_BLUE, BRIGHT_MAGENTA, BRIGHT_CYAN]
|
|
36
|
+
|
|
37
|
+
# Background colors
|
|
38
|
+
BKG_WHITE = '\033[47m'
|
|
39
|
+
BKG_MEDIUM_GRAY = '\033[100m'
|
|
40
|
+
BKG_BLACK = '\033[40m'
|
|
41
|
+
BKG_RED = '\033[41m'
|
|
42
|
+
BKG_GREEN = '\033[42m'
|
|
43
|
+
BKG_YELLOW = '\033[43m'
|
|
44
|
+
BKG_BLUE = '\033[44m'
|
|
45
|
+
BKG_MAGENTA = '\033[45m'
|
|
46
|
+
BKG_CYAN = '\033[46m'
|
|
47
|
+
BKG_BRIGHT_WHITE = '\033[107m'
|
|
48
|
+
BKG_BRIGHT_RED = '\033[101m'
|
|
49
|
+
BKG_BRIGHT_GREEN = '\033[102m'
|
|
50
|
+
BKG_BRIGHT_YELLOW = '\033[103m'
|
|
51
|
+
BKG_BRIGHT_BLUE = '\033[104m'
|
|
52
|
+
BKG_BRIGHT_MAGENTA = '\033[105m'
|
|
53
|
+
BKG_BRIGHT_CYAN = '\033[106m'
|
|
54
|
+
|
|
55
|
+
BKG = [BKG_WHITE, BKG_MEDIUM_GRAY, BKG_BLACK, BKG_RED, BKG_GREEN, BKG_YELLOW, BKG_BLUE, BKG_MAGENTA, BKG_CYAN, BKG_BRIGHT_WHITE, BKG_BRIGHT_RED, BKG_BRIGHT_GREEN, BKG_BRIGHT_YELLOW, BKG_BRIGHT_BLUE, BKG_BRIGHT_MAGENTA, BKG_BRIGHT_CYAN]
|
|
56
|
+
|
|
57
|
+
RESET = '\033[0m'
|
|
58
|
+
|
|
59
|
+
URL_START = '\033]8;;'
|
|
60
|
+
URL_TEXT = '\a'
|
|
61
|
+
URL_END = '\033]8;;\a'
|
|
62
|
+
|
|
63
|
+
ALL = BKG + TEXT + STYLES + END + [RESET, URL_START, URL_TEXT, URL_END]
|
|
64
|
+
|
|
65
|
+
GLYPHS = {
|
|
66
|
+
'ahead': ['\uf062', 'Ahead'],
|
|
67
|
+
'behind': ['\uf063', 'Behind'],
|
|
68
|
+
'modified': ['\uf040', '*'],
|
|
69
|
+
'added': ['\uf067', '+'],
|
|
70
|
+
'removed': ['\uf1f8', '-'],
|
|
71
|
+
'moved': ['\uf064', 'M'],
|
|
72
|
+
'clear': ['\uf00c', 'Clear'],
|
|
73
|
+
'synced': ['\uf00c', 'Up to date'],
|
|
74
|
+
'master': ['\uf015', ''],
|
|
75
|
+
'bugfix': ['\uf188', ''],
|
|
76
|
+
'release': ['\uf135', ''],
|
|
77
|
+
'feature': ['\uf0ad', ''],
|
|
78
|
+
'test': ['\uf0c3', ''],
|
|
79
|
+
'branch': ['\ue725', ''],
|
|
80
|
+
'failed': ['\uf00d', 'Failed'],
|
|
81
|
+
'finished': ['\uf00c', 'Finished'],
|
|
82
|
+
'running': ['\uf46a', 'Running'],
|
|
83
|
+
'label': ['\uf435', ''],
|
|
84
|
+
'tag': ['\uf02b', '>'],
|
|
85
|
+
'terminal': ['\ue795', ''],
|
|
86
|
+
'directory': ['\uf4d4', ''],
|
|
87
|
+
'(': ['\uE0B2', ''],
|
|
88
|
+
')': ['\uE0B0', ' '],
|
|
89
|
+
'weight': ['\uee94', ''],
|
|
90
|
+
'space': [' ', ''],
|
|
91
|
+
'git': ['\uefa0', ''],
|
|
92
|
+
'github': ['\uf09b', ''],
|
|
93
|
+
'gitlab': ['\uf296', ''],
|
|
94
|
+
'azure': ['\uebe8', ''],
|
|
95
|
+
'bitbucket': ['\ue703', '']
|
|
96
|
+
}
|