-
Notifications
You must be signed in to change notification settings - Fork 5
/
Copy pathgit-m
executable file
·524 lines (460 loc) · 17.1 KB
/
git-m
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
#!/usr/bin/python3
import os
from os.path import *
import re
import json
import yaml
import ago
import sys
import signal
from sys import *
import argparse
import inspect
import traceback
from datetime import *
from prettytable import *
from git.exc import InvalidGitRepositoryError, GitCommandError
from munch import Munch
import git
from git.repo.base import Repo
import pandas as pd
from pprint import *
from colorama.ansi import *
args = rest_args = None
fields = ("dir age count revision hash msg branch remote remote_head url linked state"
.split())
def warn(a):
print(a, file=sys.stderr)
def log(*_args, **kwargs):
global args
if args and 'verbose' in args and args.verbose:
s1 = inspect.stack()[1]
print("%s:%d %s < %s" %
(s1.filename, s1.lineno, s1.function,
inspect.stack()[2].function),
str(*_args).rstrip(), file=sys.stderr, **kwargs)
def run_line(m=None):
print(clear_line() + m, end='\r', file=sys.stderr)
def git_status_get(r, m):
" Returns status of untracked changes outside of repository "
m.untracked = len(r.untracked_files)
m.changed = len([f.a_path for f in r.index.diff(None)])
m.stashes = len(r.git.stash('list').splitlines())
log(r.remotes)
if r.remotes:
m.to_push = int(try_get(
lambda: r.git.rev_list('--count', '@{upstream}..HEAD'), 0))
def try_get(a, none=None):
try:
return a()
except BaseException:
return none
def git_get(g):
" Returns only repository status, without untracked local changes "
m = Munch()
r = Repo(g)
if len(r.branches):
m.branches = [b.name for b in r.branches]
try:
m.hash = r.git.rev_parse('--short', 'HEAD')
m.sha = r.commit('HEAD').hexsha
m.msg = r.head.object.message.split('\n')[0]
m.count = int(r.git.rev_list('--count', 'HEAD'))
m.time_sec = r.head.commit.committed_date
m.datetime = r.head.commit.committed_datetime
m.revision = r.git.describe(['--always', '--contains'])
except GitCommandError:
m.count = 0
# without commit
pass
cr = r.config_reader()
if islink(g + '/.git/config'):
m.linked = dirname(relpath(realpath(g + '/.git/config')))
if cr.has_option('core', 'worktree'):
m.worktree = cr.get_value('core', 'worktree')
# if r.head.is_detached:
# return m
m.branch = try_get(lambda: r.active_branch.name)
if not r.remotes:
return m
log(m)
# if not m.count:
# return m
tb = try_get(lambda: r.active_branch.tracking_branch())
if tb:
m.remote = tb.remote_name
else:
m.remote = r.remotes[0].name
log(r.remotes[m.remote])
m.url = try_get(lambda: r.remotes[m.remote].url)
if not tb:
return m
m.remote = tb.remote_name
log(m.remote)
m.remote_head = tb.remote_head
m.remote_sha = tb.commit.hexsha
merge_base = r.merge_base(tb, 'HEAD')[0].hexsha
m.to_push = int(r.git.rev_list('--count', str(tb) + '..HEAD'))
if not m.to_push:
del m.to_push
m.to_pull = int(r.git.rev_list('--count', 'HEAD..' + str(tb)))
if not m.to_pull:
del m.to_pull
return m
def git_compare(d, s):
same = False
if not exists(d + '/.git'):
s.state = 'absent'
log(d)
else:
r = Repo(d)
if 'sha' in s:
if s.sha != r.commit('HEAD').hexsha:
s.state = 'different'
elif (not r.head.is_detached and
r.active_branch.name == s.get('branch', '')):
s.state = 'same'
same = True
else:
s.state = 'same detached'
return same
def git_import(d, s):
if 'remote' not in s:
return
if exists(d + '/.git'):
r = Repo(d)
else:
try:
r = Repo.clone_from(s.url, d)
except (AttributeError, GitCommandError) as e:
r = Repo.init(d)
if s.remote not in [a.name for a in r.remotes]:
r.create_remote(s.remote, s.url)
r.git.fetch(s.remote)
if 'branch' in s:
r.git.checkout(s.branch)
if 'sha' not in s:
return
try:
if s.sha != r.commit('HEAD').hexsha:
r.git.checkout(s.sha)
s.state = 'imported'
except GitCommandError:
s.state = 'failed'
pass
# assure same
if s.sha == r.commit('HEAD').hexsha:
s.state += ' same'
def xstr(s):
return '' if s is None else str(s)
def short(s, right=True):
if not s or len(s) <= 16:
return xstr(s)
if right:
return s[:15] + '…'
else:
return '…' + s[-15:]
def parse_args():
ap = argparse.ArgumentParser()
ap.add_argument('--table', nargs='?', default=argparse.SUPPRESS)
ap.add_argument('--sha', nargs='?', default=argparse.SUPPRESS,
help='print sha hashes in format of sha1sum utility '
'optionally to a file')
ap.add_argument('--csv', nargs='?', default=argparse.SUPPRESS,
help='print in format csv')
ap.add_argument('--json', nargs='?', default=argparse.SUPPRESS)
ap.add_argument('--export', nargs='?', default=argparse.SUPPRESS,
help='scans directory tree and saves results '
'to default status.yaml or specified another file. '
'Can be "-" for standart output.')
ap.add_argument('--compare', nargs='?', default=argparse.SUPPRESS,
help='scans directory tree and compares with status.yaml')
ap.add_argument('--import', nargs='?', default=argparse.SUPPRESS,
help='scans directory tree and '
'synchronizes with default status.yaml '
'or specified another file. '
'Can be "-" for standart input.')
ap.add_argument('--standalone_remote', action='store_true',
help='skips linked (not standalone) '
'repositories and without remotes. '
'Leaves only unlinked standalone with remotes. '
'Linked reps belong to git submodules or repo. '
'This reps can be replicated with parent git or repo. '
'Reps without remote can\'t be synced via remote.')
ap.add_argument('--status', action='store_true',
help='prints number of untracked and changed files, '
'number of stashes '
'and number of local "ahead" commits to push '
'when any of above values is unzero')
ap.add_argument('--urls', action='store_true',
help='lists paths and remote urls')
ap.add_argument('--since', nargs=1,
help='filter aged repositories. '
'for example: git-m --since 2019-12-31')
ap.add_argument('rest', metavar='...', nargs='?', default='.',
help='directory for export or compare, '
'or git command with arguments. '
'When argument is existing file or directory the git '
'command will be executed for that location, '
'even current directory is outer to git repository. '
'When arguments of the specified git command '
'are not existed files/dirs the command '
'will be executed for all exported git directories.'
)
ap.add_argument('--verbose', action='store_true')
global args, rest_args
args, rest_args = ap.parse_known_args()
log(args)
log(rest_args)
def print_csv(p, st):
print(", ".join(
[p, xstr(st.get('datetime', '')), xstr(st.get('count', 0)),
st.get('sha', ''), '"%s"' % (st.get('msg', '')),
xstr(st.get('branch', '')),
st.get('worktree', st.get('linked', 'standalone')),
(st.get('remote', 'local') + ' ' + xstr(st.get('url', ''))),
st.get('state', '')
]
))
def age(s):
return short(ago.human(datetime.fromtimestamp(s),
1, '{}', '{}', True))
def print_sha(p, st):
print("%s %s" % (st.sha, p)) if 'sha' in st else 0
print_status_num = 0
def print_status(p, st):
log(st)
global print_status_num
format = "\r%-50s\t%8s\t%8s\t%8s\t%8s\t%8s"
if not print_status_num:
print(format % ('-', 'untracked', 'changed',
'stashes', 'to_push', 'to_pull'))
print_status_num +=1
if (st.untracked + st.changed + st.stashes
+ st.get('to_push', 0) + st.get('to_pull', 0)):
print(format % (p, st.untracked, st.changed,
st.stashes,
st.get('to_push', 0),
st.get('to_pull', 0)
))
class GitM(object):
def __init__(self):
self.loaded = self.out = self.tab = None
self.status = {}
def process_args(self):
rep = {'same': '=', 'detached': '/', 'different': '*',
'undesired': '-', 'absent': '+', 'imported': '<',
'failed': '!'}
pattern = re.compile("|".join(rep.keys()))
def table_add_row(p, st):
r = {}
r['dir'] = short(p, False)
r.update(dict(st))
r['msg'] = short(r.get('msg', ''))
if 'time_sec' in st:
r['age'] = age(st.time_sec)
r['branch'] = short(r.get('branch', ''))
r['revision'] = short(r.get('revision', ''), False)
r['url'] = short(r.get('url', ''), False)
r['linked'] = short(r.get('linked', ''), False)
r['state'] = pattern.sub(lambda m: rep[re.escape(m.group(0))],
r.get('state', ''))
self.tab.add_row([r[f] if f in r else '' for f in fields])
if 'warnings' in dir(yaml):
yaml.warnings({'YAMLLoadWarning': False})
if 'compare' in args:
if not args.compare:
args.compare = "status.yaml"
if not isfile(args.compare):
args.compare = "status.json"
log(args.compare)
with open(args.compare) as f:
if args.compare.endswith('.yaml'):
self.loaded = Munch(yaml.full_load(f))
if args.compare.endswith('.json'):
self.loaded = Munch(json.load(f))
else:
fn = vars(args).get('import', None)
fn = fn if fn else 'status.yaml'
if 'export' not in args and (isfile(fn) or fn == '-'):
if fn == '-':
f = sys.stdin
else:
f = open(fn)
self.loaded = Munch(yaml.full_load(f))
f.close()
self.out = print_sha if 'sha' in args else self.out
self.out = print_csv if 'csv' in args else self.out
if args.status:
if not self.status:
self.loaded = self.scan(args.rest)
self.out = print_status
if not self.out: # default output is table
self.tab = PrettyTable(fields,
border=False)
self.tab._left_padding_width = 0
rights = ('age', 'count')
for f in fields:
self.tab.align[f] = 'r' if f in rights else 'l'
self.out = table_add_row
# end of process_args
def git_for_subdir(self):
log()
# print(args.rest, rest_args)
dir = arg = ''
# TODO: check all arguments
if isfile(rest_args[-1]):
dir = dirname(rest_args[-1])
arg = basename(rest_args[-1])
elif isdir(rest_args[-1]):
dir = rest_args[-1]
else:
raise("Neither file or dir: " + dir)
log(dir)
log(arg)
cmd = ' '.join(["git -C", dir, args.rest] +
rest_args[:-1] + [arg])
log(cmd)
ret = os.system(cmd)
return os.WEXITSTATUS(ret)
def git_for_each(self):
log()
ret = 0
for d, s in self.loaded.items() if self.loaded else {}:
# print("project", d, flush=True)
# --src-prefix=
additional_args = (['--src-prefix=' + 'a/'+ d + '//',
'--dst-prefix=' + 'b/' + d + '//' ]
if args.rest in ['diff', 'log']
else [])
if args and 'verbose' in args and args.verbose:
print(d + ': ', flush=True)
log(additional_args)
r = os.system(' '.join(["git --no-pager -C", d, args.rest] +
additional_args +
rest_args))
if os.WTERMSIG(r) == signal.SIGINT:
raise KeyboardInterrupt
#log(r)
if r:
ret = r
return os.WEXITSTATUS(ret)
def scan(self, d):
warn('Scanning directory tree ...')
log(d)
status = {}
for path, dirs, files in os.walk(d, followlinks=False):
(dir, base) = split(path)
p = re.sub(r'^\.\/', '', path)
# log(p)
if base in ['.git', 'tmp']:
dirs[:] = [] # prune
continue
if not ('.git' in files or '.git' in dirs):
continue
run_line(p)
try:
st = git_get(p)
# Get only remote and standalone if requested
if (args.standalone_remote and
('remote' not in st or
'linked' in st or
'worktree' in st)):
continue
status[p] = dict(st)
if 'compare' in args and self.loaded and p not in self.loaded:
# only here and not in compare
st.state = 'undesired'
self.out(p, st)
if not self.loaded:
self.out(p, st) if self.out else None
except (InvalidGitRepositoryError,
GitCommandError, ValueError) as e:
warn(repr(e) +
(': ' + p if p not in repr(e) else ''))
run_line('')
return status
def compare(self, d, s):
if (args.since and s.get('datetime', datetime.now())
< pd.to_datetime( args.since)):
return
if not git_compare(d, s) and 'import' in args:
git_import(d, s)
self.out(d, s)
self.status[d] = dict(s)
def status_out(self, d, s):
log(d)
r = Repo(d)
git_status_get(r, s)
print_status(d, s)
self.status[d] = dict(s)
def urls_out(self, d, s):
log(d)
r = Repo(d)
s = [a.url for a in r.remotes]
print(d + ':')
for i in s:
print('\t' + i)
self.status[d] = s
def for_each_loaded(self, func):
for d, s in self.loaded.items() if self.loaded else {}:
try:
run_line(d)
func(d, Munch(s))
except (InvalidGitRepositoryError, GitCommandError,
ValueError) as e:
warn('Error: ' + str(e) +
(': ' + d if d not in str(e) else ''))
traceback.print_exc()
run_line('')
def output(self):
if 'json' in args:
if not args.json:
args.json = "status.json"
if args.json == '-':
f = sys.stdout
else:
f = open(args.json, "w")
f.write(json.dumps(self.status, indent=4, default=str) + "\n")
f.close()
log(args)
if 'export' in args:
if not args.export:
args.export = "status.yaml"
if args.export == '-':
f = sys.stdout
else:
f = open(args.export, "w")
f.write(yaml.dump(self.status,
default_flow_style=False, default_style=''))
f.close()
warn('Exported status into file ' + args.export)
return
print(self.tab) if self.tab else 0
def main(self):
parse_args()
self.process_args()
log(self.loaded)
if (isdir(args.rest) # without arguments or with dir
and (not self.loaded or 'compare' in args or 'export' in args)):
# don't scan if self.loaded and not in compare mode
self.status = self.scan(args.rest)
# assume git command with an argument
elif rest_args and exists(rest_args[-1]):
return self.git_for_subdir()
# assume git command without an argument
elif not isdir(args.rest):
return self.git_for_each()
if args.urls:
self.for_each_loaded(self.urls_out)
elif args.status:
self.for_each_loaded(self.status_out)
print("Total:", print_status_num)
else:
# do comprare without scan even when 'compare' not in args.
self.for_each_loaded(self.compare)
self.output()
if __name__ == "__main__":
err = GitM().main()
if err:
sys.exit(err)