Merge upstream.
[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         if author is None:
215             author = None
216         return (author, _escape_commit_message(base64.b64decode(message)), date)
217
218     def find_latest_change(self, path, revnum, recurse=False):
219         """Find latest revision that touched path.
220
221         :param path: Path to check for changes
222         :param revnum: First revision to check
223         """
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         assert row is not None, "no latest change for %r:%d" % (path, revnum)
238
239         return row[0]
240
241     def touches_path(self, path, revnum):
242         """Check whether path was changed in specified revision.
243
244         :param path:  Path to check
245         :param revnum:  Revision to check
246         """
247         if revnum > self.saved_revnum:
248             self.fetch_revisions(revnum)
249         if revnum == 0:
250             return (path == "")
251         return (self.db.execute("select 1 from changed_path where path='%s' and rev=%d" % (path, revnum)).fetchone() is not None)
252
253     def find_children(self, path, revnum):
254         """Find all children of path in revnum."""
255         # TODO: Find children by walking history, or use 
256         # cache?
257
258         try:
259             (dirents, _, _) = self.transport.get_dir(
260                 path.lstrip("/").encode('utf8'), revnum, kind=True)
261         except SubversionException, (_, num):
262             if num == svn.core.SVN_ERR_FS_NOT_DIRECTORY:
263                 return
264             raise
265
266         for p in dirents:
267             yield os.path.join(path, p)
268             # This needs to be != svn.core.svn_node_file because 
269             # some ra backends seem to return negative values for .kind.
270             # however, dirents[p].node seems to contain semi-random 
271             # values.
272             for c in self.find_children(os.path.join(path, p), revnum):
273                 yield c
274
275     def get_previous(self, path, revnum):
276         """Return path,revnum pair specified pair was derived from.
277
278         :param path:  Path to check
279         :param revnum:  Revision to check
280         """
281         assert revnum >= 0
282         if revnum > self.saved_revnum:
283             self.fetch_revisions(revnum)
284         if revnum == 0:
285             return (None, -1)
286         row = self.db.execute("select action, copyfrom_path, copyfrom_rev from changed_path where path='%s' and rev=%d" % (path, revnum)).fetchone()
287         if row[2] == -1:
288             if row[0] == 'A':
289                 return (None, -1)
290             return (path, revnum-1)
291         return (row[1], row[2])