Use write groups (compatibility with packs branch).
[jelmer/subvertpy.git] / logwalker.py
1 # Copyright (C) 2006 Jelmer Vernooij <jelmer@samba.org>
2
3 # This program is free software; you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation; either version 2 of the License, or
6 # (at your option) any later version.
7
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 # GNU General Public License for more details.
12
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software
15 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
16 """Cache of the Subversion history log."""
17
18 from bzrlib import urlutils
19 from bzrlib.errors import NoSuchRevision
20 import bzrlib.ui as ui
21 from copy import copy
22
23 from svn.core import SubversionException, Pool
24 from transport import SvnRaTransport
25 import svn.core
26
27 import base64
28
29 from cache import sqlite3
30
31 def _escape_commit_message(message):
32     """Replace xml-incompatible control characters."""
33     if message is None:
34         return None
35     import re
36     # FIXME: RBC 20060419 this should be done by the revision
37     # serialiser not by commit. Then we can also add an unescaper
38     # in the deserializer and start roundtripping revision messages
39     # precisely. See repository_implementations/test_repository.py
40     
41     # Python strings can include characters that can't be
42     # represented in well-formed XML; escape characters that
43     # aren't listed in the XML specification
44     # (http://www.w3.org/TR/REC-xml/#NT-Char).
45     message, _ = re.subn(
46         u'[^\x09\x0A\x0D\u0020-\uD7FF\uE000-\uFFFD]+',
47         lambda match: match.group(0).encode('unicode_escape'),
48         message)
49     return message
50
51
52 class LogWalker(object):
53     """Easy way to access the history of a Subversion repository."""
54     def __init__(self, transport, cache_db=None):
55         """Create a new instance.
56
57         :param transport:   SvnRaTransport to use to access the repository.
58         :param cache_db:    Optional sql database connection to use. Doesn't 
59                             cache if not set.
60         """
61         assert isinstance(transport, SvnRaTransport)
62
63         self.url = transport.base
64         self._transport = None
65
66         if cache_db is None:
67             self.db = sqlite3.connect(":memory:")
68         else:
69             self.db = cache_db
70
71         self.db.executescript("""
72           create table if not exists revision(revno integer unique, author text, message text, date text);
73           create unique index if not exists revision_revno on revision (revno);
74           create table if not exists changed_path(rev integer, action text, path text, copyfrom_path text, copyfrom_rev integer);
75           create index if not exists path_rev on changed_path(rev);
76           create unique index if not exists path_rev_path on changed_path(rev, path);
77           create unique index if not exists path_rev_path_action on changed_path(rev, path, action);
78         """)
79         self.db.commit()
80         self.saved_revnum = self.db.execute("SELECT MAX(revno) FROM revision").fetchone()[0]
81         if self.saved_revnum is None:
82             self.saved_revnum = 0
83
84     def _get_transport(self):
85         if self._transport is not None:
86             return self._transport
87         self._transport = SvnRaTransport(self.url)
88         return self._transport
89
90     def fetch_revisions(self, to_revnum=None):
91         """Fetch information about all revisions in the remote repository
92         until to_revnum.
93
94         :param to_revnum: End of range to fetch information for
95         """
96         to_revnum = max(self._get_transport().get_latest_revnum(), to_revnum)
97
98         pb = ui.ui_factory.nested_progress_bar()
99
100         def rcvr(orig_paths, rev, author, date, message, pool):
101             pb.update('fetching svn revision info', rev, to_revnum)
102             if orig_paths is None:
103                 orig_paths = {}
104             for p in orig_paths:
105                 copyfrom_path = orig_paths[p].copyfrom_path
106                 if copyfrom_path is not None:
107                     copyfrom_path = copyfrom_path.strip("/")
108
109                 self.db.execute(
110                      "replace into changed_path (rev, path, action, copyfrom_path, copyfrom_rev) values (?, ?, ?, ?, ?)", 
111                      (rev, p.strip("/"), orig_paths[p].action, copyfrom_path, orig_paths[p].copyfrom_rev))
112
113             if message is not None:
114                 message = base64.b64encode(message)
115
116             self.db.execute("replace into revision (revno, author, date, message) values (?,?,?,?)", (rev, author, date, message))
117
118             self.saved_revnum = rev
119             if self.saved_revnum % 1000 == 0:
120                 self.db.commit()
121
122         pool = Pool()
123         try:
124             try:
125                 self._get_transport().get_log("/", self.saved_revnum, 
126                                              to_revnum, 0, True, True, rcvr, 
127                                              pool)
128             finally:
129                 pb.finished()
130         except SubversionException, (_, num):
131             if num == svn.core.SVN_ERR_FS_NO_SUCH_REVISION:
132                 raise NoSuchRevision(branch=self, 
133                     revision="Revision number %d" % to_revnum)
134             raise
135         self.db.commit()
136         pool.destroy()
137
138     def follow_path(self, path, revnum):
139         """Return iterator over all the revisions between revnum and 
140         0 named path or inside path.
141
142         :param path:   Branch path to start reporting (in revnum)
143         :param revnum:        Start revision.
144
145         :return: An iterators that yields tuples with (path, paths, revnum)
146         where paths is a dictionary with all changes that happened in path 
147         in revnum.
148         """
149         assert revnum >= 0
150
151         if revnum == 0 and path == "":
152             return
153
154         recurse = (path != "")
155
156         path = path.strip("/")
157
158         while revnum >= 0:
159             assert revnum > 0 or path == ""
160             revpaths = self.get_revision_paths(revnum, path, recurse=recurse)
161
162             if revpaths != {}:
163                 yield (path, copy(revpaths), revnum)
164
165             if path == "":
166                 revnum -= 1
167                 continue
168
169             if revpaths.has_key(path):
170                 if revpaths[path][1] is None:
171                     if revpaths[path][0] in ('A', 'R'):
172                         # this path didn't exist before this revision
173                         return
174                 else:
175                     # In this revision, this path was copied from 
176                     # somewhere else
177                     revnum = revpaths[path][2]
178                     path = revpaths[path][1]
179                     assert path == "" or revnum > 0
180                     continue
181             revnum -= 1
182             for p in sorted(revpaths.keys()):
183                 if path.startswith(p+"/") and revpaths[p][0] in ('A', 'R'):
184                     assert revpaths[p][1]
185                     path = path.replace(p, revpaths[p][1])
186                     revnum = revpaths[p][2]
187                     break
188
189     def get_revision_paths(self, revnum, path=None, recurse=False):
190         """Obtain dictionary with all the changes in a particular revision.
191
192         :param revnum: Subversion revision number
193         :param path: optional path under which to return all entries
194         :param recurse: Report changes to parents as well
195         :returns: dictionary with paths as keys and 
196                   (action, copyfrom_path, copyfrom_rev) as values.
197         """
198
199         if revnum == 0:
200             assert path is None or path == ""
201             return {'': ('A', None, -1)}
202                 
203         if revnum > self.saved_revnum:
204             self.fetch_revisions(revnum)
205
206         query = "select path, action, copyfrom_path, copyfrom_rev from changed_path where rev="+str(revnum)
207         if path is not None and path != "":
208             query += " and (path='%s' or path like '%s/%%'" % (path, path)
209             if recurse:
210                 query += " or ('%s' LIKE path || '/%%')" % path
211             query += ")"
212
213         paths = {}
214         for p, act, cf, cr in self.db.execute(query):
215             paths[p.encode("utf-8")] = (act, cf, cr)
216         return paths
217
218     def get_revision_info(self, revnum):
219         """Obtain basic information for a specific revision.
220
221         :param revnum: Revision number.
222         :returns: Tuple with author, log message and date of the revision.
223         """
224         assert revnum >= 0
225         if revnum == 0:
226             return (None, None, None)
227         if revnum > self.saved_revnum:
228             self.fetch_revisions(revnum)
229         (author, message, date) = self.db.execute("select author, message, date from revision where revno="+ str(revnum)).fetchone()
230         if message is not None:
231             message = _escape_commit_message(base64.b64decode(message))
232         return (author, message, date)
233
234     def find_latest_change(self, path, revnum, recurse=False):
235         """Find latest revision that touched path.
236
237         :param path: Path to check for changes
238         :param revnum: First revision to check
239         """
240         assert isinstance(path, basestring)
241         assert isinstance(revnum, int) and revnum >= 0
242         if revnum > self.saved_revnum:
243             self.fetch_revisions(revnum)
244
245         if recurse:
246             extra = " or path like '%s/%%'" % path.strip("/")
247         else:
248             extra = ""
249         query = "select rev from changed_path where (path='%s' or ('%s' like (path || '/%%') and (action = 'R' or action = 'A'))%s) and rev <= %d order by rev desc limit 1" % (path.strip("/"), path.strip("/"), extra, revnum)
250
251         row = self.db.execute(query).fetchone()
252         if row is None and path == "":
253             return 0
254
255         if row is None:
256             return None
257
258         return row[0]
259
260     def touches_path(self, path, revnum):
261         """Check whether path was changed in specified revision.
262
263         :param path:  Path to check
264         :param revnum:  Revision to check
265         """
266         if revnum > self.saved_revnum:
267             self.fetch_revisions(revnum)
268         if revnum == 0:
269             return (path == "")
270         return (self.db.execute("select 1 from changed_path where path='%s' and rev=%d" % (path, revnum)).fetchone() is not None)
271
272     def find_children(self, path, revnum):
273         """Find all children of path in revnum."""
274         path = path.strip("/")
275         transport = self._get_transport()
276         ft = transport.check_path(path, revnum)
277         if ft == svn.core.svn_node_file:
278             return []
279         assert ft == svn.core.svn_node_dir
280
281         class TreeLister(svn.delta.Editor):
282             def __init__(self, base):
283                 self.files = []
284                 self.base = base
285
286             def set_target_revision(self, revnum):
287                 """See Editor.set_target_revision()."""
288                 pass
289
290             def open_root(self, revnum, baton):
291                 """See Editor.open_root()."""
292                 return path
293
294             def add_directory(self, path, parent_baton, copyfrom_path, copyfrom_revnum, pool):
295                 """See Editor.add_directory()."""
296                 self.files.append(urlutils.join(self.base, path))
297                 return path
298
299             def change_dir_prop(self, id, name, value, pool):
300                 pass
301
302             def change_file_prop(self, id, name, value, pool):
303                 pass
304
305             def add_file(self, path, parent_id, copyfrom_path, copyfrom_revnum, baton):
306                 self.files.append(urlutils.join(self.base, path))
307                 return path
308
309             def close_dir(self, id):
310                 pass
311
312             def close_file(self, path, checksum):
313                 pass
314
315             def close_edit(self):
316                 pass
317
318             def abort_edit(self):
319                 pass
320
321             def apply_textdelta(self, file_id, base_checksum):
322                 pass
323         pool = Pool()
324         editor = TreeLister(path)
325         edit, baton = svn.delta.make_editor(editor, pool)
326         old_base = transport.base
327         try:
328             root_repos = transport.get_repos_root()
329             transport.reparent(urlutils.join(root_repos, path))
330             reporter = transport.do_update(revnum,  True, edit, baton, pool)
331             reporter.set_path("", revnum, True, None, pool)
332             reporter.finish_report(pool)
333         finally:
334             transport.reparent(old_base)
335         return editor.files
336
337     def get_previous(self, path, revnum):
338         """Return path,revnum pair specified pair was derived from.
339
340         :param path:  Path to check
341         :param revnum:  Revision to check
342         """
343         assert revnum >= 0
344         if revnum > self.saved_revnum:
345             self.fetch_revisions(revnum)
346         if revnum == 0:
347             return (None, -1)
348         row = self.db.execute("select action, copyfrom_path, copyfrom_rev from changed_path where path='%s' and rev=%d" % (path, revnum)).fetchone()
349         if row[2] == -1:
350             if row[0] == 'A':
351                 return (None, -1)
352             return (path, revnum-1)
353         return (row[1], row[2])