Handle unicode revids.
[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         assert isinstance(path, basestring)
223         assert isinstance(revnum, int) and revnum >= 0
224         if revnum > self.saved_revnum:
225             self.fetch_revisions(revnum)
226
227         if recurse:
228             extra = " or path like '%s/%%'" % path.strip("/")
229         else:
230             extra = ""
231         query = "select rev from changed_path where (path='%s'%s) and rev <= %d order by rev desc limit 1" % (path.strip("/"), extra, revnum)
232
233         row = self.db.execute(query).fetchone()
234         if row is None and path == "":
235             return 0
236
237         if row is None:
238             return None
239
240         return row[0]
241
242     def touches_path(self, path, revnum):
243         """Check whether path was changed in specified revision.
244
245         :param path:  Path to check
246         :param revnum:  Revision to check
247         """
248         if revnum > self.saved_revnum:
249             self.fetch_revisions(revnum)
250         if revnum == 0:
251             return (path == "")
252         return (self.db.execute("select 1 from changed_path where path='%s' and rev=%d" % (path, revnum)).fetchone() is not None)
253
254     def find_children(self, path, revnum):
255         """Find all children of path in revnum."""
256         path = path.strip("/")
257         if self.transport.check_path(path, revnum) == svn.core.svn_node_file:
258             return []
259         class TreeLister(svn.delta.Editor):
260             def __init__(self, base):
261                 self.files = []
262                 self.base = base
263
264             def set_target_revision(self, revnum):
265                 pass
266
267             def open_root(self, revnum, baton):
268                 return path
269
270             def add_directory(self, path, parent_baton, copyfrom_path, copyfrom_revnum, pool):
271                 self.files.append(os.path.join(self.base, path))
272                 return path
273
274             def change_dir_prop(self, id, name, value, pool):
275                 pass
276
277             def change_file_prop(self, id, name, value, pool):
278                 pass
279
280             def add_file(self, path, parent_id, copyfrom_path, copyfrom_revnum, baton):
281                 self.files.append(os.path.join(self.base, path))
282                 return path
283
284             def close_dir(self, id):
285                 pass
286
287             def close_file(self, path, checksum):
288                 pass
289
290             def close_edit(self):
291                 pass
292
293             def abort_edit(self):
294                 pass
295
296             def apply_textdelta(self, file_id, base_checksum):
297                 pass
298         pool = Pool()
299         editor = TreeLister(path)
300         edit, baton = svn.delta.make_editor(editor, pool)
301         root_repos = self.transport.get_repos_root()
302         self.transport.reparent(os.path.join(root_repos, path))
303         reporter = self.transport.do_update(
304                         revnum, "", True, edit, baton, pool)
305         reporter.set_path("", revnum, True, None, pool)
306         reporter.finish_report(pool)
307         return editor.files
308
309     def get_previous(self, path, revnum):
310         """Return path,revnum pair specified pair was derived from.
311
312         :param path:  Path to check
313         :param revnum:  Revision to check
314         """
315         assert revnum >= 0
316         if revnum > self.saved_revnum:
317             self.fetch_revisions(revnum)
318         if revnum == 0:
319             return (None, -1)
320         row = self.db.execute("select action, copyfrom_path, copyfrom_rev from changed_path where path='%s' and rev=%d" % (path, revnum)).fetchone()
321         if row[2] == -1:
322             if row[0] == 'A':
323                 return (None, -1)
324             return (path, revnum-1)
325         return (row[1], row[2])