Bumped to version 1.0.1
[be.git] / libbe / storage / vcs / monotone.py
1 # Copyright (C) 2010-2011 Chris Ball <cjb@laptop.org>
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 """Monotone_ backend.
20
21 .. _Monotone: http://www.monotone.ca/
22 """
23
24 import os
25 import os.path
26 import random
27 import re
28 import shutil
29 import unittest
30
31 import libbe
32 import libbe.ui.util.user
33 from libbe.util.subproc import CommandError
34 import base
35
36 if libbe.TESTING == True:
37     import doctest
38     import sys
39
40
41 def new():
42     return Monotone()
43
44 class Monotone (base.VCS):
45     """:class:`base.VCS` implementation for Monotone.
46     """
47     name='monotone'
48     client='mtn'
49
50     def __init__(self, *args, **kwargs):
51         base.VCS.__init__(self, *args, **kwargs)
52         self.versioned = True
53         self._db_path = None
54         self._key_dir = None
55         self._key = None
56
57     def _vcs_version(self):
58         status,output,error = self._u_invoke_client('automate', 'interface_version')
59         return output.strip()
60
61     def version_cmp(self, *args):
62         """Compare the installed Monotone version `V_i` with another
63         version `V_o` (given in `*args`).  Returns
64
65            === ===============
66             1  if `V_i > V_o`
67             0  if `V_i == V_o`
68            -1  if `V_i < V_o`
69            === ===============
70
71         Examples
72         --------
73
74         >>> m = Monotone(repo='.')
75         >>> m._version = '7.1'
76         >>> m.version_cmp(7, 1)
77         0
78         >>> m.version_cmp(7, 2)
79         -1
80         >>> m.version_cmp(7, 0)
81         1
82         >>> m.version_cmp(8, 0)
83         -1
84         """
85         if not hasattr(self, '_parsed_version') \
86                 or self._parsed_version == None:
87             self._parsed_version = [int(x) for x in self.version().split('.')]
88         for current,other in zip(self._parsed_version, args):
89             c = cmp(current,other)
90             if c != 0:
91                 return c
92         return 0
93
94     def _require_version_ge(self, *args):
95         """Require installed interface version >= `*args`.
96
97         >>> m = Monotone(repo='.')
98         >>> m._version = '7.1'
99         >>> m._require_version_ge(6, 0)
100         >>> m._require_version_ge(7, 1)
101         >>> m._require_version_ge(7, 2)
102         Traceback (most recent call last):
103           ...
104         NotImplementedError: Operation not supported for monotone automation interface version 7.1.  Requires 7.2
105         """
106         if self.version_cmp(*args) < 0:
107             raise NotImplementedError(
108                 'Operation not supported for %s automation interface version'
109                 ' %s.  Requires %s' % (self.name, self.version(),
110                                       '.'.join([str(x) for x in args])))
111
112     def _vcs_get_user_id(self):
113         status,output,error = self._u_invoke_client('list', 'keys')
114         # output ~=
115         # ...
116         # [private keys]
117         # f7791378b49dfb47a740e9588848b510de58f64f john@doe.com
118         if '[private keys]' in output:
119             private = False
120             for line in output.splitlines():
121                 line = line.strip()
122                 if private == True:  # HACK.  Just pick the first key.
123                     return line.split(' ', 1)[1]
124                 if line == '[private keys]':
125                     private = True
126         return None  # Monotone has no infomation
127
128     def _vcs_detect(self, path):
129         if self._u_search_parent_directories(path, '_MTN') != None :
130             return True
131         return False
132
133     def _vcs_root(self, path):
134         """Find the root of the deepest repository containing path."""
135         if self.version_cmp(8, 0) >= 0:
136             if not os.path.isdir(path):
137                 dirname = os.path.dirname(path)
138             else:
139                 dirname = path
140             status,output,error = self._invoke_client(
141                 'automate', 'get_workspace_root', cwd=dirname)
142         else:
143             mtn_dir = self._u_search_parent_directories(path, '_MTN')
144             if mtn_dir == None:
145                 return None
146             return os.path.dirname(mtn_dir)
147         return output.strip()
148
149     def _invoke_client(self, *args, **kwargs):
150         """Invoke the client on our branch.
151         """
152         arglist = []
153         if self._db_path != None:
154             arglist.extend(['--db', self._db_path])
155         if self._key != None:
156             arglist.extend(['--key', self._key])
157         if self._key_dir != None:
158             arglist.extend(['--keydir', self._key_dir])
159         arglist.extend(args)
160         args = tuple(arglist)
161         return self._u_invoke_client(*args, **kwargs)
162
163     def _vcs_init(self, path):
164         self._require_version_ge(4, 0)
165         self._db_path = os.path.abspath(os.path.join(path, 'bugseverywhere.db'))
166         self._key_dir = os.path.abspath(os.path.join(path, '_monotone_keys'))
167         self._branch_name = 'bugs-everywhere-test'
168         self._key = 'bugseverywhere-%d@test.com' % random.randint(0,1e6)
169         self._passphrase = ''
170         self._u_invoke_client('db', 'init', '--db', self._db_path, cwd=path)
171         os.mkdir(self._key_dir)
172         self._u_invoke_client(
173             '--db', self._db_path,
174             '--keydir', self._key_dir,
175             'automate', 'genkey', self._key, self._passphrase)
176         self._invoke_client(
177             'setup', '--db', self._db_path,
178             '--branch', self._branch_name, cwd=path)
179
180     def _vcs_destroy(self):
181         vcs_dir = os.path.join(self.repo, '_MTN')
182         for dir in [vcs_dir, self._key_dir]:
183             if os.path.exists(dir):
184                 shutil.rmtree(dir)
185         if os.path.exists(self._db_path):
186             os.remove(self._db_path)
187
188     def _vcs_add(self, path):
189         if os.path.isdir(path):
190             return
191         self._invoke_client('add', path)
192
193     def _vcs_remove(self, path):
194         if not os.path.isdir(self._u_abspath(path)):
195             self._invoke_client('rm', path)
196
197     def _vcs_update(self, path):
198         pass
199
200     def _vcs_get_file_contents(self, path, revision=None):
201         if revision == None:
202             return base.VCS._vcs_get_file_contents(self, path, revision)
203         else:
204             self._require_version_ge(4, 0)
205             status,output,error = self._invoke_client(
206                 'automate', 'get_file_of', path, '--revision', revision)
207             return output
208
209     def _dirs_and_files(self, revision):
210         self._require_version_ge(2, 0)
211         status,output,error = self._invoke_client(
212             'automate', 'get_manifest_of', revision)
213         dirs = []
214         files = []
215         children_by_dir = {}
216         for line in output.splitlines():
217             fields = line.strip().split(' ', 1)
218             if len(fields) != 2 or len(fields[1]) < 2:
219                 continue
220             value = fields[1][1:-1]  # [1:-1] for '"XYZ"' -> 'XYZ'
221             if value == '':
222                 value = '.'
223             if fields[0] == 'dir':
224                 dirs.append(value)
225                 children_by_dir[value] = []
226             elif fields[0] == 'file':
227                 files.append(value)
228         for child in (dirs+files):
229             if child == '.':
230                 continue
231             parent = '.'
232             for p in dirs:
233                 # Does Monotone use native path separators?
234                 start = p+os.path.sep
235                 if p != child and child.startswith(start):
236                     rel = child[len(start):]
237                     if rel.count(os.path.sep) == 0:
238                         parent = p
239                         break
240             children_by_dir[parent].append(child)
241         return (dirs, files, children_by_dir)
242
243     def _vcs_path(self, id, revision):
244         dirs,files,children_by_dir = self._dirs_and_files(revision)
245         return self._u_find_id_from_manifest(id, dirs+files, revision=revision)
246
247     def _vcs_isdir(self, path, revision):
248         dirs,files,children_by_dir = self._dirs_and_files(revision)
249         return path in dirs
250
251     def _vcs_listdir(self, path, revision):
252         dirs,files,children_by_dir = self._dirs_and_files(revision)
253         children = [self._u_rel_path(c, path) for c in children_by_dir[path]]
254         return children
255
256     def _vcs_commit(self, commitfile, allow_empty=False):
257         args = ['commit', '--key', self._key, '--message-file', commitfile]
258         kwargs = {'expect': (0,1)}
259         status,output,error = self._invoke_client(*args, **kwargs)
260         strings = ['no changes to commit']
261         current_rev = self._current_revision()
262         if status == 1:
263             if self._u_any_in_string(strings, error) == True:
264                 if allow_empty == False:
265                     raise base.EmptyCommit()
266                 # note that Monotone does _not_ make an empty revision.
267                 # this returns the last non-empty revision id...
268             else:
269                 raise CommandError(
270                     [self.client] + args, status, output, error)
271         else:  # successful commit
272             assert current_rev in error, \
273                 'Mismatched revisions:\n%s\n%s' % (current_rev, error)
274         return current_rev
275
276     def _current_revision(self):
277         self._require_version_ge(2, 0)
278         status,output,error = self._invoke_client(
279             'automate', 'get_base_revision_id')  # since 2.0
280         return output.strip()
281
282     def _vcs_revision_id(self, index):
283         current_rev = self._current_revision()
284         status,output,error = self._invoke_client(
285             'automate', 'ancestors', current_rev)  # since 0.2, but output is alphebetized
286         revs = output.splitlines() + [current_rev]
287         status,output,error = self._invoke_client(
288             'automate', 'toposort', *revs)
289         revisions = output.splitlines()
290         try:
291             if index > 0:
292                 return revisions[index-1]
293             elif index < 0:
294                 return revisions[index]
295             else:
296                 return None
297         except IndexError:
298             return None
299
300     def _diff(self, revision):
301         status,output,error = self._invoke_client('-r', revision, 'diff')
302         return output
303
304     def _parse_diff(self, diff_text):
305         """_parse_diff(diff_text) -> (new,modified,removed)
306
307         `new`, `modified`, and `removed` are lists of files.
308
309         Example diff text::
310
311           #
312           # old_revision [1ce9ac2cfe3166b8ad23a60555f8a70f37686c25]
313           #
314           # delete ".be/dir/bugs/moved"
315           # 
316           # delete ".be/dir/bugs/removed"
317           # 
318           # add_file ".be/dir/bugs/moved2"
319           #  content [33e4510df9abef16dad7c65c0775e74602cc5005]
320           # 
321           # add_file ".be/dir/bugs/new"
322           #  content [45c45b5630f7446f83b0e14ee1525e449a06131c]
323           # 
324           # patch ".be/dir/bugs/modified"
325           #  from [809bf3b80423c361849386008a0ce01199d30929]
326           #    to [f13d3ec08972e2b41afecd9a90d4bc71cdcea338]
327           #
328           ============================================================
329           --- .be/dir/bugs/moved2 33e4510df9abef16dad7c65c0775e74602cc5005
330           +++ .be/dir/bugs/moved2 33e4510df9abef16dad7c65c0775e74602cc5005
331           @@ -0,0 +1 @@
332           +this entry will be moved
333           \ No newline at end of file
334           ============================================================
335           --- .be/dir/bugs/new    45c45b5630f7446f83b0e14ee1525e449a06131c
336           +++ .be/dir/bugs/new    45c45b5630f7446f83b0e14ee1525e449a06131c
337           @@ -0,0 +1 @@
338           +this entry is new
339           \ No newline at end of file
340           ============================================================
341           --- .be/dir/bugs/modified       809bf3b80423c361849386008a0ce01199d30929
342           +++ .be/dir/bugs/modified       f13d3ec08972e2b41afecd9a90d4bc71cdcea338
343           @@ -1 +1 @@
344           -some value to be modified
345           \ No newline at end of file
346           +a new value
347           \ No newline at end of file
348         """
349         new = []
350         modified = []
351         removed = []
352         lines = diff_text.splitlines()
353         for i,line in enumerate(lines):
354             if line.startswith('# add_file "'):
355                 new.append(line[len('# add_file "'):-1])
356             elif line.startswith('# patch "'):
357                 modified.append(line[len('# patch "'):-1])
358             elif line.startswith('# delete "'):
359                 removed.append(line[len('# delete "'):-1])
360             elif not line.startswith('#'):
361                 break
362         return (new,modified,removed)
363
364     def _vcs_changed(self, revision):
365         return self._parse_diff(self._diff(revision))
366
367 \f
368 if libbe.TESTING == True:
369     base.make_vcs_testcase_subclasses(Monotone, sys.modules[__name__])
370
371     unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
372     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])