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