Bumped to version 1.0.0
[be.git] / libbe / storage / vcs / arch.py
1 # Copyright (C) 2005-2011 Aaron Bentley <abentley@panoramicfeedback.com>
2 #                         Ben Finney <benf@cybersource.com.au>
3 #                         Gianluca Montecchi <gian@grys.it>
4 #                         James Rowe <jnrowe@ukfsn.org>
5 #                         W. Trevor King <wking@drexel.edu>
6 #
7 # This file is part of Bugs Everywhere.
8 #
9 # Bugs Everywhere is free software; you can redistribute it and/or modify it
10 # under the terms of the GNU General Public License as published by the
11 # Free Software Foundation, either version 2 of the License, or (at your
12 # option) any later version.
13 #
14 # Bugs Everywhere is distributed in the hope that it will be useful, but
15 # WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
17 # General Public License for more details.
18 #
19 # You should have received a copy of the GNU General Public License
20 # along with Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
21
22 """GNU Arch_ (tla) backend.
23
24 .. _Arch: http://www.gnu.org/software/gnu-arch/
25 """
26
27 import codecs
28 import os
29 import os.path
30 import re
31 import shutil
32 import sys
33 import time # work around http://mercurial.selenic.com/bts/issue618
34
35 import libbe
36 import libbe.ui.util.user
37 import libbe.storage.util.config
38 from libbe.util.id import uuid_gen
39 from libbe.util.subproc import CommandError
40 import base
41
42 if libbe.TESTING == True:
43     import unittest
44     import doctest
45
46
47 class CantAddFile(Exception):
48     def __init__(self, file):
49         self.file = file
50         Exception.__init__(self, "Can't automatically add file %s" % file)
51
52 DEFAULT_CLIENT = 'tla'
53
54 client = libbe.storage.util.config.get_val(
55     'arch_client', default=DEFAULT_CLIENT)
56
57 def new():
58     return Arch()
59
60 class Arch(base.VCS):
61     """:class:`base.VCS` implementation for GNU Arch.
62     """
63     name = 'arch'
64     client = client
65     _archive_name = None
66     _archive_dir = None
67     _tmp_archive = False
68     _project_name = None
69     _tmp_project = False
70     _arch_paramdir = os.path.expanduser('~/.arch-params')
71
72     def __init__(self, *args, **kwargs):
73         base.VCS.__init__(self, *args, **kwargs)
74         self.versioned = True
75         self.interspersed_vcs_files = True
76         self.paranoid = False
77         self.__updated = [] # work around http://mercurial.selenic.com/bts/issue618
78
79     def _vcs_version(self):
80         status,output,error = self._u_invoke_client('--version')
81         version = '\n'.join(output.splitlines()[:2])
82         return version
83
84     def _vcs_detect(self, path):
85         """Detect whether a directory is revision-controlled using Arch"""
86         if self._u_search_parent_directories(path, '{arch}') != None :
87             libbe.storage.util.config.set_val('arch_client', client)
88             return True
89         return False
90
91     def _vcs_init(self, path):
92         self._create_archive(path)
93         self._create_project(path)
94         self._add_project_code(path)
95
96     def _create_archive(self, path):
97         """Create a temporary Arch archive in the directory PATH.  This
98         archive will be removed by::
99
100             destroy->_vcs_destroy->_remove_archive
101         """
102         # http://regexps.srparish.net/tutorial-tla/new-archive.html#Creating_a_New_Archive
103         assert self._archive_name == None
104         id = self.get_user_id()
105         name, email = libbe.ui.util.user.parse_user_id(id)
106         if email == None:
107             email = '%s@example.com' % name
108         trailer = '%s-%s' % ('bugs-everywhere-auto', uuid_gen()[0:8])
109         self._archive_name = '%s--%s' % (email, trailer)
110         self._archive_dir = '/tmp/%s' % trailer
111         self._tmp_archive = True
112         self._u_invoke_client('make-archive', self._archive_name,
113                               self._archive_dir, cwd=path)
114
115     def _invoke_client(self, *args, **kwargs):
116         """Invoke the client on our archive.
117         """
118         assert self._archive_name != None
119         command = args[0]
120         if len(args) > 1:
121             tailargs = args[1:]
122         else:
123             tailargs = []
124         arglist = [command, '-A', self._archive_name]
125         arglist.extend(tailargs)
126         args = tuple(arglist)
127         return self._u_invoke_client(*args, **kwargs)
128
129     def _remove_archive(self):
130         assert self._tmp_archive == True
131         assert self._archive_dir != None
132         assert self._archive_name != None
133         os.remove(os.path.join(self._arch_paramdir,
134                                '=locations', self._archive_name))
135         shutil.rmtree(self._archive_dir)
136         self._tmp_archive = False
137         self._archive_dir = False
138         self._archive_name = False
139
140     def _create_project(self, path):
141         """
142         Create a temporary Arch project in the directory PATH.  This
143         project will be removed by
144           destroy->_vcs_destroy->_remove_project
145         """
146         # http://mwolson.org/projects/GettingStartedWithArch.html
147         # http://regexps.srparish.net/tutorial-tla/new-project.html#Starting_a_New_Project
148         category = 'bugs-everywhere'
149         branch = 'mainline'
150         version = '0.1'
151         self._project_name = '%s--%s--%s' % (category, branch, version)
152         self._invoke_client('archive-setup', self._project_name,
153                             cwd=path)
154         self._tmp_project = True
155
156     def _remove_project(self):
157         assert self._tmp_project == True
158         assert self._project_name != None
159         assert self._archive_dir != None
160         shutil.rmtree(os.path.join(self._archive_dir, self._project_name))
161         self._tmp_project = False
162         self._project_name = False
163
164     def _archive_project_name(self):
165         assert self._archive_name != None
166         assert self._project_name != None
167         return '%s/%s' % (self._archive_name, self._project_name)
168
169     def _adjust_naming_conventions(self, path):
170         """Adjust `Arch naming conventions`_ so ``.be`` is considered source
171         code.
172
173         By default, Arch restricts source code filenames to::
174
175             ^[_=a-zA-Z0-9].*$
176
177         Since our bug directory ``.be`` doesn't satisfy these conventions,
178         we need to adjust them.  The conventions are specified in::
179
180             project-root/{arch}/=tagging-method
181
182         .. _Arch naming conventions:
183           http://regexps.srparish.net/tutorial-tla/naming-conventions.html
184         """
185         tagpath = os.path.join(path, '{arch}', '=tagging-method')
186         lines_out = []
187         f = codecs.open(tagpath, 'r', self.encoding)
188         for line in f:
189             if line.startswith('source '):
190                 lines_out.append('source ^[._=a-zA-X0-9].*$\n')
191             else:
192                 lines_out.append(line)
193         f.close()
194         f = codecs.open(tagpath, 'w', self.encoding)
195         f.write(''.join(lines_out))
196         f.close()
197
198     def _add_project_code(self, path):
199         # http://mwolson.org/projects/GettingStartedWithArch.html
200         # http://regexps.srparish.net/tutorial-tla/new-source.html
201         # http://regexps.srparish.net/tutorial-tla/importing-first.html
202         self._invoke_client('init-tree', self._project_name,
203                             cwd=path)
204         self._adjust_naming_conventions(path)
205         self._invoke_client('import', '--summary', 'Began versioning',
206                             cwd=path)
207
208     def _vcs_destroy(self):
209         if self._tmp_project == True:
210             self._remove_project()
211         if self._tmp_archive == True:
212             self._remove_archive()
213         vcs_dir = os.path.join(self.repo, '{arch}')
214         if os.path.exists(vcs_dir):
215             shutil.rmtree(vcs_dir)
216         self._archive_name = None
217
218     def _vcs_root(self, path):
219         if not os.path.isdir(path):
220             dirname = os.path.dirname(path)
221         else:
222             dirname = path
223         status,output,error = self._u_invoke_client('tree-root', dirname)
224         root = output.rstrip('\n')
225
226         self._get_archive_project_name(root)
227
228         return root
229
230     def _get_archive_name(self, root):
231         status,output,error = self._u_invoke_client('archives')
232         lines = output.split('\n')
233         # e.g. output:
234         # jdoe@example.com--bugs-everywhere-auto-2008.22.24.52
235         #     /tmp/BEtestXXXXXX/rootdir
236         # (+ repeats)
237         for archive,location in zip(lines[::2], lines[1::2]):
238             if os.path.realpath(location) == os.path.realpath(root):
239                 self._archive_name = archive
240         assert self._archive_name != None
241
242     def _get_archive_project_name(self, root):
243         # get project names
244         status,output,error = self._u_invoke_client('tree-version', cwd=root)
245         # e.g output
246         # jdoe@example.com--bugs-everywhere-auto-2008.22.24.52/be--mainline--0.1
247         archive_name,project_name = output.rstrip('\n').split('/')
248         self._archive_name = archive_name
249         self._project_name = project_name
250
251     def _vcs_get_user_id(self):
252         try:
253             status,output,error = self._u_invoke_client('my-id')
254             return output.rstrip('\n')
255         except Exception, e:
256             if 'no arch user id set' in e.args[0]:
257                 return None
258             else:
259                 raise
260
261     def _vcs_add(self, path):
262         self._u_invoke_client('add-id', path)
263         realpath = os.path.realpath(self._u_abspath(path))
264         pathAdded = realpath in self._list_added(self.repo)
265         if self.paranoid and not pathAdded:
266             self._force_source(path)
267
268     def _list_added(self, root):
269         assert os.path.exists(root)
270         assert os.access(root, os.X_OK)
271         root = os.path.realpath(root)
272         status,output,error = self._u_invoke_client('inventory', '--source',
273                                                     '--both', '--all', root)
274         inv_str = output.rstrip('\n')
275         return [os.path.join(root, p) for p in inv_str.split('\n')]
276
277     def _add_dir_rule(self, rule, dirname, root):
278         inv_path = os.path.join(dirname, '.arch-inventory')
279         f = codecs.open(inv_path, 'a', self.encoding)
280         f.write(rule)
281         f.close()
282         if os.path.realpath(inv_path) not in self._list_added(root):
283             paranoid = self.paranoid
284             self.paranoid = False
285             self.add(inv_path)
286             self.paranoid = paranoid
287
288     def _force_source(self, path):
289         rule = 'source %s\n' % self._u_rel_path(path)
290         self._add_dir_rule(rule, os.path.dirname(path), self.repo)
291         if os.path.realpath(path) not in self._list_added(self.repo):
292             raise CantAddFile(path)
293
294     def _vcs_remove(self, path):
295         if self._vcs_is_versioned(path):
296             self._u_invoke_client('delete-id', path)
297         arch_ids = os.path.join(self.repo, path, '.arch-ids')
298         if os.path.exists(arch_ids):
299             shutil.rmtree(arch_ids)
300
301     def _vcs_update(self, path):
302         self.__updated.append(path) # work around http://mercurial.selenic.com/bts/issue618
303
304     def _vcs_is_versioned(self, path):
305         if '.arch-ids' in path:
306             return False
307         return True
308
309     def _vcs_get_file_contents(self, path, revision=None):
310         if revision == None:
311             return base.VCS._vcs_get_file_contents(self, path, revision)
312         else:
313             relpath = self._file_find(path, revision, relpath=True)
314             return base.VCS._vcs_get_file_contents(self, relpath)
315
316     def _file_find(self, path, revision, relpath=False):
317         try:
318             status,output,error = \
319                 self._invoke_client(
320                 'file-find', '--unescaped', path, revision)
321             path = output.rstrip('\n').splitlines()[-1]
322         except CommandError, e:
323             if e.status == 2 \
324                     and 'illegally formed changeset index' in e.stderr:
325                 raise NotImplementedError(
326 """Outstanding tla bug, see
327   https://bugs.launchpad.net/ubuntu/+source/tla/+bug/513472
328 """)
329             raise
330         if relpath == True:
331             return path
332         return os.path.abspath(os.path.join(self.repo, path))
333
334     def _vcs_path(self, id, revision):
335         return self._u_find_id(id, revision)
336
337     def _vcs_isdir(self, path, revision):
338         abspath = self._file_find(path, revision)
339         return os.path.isdir(abspath)
340
341     def _vcs_listdir(self, path, revision):
342         abspath = self._file_find(path, revision)
343         return [p for p in os.listdir(abspath) if self._vcs_is_versioned(p)]
344
345     def _vcs_commit(self, commitfile, allow_empty=False):
346         if allow_empty == False:
347             # arch applies empty commits without complaining, so check first
348             status,output,error = self._u_invoke_client('changes',expect=(0,1))
349             if status == 0:
350                 # work around http://mercurial.selenic.com/bts/issue618
351                 time.sleep(1)
352                 for path in self.__updated:
353                     os.utime(os.path.join(self.repo, path), None)
354                 self.__updated = []
355                 status,output,error = self._u_invoke_client('changes',expect=(0,1))
356                 if status == 0:
357                 # end work around
358                     raise base.EmptyCommit()
359         summary,body = self._u_parse_commitfile(commitfile)
360         args = ['commit', '--summary', summary]
361         if body != None:
362             args.extend(['--log-message',body])
363         status,output,error = self._u_invoke_client(*args)
364         revision = None
365         revline = re.compile('[*] committed (.*)')
366         match = revline.search(output)
367         assert match != None, output+error
368         assert len(match.groups()) == 1
369         revpath = match.groups()[0]
370         assert not " " in revpath, revpath
371         assert revpath.startswith(self._archive_project_name()+'--')
372         revision = revpath[len(self._archive_project_name()+'--'):]
373         return revpath
374
375     def _vcs_revision_id(self, index):
376         status,output,error = self._u_invoke_client('logs')
377         logs = output.splitlines()
378         first_log = logs.pop(0)
379         assert first_log == 'base-0', first_log
380         try:
381             if index > 0:
382                 log = logs[index-1]
383             elif index < 0:
384                 log = logs[index]
385             else:
386                 return None
387         except IndexError:
388             return None
389         return '%s--%s' % (self._archive_project_name(), log)
390
391     def _diff(self, revision):
392         status,output,error = self._u_invoke_client(
393             'diff', '--summary', '--unescaped', revision, expect=(0,1))
394         return output
395     
396     def _parse_diff(self, diff_text):
397         """
398         Example diff text:
399         
400         * local directory is at ...
401         * build pristine tree for ...
402         * from import revision: ...
403         * patching for revision: ...
404         * comparing to ...
405         D  .be/dir/bugs/.arch-ids/moved.id
406         D  .be/dir/bugs/.arch-ids/removed.id
407         D  .be/dir/bugs/moved
408         D  .be/dir/bugs/removed
409         A  .be/dir/bugs/.arch-ids/moved2.id
410         A  .be/dir/bugs/.arch-ids/new.id
411         A  .be/dir/bugs/moved2
412         A  .be/dir/bugs/new
413         A  {arch}/bugs-everywhere/bugs-everywhere--mainline/...
414         M  .be/dir/bugs/modified
415         """
416         new = []
417         modified = []
418         removed = []
419         lines = diff_text.splitlines()
420         for i,line in enumerate(lines):
421             if line.startswith('* ') or '/.arch-ids/' in line:
422                 continue
423             change,file = line.split('  ',1)
424             if  file.startswith('{arch}/'):
425                 continue
426             if change == 'A':
427                 new.append(file)
428             elif change == 'M':
429                 modified.append(file)
430             elif change == 'D':
431                 removed.append(file)
432         return (new,modified,removed)
433
434     def _vcs_changed(self, revision):
435         return self._parse_diff(self._diff(revision))
436
437 \f
438 if libbe.TESTING == True:
439     base.make_vcs_testcase_subclasses(Arch, sys.modules[__name__])
440
441     unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
442     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])