This repository has been archived by the owner on Jan 23, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
/
idlemerge.py
executable file
·1579 lines (1361 loc) · 57.4 KB
/
idlemerge.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 python2.6
# Style based on: http://google-styleguide.googlecode.com/svn/trunk/pyguide.html
# Exception: 100 characters width.
#
# Copyright idle-games.com
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import datetime
import hashlib
import os
import re
import select
import shutil
import smtplib
import subprocess
import sys
import types
import xml.etree.ElementTree
from optparse import OptionParser
USAGE = """%prog <options
IdleMerge auomatically merges commits from one Subversion branch to an other.
The target use case is a 3 branches model which works well for 'online' projects and similar to
the Debian branching model of 'stable', 'testing', 'unstable'. The idea is that any changes made
to 'stable' should always go to 'testing' and then 'unstable'. The traditional way is to always
work in 'unstable' (usually trunk) and then merge up to the branches with cherry pick frequently
and branch cut on a regular basis. Here we call the branches trunk, stable, prod, and with a two
weeks release cycle:
- trunk: get all the medium term work 1-2 weeks from release. This is the lower branch.
- stable: get the work for release within a week
- prod: currently live code or soon to be live, holds the patch releases code.
The issues with that are:
- making a simple bug fix internted for stable in unstable is difficult, because of how unstable
it is. The more radical work than happens in unstable make it challenging to find a good time
when the rest of the code is in a testable/runnable state.
- additional work to cherry pick the fixes to put in the stable/prod branches, sometimes two
cherry-picks are required.
- unreliable tracking, some engineers will cherry pick 'manually' bypassing the native svn merge
command and inlude a last minute fix in the stable/testing branches. When the code get released
these last minute changes are lost because they never made it back to the trunk.
- ease of use. Artists and other non engineers usually do not know how to merge. They just need to
save in a different directory when appropriate, the auomerge takes care of the rest.
Downsides are:
- merge conflicts are public. Merge conflicts will happen. In practice they are not frequent if
the workflow is followed. Subversion is not very good at resolving obvious non-conflict, some
of it can be automated safely.
- merge conflicts block the automergeing. If an important fix is pending in the merge queue
because a conflict is pending resolution then engineers should not wait for the queue to clear
up automatically but should be proactive to either fix the conflict, or merge down the critical
fixes.
- somtimes some fixes are really for the prod branch only, use the NO_MERGE flag as part of the
commit. To be used sparringly otherwise it makes the workflow unreliable.
>"""
DEFAULT_NO_MERGE_PATTERNS = (
'maven-release-plugin', 'NOMERGE', 'NO-MERGE', 'NO MERGE', 'NO_MERGE')
BIG_MUST_READ = """
__ __ _ _ _____ _______ _____ ______ _____ _ _ _
| \/ | | | |/ ____|__ __| | __ \| ____| /\ | __ \| | | |
| \ / | | | | (___ | | | |__) | |__ / \ | | | | | | |
| |\/| | | | |\___ \ | | | _ /| __| / /\ \ | | | | | | |
| | | | |__| |____) | | | | | \ \| |____ / ____ \| |__| |_|_|_|
|_| |_|\____/|_____/ |_| |_| \_\______/_/ \_\_____/(_|_|_)
"""
END_BIG_MUST_READ = """
____ __ _ _ _____ _______ _____ ______ _____ _ _ _
/ / \/ | | | |/ ____|__ __| | __ \| ____| /\ | __ \| | | |
/ /| \ / | | | | (___ | | | |__) | |__ / \ | | | | | | |
/ / | |\/| | | | |\___ \ | | | _ /| __| / /\ \ | | | | | | |
/ / | | | | |__| |____) | | | | | \ \| |____ / ____ \| |__| |_|_|_|
/_/ |_| |_|\____/|_____/ |_| |_| \_\______/_/ \_\_____/(_|_|_)
"""
# Sample merge logs:
# [automerge ^/branches/prod@1234] Original comment for the revision
# on multiple lines
# -- IDLEMERGE DATA --
# REVISIONS=1234
# MERGEINFO_REVISIONS=1230,1233
#
# merge revisions r1235,1236 from ^/x to ^/bar
# -- IDLEMERGE DATA --
# REVISIONS=1235,1236
# MERGEINFO_REVISIONS=1230,1233
# r39389 | _jenkins | 2012-02-17 17:13:35 -0800 (Fri, 17 Feb 2012)
# Original comment for 1235
# spanning multiple lines
# r39389 | _jenkins | 2012-02-17 17:13:35 -0800 (Fri, 17 Feb 2012)
# Original comment for 1236
# spanning multiple lines
class Error(Exception):
pass
def force_line_buffer():
if hasattr(sys.stdout, 'fileno'):
# Force stdout to be line-buffered
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
class Conflict(Error):
"""Class to handle merge conflicts exceptions."""
def __init__(
self, revision, mergeinfos=None, merges=None, message=None, source=None, target=None):
super(Conflict, self).__init__()
self.revision = revision
self.mergeinfos = mergeinfos
self.merges = merges
self.source = source
self.target = target
self._message = message
self._status = None
def __str__(self):
message_lines = [self._message] if self._message else []
message_lines.append(self.subject)
if self.mergeinfos:
message_lines.append(
'Pending record-only merges: ' + revisions_as_string(self.mergeinfos))
if self.merges:
message_lines.append('Pending clean merges: ' + revisions_as_string(self.merges))
conflict_files = []
merged_files = []
added_files = []
deleted_files = []
for line in self.status:
match = re.match(r'(?:!\s+C|C)\s+(.*\w)$', line)
if match:
conflict_files.append(match.group(1))
continue
match = re.match(r'[ ]?M\s+(.*\w)$', line)
if match:
merged_files.append(match.group(1))
match = re.match(r'[ ]?A\s+\+\s+(.*\w)$', line)
if match:
added_files.append(match.group(1))
match = re.match(r'[ ]?D\s+(.*\w)$', line)
if match:
deleted_files.append(match.group(1))
target = self.target
source = self.source
resolve_lines = [
'',
BIG_MUST_READ,
''
'To resolve use the official subversion command line client.:',
'Do not use a GUI client such as Eclipse or TortoiseSVN for any of the steps.',
'If you use them, even to commit the merge metada will be skipped breaking idlemerge.',
'You must be in the target branch not in the %s branch' % (source,),
'$ cd %s # or your own working copy equivalent of the *target* branch' % (target,),
'$ svn up',
'$ svn st',
'# make sure that none of these files '
'have pending changes: %s' % (' '.join(conflict_files + merged_files)),
'$ svn merge -c %s --accept postpone %s' % (self.revision.number, source),
'$ svn st',
'# resolve the conflicted files, '
'stay directly in the base directory of the branch to commit',
'$ svn commit -N . %s'
% (' '.join(conflict_files + merged_files + added_files + deleted_files)),
'# Note that the dot is important to commit since '
'it contains the svn:mergeinfo metadata required for idlemerge to work properly.',
'',
END_BIG_MUST_READ,
''
]
return '\n'.join(message_lines + self.status + resolve_lines)
@property
def status(self):
if self._status is None:
status_lines = execute_command(['svn', 'status'])['stdout']
meta_lines = []
other_lines = []
for line in status_lines:
if re.match(r'\s?\S{1,2}\s+', line):
meta_lines.append(line.rstrip())
else:
other_lines.append(line.rstrip())
self._status = sorted(meta_lines) + other_lines
return self._status
@property
def subject(self):
return 'MANUAL MERGE NEEDS TO BE DONE: revision %s by %s from %s' % (
self.revision, self.revision.author, self.source)
def parse_args(argv):
parser = OptionParser(USAGE)
parser.add_option('-S', '--source', dest='source',
help='source repository url to merge [REQUIRED]')
parser.add_option('-n', '--noop', dest='noop', action='store_true',
help='No Operation, do not commit merges')
parser.add_option('-s', '--single', dest='single', action='store_true',
help='Merge one revision by one. One two source revisions, two commits')
parser.add_option('-c', '--concise', dest='concise', action='store_true',
help='if --single is activated, bundle up mergeinfo only merges together to reduce noise.')
parser.add_option('-a', '--patterns', dest='patterns',
help='patterns contained in comments of revisions not to be merged, comma separated')
parser.add_option('-m', '--max', dest='max', default=10, type='int',
help='maximum number of revisions to merge in this pass.'
' Used for troubleshooting, 0 is infinite.')
parser.add_option('-r', '--record_only_file', dest='record_only_filename',
help='file to store/read record-only revisions.')
parser.add_option('-v', '--verbose', dest='verbose', action='store_true', help='verbose mode')
# parser.add_option('-V', '--validation', dest='validation', help='validation script')
parser.add_option('-M', '--commit_mergeinfo', dest='commit_mergeinfo', action='store_true',
help='Commit mergeinfo-only merges even if no other changes are found')
# email options:
parser.add_option('-E', '--send_email', dest='send_email', default='no',
help='To email on conflict, set this to "conflict"')
parser.add_option('-D', '--email_domain', dest='email_domain',
help='The domain name to use for the email. It will be appended to the svn user name.')
parser.add_option('-R', '--default_recipients', dest='default_recipients',
help='A comma separated list of email recipients.')
parser.add_option('-F', '--from_email', dest='from_email', default='noreply',
help='Email address for the sender')
parser.add_option('-A', '--append_email', dest='append_email_filename',
help='Path to a text file to append to the body of the conflict email')
parser.add_option('-i', '--ignore', dest='ignore',
help='A comma separated list of files to not merge, usually branch specific files'
' such as pom.xml. Each entry is a relative path in the branch.'
)
# TODO(stephane): options to be implemented:
# merge subdirs independently as long as no pending conflict is in the same directory.
# potentially dangerous
# ignore revisions: sometime some conflicts cannot be resolved fast enough and are blocking
# the merge queue, in suh case it can be valid to 'skip' them temporarily.
# HEAD, we want to gatekeep the merges with a valid parent build, if it then picks the latest
# head when the merge start, it defeats the pupose of gatekeeping.
# Store mergeingo/revcord-only revisions to disk for the next run. This might shave some
# seconds for the next run.
# Authentication with username and password -- low priority.
# Validation script: external command to run to resolve remaining conflicts for example.
options, _ = parser.parse_args(argv[1:])
if not options.source:
print USAGE
raise Error()
return options
def execute_command(
command, discard_output=False, verbose=False, stdout=None, stderr=None, password=None,
handle_process=True, bufsize=None
):
"""Call a subprocess and handle the stder/stdout.
Args:
command: A list fo strings, the command to run.
discard_output: A boolean, if True do not keep stdout/stderr. Default is False
verbose: A boolean, if True print the command wit outputs as it runs. Default is False.
stdout: A file like instance, where to pass stdout of the command only when verbose=True.
Default is sys.stdout.
stderr: A file like instance, where to pass stderrof the command only when verbose=True.
Default is sys.stderr.
password: A string, a password to replace in the command arguments with the %%PASSWORD%%
pattern. This allows us to hide the password from the verbose output.
handle_process: A boolean, if True handle the output and return a dict with the return code
and outputs from the process. If False, return the subprocess instance as is .
Default is True.
bufsize: An integer, passed to subprocess.Popen(), see official Python docs for details.
Default is 1.
Returns:
If handle_process is True, default, a dict of 3 items:
return_code: and integer, the exit code of the process called.
stdout: A list of strings, the stdout lines.
stderr: A list of string, the stderr lines.
If handle_process is False, the subprocess instance. The caller is in charge of processing
the output and closing/terminating the subprocess.
"""
if stdout is None:
stdout = sys.stdout
if stderr is None:
stderr = sys.stderr
if bufsize is None:
bufsize = 1
if verbose:
print >> stdout, '[DEBUG] executing command %r.' % ' '.join(command)
if password is not None:
cmd = [(x if x != '%%PASSWORD%%' else password) for x in command]
else:
cmd = command
process = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, bufsize=bufsize)
if not handle_process:
return process
stdout_lines = []
stderr_lines = []
out_buffer = process.stdout
err_buffer = process.stderr
output_targets = {out_buffer: stdout, err_buffer: stderr}
lines_targets = {out_buffer: stdout_lines, err_buffer: stderr_lines}
read = 0
inputs = (out_buffer, err_buffer)
while True:
readable, _ , _ = select.select(inputs, (), ())
if not readable:
break
for stream in readable:
output = stream.readline()
if not output:
continue
read += 1
if verbose:
output_targets[stream].write(output)
if not discard_output:
lines_targets[stream].append(output)
if process.poll() is not None and read == 0:
break
read = 0
return_code = process.wait()
if verbose:
print >> stdout, '[DEBUG] exit value : %d' % return_code
process_output = {
'return_code': return_code,
'stdout': stdout_lines,
'stderr': stderr_lines
}
return process_output
class AuthToken(object):
"""Simple wrapper used to pass username and password around."""
def __init__(self, username, password):
self.username = username
self.password = password
# <path
# kind="file"
# action="D|M|A">/trunk/bi/reducer_uid_session.py</path>
class LogPath(object):
"""Abstraction class for <path> entries from svn log -v."""
def __init__(self, xml_element):
self._xml = xml_element
self._action = None
@property
def action(self):
if self._action is None:
self._action = self._xml.attrib['action']
return self._action
@property
def path(self):
return self._xml.text
@property
def kind(self):
return self._xml.attrib['kind']
@property
def is_file(self):
return self.kind == 'file'
@property
def is_dir(self):
return not self.is_file
class Revision(object):
"""Svn revision class.
Sample xml log entry:
<logentry revision="36317">
<author>ravi</author>
<date>2012-01-27T02:08:20.565277Z</date>
<msg>change of uge test</msg>
</logentry>
Args:
number: An integer or string, the revision number. Optional.
svn: An SvnWrapper instance. Optional.
xml_element: An xml.etree.ElementTree instance.
branch: A string, the path to the branch. Default is ^/ but can be 50% slower.
"""
def __init__(self, number=None, svn=None, xml_element=None, branch='^/'):
if number is None and xml_element is None:
raise Error('Must provide either number or xml to Revision().')
if svn is None:
svn = SvnWrapper()
self.svn = svn
self.branch = branch
self._number = int(number) if number is not None else None
self._xml = xml_element
self._author = None
self._date = None
self._msg = None
self._full_msg = None
self._idle_data = None
self._paths = None
self._original_branch = None
def __str__(self):
return str(self.number)
def __int__(self):
return self.number
def __hash__(self):
return self.number
def __cmp__(self, other):
return cmp(self.number, other.number)
@property
def number(self):
if self._number is None and self._xml:
self._number = int(self._xml.attrib['revision'])
return self._number
@number.setter
def number(self, revision_number):
if int(revision_number) != self.number:
self._delete_properties()
self._number = int(revision_number)
@property
def xml_element(self):
if self._xml is None:
self._get_log()
return self._xml
@xml_element.setter
def xml_element(self, data):
self._delete_properties()
self._number = None
self._xml = data
@property
def author(self):
if self._author is None and self.xml_element:
self._author = self.xml_element.find('author').text
return self._author
@property
def date(self):
if self._date is None and self.xml_element:
date_string = self.xml_element.find('date').text
self._date = datetime.datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S.%fZ')
return self._date
@property
def full_msg(self):
if self._full_msg is None and self.xml_element:
self._full_msg = self.xml_element.find('msg').text
return self._msg
@property
def msg(self):
if self._full_msg is None:
self._get_msg()
return self._msg
@property
def idle_data(self):
if self._full_msg is None:
self._get_msg()
return self._idle_data
def _get_msg(self):
if not self.xml_element:
raise Error('Cannot get data')
full_msg = self.xml_element.find('msg').text
if not full_msg:
full_msg = ''
self._full_msg = full_msg
# Note: Python2.7 supports flags=re.MULTILINE -- stephane
match = re.split(r'(^|\n)-- IDLEMERGE DATA --\n', full_msg, 1)
self._msg = match[0]
self._idle_data = match[1] if len(match) > 1 else ''
@property
def paths(self):
if self._paths is None:
self._paths = [LogPath(x) for x in self.xml_element.find('paths')]
return self._paths
@property
def original_branch(self):
if self._original_branch is None:
self._original_branch = self._get_original_branch()
return self._original_branch
def _get_original_branch(self):
match = re.match(r'(\^.*)/(?:trunk|branches/\w+)$', self.branch)
if not match:
return self.branch
project_path = match.group(1)
potential_path = ''
for log_path in self.paths:
this_path = '^' + log_path.path
if not this_path.startswith('^/'):
continue
if this_path.startswith(self.branch):
return self.branch
if this_path.startswith(project_path):
potential_path = this_path
if not potential_path:
return self.branch
match = re.match(r'(\^/.*(?:/trunk|/branches/\w+))(?:/|$)', potential_path)
if not match:
return self.branch
return match.group(1)
def _delete_properties(self):
self._xml = None
self._author = None
self._date = None
self._msg = None
def _get_log(self):
self._delete_properties()
self.svn.log(['--xml', '-v', '-r', str(self.number), self.branch])
log = xml.etree.ElementTree.fromstring(''.join(self.svn.stdout))
self._xml = log.find('logentry')
def revisions_as_string(revisions, separator=', '):
sorted_revisions = sorted([int(revision) for revision in revisions if revision])
return separator.join([str(revision) for revision in sorted_revisions])
class StatusEntry(object):
"""Wrapper class for svn status entries."""
def __init__(self, xml_element):
self._xml = xml_element
self._wc_status = None
self._commit = None
self._commit_fetched = False
@property
def path(self):
return self._xml.attrib['path']
@property
def wc_status(self):
if self._wc_status is None:
self._wc_status = self._xml.find('wc-status') # should always return something.
return self._wc_status
@property
def commit(self):
if self._commit_fetched is False:
self._commit_fetched = True
self._commit = self._xml.find('commit')
return self._commit
@property
def props(self):
"""Returns the status for the file/directory properties.
Returns:
A string, values are 'none', 'conflicted', 'normal', 'modified'.
"""
return self.wc_status.attrib['props']
@property
def item(self):
"""Returns the status for the file/directory itself.
Returns:
A string, values are 'added', 'conflicted', 'deleted', 'normal', 'missing',
'modified', 'unversionned'.
"""
return self.wc_status.attrib['item']
@property
def wc_revision(self):
return self.wc_status.attrib.get('revision')
@property
def commit_revision(self):
_commit = self.commit
if not _commit:
return None
return _commit.attrib['revision']
def is_dir(self):
return os.path.isdir(self.path)
def conflict_prej_filepath(self):
if self.is_dir():
if self.props == 'conflicted':
conflict_file = os.path.join(self.path, 'dir_conflicts.prej')
return conflict_file if os.path.exists(conflict_file) else None
return None
@property
def tree_conflicted(self):
return self.wc_status.attrib.get('tree-conflicted') == 'true'
@property
def has_conflict(self):
return self.tree_conflicted or 'conflicted' in (self.props, self.item)
@property
def has_non_props_changes(self):
# TODO(stephane): this is a potential 'bug' we should specifically ignore svn:mergeinfo.
# The downside is small enough to not fix it on the first revision.
return self.has_conflict or self.item not in ('normal', 'unversionnned')
@property
def is_unversionned(self):
return self.item == 'unversionned'
class Status(object):
"""Wrapper class for 'svn status --xml' results."""
def __init__(self, xml_element):
self._xml = xml_element
self._entries = None
self._entries_by_path = None
self._conflict_entries = None
self._conflict_entries_by_path = None
self._unversionned = None
@property
def entries(self):
if self._entries is None:
self._get_entries()
return self._entries
@property
def entries_by_path(self):
if self._entries_by_path is None:
self._entries_by_path()
return self._entries_by_path
def _get_entries(self):
_entries_by_path = {}
_entries = []
targets = self._xml.findall('target')
for target in targets:
entries = [StatusEntry(x) for x in target.findall('entry')]
for entry in entries:
if entry.path in _entries_by_path:
continue
_entries.append(entry)
_entries_by_path[entry.path] = entry
self._entries = _entries
self._entries_by_path = _entries_by_path
@property
def conflict_entries(self):
if self._conflict_entries is None:
self._get_conflicted_entries()
return self._conflict_entries
@property
def conflict_entries_by_path(self):
if self._conflict_entries_by_path is None:
self._get_conflicted_entries()
return self._conflict_entries_by_path
def _get_conflicted_entries(self):
_conflict_entries = []
_conflict_entries_by_path = {}
for entry in self.entries:
if not entry.has_conflict:
continue
_conflict_entries.append(entry)
_conflict_entries_by_path[entry.path] = entry
self._conflict_entries = _conflict_entries
self._conflict_entries_by_path = _conflict_entries_by_path
@property
def has_conflict(self):
return bool(self.conflict_entries)
def has_non_props_changes(self):
for entry in self.entries:
if entry.has_non_props_changes:
return True
return False
@property
def unversionned(self):
if self._unversionned is None:
self._unversionned = [entry for entry in self.entries if not entry.is_unversionned]
return self._unversionned
class InfoEntry(object):
"""Wrapper class for the <entry> items of svn info --xml"""
def __init__(self, xml_element):
self._xml = xml_element
self._wc_info = False
self._commit = False
@property
def path(self):
return self._xml.attrib['path']
@property
def wc_info(self):
if self._wc_info is False:
self._wc_info = self._xml.find('wc-info')
return self._wc_info
@property
def commit(self):
if self._commit is False:
self._commit = self._xml.find('commit')
return self._commit
@property
def kind(self):
return self._xml.attrib['kind']
@property
def is_file(self):
return self.kind == 'file'
@property
def is_dir(self):
return not self.is_file
@property
def url(self):
return self._xml.find('url').text
@property
def repo_root(self):
return self._xml.find('repository/root').text
@property
def repo_path(self):
url = self.url
root = self.repo_root.rstrip('/')
if url.startswith(root):
return '^' + url[len(root):]
return url
@property
def tree_conflict(self):
return self._xml.find('tree-conflict')
# TODO(stephane): make a base class for xml handling, <entry> is common to some of the commands.
class Info(object):
"""Wrapper class for 'svn info --xml' results."""
def __init__(self, xml_element):
self._xml = xml_element
self._entries = None
self._entries_by_path = None
@property
def entries(self):
if self._entries is None:
self._get_entries()
return self._entries
@property
def entries_by_path(self):
if self._entries_by_path is None:
self._get_entries()
return self._entries_by_path
def _get_entries(self):
_entries_by_path = {}
_entries = []
entries = [InfoEntry(x) for x in self._xml.findall('entry')]
for entry in entries:
if entry.path in _entries_by_path:
continue
_entries.append(entry)
_entries_by_path[entry.path] = entry
self._entries = _entries
self._entries_by_path = _entries_by_path
class SvnWrapper(object):
"""Class to manage svn calls."""
def __init__(self, auth=None, no_commit=False, verbose=False, stdout=None):
if stdout is None:
stdout = sys.stdout
self._stdout = stdout
self.no_commit = no_commit
self.verbose = verbose
self.auth = auth
self._last_status = None
@property
def return_code(self):
return self._last_status['return_code'] if self._last_status else None
@property
def stdout(self):
return self._last_status['stdout'] if self._last_status else None
@property
def stderr(self):
return self._last_status['stderr'] if self._last_status else None
def run(self, options, discard_output=False, handle_process=True, bufsize=None):
svn_cmd = ['svn', '--non-interactive']
password = None
if self.auth:
svn_cmd += ['--username', self.auth.username]
if self.auth.password:
password = self.auth.password
svn_cmd += ['--password', '%%PASSWORD%%']
svn_cmd += options
self._last_status = None
command_result = execute_command(
svn_cmd, discard_output=discard_output, verbose=self.verbose, stdout=self._stdout,
password=password, handle_process=handle_process, bufsize=bufsize
)
if handle_process:
self._last_status = command_result
return self.return_code
# We got the command process back
return command_result
def log(self, options):
log_cmd = ['log'] + options
return self.run(log_cmd)
def add_email_domain(email, domain):
"""Append the domain name to an email address if it is missing.
This is probably breaking pure RFC 5322, but should be reliable enough 99.9% of the time.
"""
if not domain:
return email
if '@' in email:
return email
at_domain = domain if domain.startswith('@') else '@' + domain
if email.endswith(at_domain):
return email
if email.endswith(at_domain + '>'):
return email
return email + at_domain
class MergeEmail(object):
"""Small email management class."""
def __init__(self, send, domain, default_recipients, sender, append_filename):
self.send = send.strip() if send else ''
self.domain = domain.strip() if domain else ''
self._default_recipients = default_recipients
self._sender = sender.strip() if sender else ''
self._append_filename = append_filename
self._append_text = None
@property
def default_recipients(self):
if not self._default_recipients:
return set()
if isinstance(self._default_recipients, types.StringTypes):
return set([x.strip() for x in self._default_recipients.split(',') if x and x.strip()])
return set(self._default_recipients)
@property
def sender(self):
return add_email_domain(self._sender, self.domain)
def load_append_text(self):
"""Return the content of the append file."""
if not self._append_filename:
return ''
with open(self._append_filename, 'r') as append_file:
return append_file.read()
def get_append_text(self):
"""Return the content of the append file if set."""
if self._append_text is None:
self._append_text = self.load_append_text()
return self._append_text
def recipients_for_conflict(self, conflict):
"""Generate the list of email recipient for the conflict email.
Args:
conflict: A Conflict() exception.
Returns:
A list of strings, the unique email addresses for the error email.
"""
recipients = self.default_recipients
recipients.add(conflict.revision.author)
filtered_recipients = set([x for x in [y.strip() for y in recipients if y] if x])
return set([add_email_domain(x, self.domain) for x in filtered_recipients])
def email_conflict(self, conflict):
"""Send an email about the merge conflict.
Args:
conflict: A Conflict() exception.
"""
if not self.send or self.send == 'no':
return
subject = conflict.subject
body = '%s\n\n%s' % (str(conflict), self.get_append_text())
sender = self.sender
recipients = self.recipients_for_conflict(conflict)
message = (
'Subject: %(subject)s\n'
'From: %(from)s\n'
'To: %(to)s\n'
'\n'
'%(body)s' % {
'subject': subject,
'from': sender,
'to': ', '.join(recipients),
'body': body
}
)
try:
smtp = smtplib.SMTP('localhost')
smtp.sendmail(sender, recipients, message)
print 'Successfully sent email from %s to %s' % (sender, ', '.join(recipients))
except smtplib.SMTPException:
print 'Error: unable to send email'
def idle_merge_metacomment(revisions=None, mergeinfo_revisions=None):
if revisions is None:
revisions = set()
if mergeinfo_revisions is None:
mergeinfo_revisions = set()
if type(revisions) is Revision:
revisions = set([revisions])
else:
revisions = set(revisions)
comment = ['-- IDLEMERGE DATA --']
if revisions:
comment.append('REVISIONS=' + revisions_as_string(revisions, ','))
if mergeinfo_revisions:
comment.append('MERGEINFO_REVISIONS=' + revisions_as_string(mergeinfo_revisions, ','))
all_revisions = sorted(revisions.union(mergeinfo_revisions))
comment += ['r%s | %s | %s' % (r.number, r.author, r.date) for r in all_revisions]
return '\n '.join(comment)
class IdleMerge(object):
def __init__(self, source, target='.', noop=True, single=False, verbose=False, stdout=None,
commit_mergeinfo=False):
if stdout is None:
stdout = sys.stdout
self.source = source
self.target = target
self._target_url = None
self._stdout = stdout
self.commit_mergeinfo = commit_mergeinfo
self.noop = noop