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