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