Merged Anton Batenev's report of Nicolas Alvarez' unicode-in-be-new bug
[be.git] / libbe / storage / vcs / darcs.py
1 # Copyright (C) 2009-2010 Gianluca Montecchi <gian@grys.it>
2 #                         W. Trevor King <wking@drexel.edu>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along
15 # with this program; if not, write to the Free Software Foundation, Inc.,
16 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
18 """Darcs_ backend.
19
20 .. _Darcs: http://darcs.net/
21 """
22
23 import codecs
24 import os
25 import re
26 import shutil
27 import sys
28 import time # work around http://mercurial.selenic.com/bts/issue618
29 import types
30 try: # import core module, Python >= 2.5
31     from xml.etree import ElementTree
32 except ImportError: # look for non-core module
33     from elementtree import ElementTree
34 from xml.sax.saxutils import unescape
35
36 import libbe
37 import base
38
39 if libbe.TESTING == True:
40     import doctest
41     import unittest
42
43
44 def new():
45     return Darcs()
46
47 class Darcs(base.VCS):
48     """:class:`base.VCS` implementation for Darcs.
49     """
50     name='darcs'
51     client='darcs'
52
53     def __init__(self, *args, **kwargs):
54         base.VCS.__init__(self, *args, **kwargs)
55         self.versioned = True
56         self.__updated = [] # work around http://mercurial.selenic.com/bts/issue618
57
58     def _vcs_version(self):
59         status,output,error = self._u_invoke_client('--version')
60         return output.strip()
61
62     def version_cmp(self, *args):
63         """Compare the installed Darcs version `V_i` with another version
64         `V_o` (given in `*args`).  Returns
65
66            === ===============
67             1  if `V_i > V_o`
68             0  if `V_i == V_o`
69            -1  if `V_i < V_o`
70            === ===============
71
72         Examples
73         --------
74
75         >>> d = Darcs(repo='.')
76         >>> d._vcs_version = lambda : "2.3.1 (release)"
77         >>> d.version_cmp(2,3,1)
78         0
79         >>> d.version_cmp(2,3,2)
80         -1
81         >>> d.version_cmp(2,3,0)
82         1
83         >>> d.version_cmp(3)
84         -1
85         >>> d._vcs_version = lambda : "2.0.0pre2"
86         >>> d._parsed_version = None
87         >>> d.version_cmp(3)
88         -1
89         >>> d.version_cmp(2,0,1)
90         Traceback (most recent call last):
91           ...
92         NotImplementedError: Cannot parse non-integer portion "0pre2" of Darcs version "2.0.0pre2"
93         """
94         if not hasattr(self, '_parsed_version') \
95                 or self._parsed_version == None:
96             num_part = self._vcs_version().split(' ')[0]
97             self._parsed_version = []
98             for num in num_part.split('.'):
99                 try:
100                     self._parsed_version.append(int(num))
101                 except ValueError, e:
102                     self._parsed_version.append(num)
103         for current,other in zip(self._parsed_version, args):
104             if type(current) != types.IntType:
105                 raise NotImplementedError(
106                     'Cannot parse non-integer portion "%s" of Darcs version "%s"'
107                     % (current, self._vcs_version()))
108             c = cmp(current,other)
109             if c != 0:
110                 return c
111         return 0
112
113     def _vcs_get_user_id(self):
114         # following http://darcs.net/manual/node4.html#SECTION00410030000000000000
115         # as of June 29th, 2009
116         if self.repo == None:
117             return None
118         darcs_dir = os.path.join(self.repo, '_darcs')
119         if darcs_dir != None:
120             for pref_file in ['author', 'email']:
121                 pref_path = os.path.join(darcs_dir, 'prefs', pref_file)
122                 if os.path.exists(pref_path):
123                     return self.get_file_contents(pref_path)
124         for env_variable in ['DARCS_EMAIL', 'EMAIL']:
125             if env_variable in os.environ:
126                 return os.environ[env_variable]
127         return None
128
129     def _vcs_detect(self, path):
130         if self._u_search_parent_directories(path, "_darcs") != None :
131             return True
132         return False
133
134     def _vcs_root(self, path):
135         """Find the root of the deepest repository containing path."""
136         # Assume that nothing funny is going on; in particular, that we aren't
137         # dealing with a bare repo.
138         if os.path.isdir(path) != True:
139             path = os.path.dirname(path)
140         darcs_dir = self._u_search_parent_directories(path, '_darcs')
141         if darcs_dir == None:
142             return None
143         return os.path.dirname(darcs_dir)
144
145     def _vcs_init(self, path):
146         self._u_invoke_client('init', cwd=path)
147
148     def _vcs_destroy(self):
149         vcs_dir = os.path.join(self.repo, '_darcs')
150         if os.path.exists(vcs_dir):
151             shutil.rmtree(vcs_dir)
152
153     def _vcs_add(self, path):
154         if os.path.isdir(path):
155             return
156         self._u_invoke_client('add', path)
157
158     def _vcs_remove(self, path):
159         if not os.path.isdir(self._u_abspath(path)):
160             os.remove(os.path.join(self.repo, path)) # darcs notices removal
161
162     def _vcs_update(self, path):
163         self.__updated.append(path) # work around http://mercurial.selenic.com/bts/issue618
164         pass # darcs notices changes
165
166     def _vcs_get_file_contents(self, path, revision=None):
167         if revision == None:
168             return base.VCS._vcs_get_file_contents(self, path, revision)
169         if self.version_cmp(2, 0, 0) == 1:
170             status,output,error = self._u_invoke_client( \
171                 'show', 'contents', '--patch', revision, path)
172             return output
173         # Darcs versions < 2.0.0pre2 lack the 'show contents' command
174
175         patch = self._diff(revision, path=path, unicode_output=False)
176
177         # '--output -' to be supported in GNU patch > 2.5.9
178         # but that hasn't been released as of June 30th, 2009.
179
180         # Rewrite path to status before the patch we want
181         args=['patch', '--reverse', path]
182         status,output,error = self._u_invoke(args, stdin=patch)
183
184         if os.path.exists(os.path.join(self.repo, path)) == True:
185             contents = base.VCS._vcs_get_file_contents(self, path)
186         else:
187             contents = ''
188
189         # Now restore path to it's current incarnation
190         args=['patch', path]
191         status,output,error = self._u_invoke(args, stdin=patch)
192         return contents
193
194     def _vcs_path(self, id, revision):
195         return self._u_find_id(id, revision)
196
197     def _vcs_isdir(self, path, revision):
198         if self.version_cmp(2, 3, 1) == 1:
199             # Sun Nov 15 20:32:06 EST 2009  thomashartman1@gmail.com
200             #   * add versioned show files functionality (darcs show files -p 'some patch')
201             status,output,error = self._u_invoke_client( \
202                 'show', 'files', '--no-files', '--patch', revision)
203             children = output.rstrip('\n').splitlines()
204             rpath = '.'
205             children = [self._u_rel_path(c, rpath) for c in children]
206             if path in children:
207                 return True
208             return False
209         raise NotImplementedError(
210             'Darcs versions <= 2.3.1 lack the --patch option for "show files"')
211
212     def _vcs_listdir(self, path, revision):
213         if self.version_cmp(2, 3, 1) == 1:
214             # Sun Nov 15 20:32:06 EST 2009  thomashartman1@gmail.com
215             #   * add versioned show files functionality (darcs show files -p 'some patch')
216             # Wed Dec  9 05:42:21 EST 2009  Luca Molteni <volothamp@gmail.com>
217             #   * resolve issue835 show file with file directory arguments
218             path = path.rstrip(os.path.sep)
219             status,output,error = self._u_invoke_client( \
220                 'show', 'files', '--patch', revision, path)
221             files = output.rstrip('\n').splitlines()
222             if path == '.':
223                 descendents = [self._u_rel_path(f, path) for f in files
224                                if f != '.']
225             else:
226                 descendents = [self._u_rel_path(f, path) for f in files
227                                if f.startswith(path)]
228             return [f for f in descendents if f.count(os.path.sep) == 0]
229         # Darcs versions <= 2.3.1 lack the --patch option for 'show files'
230         raise NotImplementedError
231
232     def _vcs_commit(self, commitfile, allow_empty=False):
233         id = self.get_user_id()
234         if id == None or '@' not in id:
235             id = '%s <%s@invalid.com>' % (id, id)
236         args = ['record', '--all', '--author', id, '--logfile', commitfile]
237         status,output,error = self._u_invoke_client(*args)
238         empty_strings = ['No changes!']
239         # work around http://mercurial.selenic.com/bts/issue618
240         if self._u_any_in_string(empty_strings, output) == True \
241                 and len(self.__updated) > 0:
242             time.sleep(1)
243             for path in self.__updated:
244                 os.utime(os.path.join(self.repo, path), None)
245             status,output,error = self._u_invoke_client(*args)
246         self.__updated = []
247         # end work around
248         if self._u_any_in_string(empty_strings, output) == True:
249             if allow_empty == False:
250                 raise base.EmptyCommit()
251             # note that darcs does _not_ make an empty revision.
252             # this returns the last non-empty revision id...
253             revision = self._vcs_revision_id(-1)
254         else:
255             revline = re.compile("Finished recording patch '(.*)'")
256             match = revline.search(output)
257             assert match != None, output+error
258             assert len(match.groups()) == 1
259             revision = match.groups()[0]
260         return revision
261
262     def _revisions(self):
263         """
264         Return a list of revisions in the repository.
265         """
266         status,output,error = self._u_invoke_client('changes', '--xml')
267         revisions = []
268         xml_str = output.encode('unicode_escape').replace(r'\n', '\n')
269         element = ElementTree.XML(xml_str)
270         assert element.tag == 'changelog', element.tag
271         for patch in element.getchildren():
272             assert patch.tag == 'patch', patch.tag
273             for child in patch.getchildren():
274                 if child.tag == 'name':
275                     text = unescape(unicode(child.text).decode('unicode_escape').strip())
276                     revisions.append(text)
277         revisions.reverse()
278         return revisions
279
280     def _vcs_revision_id(self, index):
281         revisions = self._revisions()
282         try:
283             if index > 0:
284                 return revisions[index-1]
285             elif index < 0:
286                 return revisions[index]
287             else:
288                 return None
289         except IndexError:
290             return None
291
292     def _diff(self, revision, path=None, unicode_output=True):
293         revisions = self._revisions()
294         i = revisions.index(revision)
295         args = ['diff', '--unified']
296         if i+1 < len(revisions):
297             next_rev = revisions[i+1]
298             args.extend(['--from-patch', next_rev])
299         if path != None:
300             args.append(path)
301         kwargs = {'unicode_output':unicode_output}
302         status,output,error = self._u_invoke_client(
303             *args, **kwargs)
304         return output
305
306     def _parse_diff(self, diff_text):
307         """_parse_diff(diff_text) -> (new,modified,removed)
308
309         `new`, `modified`, and `removed` are lists of files.
310
311         Example diff text::
312
313           Mon Jan 18 15:19:30 EST 2010  None <None@invalid.com>
314             * Final state
315           diff -rN --unified old-BEtestgQtDuD/.be/dir/bugs/modified new-BEtestgQtDuD/.be/dir/bugs/modified
316           --- old-BEtestgQtDuD/.be/dir/bugs/modified      2010-01-18 15:19:30.000000000 -0500
317           +++ new-BEtestgQtDuD/.be/dir/bugs/modified      2010-01-18 15:19:30.000000000 -0500
318           @@ -1 +1 @@
319           -some value to be modified
320           \ No newline at end of file
321           +a new value
322           \ No newline at end of file
323           diff -rN --unified old-BEtestgQtDuD/.be/dir/bugs/moved new-BEtestgQtDuD/.be/dir/bugs/moved
324           --- old-BEtestgQtDuD/.be/dir/bugs/moved 2010-01-18 15:19:30.000000000 -0500
325           +++ new-BEtestgQtDuD/.be/dir/bugs/moved 1969-12-31 19:00:00.000000000 -0500
326           @@ -1 +0,0 @@
327           -this entry will be moved
328           \ No newline at end of file
329           diff -rN --unified old-BEtestgQtDuD/.be/dir/bugs/moved2 new-BEtestgQtDuD/.be/dir/bugs/moved2
330           --- old-BEtestgQtDuD/.be/dir/bugs/moved2        1969-12-31 19:00:00.000000000 -0500
331           +++ new-BEtestgQtDuD/.be/dir/bugs/moved2        2010-01-18 15:19:30.000000000 -0500
332           @@ -0,0 +1 @@
333           +this entry will be moved
334           \ No newline at end of file
335           diff -rN --unified old-BEtestgQtDuD/.be/dir/bugs/new new-BEtestgQtDuD/.be/dir/bugs/new
336           --- old-BEtestgQtDuD/.be/dir/bugs/new   1969-12-31 19:00:00.000000000 -0500
337           +++ new-BEtestgQtDuD/.be/dir/bugs/new   2010-01-18 15:19:30.000000000 -0500
338           @@ -0,0 +1 @@
339           +this entry is new
340           \ No newline at end of file
341           diff -rN --unified old-BEtestgQtDuD/.be/dir/bugs/removed new-BEtestgQtDuD/.be/dir/bugs/removed
342           --- old-BEtestgQtDuD/.be/dir/bugs/removed       2010-01-18 15:19:30.000000000 -0500
343           +++ new-BEtestgQtDuD/.be/dir/bugs/removed       1969-12-31 19:00:00.000000000 -0500
344           @@ -1 +0,0 @@
345           -this entry will be deleted
346           \ No newline at end of file
347           
348         """
349         new = []
350         modified = []
351         removed = []
352         lines = diff_text.splitlines()
353         repodir = os.path.basename(self.repo) + os.path.sep
354         i = 0
355         while i < len(lines):
356             line = lines[i]; i += 1
357             if not line.startswith('diff '):
358                 continue
359             file_a,file_b = line.split()[-2:]
360             assert file_a.startswith('old-'), \
361                 'missformed file_a %s' % file_a
362             assert file_b.startswith('new-'), \
363                 'missformed file_a %s' % file_b
364             file = file_a[4:]
365             assert file_b[4:] == file, \
366                 'diff file missmatch %s != %s' % (file_a, file_b)
367             assert file.startswith(repodir), \
368                 'missformed file_a %s' % file_a
369             file = file[len(repodir):]
370             lines_added = 0
371             lines_removed = 0
372             line = lines[i]; i += 1
373             assert line.startswith('--- old-'), \
374                 'missformed "---" line %s' % line
375             time_a = line.split('\t')[1]
376             line = lines[i]; i += 1
377             assert line.startswith('+++ new-'), \
378                 'missformed "+++" line %s' % line
379             time_b = line.split('\t')[1]
380             zero_time = time.strftime('%Y-%m-%d %H:%M:%S.000000000 ',
381                                       time.localtime(0))
382             # note that zero_time is missing the trailing timezone offset
383             if time_a.startswith(zero_time):
384                 new.append(file)
385             elif time_b.startswith(zero_time):
386                 removed.append(file)
387             else:
388                 modified.append(file)
389         return (new,modified,removed)
390
391     def _vcs_changed(self, revision):
392         return self._parse_diff(self._diff(revision))
393
394 \f
395 if libbe.TESTING == True:
396     base.make_vcs_testcase_subclasses(Darcs, sys.modules[__name__])
397
398     unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
399     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])