-
Notifications
You must be signed in to change notification settings - Fork 15
/
ac2git.py
executable file
·4101 lines (3542 loc) · 258 KB
/
ac2git.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/python3
# ################################################################################################ #
# AccuRev to Git conversion script #
# Author: Lazar Sumar #
# Date: 06/11/2014 #
# #
# This script is intended to convert an entire AccuRev depot into a git repository converting #
# workspaces and streams into branches and respecting merges. #
# ################################################################################################ #
import sys
import argparse
import os
import os.path
import shutil
import subprocess
import logging
import warnings
import xml.etree.ElementTree as ElementTree
from datetime import datetime, timedelta
import time
import re
import types
import copy
import codecs
import json
import pytz
import tempfile
import stat
from collections import OrderedDict
import accurev
import git
logger = None
ignored_transaction_types = [ "archive", "compress", "defcomp", "dispatch", "unarchive" ]
# Taken from this StackOverflow answer: http://stackoverflow.com/a/19238551
# Compulsary quote: https://twitter.com/codinghorror/status/712467615780708352
def utc2local(utc):
epoch = time.mktime(utc.timetuple())
offset = datetime.fromtimestamp (epoch) - datetime.utcfromtimestamp (epoch)
return utc + offset
# This function calls the provided function func, only with arguments that were
# not None.
def CallOnNonNoneArgs(func, *args):
return func(a for a in args if a is not None)
# ################################################################################################ #
# Script Classes #
# ################################################################################################ #
class Config(object):
class AccuRev(object):
@classmethod
def fromxmlelement(cls, xmlElement):
if xmlElement is not None and xmlElement.tag == 'accurev':
depot = xmlElement.attrib.get('depot')
username = xmlElement.attrib.get('username')
password = xmlElement.attrib.get('password')
startTransaction = xmlElement.attrib.get('start-transaction')
endTransaction = xmlElement.attrib.get('end-transaction')
commandCacheFilename = xmlElement.attrib.get('command-cache-filename')
excludeStreamTypes = None
streamMap = None
streamListElement = xmlElement.find('stream-list')
if streamListElement is not None:
excludeStreamTypes = streamListElement.attrib.get("exclude-types")
if excludeStreamTypes is not None:
excludeStreamTypes = [x.strip() for x in excludeStreamTypes.split(',') if len(x.strip()) > 0]
streamMap = OrderedDict()
streamElementList = streamListElement.findall('stream')
for streamElement in streamElementList:
streamName = streamElement.text
branchName = streamElement.attrib.get("branch-name")
if branchName is None:
branchName = streamName
streamMap[streamName] = branchName
return cls(depot, username, password, startTransaction, endTransaction, streamMap, commandCacheFilename, excludeStreamTypes)
else:
return None
def __init__(self, depot = None, username = None, password = None, startTransaction = None, endTransaction = None, streamMap = None, commandCacheFilename = None, excludeStreamTypes = None):
self.depot = depot
self.username = username
self.password = password
self.startTransaction = startTransaction
self.endTransaction = endTransaction
self.streamMap = streamMap
self.commandCacheFilename = commandCacheFilename
self.excludeStreamTypes = excludeStreamTypes
def __repr__(self):
str = "Config.AccuRev(depot=" + repr(self.depot)
str += ", username=" + repr(self.username)
str += ", password=" + repr(self.password)
str += ", startTransaction=" + repr(self.startTransaction)
str += ", endTransaction=" + repr(self.endTransaction)
if self.streamMap is not None:
str += ", streamMap=" + repr(self.streamMap)
if self.commandCacheFilename is not None:
str += ", commandCacheFilename=" + repr(self.commandCacheFilename)
if self.excludeStreamTypes is not None:
str += ", excludeStreamTypes=" + repr(self.excludeStreamTypes)
str += ")"
return str
def UseCommandCache(self):
return self.commandCacheFilename is not None
class Git(object):
@classmethod
def fromxmlelement(cls, xmlElement):
if xmlElement is not None and xmlElement.tag == 'git':
repoPath = xmlElement.attrib.get('repo-path')
messageStyle = xmlElement.attrib.get('message-style')
messageKey = xmlElement.attrib.get('message-key')
authorIsCommitter = xmlElement.attrib.get('author-is-committer')
emptyChildStreamAction = xmlElement.attrib.get('empty-child-stream-action')
sourceStreamFastForward = xmlElement.attrib.get('source-stream-fast-forward')
sourceStreamInferrence = xmlElement.attrib.get('source-stream-inferrence')
newBasisIsFirstParent = xmlElement.attrib.get('new-basis-is-first-parent')
remoteMap = OrderedDict()
remoteElementList = xmlElement.findall('remote')
for remoteElement in remoteElementList:
remoteName = remoteElement.attrib.get("name")
remoteUrl = remoteElement.attrib.get("url")
remotePushUrl = remoteElement.attrib.get("push-url")
remoteMap[remoteName] = git.GitRemoteListItem(name=remoteName, url=remoteUrl, pushUrl=remotePushUrl)
return cls(repoPath=repoPath, messageStyle=messageStyle, messageKey=messageKey, authorIsCommitter=authorIsCommitter, remoteMap=remoteMap, emptyChildStreamAction=emptyChildStreamAction, sourceStreamFastForward=sourceStreamFastForward, sourceStreamInferrence=sourceStreamInferrence, newBasisIsFirstParent=newBasisIsFirstParent)
else:
return None
def __init__(self, repoPath, messageStyle=None, messageKey=None, authorIsCommitter=None, remoteMap=None, emptyChildStreamAction=None, sourceStreamFastForward=None, sourceStreamInferrence=None, newBasisIsFirstParent=None):
self.repoPath = repoPath
self.messageStyle = messageStyle
self.messageKey = messageKey
self.remoteMap = remoteMap
if authorIsCommitter is not None:
authorIsCommitter = authorIsCommitter.lower()
if authorIsCommitter not in [ "true", "false" ]:
raise Exception("The author-is-committer attribute only accepts true or false but was set to '{v}'.".format(v=authorIsCommitter))
authorIsCommitter = (authorIsCommitter == "true")
else:
authroIsCommitter = True
self.authorIsCommitter = authorIsCommitter
if emptyChildStreamAction is not None:
if emptyChildStreamAction not in [ "merge", "cherry-pick" ]:
raise Exception("Error, the empty-child-stream-action attribute only accepts merge or cherry-pick options but got: {0}".format(emptyChildStreamAction))
self.emptyChildStreamAction = emptyChildStreamAction
else:
self.emptyChildStreamAction = "cherry-pick"
if sourceStreamFastForward is not None:
sourceStreamFastForward = sourceStreamFastForward.lower()
if sourceStreamFastForward not in [ "true", "false" ]:
raise Exception("Error, the source-stream-fast-forward attribute only accepts true or false options but got: {0}".format(sourceStreamFastForward))
self.sourceStreamFastForward = (sourceStreamFastForward == "true")
else:
self.sourceStreamFastForward = False
if sourceStreamInferrence is not None:
sourceStreamInferrence = sourceStreamInferrence.lower()
if sourceStreamInferrence not in [ "true", "false" ]:
raise Exception("Error, the source-stream-inferrence attribute only accepts true or false options but got: {0}".format(sourceStreamInferrence))
self.sourceStreamInferrence = (sourceStreamInferrence == "true")
else:
self.sourceStreamInferrence = False
if newBasisIsFirstParent is not None:
newBasisIsFirstParent = newBasisIsFirstParent.lower()
if newBasisIsFirstParent not in [ "true", "false" ]:
raise Exception("Error, the new-basis-is-first-parent attribute only accepts true or false options but got: {0}".format(newBasisIsFirstParent))
self.newBasisIsFirstParent = (newBasisIsFirstParent == "true")
else:
self.newBasisIsFirstParent = True
def __repr__(self):
str = "Config.Git(repoPath=" + repr(self.repoPath)
if self.messageStyle is not None:
str += ", messageStyle=" + repr(self.messageStyle)
if self.messageKey is not None:
str += ", messageKey=" + repr(self.messageKey)
if self.remoteMap is not None:
str += ", remoteMap=" + repr(self.remoteMap)
if self.authorIsCommitter is not None:
str += ", authorIsCommitter=" + repr(self.authorIsCommitter)
if self.newBasisIsFirstParent is not None:
str += ", newBasisIsFirstParent=" + repr(self.newBasisIsFirstParent)
str += ")"
return str
class UserMap(object):
@classmethod
def fromxmlelement(cls, xmlElement):
if xmlElement is not None and xmlElement.tag == 'map-user':
accurevUsername = None
gitName = None
gitEmail = None
timezone = None
accurevElement = xmlElement.find('accurev')
if accurevElement is not None:
accurevUsername = accurevElement.attrib.get('username')
gitElement = xmlElement.find('git')
if gitElement is not None:
gitName = gitElement.attrib.get('name')
gitEmail = gitElement.attrib.get('email')
timezone = gitElement.attrib.get('timezone')
return cls(accurevUsername=accurevUsername, gitName=gitName, gitEmail=gitEmail, timezone=timezone)
else:
return None
def __init__(self, accurevUsername, gitName, gitEmail, timezone=None):
self.accurevUsername = accurevUsername
self.gitName = gitName
self.gitEmail = gitEmail
self.timezone = timezone
def __repr__(self):
str = "Config.UserMap(accurevUsername=" + repr(self.accurevUsername)
str += ", gitName=" + repr(self.gitName)
str += ", gitEmail=" + repr(self.gitEmail)
str += ", timezone=" + repr(self.timezone)
str += ")"
return str
@staticmethod
def FilenameFromScriptName(scriptName):
(root, ext) = os.path.splitext(scriptName)
return root + '.config.xml'
@ staticmethod
def GetBooleanAttribute(xmlElement, attribute):
if xmlElement is None or attribute is None:
return None
value = xmlElement.attrib.get(attribute)
if value is not None:
if value.lower() == "true":
value = True
elif value.lower() == "false":
value = False
else:
Exception("Error, could not parse {attr} attribute of tag {tag}. Expected 'true' or 'false', but got '{value}'.".format(attr=attribute, tag=xmlElement.tag, value=value))
return value
@staticmethod
def GetAbsoluteUsermapsFilename(filename, includedFilename):
if includedFilename is None:
return None
if os.path.isabs(includedFilename):
return includedFilename
if filename is None:
return None
drive, path = os.path.splitdrive(filename)
head, tail = os.path.split(path)
if len(head) > 0 and head != '/' and head != '\\': # For an absolute path the starting slash isn't removed from head.
return os.path.abspath(os.path.join(head, includedFilename))
return os.path.abspath(includedFilename)
@staticmethod
def GetUsermapsFromXmlElement(usermapsElem):
usermaps = []
if usermapsElem is not None and usermapsElem.tag == 'usermaps':
for usermapElem in usermapsElem.findall('map-user'):
usermaps.append(Config.UserMap.fromxmlelement(usermapElem))
return usermaps
@staticmethod
def GetUsermapsFromFile(filename, ignoreFiles=None):
usermaps = []
knownAccurevUsers = set()
directCount, indirectCount = 0, 0
if filename is not None:
if os.path.exists(filename):
with codecs.open(filename) as f:
mapXmlString = f.read()
mapXmlRoot = ElementTree.fromstring(mapXmlString)
if mapXmlRoot is not None:
userMapElements = []
if mapXmlRoot.tag == "usermaps":
userMapElements.append(mapXmlRoot)
else:
for userMapElem in mapXmlRoot.findall('usermaps'):
userMapElements.append(userMapElem)
fileList = [] # the linked files are processed after direct usermaps so that the direct usermaps override the same users in the linked files...
for userMapElem in userMapElements:
directUsermaps = Config.GetUsermapsFromXmlElement(userMapElem)
directCount += len(directUsermaps)
for user in directUsermaps:
if user.accurevUsername not in knownAccurevUsers:
usermaps.append(user)
knownAccurevUsers.add(user.accurevUsername)
else:
#print("Ignoring duplicated user:", user.accurevUsername)
pass
mapFile = userMapElem.attrib.get('filename')
if mapFile is not None:
fileList.append(mapFile)
for mapFile in fileList:
if ignoreFiles is None:
ignoreFiles = set()
mapFile = Config.GetAbsoluteUsermapsFilename(filename, mapFile) # Prevent circular loads.
if mapFile not in ignoreFiles:
ignoreFiles.add(mapFile)
includedUsermaps = Config.GetUsermapsFromFile(mapFile, ignoreFiles=ignoreFiles)
indirectCount += len(includedUsermaps)
for user in includedUsermaps:
if user.accurevUsername not in knownAccurevUsers:
usermaps.append(user)
knownAccurevUsers.add(user.accurevUsername)
else:
#print("Ignoring duplicated user:", user.accurevUsername)
pass
else:
print("Circular usermaps inclusion detected at file,", mapFile, "which was already processed.", file=sys.stderr)
print("usermaps: filename", filename, "direct", directCount, "included", indirectCount)
return usermaps
@classmethod
def fromxmlstring(cls, xmlString, filename=None):
# Load the XML
xmlRoot = ElementTree.fromstring(xmlString)
if xmlRoot is not None and xmlRoot.tag == "accurev2git":
accurev = Config.AccuRev.fromxmlelement(xmlRoot.find('accurev'))
git = Config.Git.fromxmlelement(xmlRoot.find('git'))
method = "diff" # Defaults to diff
methodElem = xmlRoot.find('method')
if methodElem is not None:
method = methodElem.text
mergeStrategy = "normal" # Defaults to normal
mergeStrategyElem = xmlRoot.find('merge-strategy')
if mergeStrategyElem is not None:
mergeStrategy = mergeStrategyElem.text
logFilename = None
logFileElem = xmlRoot.find('logfile')
if logFileElem is not None:
logFilename = logFileElem.text
usermaps = []
userMapsElem = xmlRoot.find('usermaps')
if userMapsElem is not None:
usermaps = Config.GetUsermapsFromXmlElement(userMapsElem)
knownAccurevUsers = set([x.accurevUsername for x in usermaps])
# Check if we need to load extra usermaps from a file.
mapFilename = userMapsElem.attrib.get("filename")
if mapFilename is not None:
if filename is not None:
mapFilename = Config.GetAbsoluteUsermapsFilename(filename, mapFilename) # Prevent circular loads.
includedUsermaps = Config.GetUsermapsFromFile(mapFilename)
for user in includedUsermaps:
if user.accurevUsername not in knownAccurevUsers:
usermaps.append(user)
else:
#print("Known user:", user.accurevUsername)
pass
return cls(accurev=accurev, git=git, usermaps=usermaps, method=method, mergeStrategy=mergeStrategy, logFilename=logFilename)
else:
# Invalid XML for an accurev2git configuration file.
return None
@staticmethod
def fromfile(filename):
config = None
if os.path.exists(filename):
with codecs.open(filename) as f:
configXml = f.read()
config = Config.fromxmlstring(configXml, filename=filename)
return config
def __init__(self, accurev=None, git=None, usermaps=None, method=None, mergeStrategy=None, logFilename=None):
self.accurev = accurev
self.git = git
self.usermaps = usermaps
self.method = method
self.mergeStrategy = mergeStrategy
self.logFilename = logFilename
def __repr__(self):
str = "Config(accurev=" + repr(self.accurev)
str += ", git=" + repr(self.git)
str += ", usermaps=" + repr(self.usermaps)
str += ", method=" + repr(self.method)
str += ", mergeStrategy=" + repr(self.mergeStrategy)
str += ", logFilename=" + repr(self.logFilename)
str += ")"
return str
# Prescribed recepie:
# - Get the list of tracked streams from the config file.
# - For each stream in the list
# + If this stream is new (there is no data in git for it yet)
# * Create the git branch for the stream
# * Get the stream create (mkstream) transaction number and set it to be the start-transaction. Note: The first stream in the depot has no mkstream transaction.
# + otherwise
# * Get the last processed transaction number and set that to be the start-transaction.
# * Obtain a diff from accurev listing all of the files that have changed and delete them all.
# + Get the end-transaction from the user or from accurev's highest/now keyword for the hist command.
# + For all transactions between the start-transaction and end-transaction
# * Checkout the git branch at latest (or just checkout if no-commits yet).
# * Populate the retrieved transaction with the recursive option but without the overwrite option (quick).
# * Preserve empty directories by adding .gitignore files.
# * Commit the current state of the directory but don't respect the .gitignore file contents. (in case it was added to accurev in the past).
# * Increment the transaction number by one
# * Obtain a diff from accurev listing all of the files that have changed and delete them all.
class AccuRev2Git(object):
gitRefsNamespace = 'refs/ac2git/'
gitNotesRef_state = 'ac2git'
gitNotesRef_accurevInfo = 'accurev'
commandFailureRetryCount = 3
commandFailureSleepSeconds = 3
cachedDepots = None
def __init__(self, config):
self.config = config
self.cwd = None
self.gitRepo = None
# Returns True if the path was deleted, otherwise false
def DeletePath(self, path):
if os.path.lexists(path):
if os.path.islink(path):
os.unlink(path)
elif os.path.isfile(path):
try:
os.unlink(path)
except OSError:
os.chmod(path, stat.S_IWRITE )
os.unlink(path)
elif os.path.isdir(path):
shutil.rmtree(path)
return not os.path.lexists(path)
def ClearGitRepo(self):
# Delete everything except the .git folder from the destination (git repo)
logger.debug( "Clear git repo." )
for root, dirs, files in os.walk(self.gitRepo.path, topdown=False):
for name in files:
path = os.path.join(root, name)
if git.GetGitDirPrefix(path) is None:
self.DeletePath(path)
for name in dirs:
path = os.path.join(root, name)
if git.GetGitDirPrefix(path) is None:
self.DeletePath(path)
def PreserveEmptyDirs(self):
preservedDirs = []
for root, dirs, files in os.walk(self.gitRepo.path, topdown=True):
for name in dirs:
path = ToUnixPath(os.path.join(root, name))
# Preserve empty directories that are not under the .git/ directory.
if git.GetGitDirPrefix(path) is None and len(os.listdir(path)) == 0:
filename = os.path.join(path, '.gitignore')
with codecs.open(filename, 'w', 'utf-8') as file:
#file.write('# accurev2git.py preserve empty dirs\n')
preservedDirs.append(filename)
if not os.path.exists(filename):
logger.error("Failed to preserve directory. Couldn't create '{0}'.".format(filename))
return preservedDirs
def DeleteEmptyDirs(self):
deletedDirs = []
for root, dirs, files in os.walk(self.gitRepo.path, topdown=True):
for name in dirs:
path = ToUnixPath(os.path.join(root, name))
# Delete empty directories that are not under the .git/ directory.
if git.GetGitDirPrefix(path) is None:
dirlist = os.listdir(path)
count = len(dirlist)
delete = (len(dirlist) == 0)
if len(dirlist) == 1 and '.gitignore' in dirlist:
with codecs.open(os.path.join(path, '.gitignore')) as gi:
contents = gi.read().strip()
delete = (len(contents) == 0)
if delete:
if not self.DeletePath(path):
logger.error("Failed to delete empty directory '{0}'.".format(path))
raise Exception("Failed to delete '{0}'".format(path))
else:
deletedDirs.append(path)
return deletedDirs
def GetGitUserFromAccuRevUser(self, accurevUsername):
if accurevUsername is not None:
for usermap in self.config.usermaps:
if usermap.accurevUsername == accurevUsername:
return (usermap.gitName, usermap.gitEmail)
logger.error("Cannot find git details for accurev username {0}".format(accurevUsername))
return (accurevUsername, None)
def GetGitTimezoneFromDelta(self, time_delta):
seconds = time_delta.total_seconds()
absSec = abs(seconds)
offset = (int(absSec / 3600) * 100) + (int(absSec / 60) % 60)
if seconds < 0:
offset = -offset
return offset
def GetDeltaFromGitTimezone(self, timezone):
# Git timezone strings follow the +0100 format
tz = int(timezone)
tzAbs = abs(tz)
tzdelta = timedelta(seconds=((int(tzAbs / 100) * 3600) + ((tzAbs % 100) * 60)))
return tzdelta
def GetGitDatetime(self, accurevUsername, accurevDatetime):
usertime = accurevDatetime
tz = None
if accurevUsername is not None:
for usermap in self.config.usermaps:
if usermap.accurevUsername == accurevUsername:
tz = usermap.timezone
break
if tz is None:
# Take the following default times 48 hours from Epoch as reference to compute local time.
refTimestamp = 172800
utcRefTime = datetime.utcfromtimestamp(refTimestamp)
refTime = datetime.fromtimestamp(refTimestamp)
tzdelta = (refTime - utcRefTime)
usertime = accurevDatetime + tzdelta
tz = self.GetGitTimezoneFromDelta(tzdelta)
else:
match = re.match(r'^[+-][0-9]{4}$', tz)
if match:
# This is the git style format
tzdelta = self.GetDeltaFromGitTimezone(tz)
usertime = accurevDatetime + tzdelta
tz = int(tz)
else:
# Assuming it is an Olson timezone format
userTz = pytz.timezone(tz)
usertime = userTz.localize(accurevDatetime)
tzdelta = usertime.utcoffset() # We need two aware times to get the datetime.timedelta.
usertime = accurevDatetime + tzdelta # Adjust the time by the timezone since localize din't.
tz = self.GetGitTimezoneFromDelta(tzdelta)
return usertime, tz
def GetFirstTransaction(self, depot, streamName, startTransaction=None, endTransaction=None, useCache=False):
invalidRetVal = (None, None)
# Get the stream creation transaction (mkstream). Note: The first stream in the depot doesn't have an mkstream transaction.
tr = accurev.ext.get_mkstream_transaction(stream=streamName, depot=depot, useCache=useCache)
if tr is None:
logger.warning("Failed to find the mkstream transaction for stream {s}. Trying to get first transaction.".format(s=streamName))
hist, histXml = self.TryHist(depot=depot, timeSpec="highest-1", streamName=streamName)
if hist is not None and len(hist.transactions) > 0:
tr = hist.transactions[-1] # Get first transaction
hist, histXml = self.TryHist(depot=depot, timeSpec=tr.id) # Make the first transaction be the mkstream transaction.
if startTransaction is not None:
startTrHist, startTrXml = self.TryHist(depot=depot, timeSpec=startTransaction)
if startTrHist is None:
return invalidRetVal
startTr = startTrHist.transactions[0]
if tr.id < startTr.id:
logger.info( "The first transaction (#{0}) for stream {1} is earlier than the conversion start transaction (#{2}).".format(tr.id, streamName, startTr.id) )
tr = startTr
hist = startTrHist
histXml = startTrXml
if endTransaction is not None:
endTrHist, endTrHistXml = self.TryHist(depot=depot, timeSpec=endTransaction)
if endTrHist is None:
return invalidRetVal
endTr = endTrHist.transactions[0]
if endTr.id < tr.id:
logger.info( "The first transaction (#{0}) for stream {1} is later than the conversion end transaction (#{2}).".format(tr.id, streamName, startTr.id) )
tr = None
return invalidRetVal
return hist, histXml
def TryGitCommand(self, cmd, allowEmptyString=False, retry=True):
rv = None
for i in range(0, AccuRev2Git.commandFailureRetryCount):
rv = self.gitRepo.raw_cmd(cmd)
if not retry:
break
if rv is not None:
rv = rv.strip()
if not allowEmptyString and len(rv) == 0:
rv = None
else:
break
time.sleep(AccuRev2Git.commandFailureSleepSeconds)
return rv
def GetLastCommitHash(self, branchName=None, ref=None, retry=True):
cmd = []
commitHash = None
if ref is not None:
cmd = [ u'git', u'show-ref', u'--hash', ref ]
else:
cmd = [u'git', u'log', u'-1', u'--format=format:%H']
if branchName is not None:
cmd.append(branchName)
commitHash = self.TryGitCommand(cmd=cmd, retry=retry)
if commitHash is None:
logger.error("Failed to retrieve last git commit hash. Command `{0}` failed.".format(' '.join(cmd)))
return commitHash
def GetTreeFromRef(self, ref):
treeHash = None
cmd = [u'git', u'log', u'-1', u'--format=format:%T']
if ref is not None:
cmd.append(ref)
treeHash = self.TryGitCommand(cmd=cmd)
if treeHash is None:
logger.error("Failed to retrieve tree hash. Command `{0}` failed.".format(' '.join(cmd)))
return treeHash
def UpdateAndCheckoutRef(self, ref, commitHash, checkout=True):
if ref is not None and commitHash is not None and len(ref) > 0 and len(commitHash) > 0:
# refs/heads are branches which are updated automatically when you commit to them (provided we have them checked out).
# so at least raise a warning for the user.
# If we were asked to update a ref, not updating it is considered a failure to commit.
if self.gitRepo.raw_cmd([ u'git', u'update-ref', ref, commitHash ]) is None:
logger.error( "Failed to update ref {ref} to commit {hash}".format(ref=ref, hash=commitHash) )
return False
if checkout and ref != 'HEAD' and self.gitRepo.checkout(branchName=ref) is None: # no point in checking out HEAD if that's what we've updated!
logger.error( "Failed to checkout ref {ref} to commit {hash}".format(ref=ref, hash=commitHash) )
return False
return True
return None
def SafeCheckout(self, ref, doReset=False, doClean=False):
status = self.gitRepo.status()
if status is None:
logger.error("git status - command failed!")
logger.error(" exit code: {0}".format(self.gitRepo.lastReturnCode))
logger.error(" stderr: {0}".format(self.gitRepo.lastStderr))
logger.error(" stdout: {0}".format(self.gitRepo.lastStdout))
raise Exception("SafeCheckout - failed to invoke `git status`")
if doReset:
logger.debug( "Reset current branch - '{br}'".format(br=status.branch) )
self.gitRepo.reset(isHard=True)
if doClean:
logger.debug( "Clean current branch - '{br}'".format(br=status.branch) )
self.gitRepo.clean(directories=True, force=True, forceSubmodules=True, includeIgnored=True)
pass
if ref is not None and status.branch != ref:
logger.debug( "Checkout {ref}".format(ref=ref) )
self.gitRepo.checkout(branchName=ref)
status = self.gitRepo.status()
logger.debug( "On branch {branch} - {staged} staged, {changed} changed, {untracked} untracked files{initial_commit}.".format(branch=status.branch, staged=len(status.staged), changed=len(status.changed), untracked=len(status.untracked), initial_commit=', initial commit' if status.initial_commit else '') )
if status is None:
raise Exception("Invalid initial state! The status command return is invalid.")
if status.branch is None or status.branch != ref:
# The parser for the status isn't very smart and git doesn't necessarily report the name of the ref that you have checked out. So, check if the current HEAD points to the desired ref by comparing hashes.
headHash = self.gitRepo.raw_cmd(['git', 'log', '--format=%H', 'HEAD', '-1'])
refHash = self.gitRepo.raw_cmd(['git', 'log', '--format=%H', ref, '-1'])
if headHash is None:
raise Exception("Failed to determine the hash of the HEAD commit!")
elif refHash is None:
raise Exception("Failed to determine the hash of the {ref} commit!".format(ref=ref))
elif refHash != headHash:
raise Exception("Invalid initial state! The status command returned an invalid name for current branch. Expected {ref} but got {statusBranch}.".format(ref=ref, statusBranch=status.branch))
if len(status.staged) != 0 or len(status.changed) != 0 or len(status.untracked) != 0:
raise Exception("Invalid initial state! There are changes in the tracking repository. Staged {staged}, changed {changed}, untracked {untracked}.".format(staged=status.staged, changed=status.changed, untracked=status.untracked))
def Commit(self, transaction=None, allowEmptyCommit=False, messageOverride=None, parents=None, treeHash=None, ref=None, checkout=True, authorIsCommitter=None):
usePlumbing = (parents is not None or treeHash is not None)
if authorIsCommitter is None:
authorIsCommitter = self.config.git.authorIsCommitter
# Custom messages for when we have a transaction.
trMessage, forTrMessage = '', ''
if transaction is not None:
trMessage = ' transaction {0}'.format(transaction.id)
forTrMessage = ' for{0}'.format(trMessage)
# Begin the commit processing.
if treeHash is None:
self.PreserveEmptyDirs()
# Add all of the files to the index
self.gitRepo.add(force=True, all=True, git_opts=[u'-c', u'core.autocrlf=false'])
# Create temporary file for the commit message.
messageFilePath = None
with tempfile.NamedTemporaryFile(mode='w+', prefix='ac2git_commit_', encoding='utf-8', delete=False) as messageFile:
messageFilePath = messageFile.name
emptyMessage = True
if messageOverride is not None:
if len(messageOverride) > 0:
messageFile.write(messageOverride)
emptyMessage = False
elif transaction is not None and transaction.comment is not None and len(transaction.comment) > 0:
# In git the # at the start of the line indicate that this line is a comment inside the message and will not be added.
# So we will just add a space to the start of all the lines starting with a # in order to preserve them.
messageFile.write(transaction.comment)
emptyMessage = False
if emptyMessage:
# `git commit` and `git commit-tree` commands, when given an empty file for the commit message, seem to revert to
# trying to read the commit message from the STDIN. This is annoying since we don't want to be opening a pipe to
# the spawned process all the time just to write an EOF character so instead we will just add a single space as the
# message and hope the user doesn't notice.
# For the `git commit` command it's not as bad since white-space is always stripped from commit messages. See the
# `git commit --cleanup` option for details.
messageFile.write(' ')
if messageFilePath is None:
logger.error("Failed to create temporary file for commit message{0}".format(forTrMessage))
return None
# Get the author's and committer's name, email and timezone information.
authorName, authorEmail, authorDate, authorTimezone = None, None, None, None
if transaction is not None:
authorName, authorEmail = self.GetGitUserFromAccuRevUser(transaction.user)
authorDate, authorTimezone = self.GetGitDatetime(accurevUsername=transaction.user, accurevDatetime=transaction.time)
# If the author-is-committer flag is set to true make the committer the same as the author.
committerName, committerEmail, committerDate, committerTimezone = None, None, None, None
if authorIsCommitter:
committerName, committerEmail, committerDate, committerTimezone = authorName, authorEmail, authorDate, authorTimezone
lastCommitHash = None
if parents is None:
lastCommitHash = self.GetLastCommitHash(ref=ref) # If ref is None, it will get the last commit hash from the HEAD ref.
if lastCommitHash is None:
parents = []
else:
parents = [ lastCommitHash ]
elif len(parents) != 0:
lastCommitHash = parents[0]
# Make the commit.
commitHash = None
if usePlumbing:
if treeHash is None:
treeHash = self.gitRepo.write_tree()
if treeHash is not None and len(treeHash.strip()) > 0:
treeHash = treeHash.strip()
commitHash = self.gitRepo.commit_tree(tree=treeHash, parents=parents, message_file=messageFilePath, committer_name=committerName, committer_email=committerEmail, committer_date=committerDate, committer_tz=committerTimezone, author_name=authorName, author_email=authorEmail, author_date=authorDate, author_tz=authorTimezone, allow_empty=allowEmptyCommit, git_opts=[u'-c', u'core.autocrlf=false'])
if commitHash is None:
logger.error( "Failed to commit tree {0}{1}. Error:\n{2}".format(treeHash, forTrMessage, self.gitRepo.lastStderr) )
else:
commitHash = commitHash.strip()
else:
logger.error( "Failed to write tree{0}. Error:\n{1}".format(forTrMessage, self.gitRepo.lastStderr) )
else:
commitResult = self.gitRepo.commit(message_file=messageFilePath, committer_name=committerName, committer_email=committerEmail, committer_date=committerDate, committer_tz=committerTimezone, author_name=authorName, author_email=authorEmail, author_date=authorDate, author_tz=authorTimezone, allow_empty_message=True, allow_empty=allowEmptyCommit, cleanup='whitespace', git_opts=[u'-c', u'core.autocrlf=false'])
if commitResult is not None:
commitHash = commitResult.shortHash
if commitHash is None:
commitHash = self.GetLastCommitHash()
elif "nothing to commit" in self.gitRepo.lastStdout:
logger.debug( "nothing to commit{0}...?".format(forTrMessage) )
else:
logger.error( "Failed to commit".format(trMessage) )
logger.error( "\n{0}\n{1}\n".format(self.gitRepo.lastStdout, self.gitRepo.lastStderr) )
# For detached head states (which occur when you're updating a ref and not a branch, even if checked out) we need to make sure to update the HEAD. Either way it doesn't hurt to
# do this step whether we are using plumbing or not...
if commitHash is not None:
if ref is None:
ref = 'HEAD'
if self.UpdateAndCheckoutRef(ref=ref, commitHash=commitHash, checkout=(checkout and ref != 'HEAD')) != True:
logger.error( "Failed to update ref {ref} with commit {h}{forTr}".format(ref=ref, h=commitHash, forTr=forTrMessage) )
commitHash = None
os.remove(messageFilePath)
if commitHash is not None:
if lastCommitHash == commitHash:
logger.error("Commit command returned True when nothing was committed...? Last commit hash {0} didn't change after the commit command executed.".format(lastCommitHash))
commitHash = None # Invalidate return value
else:
logger.error("Failed to commit{tr}.".format(tr=trMessage))
return commitHash
def GetStreamMap(self, printInfo=False):
streamMap = self.config.accurev.streamMap
if streamMap is None:
streamMap = OrderedDict()
if len(streamMap) == 0:
# When the stream map is missing or empty we intend to process all streams
includeDeactivatedItems = "hidden" not in self.config.accurev.excludeStreamTypes
streams = accurev.show.streams(depot=self.config.accurev.depot, includeDeactivatedItems=includeDeactivatedItems, includeOldDefinitions=False)
included, excluded = [], []
for stream in streams.streams:
if self.config.accurev.excludeStreamTypes is not None and stream.Type in self.config.accurev.excludeStreamTypes:
excluded.append(stream)
else:
included.append(stream)
streamMap[stream.name] = self.SanitizeBranchName(stream.name)
if printInfo:
logger.info("Auto-generated stream list ({0} included, {1} excluded):".format(len(included), len(excluded)))
logger.info(" Included streams ({0}):".format(len(included)))
for s in included:
logger.info(" + {0}: {1} -> {2}".format(s.Type, s.name, streamMap[s.name]))
logger.info(" Excluded streams ({0}):".format(len(excluded)))
for s in excluded:
logger.info(" - {0}: {1}".format(s.Type, s.name))
return streamMap
def FindNextChangeTransaction(self, streamName, startTrNumber, endTrNumber, deepHist=None):
# Iterate over transactions in order using accurev diff -a -i -v streamName -V streamName -t <lastProcessed>-<current iterator>
if self.config.method == "diff":
nextTr = startTrNumber + 1
diff, diffXml = self.TryDiff(streamName=streamName, firstTrNumber=startTrNumber, secondTrNumber=nextTr)
if diff is None:
return (None, None)
# Note: This is likely to be a hot path. However, it cannot be optimized since a revert of a transaction would not show up in the diff even though the
# state of the stream was changed during that period in time. Hence to be correct we must iterate over the transactions one by one unless we have
# explicit knowlege of all the transactions which could affect us via some sort of deep history option...
while nextTr <= endTrNumber and len(diff.elements) == 0:
nextTr += 1
diff, diffXml = self.TryDiff(streamName=streamName, firstTrNumber=startTrNumber, secondTrNumber=nextTr)
if diff is None:
return (None, None)
logger.debug("FindNextChangeTransaction diff: {0}".format(nextTr))
return (nextTr, diff)
elif self.config.method == "deep-hist":
if deepHist is None:
raise Exception("Script error! deepHist argument cannot be none when running a deep-hist method.")
# Find the next transaction
for tr in deepHist:
if tr.id > startTrNumber:
if tr.Type in ignored_transaction_types:
logger.debug("Ignoring transaction #{id} - {Type} (transaction type is in ignored_transaction_types list)".format(id=tr.id, Type=tr.Type))
else:
diff, diffXml = self.TryDiff(streamName=streamName, firstTrNumber=startTrNumber, secondTrNumber=tr.id)
if diff is None:
return (None, None)
elif len(diff.elements) > 0:
logger.debug("FindNextChangeTransaction deep-hist: {0}".format(tr.id))
return (tr.id, diff)
else:
logger.debug("FindNextChangeTransaction deep-hist skipping: {0}, diff was empty...".format(tr.id))
diff, diffXml = self.TryDiff(streamName=streamName, firstTrNumber=startTrNumber, secondTrNumber=endTrNumber)
return (endTrNumber + 1, diff) # The end transaction number is inclusive. We need to return the one after it.
elif self.config.method == "pop":
logger.debug("FindNextChangeTransaction pop: {0}".format(startTrNumber + 1))
return (startTrNumber + 1, None)
else:
logger.error("Method is unrecognized, allowed values are 'pop', 'diff' and 'deep-hist'")
raise Exception("Invalid configuration, method unrecognized!")
def DeleteDiffItemsFromRepo(self, diff):
# Delete all of the files which are even mentioned in the diff so that we can do a quick populate (wouth the overwrite option)
deletedPathList = []
for element in diff.elements:
for change in element.changes:
for stream in [ change.stream1, change.stream2 ]:
if stream is not None and stream.name is not None:
name = stream.name
if name.startswith('\\.\\') or name.startswith('/./'):
# Replace the accurev depot relative path start with a normal relative path.
name = name[3:]
if os.path.isabs(name):
# For os.path.join() to work we need a non absolute path so turn the absolute path (minus any drive letter or UNC path part) into a relative path w.r.t. the git repo.
name = os.path.splitdrive(name)[1][1:]
path = os.path.abspath(os.path.join(self.gitRepo.path, name))
# Ensure we restrict the deletion to the git repository and that we don't delete the git repository itself.
doClearAll = False
relPath = os.path.relpath(path, self.gitRepo.path)
relPathDirs = SplitPath(relPath)
if relPath.startswith('..'):
logger.error("Trying to delete path outside the worktree! Deleting worktree instead. git path: {gp}, depot path: {dp}".format(gp=path, dp=stream.name))
doClearAll = True
elif relPathDirs[0] == '.git':
logger.error("Trying to delete git directory! Ignored... git path: {gp}, depot path: {dp}".format(gp=path, dp=stream.name))
elif relPath == '.':
logger.error("Deleting the entire worktree due to diff with bad '..' elements! git path: {gp}, depot path: {dp}".format(gp=path, dp=stream.name))
doClearAll = True
if doClearAll:
self.ClearGitRepo()
return [ self.gitRepo.path ]
if os.path.lexists(path): # Ensure that broken links are also deleted!
if not self.DeletePath(path):
logger.error("Failed to delete '{0}'.".format(path))
raise Exception("Failed to delete '{0}'".format(path))
else:
deletedPathList.append(path)
return deletedPathList
def TryDiff(self, streamName, firstTrNumber, secondTrNumber):
for i in range(0, AccuRev2Git.commandFailureRetryCount):
diffXml = accurev.raw.diff(all=True, informationOnly=True, verSpec1=streamName, verSpec2=streamName, transactionRange="{0}-{1}".format(firstTrNumber, secondTrNumber), isXmlOutput=True, useCache=self.config.accurev.UseCommandCache())
if diffXml is not None:
diff = accurev.obj.Diff.fromxmlstring(diffXml)
if diff is not None:
break
if diff is None:
logger.error( "accurev diff failed! stream: {0} time-spec: {1}-{2}".format(streamName, firstTrNumber, secondTrNumber) )
return diff, diffXml
def TryHist(self, depot, timeSpec, streamName=None, transactionKind=None):
trHist = None
for i in range(0, AccuRev2Git.commandFailureRetryCount):
trHistXml = accurev.raw.hist(depot=depot, stream=streamName, timeSpec=timeSpec, transactionKind=transactionKind, useCache=self.config.accurev.UseCommandCache(), isXmlOutput=True, expandedMode=True, verboseMode=True)
if trHistXml is not None:
trHist = accurev.obj.History.fromxmlstring(trHistXml)
if trHist is not None:
break
return trHist, trHistXml
def TryPop(self, streamName, transaction, overwrite=False):
for i in range(0, AccuRev2Git.commandFailureRetryCount):
popResult = accurev.pop(verSpec=streamName, location=self.gitRepo.path, isRecursive=True, isOverride=overwrite, timeSpec=transaction.id, elementList='.')
if popResult:
break
else:
logger.error("accurev pop failed:")
for message in popResult.messages:
if message.error is not None and message.error:
logger.error(" {0}".format(message.text))
else:
logger.info(" {0}".format(message.text))
return popResult
def TryStreams(self, depot, timeSpec, stream=None):
streams = None
for i in range(0, AccuRev2Git.commandFailureRetryCount):
streamsXml = accurev.raw.show.streams(depot=depot, timeSpec=timeSpec, stream=stream, isXmlOutput=True, includeDeactivatedItems=True, includeHasDefaultGroupAttribute=True, useCache=self.config.accurev.UseCommandCache())
if streamsXml is not None:
streams = accurev.obj.Show.Streams.fromxmlstring(streamsXml)
if streams is not None:
break
return streams, streamsXml
def TryDepots(self):
depots = None
for i in range(0, AccuRev2Git.commandFailureRetryCount):
depotsXml = accurev.raw.show.depots(isXmlOutput=True, includeDeactivatedItems=True)
if depotsXml is not None:
depots = accurev.obj.Show.Depots.fromxmlstring(depotsXml)
if depots is not None:
break
return depots, depotsXml
def NormalizeAccurevXml(self, xml):
xmlNormalized = re.sub('TaskId="[0-9]+"', 'TaskId="0"', xml)
xmlDecoded = git.decode_proc_output(xmlNormalized)
return xmlDecoded
def WriteInfoFiles(self, path, depot, transaction, streamsXml=None, histXml=None, streamName=None, diffXml=None, useCommandCache=False):
streams = None
hist = None