-
Notifications
You must be signed in to change notification settings - Fork 3
/
git2dot.py
executable file
·1641 lines (1365 loc) · 57.3 KB
/
git2dot.py
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
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python
r'''
Tool to visualize a git repository using the graphviz dot tool.
It is useful for understanding how git works in detail. You can use
it to analyze repositories before and after operations like merge and
rebase to really get a feeling for what happens. It can also be used
for looking at subsets of history on live repositories.
It works by running over the .git repository in the current directory
and generating a commit relationship DAG that has both parent and
child relationships.
The generated graph shows commits, tags and branches as nodes.
Commits are further broken down into simple commits and merged commits
where merged commits are commits with 2 or more children. There is an
additional option that allows you to squash long chains of simple
commits with no branch or tag data.
It has a number of different options for customizing the nodes,
using your own custom git command to generate the data, keeping
the generated data for re-use and generating graphical output like
PNG, SVG or even HTML files.
Here is an example run:
$ cd SANDBOX
$ git2dot.py --png git.dot
$ open -a Preview git.dot.png # on Mac OS X
$ display git.dot.png # linux
If you want to create a simple HTML page that allows panning and
zooming of the generated SVG then use the --html option like
this.
$ cd SANDBOX
$ git2dot.py --svg --html ~/web/index.html ~/web/git.dot
$ $ ls ~/web
git.dot git.dot.svg git.html svg-pan-zoom.min.js
$ cd ~/web
$ python -m SimpleHTTPServer 8090 # start server
$ # Browse to http://localhost:8090/git.dot.svg
It assumes that existence of svg-pan-zoom.min.js from the
https://github.com/ariutta/svg-pan-zoom package.
The output is pretty customizable. For example, to add the subject and
commit date to the commit node names use -l '%s|%cr'. The items come
from the git format placeholders or variables that you define using
-D. The | separator is used to define the end of a line. The maximum
width of each line can be specified by -w. Variables are defined by -D
and come from text in the commit message. See -D for more details.
You can customize the attributes of the different types of nodes and
edges in the graph using the -?node and -?edge attributes. The table
below briefly describes the different node types:
bedge Edge connecting to a bnode.
bnode Branch node associated with a commit.
cnode Commit node (simple commit node).
mnode Merge node. A commit node with multiple children.
snode Squashed node. End point of a sequence of squashed nodes.
tedge Edge connecting to a tnode.
tnode Tag node associated with a commit.
If you have long chains of single commits use the --squash option to
squash out the middle ones. That is generally helpful for filtering
out extraneous commit details for moderately sized repos.
If you find that dot is placing your bnode and tnode nodes in odd
places, use the --crunch option to collapse the bnode nodes into
a single node and the tnodes into a single node for each commit.
If you want to limit the analysis to commits between certain dates,
use the --since and --until options.
If you want to limit the analysis to commits in a certain range use
the --range option.
If you want to limit the analysis to a small set of branches or tags
you can use the --choose-branch and --choose-tag options. These options
prune the graph so that only parents of commits with the choose branch
or tag ids are included in the graph. This gives you more detail
controlled that the git options allowed in the --range command. It
is very useful for determining where branches occurred.
You can choose to keep the git output to re-use multiple times with
different display options or to share by specifying the -k (--keep)
option.
'''
import argparse
import copy
import datetime
import dateutil.parser
import inspect
import os
import re
import subprocess
import sys
VERSION = '0.8.3'
DEFAULT_GITCMD = 'git log --format="|Record:|%h|%p|%d|%ci%n%b"' # --gitcmd
DEFAULT_RANGE = '--all --topo-order' # --range
class Node:
r'''
Each node represents a commit.
A commit can have zero or parents.
A parent link is created each time a merge is done.
'''
m_list = []
m_map = {}
m_list_bydate = []
m_vars_usage = {} # nodes that have var values
def __init__(self, cid, pids=[], branches=[], tags=[], dts=None):
self.m_cid = cid
self.m_idx = len(Node.m_list)
self.m_parents = pids
self.m_label = ''
self.m_branches = branches
self.m_tags = tags
self.m_children = []
self.m_vars = {} # user defined variable values
self.m_choose = True # used by the --choose-* options only
self.m_extra = []
self.m_dts = dts # date/time stamp, used for invisible constraints.
# For squashing.
self.m_chain_head = None
self.m_chain_tail = None
self.m_chain_size = -1
Node.m_list.append(self)
Node.m_map[cid] = self
def is_squashable(self):
if len(self.m_branches) > 0 or len(self.m_tags) > 0 or len(self.m_parents) > 1 or len(self.m_children) > 1:
return False
return True
def is_squashed(self):
if self.m_chain_head is None:
return False
if self.m_chain_tail is None:
return False
return self.m_chain_size > 0 and self.m_cid != self.m_chain_head.m_cid and self.m_cid != self.m_chain_tail.m_cid
def is_squashed_head(self):
if self.m_chain_head is None:
return False
return self.m_chain_head.m_cid == self.m_cid
def is_squashed_tail(self):
if self.m_chain_tail is None:
return False
return self.m_chain_tail.m_cid == self.m_cid
def is_merge_node(self):
return len(self.m_children) > 1
def find_chain_head(self):
if self.is_squashable() == False:
return None
if self.m_chain_head is not None:
return self.m_chain_head
# Get the head node, traversing via parents.
chain_head = None
chain_next = self
while chain_next is not None and chain_next.is_squashable():
chain_head = chain_next
if len(chain_next.m_parents) > 0:
chain_next = Node.m_map[chain_next.m_parents[0]]
else:
chain_next = None
return chain_head
def find_chain_tail(self):
if self.is_squashable() == False:
return None
if self.m_chain_tail is not None:
return self.m_chain_tail
# Get the tail node, traversing via children.
chain_tail = None
chain_next = self
while chain_next is not None and chain_next.is_squashable():
chain_tail = chain_next
if len(chain_next.m_children) > 0:
chain_next = chain_next.m_children[0]
else:
chain_next = None
return chain_tail
@staticmethod
def squash():
'''
Squash nodes that in a chain of single commits.
'''
update = {}
for nd in Node.m_list:
head = nd.find_chain_head()
if head is not None:
update[head.m_cid] = head
for key in update:
head = update[key]
tail = head.find_chain_tail()
cnext = head
clast = head
distance = 0
while clast != tail:
distance += 1
clast = cnext
cnext = cnext.m_children[0]
cnext = head
clast = head
while clast != tail:
idx = cnext.m_idx
cid = cnext.m_cid
Node.m_list[idx].m_chain_head = head
Node.m_list[idx].m_chain_tail = tail
Node.m_list[idx].m_chain_size = distance
Node.m_map[cid].m_chain_head = head
Node.m_map[cid].m_chain_tail = tail
Node.m_map[cid].m_chain_size = distance
clast = cnext
cnext = cnext.m_children[0]
def rm_parent(self, pcid):
while pcid in self.m_parents:
i = self.m_parents.index(pcid)
self.m_parents = self.m_parents[:i] + self.m_parents[i+1:]
def rm_child(self, ccid):
for i, cnd in reversed(list(enumerate(self.m_children))):
if cnd.m_cid == ccid:
self.m_children = self.m_children[:i] + self.m_children[i+1:]
def info(msg, lev=1):
''' Print an informational message with the source line number. '''
print('// INFO:{} {}'.format(inspect.stack()[lev][2], msg))
def infov(opts, msg, lev=1):
''' Print an informational message with the source line number. '''
if opts.verbose > 0:
print('// INFO:{} {}'.format(inspect.stack()[lev][2], msg))
def warn(msg, lev=1):
''' Print a warning message with the source line number. '''
print('// WARNING:{} {}'.format(inspect.stack()[lev][2], msg))
def err(msg, lev=1):
''' Print an error message and exit. '''
sys.stderr.write('// ERROR:{} {}\n'.format(inspect.stack()[lev][2], msg))
sys.exit(1)
def runcmd_long(cmd, show_output=True):
'''
Execute a long running shell command with no inputs.
Capture output and exit status.
For long running commands, this implementation displays output
information as it is captured.
For fast running commands it would be better to use
subprocess.check_output.
'''
proc = subprocess.Popen(cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
# Read the output 1 character at a time so that it can be
# displayed in real time.
output = ''
while not proc.returncode:
char = proc.stdout.read(1)
if not char:
# all done, wait for returncode to get populated
break
else:
try:
# There is probably a better way to do this.
char = char.decode('utf-8')
except UnicodeDecodeError:
continue
output += char
if show_output:
sys.stdout.write(char)
sys.stdout.flush()
proc.wait()
return proc.returncode, output
def runcmd_short(cmd, show_output=True):
'''
Execute a short running shell command with no inputs.
Capture output and exit status.
'''
try:
output = subprocess.check_output(cmd, stderr=subprocess.STDOUT, shell=True)
status = 0
except subprocess.CalledProcessError as obj:
output = obj.output
status = obj.returncode
if show_output:
sys.stdout.write(output)
return status, output
def runcmd(cmd, show_output=True):
'''
Wrapper for run commands.
'''
return runcmd_long(cmd, show_output)
def read(opts):
'''
Read the input data.
The input can come from two general sources: the output of a git
command or a file that contains the output from a git comment
(-i).
'''
# Run the git command.
infov(opts, 'reading git repo data')
out = ''
if opts.input != '':
# The user specified a file that contains the input data
# via the -i option.
try:
with open(opts.input, 'r') as ifp:
out = ifp.read()
except IOError as e:
err('input read failed: {}'.format(e))
else:
# The user chose to run a git command.
cmd = opts.gitcmd
if cmd.replace('%%', '%') == DEFAULT_GITCMD:
cmd = cmd.replace('%%', '%')
if opts.cnode_label != '':
x = cmd.rindex('"')
cmd = cmd[:x] + '%n{}|{}'.format(opts.cnode_label_recid, opts.cnode_label) + cmd[x:]
if opts.since != '':
cmd += ' --since="{}"'.format(opts.since)
if opts.until != '':
cmd += ' --until="{}"'.format(opts.until)
if opts.range != '':
cmd += ' {}'.format(opts.range)
else:
# If the user specified a custom command then we
# do not allow the user options to affect it.
if opts.cnode_label != '':
warn('-l <label> ignored when -g is specified')
if opts.since != '':
warn('--since ignored when -g is specified')
if opts.until != '':
warn('--until ignored when -g is specified')
if opts.range != DEFAULT_RANGE:
warn('--range ignored when -g is specified')
infov(opts, 'running command: {}'.format(cmd))
st, out = runcmd(cmd, show_output=opts.verbose > 1)
if st:
err('Command failed: {}\n{}'.format(cmd, out))
infov(opts, 'read {:,} bytes'.format(len(out)))
if opts.keep is True:
# The user decided to keep the generated output for
# re-use.
ofn = opts.DOT_FILE[0] + '.keep'
infov(opts, 'writing command output to {}'.format(ofn))
try:
with open(ofn, 'w') as ofp:
ofp.write(out)
except IOError as e:
err('unable to write to {}: {}'.format(ofn, e))
return out.splitlines()
def prune_by_date(opts):
'''
Prune by date is --since, --until or --range were specified.
'''
if opts.since != '' or opts.until != '' or opts.range != '':
infov(opts, 'pruning parents')
nump = 0
numt = 0
for i, nd in enumerate(Node.m_list):
np = []
for cid in nd.m_parents:
numt += 1
if cid in Node.m_map:
np.append(cid)
else:
nump += 1
if len(np) < len(nd.m_parents): # pruned
Node.m_list[i].m_parents = np
Node.m_map[nd.m_cid].m_parents = np
infov(opts, 'pruned {:,} parent node references out of {:,}'.format(nump, numt))
def prune_by_choice(opts):
'''
Prune by --choose-branch and --choose-tag if they were specified.
'''
if len(opts.choose_branch) > 0 or len(opts.choose_tag) > 0:
# The algorithm is as follows:
# 1. for each branch and tag find the associated node.
#
# 2. mark all nodes for deletion (m_choose=False)
#
# 3. walk back through graph and tag all nodes accessible
# from the parent link as keepers (m_choose=True).
# any node found that already has m_choose=True can be
# skipped because it was already processed by another
# traversal.
#
# 4. delete all nodes marked for deletion.
# iterate over all nodes, collect the delete ids in a cache
# reverse iterate over the cache and remove them
# make sure that they are removed from the list and the map
# just prior to delete a node, remove it from child list
# of its parents and from the parent list of its children.
# make sure that all m_idx settings are correctly updated.
infov(opts, 'pruning graph based on choices')
bs = {}
ts = {}
# initialize
for b in opts.choose_branch:
bs[b] = []
for t in opts.choose_tag:
ts[t] = []
for idx in range(len(Node.m_list)):
Node.m_list[idx].m_choose = False # step 2
# Step 1.
nd = Node.m_list[idx]
for b in opts.choose_branch:
if b in nd.m_branches:
bs[b].append(idx)
for t in opts.choose_tag:
if t in nd.m_tags:
ts[t].append(idx)
# Warn if any were not found.
for b, a in sorted(bs.items()):
if len(a) == 0:
warn('--choose-branch not found: "{}"'.format(b))
for t, a in sorted(ts.items()):
if len(a) == 0:
warn('--choose-branch not found: "{}"'.format(t))
# At this point all of the branches and tags have been found.
def get_parents(idx, parents):
# Can't use recursion because large graphs may have very
# long chains.
# Use a breadth first expansion instead.
# This works because git commits are always a DAG.
stack = []
stack.append(idx)
while len(stack) > 0:
idx = stack.pop()
if idx in parents:
continue # already processed
nd = Node.m_list[idx]
Node.m_list[idx].m_choose = True
parents[idx] = nd.m_cid
for pcid in nd.m_parents:
pidx = Node.m_map[pcid].m_idx
stack.append(pidx)
parents = {} # key=idx, val=cid
for b, a in sorted(bs.items()):
if len(a) > 0:
for idx in a:
get_parents(idx, parents)
for t, a in sorted(ts.items()):
if len(a) > 0:
for idx in a:
get_parents(idx, parents)
pruning = len(Node.m_list) - len(parents)
infov(opts, 'keeping {:,}'.format(len(parents)))
infov(opts, 'pruning {:,}'.format(pruning))
if pruning == 0:
warn('nothing to prune')
return
# We now have all of the nodes that we want to keep.
# We need to delete the others.
todel = []
for nd in Node.m_list[::-1]:
if nd.m_choose == False:
cid = nd.m_cid
idx = nd.m_idx
# Update the parents child lists.
# The parent list is composed of cids.
# Note that the child lists stored nodes.
for pcid in nd.m_parents:
if pcid in Node.m_map: # might have been deleted already
pnd = Node.m_map[pcid]
if pnd.m_choose == True: # ignore pruned nodes (e.g. False)
pnd.rm_child(cid)
# Update the child parent lists.
# The child list is composed of nodes.
# Note that the parent lists store cids.
for cnd in nd.m_children:
if cnd.m_choose == True: # ignore pruned nodes (e.g. False)
cnd.rm_parent(cid)
# Actual deletion.
Node.m_list = Node.m_list[:idx] + Node.m_list[idx+1:]
del Node.m_map[cid]
for i, nd in enumerate(Node.m_list):
Node.m_list[i].m_idx = i
Node.m_map[nd.m_cid].m_idx = i
infov(opts, 'remaining {:,}'.format(len(Node.m_list)))
def parse(opts):
'''
Parse the node data.
'''
infov(opts, 'loading nodes (commit data)')
nd = None
lines = read(opts)
infov(opts, 'parsing read data')
for line in lines:
line = line.strip()
if line.find(u'|Record:|') >= 0:
flds = line.split('|')
assert flds[1] == 'Record:'
cid = flds[2] # Commit id.
pids = flds[3].split() # parent ids
tags = []
branches = []
refs = flds[4].strip()
try:
dts = dateutil.parser.parse(flds[5])
except:
err('unrecognized date format: {}\n\tline: {}'.format(flds[5], line))
if len(refs):
# branches and tags
if refs[0] == '(' and refs[-1] == ')':
refs = refs[1:-1]
for fld in refs.split(','):
fld = fld.strip()
if 'tag: ' in fld:
tags.append(fld)
else:
ref = fld
if ' -> ' in fld:
ref = fld.split(' -> ')[1]
branches.append(ref)
nd = Node(cid, pids, branches, tags, dts)
if opts.define_var is not None:
# The user defined one or more variables.
# Scan each line to see if the variable
# specification exists.
for p in opts.define_var:
var = p[0]
reg = p[1]
m = re.search(reg, line)
if m:
# A variable was found.
val = m.group(1)
# Set the value on the node.
idx = nd.m_idx
if var not in Node.m_list[idx].m_vars:
Node.m_list[idx].m_vars[var] = []
Node.m_list[idx].m_vars[var].append(val)
# keep track of which nodes have this defined.
if var not in Node.m_vars_usage:
Node.m_vars_usage[var] = []
Node.m_vars_usage[var].append(nd.m_cid)
if opts.cnode_label_recid in line:
# Add the additional commit node label data into the node.
th = opts.cnode_label_maxwidth
flds = line.split('|')
idx = nd.m_idx
def setval(idx, th, val):
if th > 0:
val = val[:th]
val = val.replace('"', '\\"')
Node.m_list[idx].m_extra.append(val)
# Update the field values.
for fld in flds[1:]: # skip the record field
# We have the list of fields but these are not, necessarily
# the same as the variables.
# Example: @CHID@
# Example: FOO@CHID@BAR
# Example: @CHID@ + %s | next field |
# Get the values for each variable and substitute them.
found = False
if opts.define_var is not None:
for p in opts.define_var:
var = p[0]
if var in fld:
found = True
# The value is defined on this node.
# If it isn't we just ignore it.
if var in Node.m_list[idx].m_vars:
vals = Node.m_list[idx].m_vars[var]
if len(vals) == 1:
fld = fld.replace(var, vals[0])
setval(idx, th, fld)
else:
# This is hard because there may be
# multiple variables that are vectors
# of different sizes, punt for now.
fld = fld.replace(var, '{}'.format(vals))
setval(idx, th, fld)
if not found:
setval(idx, th, fld)
if len(Node.m_list) == 0:
err('no records found')
prune_by_date(opts)
prune_by_choice(opts)
# Update the child list for each node by looking at the parents.
# This helps us identify merge nodes.
infov(opts, 'updating children')
num_edges = 0
for nd in Node.m_list:
for p in nd.m_parents:
num_edges += 1
Node.m_map[p].m_children.append(nd)
# Summary of initial read.
infov(opts, 'found {:,} commit nodes'.format(len(Node.m_list)))
infov(opts, 'found {:,} commit edges'.format(num_edges))
if opts.verbose:
for var in Node.m_vars_usage:
info('found {:,} nodes with values for variable "{}"'.format(len(Node.m_vars_usage[var]), var))
# Squash nodes.
if opts.squash:
infov(opts, 'squashing chains')
Node.squash()
# Create the bydate list to enable ranking using invisible
# constraints.
infov(opts, 'sorting by date')
Node.m_list_bydate = [nd.m_cid for nd in Node.m_list]
Node.m_list_bydate.sort(key=lambda x: Node.m_map[x].m_dts)
def gendot(opts):
'''
Generate a test graph.
'''
# Write out the graph stuff.
infov(opts, 'gendot')
try:
ofp = open(opts.DOT_FILE[0], 'w')
except IOError as e:
err('file open failed: {}'.format(e))
# Keep track of the node information so
# that it can be reported at the end.
summary = {'num_graph_commit_nodes': 0,
'num_graph_merge_nodes': 0,
'num_graph_squash_nodes': 0,
'total_graph_commit_nodes': 0, # sum of commit, merge and squash nodes
'total_commits': 0} # total nodes with no squashing
ofp.write('digraph G {\n')
for v in opts.dot_option:
if len(opts.font_size) and 'fontsize=' in v:
v = re.sub(r'(fontsize=)[^,]+,', r'\1"' + opts.font_size + r'",' , v)
if len(opts.font_name) and 'fontsize=' in v:
v = re.sub(r'(fontsize=[^,]+),', r'\1, fontname="' + opts.font_name + r'",', v)
ofp.write(' {}'.format(v))
if v[-1] != ';':
ofp.write(';')
ofp.write('\n')
ofp.write('\n')
ofp.write(' // label cnode, mnode and snodes\n')
for nd in Node.m_list:
if opts.squash and nd.is_squashed():
continue
if nd.is_merge_node():
label = '\\n'.join(nd.m_extra)
attrs = opts.mnode.format(label=label)
ofp.write(' "{}" {};\n'.format(nd.m_cid, attrs))
summary['num_graph_merge_nodes'] += 1
summary['total_graph_commit_nodes'] += 1
summary['total_commits'] += 1
elif nd.is_squashed_head() or nd.is_squashed_tail():
label = '\\n'.join(nd.m_extra)
attrs = opts.snode.format(label=label)
ofp.write(' "{}" {};\n'.format(nd.m_cid, attrs))
summary['num_graph_squash_nodes'] += 1
summary['total_graph_commit_nodes'] += 1
else:
label = '\\n'.join(nd.m_extra)
attrs = opts.cnode.format(label=label)
ofp.write(' "{}" {};\n'.format(nd.m_cid, attrs))
summary['num_graph_commit_nodes'] += 1
summary['total_graph_commit_nodes'] += 1
summary['total_commits'] += 1
infov(opts, 'defining edges')
ofp.write('\n')
ofp.write(' // edges\n')
for nd in Node.m_list:
if nd.is_squashed():
continue
elif nd.is_squashed_tail():
continue
if nd.is_squashed_head():
# Special handling for squashed head nodes, create
# a squash edge between the head and tail.
attrs = opts.sedge.format(label=nd.m_chain_size)
ofp.write(' "{}" -> "{}" {};\n'.format(nd.m_cid, nd.m_chain_tail.m_cid, attrs))
summary['total_commits'] += nd.m_chain_size
# Create the edges to the parents.
for pid in nd.m_parents:
pnd = Node.m_map[pid]
attrs = ''
if nd.is_merge_node():
if len(opts.mnode_pedge) > 0:
attrs = opts.mnode_pedge.format(label='{} to {}'.format(nd.m_cid, pid))
ofp.write(' "{}" -> "{}" {};\n'.format(pid, nd.m_cid, attrs))
else:
if len(opts.cnode_pedge) > 0:
attrs = opts.cnode_pedge.format(label='{} to {}'.format(nd.m_cid, pid))
ofp.write(' "{}" -> "{}" {};\n'.format(pid, nd.m_cid, attrs))
# Annote the tags and branches for each node.
# Can't use subgraphs because rankdir is not
# supported.
infov(opts, 'annotating branches and tags')
ofp.write('\n')
ofp.write(' // annotate branches and tags\n')
first = True
for idx, nd in enumerate(Node.m_list):
# technically this is redundant because squashed nodes, by
# definition, do not have branches or tag refs.
if nd.is_squashed():
continue
if len(nd.m_branches) > 0 or len(nd.m_tags) > 0:
torank = [nd.m_cid]
if first:
first = False
else:
ofp.write('\n')
if len(nd.m_tags) > 0:
if opts.crunch:
# Create the node name.
tid = 'tid-{:>08}'.format(idx)
label = '\\n'.join(nd.m_tags)
attrs = opts.tnode.format(label=label)
ofp.write(' "{}" {};\n'.format(tid, attrs))
torank += [tid]
# Write the connecting edge.
ofp.write(' "{}" -> "{}"'.format(tid, nd.m_cid))
else:
torank += nd.m_tags
for t in nd.m_tags:
# Tag node definitions.
attrs = opts.tnode.format(label=t)
ofp.write(' "{}+{}" {};\n'.format(nd.m_cid, t, attrs))
tl = nd.m_tags
ofp.write(' "{}+{}"'.format(nd.m_cid, tl[0]))
for t in tl[1:]:
ofp.write(' -> "{}+{}"'.format(nd.m_cid, t))
ofp.write(' -> "{}"'.format(nd.m_cid))
attrs = opts.tedge.format(label=nd.m_cid)
ofp.write(' {};\n'.format(attrs))
if len(nd.m_branches) > 0:
if opts.crunch:
# Create the node name.
bid = 'bid-{:>08}'.format(idx)
label = '\\n'.join(nd.m_branches)
attrs = opts.bnode.format(label=label)
ofp.write(' "{}" {};\n'.format(bid, attrs))
torank += [bid]
# Write the connecting edge.
ofp.write(' "{}" -> "{}"'.format(nd.m_cid, bid))
else:
torank += nd.m_branches
for b in nd.m_branches:
# Branch node definitions.
attrs = opts.bnode.format(label=b)
ofp.write(' "{}+{}" {};\n'.format(nd.m_cid, b, attrs))
ofp.write(' "{}"'.format(nd.m_cid))
for b in nd.m_branches[::-1]:
ofp.write(' -> "{}+{}"'.format(nd.m_cid, b))
attrs = opts.bedge.format(label=nd.m_cid)
ofp.write(' {};\n'.format(attrs))
# Make sure that they line up by putting them in the same rank.
ofp.write(' {{rank=same; "{}"'.format(torank[0]))
for cid in torank[1:]:
if opts.crunch:
ofp.write('; "{}"'.format(cid))
else:
ofp.write('; "{}+{}"'.format(nd.m_cid, cid))
ofp.write('};\n')
# Align nodes by commit date.
if opts.align_by_date != 'none':
infov(opts, 'align by {}'.format(opts.align_by_date))
ofp.write('\n')
ofp.write(' // rank by date using invisible constraints between groups\n')
lnd = Node.m_map[Node.m_list_bydate[0]]
attrs = ['year', 'month', 'day', 'hour', 'minute', 'second']
for cid in Node.m_list_bydate:
nd = Node.m_map[cid]
if nd.is_squashed():
continue
for attr in attrs:
v1 = getattr(nd.m_dts, attr)
v2 = getattr(lnd.m_dts, attr)
if v1 < v2:
# Add an invisible constraint to guarantee that the
# later node appears somewhere to the right.
if opts.verbose > 1:
info('aligning {} {} to the left of {} {}'.format(lnd.m_cid, lnd.m_dts, nd.m_cid, nd.m_dts))
ofp.write(' "{}" -> "{}" [style=invis];\n'.format(lnd.m_cid, nd.m_cid))
elif v1 > v2:
break
if attr == opts.align_by_date:
continue
if lnd.m_dts < nd.m_dts:
lnd = nd
# Output the graph label.
if opts.graph_label is not None:
infov(opts, 'generate graph label')
ofp.write('\n')
ofp.write(' // graph label\n')
ofp.write(' {}'.format(opts.graph_label))
if opts.graph_label[-1] != ';':
ofp.write(';')
ofp.write('\n')
ofp.write('}\n')
# Output the summary data.
for k in sorted(summary, key=str.lower):
v = summary[k]
ofp.write('// summary:{} {}\n'.format(k, v))
ofp.close()
def html(opts):
'''
Generate an HTML file that allows pan and zoom.
It uses https://github.com/ariutta/svg-pan-zoom.
'''
# TODO: resize the image height on demand
if opts.html is not None:
infov(opts, 'generating HTML to {}'.format(opts.html))
try:
html = opts.html
svg = opts.DOT_FILE[0] + '.svg'
js = 'svg-pan-zoom.min.js'
with open(html, 'w') as ofp:
ofp.write('''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{4}</title>
{3}
</head>
<body>
<h3>{4}</h3>
<div style="border-width:3px; border-style:solid; border-color:lightgrey;">
<object id="digraph" type="image/svg+xml" data="{0}" style="width:100%; min-height:{2};">
SVG not supported by this browser.
</object>
</div>
<script>
window.onload = function() {{
svgPanZoom('#digraph', {{
zoomEnabled: true,
controlIconsEnabled: true,
fit: true,
center: true,
maxZoom: 1000,
zoomScaleSensitivity: 0.5
}});
}};
window.addEventListener("resize", function() {{
if (spz != null) {{
spz.resize();
spz.fit();
spz.center();
}}
}});
</script>
</body>
</html>
'''.format(svg, js, opts.html_min_height, ' \n'.join([x for x in opts.html_head]), opts.html_title))
except IOError as e:
err('HTML write failed: {}'.format(e))
def gengraph(opts, fmt):
'''
Generate the graph file using dot with -O option.
'''
if fmt:
infov(opts, 'generating {}'.format(fmt))
cmd = 'dot -T{} -O {}'.format(fmt, opts.DOT_FILE[0])
if opts.verbose:
cmd += ' -v'
infov(opts, 'running command: {}'.format(cmd))
st, _ = runcmd(cmd, show_output=opts.verbose > 1)
if st:
err('command failed with status {}: :"'.format(st, cmd))
def getopts():
'''
Get the command line options using argparse.
'''
# Trick to capitalize the built-in headers.
# Unfortunately I can't get rid of the ":" reliably.
def gettext(s):
lookup = {
'usage: ': 'USAGE:',
'positional arguments': 'POSITIONAL ARGUMENTS',
'optional arguments': 'OPTIONAL ARGUMENTS',
'show this help message and exit': 'Show this help message and exit.\n ',
}
return lookup.get(s, s)
argparse._ = gettext # to capitalize help headers
base = os.path.basename(sys.argv[0])
name = os.path.splitext(base)[0]
usage = '\n {0} [OPTIONS] <DOT_FILE>'.format(base)
desc = 'DESCRIPTION:{0}'.format('\n '.join(__doc__.split('\n')))
epilog = r'''EXAMPLES:
# Example 1: help
$ {0} -h
# Example 2: generate a dot file
$ cd <git-repo>
$ {0} git.dot
# Example 3: generate a dot file and a PNG file
# you can also run dot manually:
# $ dot -v -Tpng -O git.dot
$ cd <git-repo>