Fix bug in revid caching.
[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.errors import NoSuchRevision
19 import bzrlib.ui as ui
20
21 import os
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=None, cache_db=None, last_revnum=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         :param last_revnum: Last known revnum in the repository. Will be 
61                             determined if not specified.
62         """
63         assert isinstance(transport, SvnRaTransport)
64
65         if last_revnum is None:
66             last_revnum = transport.get_latest_revnum()
67
68         self.last_revnum = last_revnum
69
70         self.transport = SvnRaTransport(transport.base)
71
72         if cache_db is None:
73             self.db = sqlite3.connect(":memory:")
74         else:
75             self.db = cache_db
76
77         self.db.executescript("""
78           create table if not exists revision(revno integer unique, author text, message text, date text);
79           create unique index if not exists revision_revno on revision (revno);
80           create table if not exists changed_path(rev integer, action text, path text, copyfrom_path text, copyfrom_rev integer);
81           create index if not exists path_rev on changed_path(rev);
82           create index if not exists path_rev_path on changed_path(rev, path);
83         """)
84         self.db.commit()
85         self.saved_revnum = self.db.execute("SELECT MAX(revno) FROM revision").fetchone()[0]
86         if self.saved_revnum is None:
87             self.saved_revnum = 0
88
89     def fetch_revisions(self, to_revnum):
90         """Fetch information about all revisions in the remote repository
91         until to_revnum.
92
93         :param to_revnum: End of range to fetch information for
94         """
95         to_revnum = max(self.last_revnum, to_revnum)
96
97         pb = ui.ui_factory.nested_progress_bar()
98
99         def rcvr(orig_paths, rev, author, date, message, pool):
100             pb.update('fetching svn revision info', rev, to_revnum)
101             if orig_paths is None:
102                 orig_paths = {}
103             for p in orig_paths:
104                 copyfrom_path = orig_paths[p].copyfrom_path
105                 if copyfrom_path:
106                     copyfrom_path = copyfrom_path.strip("/")
107
108                 self.db.execute(
109                      "insert into changed_path (rev, path, action, copyfrom_path, copyfrom_rev) values (?, ?, ?, ?, ?)", 
110                      (rev, p.strip("/"), orig_paths[p].action, copyfrom_path, orig_paths[p].copyfrom_rev))
111
112             if message is not None:
113                 message = base64.b64encode(message)
114
115             self.db.execute("replace into revision (revno, author, date, message) values (?,?,?,?)", (rev, author, date, message))
116
117             self.saved_revnum = rev
118             if self.saved_revnum % 1000 == 0:
119                 self.db.commit()
120
121         pool = Pool()
122         try:
123             try:
124                 self.transport.get_log("/", self.saved_revnum, to_revnum, 
125                                0, True, True, rcvr, pool)
126             finally:
127                 pb.finished()
128         except SubversionException, (_, num):
129             if num == svn.core.SVN_ERR_FS_NO_SUCH_REVISION:
130                 raise NoSuchRevision(branch=self, 
131                     revision="Revision number %d" % to_revnum)
132             raise
133         self.db.commit()
134         pool.destroy()
135
136     def follow_path(self, path, revnum):
137         """Return iterator over all the revisions between revnum and 
138         0 named path or inside path.
139
140         :param path:   Branch path to start reporting (in revnum)
141         :param revnum:        Start revision.
142
143         :return: An iterators that yields tuples with (path, paths, revnum)
144         where paths is a dictionary with all changes that happened in path 
145         in revnum.
146         """
147         assert revnum >= 0
148
149         if revnum == 0 and path == "":
150             return
151
152         path = path.strip("/")
153
154         while revnum >= 0:
155             revpaths = self.get_revision_paths(revnum, path)
156
157             if revpaths != {}:
158                 yield (path, revpaths, revnum)
159
160             if revpaths.has_key(path):
161                 if revpaths[path][1] is None:
162                     if revpaths[path][0] in ('A', 'R'):
163                         # this path didn't exist before this revision
164                         return
165                 else:
166                     # In this revision, this path was copied from 
167                     # somewhere else
168                     revnum = revpaths[path][2]
169                     path = revpaths[path][1]
170                     continue
171             revnum -= 1
172
173     def get_revision_paths(self, revnum, path=None):
174         """Obtain dictionary with all the changes in a particular revision.
175
176         :param revnum: Subversion revision number
177         :param path: optional path under which to return all entries
178         :returns: dictionary with paths as keys and 
179                   (action, copyfrom_path, copyfrom_rev) as values.
180         """
181
182         if revnum == 0:
183             return {'': ('A', None, -1)}
184                 
185         if revnum > self.saved_revnum:
186             self.fetch_revisions(revnum)
187
188         query = "select path, action, copyfrom_path, copyfrom_rev from changed_path where rev="+str(revnum)
189         if path is not None and path != "":
190             query += " and (path='%s' or path like '%s/%%')" % (path, path)
191
192         paths = {}
193         for p, act, cf, cr in self.db.execute(query):
194             paths[p] = (act, cf, cr)
195         return paths
196
197     def get_revision_info(self, revnum):
198         """Obtain basic information for a specific revision.
199
200         :param revnum: Revision number.
201         :returns: Tuple with author, log message and date of the revision.
202         """
203         assert revnum >= 0
204         if revnum == 0:
205             return (None, None, None)
206         if revnum > self.saved_revnum:
207             self.fetch_revisions(revnum)
208         (author, message, date) = self.db.execute("select author, message, date from revision where revno="+ str(revnum)).fetchone()
209         if message is not None:
210             message = _escape_commit_message(base64.b64decode(message))
211         return (author, message, date)
212
213     def find_latest_change(self, path, revnum, recurse=False):
214         """Find latest revision that touched path.
215
216         :param path: Path to check for changes
217         :param revnum: First revision to check
218         """
219         assert isinstance(path, basestring)
220         assert isinstance(revnum, int) and revnum >= 0
221         if revnum > self.saved_revnum:
222             self.fetch_revisions(revnum)
223
224         if recurse:
225             extra = " or path like '%s/%%'" % path.strip("/")
226         else:
227             extra = ""
228         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)
229
230         row = self.db.execute(query).fetchone()
231         if row is None and path == "":
232             return 0
233
234         if row is None:
235             return None
236
237         return row[0]
238
239     def touches_path(self, path, revnum):
240         """Check whether path was changed in specified revision.
241
242         :param path:  Path to check
243         :param revnum:  Revision to check
244         """
245         if revnum > self.saved_revnum:
246             self.fetch_revisions(revnum)
247         if revnum == 0:
248             return (path == "")
249         return (self.db.execute("select 1 from changed_path where path='%s' and rev=%d" % (path, revnum)).fetchone() is not None)
250
251     def find_children(self, path, revnum):
252         """Find all children of path in revnum."""
253         path = path.strip("/")
254         if self.transport.check_path(path, revnum) == svn.core.svn_node_file:
255             return []
256         class TreeLister(svn.delta.Editor):
257             def __init__(self, base):
258                 self.files = []
259                 self.base = base
260
261             def set_target_revision(self, revnum):
262                 pass
263
264             def open_root(self, revnum, baton):
265                 return path
266
267             def add_directory(self, path, parent_baton, copyfrom_path, copyfrom_revnum, pool):
268                 self.files.append(os.path.join(self.base, path))
269                 return path
270
271             def change_dir_prop(self, id, name, value, pool):
272                 pass
273
274             def change_file_prop(self, id, name, value, pool):
275                 pass
276
277             def add_file(self, path, parent_id, copyfrom_path, copyfrom_revnum, baton):
278                 self.files.append(os.path.join(self.base, path))
279                 return path
280
281             def close_dir(self, id):
282                 pass
283
284             def close_file(self, path, checksum):
285                 pass
286
287             def close_edit(self):
288                 pass
289
290             def abort_edit(self):
291                 pass
292
293             def apply_textdelta(self, file_id, base_checksum):
294                 pass
295         pool = Pool()
296         editor = TreeLister(path)
297         edit, baton = svn.delta.make_editor(editor, pool)
298         root_repos = self.transport.get_repos_root()
299         self.transport.reparent(os.path.join(root_repos, path))
300         reporter = self.transport.do_update(
301                         revnum, "", True, edit, baton, pool)
302         reporter.set_path("", revnum, True, None, pool)
303         reporter.finish_report(pool)
304         return editor.files
305
306     def get_previous(self, path, revnum):
307         """Return path,revnum pair specified pair was derived from.
308
309         :param path:  Path to check
310         :param revnum:  Revision to check
311         """
312         assert revnum >= 0
313         if revnum > self.saved_revnum:
314             self.fetch_revisions(revnum)
315         if revnum == 0:
316             return (None, -1)
317         row = self.db.execute("select action, copyfrom_path, copyfrom_rev from changed_path where path='%s' and rev=%d" % (path, revnum)).fetchone()
318         if row[2] == -1:
319             if row[0] == 'A':
320                 return (None, -1)
321             return (path, revnum-1)
322         return (row[1], row[2])