##// END OF EJS Templates
convert: urlify svn repos if necessary....
Brendan Cully -
r5008:b6c3abdb default
parent child Browse files
Show More
@@ -1,667 +1,672 b''
1 # Subversion 1.4/1.5 Python API backend
1 # Subversion 1.4/1.5 Python API backend
2 #
2 #
3 # Copyright(C) 2007 Daniel Holth et al
3 # Copyright(C) 2007 Daniel Holth et al
4 #
4 #
5 # Configuration options:
5 # Configuration options:
6 #
6 #
7 # convert.svn.trunk
7 # convert.svn.trunk
8 # Relative path to the trunk (default: "trunk")
8 # Relative path to the trunk (default: "trunk")
9 # convert.svn.branches
9 # convert.svn.branches
10 # Relative path to tree of branches (default: "branches")
10 # Relative path to tree of branches (default: "branches")
11 #
11 #
12 # Set these in a hgrc, or on the command line as follows:
12 # Set these in a hgrc, or on the command line as follows:
13 #
13 #
14 # hg convert --config convert.svn.trunk=wackoname [...]
14 # hg convert --config convert.svn.trunk=wackoname [...]
15
15
16 import locale
16 import locale
17 import os
17 import os
18 import cPickle as pickle
18 import cPickle as pickle
19 from mercurial import util
19 from mercurial import util
20
20
21 # Subversion stuff. Works best with very recent Python SVN bindings
21 # Subversion stuff. Works best with very recent Python SVN bindings
22 # e.g. SVN 1.5 or backports. Thanks to the bzr folks for enhancing
22 # e.g. SVN 1.5 or backports. Thanks to the bzr folks for enhancing
23 # these bindings.
23 # these bindings.
24
24
25 from cStringIO import StringIO
25 from cStringIO import StringIO
26
26
27 from common import NoRepo, commit, converter_source
27 from common import NoRepo, commit, converter_source
28
28
29 try:
29 try:
30 from svn.core import SubversionException, Pool
30 from svn.core import SubversionException, Pool
31 import svn.core
31 import svn.core
32 import svn.ra
32 import svn.ra
33 import svn.delta
33 import svn.delta
34 import svn
34 import svn
35 import transport
35 import transport
36 except ImportError:
36 except ImportError:
37 pass
37 pass
38
38
39 def geturl(path):
40 if os.path.isdir(path):
41 return 'file://%s' % os.path.normpath(os.path.abspath(path))
42 return path
43
39 class CompatibilityException(Exception): pass
44 class CompatibilityException(Exception): pass
40
45
41 class changedpath(object):
46 class changedpath(object):
42 def __init__(self, p):
47 def __init__(self, p):
43 self.copyfrom_path = p.copyfrom_path
48 self.copyfrom_path = p.copyfrom_path
44 self.copyfrom_rev = p.copyfrom_rev
49 self.copyfrom_rev = p.copyfrom_rev
45 self.action = p.action
50 self.action = p.action
46
51
47 # SVN conversion code stolen from bzr-svn and tailor
52 # SVN conversion code stolen from bzr-svn and tailor
48 class convert_svn(converter_source):
53 class convert_svn(converter_source):
49 def __init__(self, ui, url, rev=None):
54 def __init__(self, ui, url, rev=None):
50 super(convert_svn, self).__init__(ui, url, rev=rev)
55 super(convert_svn, self).__init__(ui, url, rev=rev)
51
56
52 try:
57 try:
53 SubversionException
58 SubversionException
54 except NameError:
59 except NameError:
55 msg = 'subversion python bindings could not be loaded\n'
60 msg = 'subversion python bindings could not be loaded\n'
56 ui.warn(msg)
61 ui.warn(msg)
57 raise NoRepo(msg)
62 raise NoRepo(msg)
58
63
59 self.encoding = locale.getpreferredencoding()
64 self.encoding = locale.getpreferredencoding()
60 self.lastrevs = {}
65 self.lastrevs = {}
61
66
62 latest = None
67 latest = None
63 if rev:
68 if rev:
64 try:
69 try:
65 latest = int(rev)
70 latest = int(rev)
66 except ValueError:
71 except ValueError:
67 raise util.Abort('svn: revision %s is not an integer' % rev)
72 raise util.Abort('svn: revision %s is not an integer' % rev)
68 try:
73 try:
69 # Support file://path@rev syntax. Useful e.g. to convert
74 # Support file://path@rev syntax. Useful e.g. to convert
70 # deleted branches.
75 # deleted branches.
71 at = url.rfind('@')
76 at = url.rfind('@')
72 if at >= 0:
77 if at >= 0:
73 latest = int(url[at+1:])
78 latest = int(url[at+1:])
74 url = url[:at]
79 url = url[:at]
75 except ValueError, e:
80 except ValueError, e:
76 pass
81 pass
77 self.url = url
82 self.url = geturl(url)
78 self.encoding = 'UTF-8' # Subversion is always nominal UTF-8
83 self.encoding = 'UTF-8' # Subversion is always nominal UTF-8
79 try:
84 try:
80 self.transport = transport.SvnRaTransport(url=url)
85 self.transport = transport.SvnRaTransport(url=self.url)
81 self.ra = self.transport.ra
86 self.ra = self.transport.ra
82 self.ctx = self.transport.client
87 self.ctx = self.transport.client
83 self.base = svn.ra.get_repos_root(self.ra)
88 self.base = svn.ra.get_repos_root(self.ra)
84 self.module = self.url[len(self.base):]
89 self.module = self.url[len(self.base):]
85 self.modulemap = {} # revision, module
90 self.modulemap = {} # revision, module
86 self.commits = {}
91 self.commits = {}
87 self.files = {}
92 self.files = {}
88 self.uuid = svn.ra.get_uuid(self.ra).decode(self.encoding)
93 self.uuid = svn.ra.get_uuid(self.ra).decode(self.encoding)
89 except SubversionException, e:
94 except SubversionException, e:
90 raise NoRepo("couldn't open SVN repo %s" % url)
95 raise NoRepo("couldn't open SVN repo %s" % url)
91
96
92 try:
97 try:
93 self.get_blacklist()
98 self.get_blacklist()
94 except IOError, e:
99 except IOError, e:
95 pass
100 pass
96
101
97 self.last_changed = self.latest(self.module, latest)
102 self.last_changed = self.latest(self.module, latest)
98
103
99 self.head = self.revid(self.last_changed)
104 self.head = self.revid(self.last_changed)
100
105
101 def setrevmap(self, revmap):
106 def setrevmap(self, revmap):
102 lastrevs = {}
107 lastrevs = {}
103 for revid in revmap.keys():
108 for revid in revmap.keys():
104 uuid, module, revnum = self.revsplit(revid)
109 uuid, module, revnum = self.revsplit(revid)
105 lastrevnum = lastrevs.setdefault(module, revnum)
110 lastrevnum = lastrevs.setdefault(module, revnum)
106 if revnum > lastrevnum:
111 if revnum > lastrevnum:
107 lastrevs[module] = revnum
112 lastrevs[module] = revnum
108 self.lastrevs = lastrevs
113 self.lastrevs = lastrevs
109
114
110 def exists(self, path, optrev):
115 def exists(self, path, optrev):
111 try:
116 try:
112 return svn.client.ls(self.url.rstrip('/') + '/' + path,
117 return svn.client.ls(self.url.rstrip('/') + '/' + path,
113 optrev, False, self.ctx)
118 optrev, False, self.ctx)
114 except SubversionException, err:
119 except SubversionException, err:
115 return []
120 return []
116
121
117 def getheads(self):
122 def getheads(self):
118 # detect standard /branches, /tags, /trunk layout
123 # detect standard /branches, /tags, /trunk layout
119 optrev = svn.core.svn_opt_revision_t()
124 optrev = svn.core.svn_opt_revision_t()
120 optrev.kind = svn.core.svn_opt_revision_number
125 optrev.kind = svn.core.svn_opt_revision_number
121 optrev.value.number = self.last_changed
126 optrev.value.number = self.last_changed
122 rpath = self.url.strip('/')
127 rpath = self.url.strip('/')
123 cfgtrunk = self.ui.config('convert', 'svn.trunk')
128 cfgtrunk = self.ui.config('convert', 'svn.trunk')
124 cfgbranches = self.ui.config('convert', 'svn.branches')
129 cfgbranches = self.ui.config('convert', 'svn.branches')
125 trunk = (cfgtrunk or 'trunk').strip('/')
130 trunk = (cfgtrunk or 'trunk').strip('/')
126 branches = (cfgbranches or 'branches').strip('/')
131 branches = (cfgbranches or 'branches').strip('/')
127 if self.exists(trunk, optrev) and self.exists(branches, optrev):
132 if self.exists(trunk, optrev) and self.exists(branches, optrev):
128 self.ui.note('found trunk at %r and branches at %r\n' %
133 self.ui.note('found trunk at %r and branches at %r\n' %
129 (trunk, branches))
134 (trunk, branches))
130 oldmodule = self.module
135 oldmodule = self.module
131 self.module += '/' + trunk
136 self.module += '/' + trunk
132 lt = self.latest(self.module, self.last_changed)
137 lt = self.latest(self.module, self.last_changed)
133 self.head = self.revid(lt)
138 self.head = self.revid(lt)
134 self.heads = [self.head]
139 self.heads = [self.head]
135 branchnames = svn.client.ls(rpath + '/' + branches, optrev, False,
140 branchnames = svn.client.ls(rpath + '/' + branches, optrev, False,
136 self.ctx)
141 self.ctx)
137 for branch in branchnames.keys():
142 for branch in branchnames.keys():
138 if oldmodule:
143 if oldmodule:
139 module = '/' + oldmodule + '/' + branches + '/' + branch
144 module = '/' + oldmodule + '/' + branches + '/' + branch
140 else:
145 else:
141 module = '/' + branches + '/' + branch
146 module = '/' + branches + '/' + branch
142 brevnum = self.latest(module, self.last_changed)
147 brevnum = self.latest(module, self.last_changed)
143 brev = self.revid(brevnum, module)
148 brev = self.revid(brevnum, module)
144 self.ui.note('found branch %s at %d\n' % (branch, brevnum))
149 self.ui.note('found branch %s at %d\n' % (branch, brevnum))
145 self.heads.append(brev)
150 self.heads.append(brev)
146 elif cfgtrunk or cfgbranches:
151 elif cfgtrunk or cfgbranches:
147 raise util.Abort(_('trunk/branch layout expected, '
152 raise util.Abort(_('trunk/branch layout expected, '
148 'but not found'))
153 'but not found'))
149 else:
154 else:
150 self.ui.note('working with one branch\n')
155 self.ui.note('working with one branch\n')
151 self.heads = [self.head]
156 self.heads = [self.head]
152 return self.heads
157 return self.heads
153
158
154 def getfile(self, file, rev):
159 def getfile(self, file, rev):
155 data, mode = self._getfile(file, rev)
160 data, mode = self._getfile(file, rev)
156 self.modecache[(file, rev)] = mode
161 self.modecache[(file, rev)] = mode
157 return data
162 return data
158
163
159 def getmode(self, file, rev):
164 def getmode(self, file, rev):
160 return self.modecache[(file, rev)]
165 return self.modecache[(file, rev)]
161
166
162 def getchanges(self, rev):
167 def getchanges(self, rev):
163 self.modecache = {}
168 self.modecache = {}
164 files = self.files[rev]
169 files = self.files[rev]
165 cl = files
170 cl = files
166 cl.sort()
171 cl.sort()
167 # caller caches the result, so free it here to release memory
172 # caller caches the result, so free it here to release memory
168 del self.files[rev]
173 del self.files[rev]
169 return cl
174 return cl
170
175
171 def getcommit(self, rev):
176 def getcommit(self, rev):
172 if rev not in self.commits:
177 if rev not in self.commits:
173 uuid, module, revnum = self.revsplit(rev)
178 uuid, module, revnum = self.revsplit(rev)
174 self.module = module
179 self.module = module
175 self.reparent(module)
180 self.reparent(module)
176 stop = self.lastrevs.get(module, 0)
181 stop = self.lastrevs.get(module, 0)
177 self._fetch_revisions(from_revnum=revnum, to_revnum=stop)
182 self._fetch_revisions(from_revnum=revnum, to_revnum=stop)
178 commit = self.commits[rev]
183 commit = self.commits[rev]
179 # caller caches the result, so free it here to release memory
184 # caller caches the result, so free it here to release memory
180 del self.commits[rev]
185 del self.commits[rev]
181 return commit
186 return commit
182
187
183 def get_log(self, paths, start, end, limit=0, discover_changed_paths=True,
188 def get_log(self, paths, start, end, limit=0, discover_changed_paths=True,
184 strict_node_history=False):
189 strict_node_history=False):
185 '''wrapper for svn.ra.get_log.
190 '''wrapper for svn.ra.get_log.
186 on a large repository, svn.ra.get_log pins huge amounts of
191 on a large repository, svn.ra.get_log pins huge amounts of
187 memory that cannot be recovered. work around it by forking
192 memory that cannot be recovered. work around it by forking
188 and writing results over a pipe.'''
193 and writing results over a pipe.'''
189
194
190 def child(fp):
195 def child(fp):
191 protocol = -1
196 protocol = -1
192 def receiver(orig_paths, revnum, author, date, message, pool):
197 def receiver(orig_paths, revnum, author, date, message, pool):
193 if orig_paths is not None:
198 if orig_paths is not None:
194 for k, v in orig_paths.iteritems():
199 for k, v in orig_paths.iteritems():
195 orig_paths[k] = changedpath(v)
200 orig_paths[k] = changedpath(v)
196 pickle.dump((orig_paths, revnum, author, date, message),
201 pickle.dump((orig_paths, revnum, author, date, message),
197 fp, protocol)
202 fp, protocol)
198
203
199 try:
204 try:
200 # Use an ra of our own so that our parent can consume
205 # Use an ra of our own so that our parent can consume
201 # our results without confusing the server.
206 # our results without confusing the server.
202 t = transport.SvnRaTransport(url=self.url)
207 t = transport.SvnRaTransport(url=self.url)
203 svn.ra.get_log(t.ra, paths, start, end, limit,
208 svn.ra.get_log(t.ra, paths, start, end, limit,
204 discover_changed_paths,
209 discover_changed_paths,
205 strict_node_history,
210 strict_node_history,
206 receiver)
211 receiver)
207 except SubversionException, (_, num):
212 except SubversionException, (_, num):
208 self.ui.print_exc()
213 self.ui.print_exc()
209 pickle.dump(num, fp, protocol)
214 pickle.dump(num, fp, protocol)
210 else:
215 else:
211 pickle.dump(None, fp, protocol)
216 pickle.dump(None, fp, protocol)
212 fp.close()
217 fp.close()
213
218
214 def parent(fp):
219 def parent(fp):
215 while True:
220 while True:
216 entry = pickle.load(fp)
221 entry = pickle.load(fp)
217 try:
222 try:
218 orig_paths, revnum, author, date, message = entry
223 orig_paths, revnum, author, date, message = entry
219 except:
224 except:
220 if entry is None:
225 if entry is None:
221 break
226 break
222 raise SubversionException("child raised exception", entry)
227 raise SubversionException("child raised exception", entry)
223 yield entry
228 yield entry
224
229
225 rfd, wfd = os.pipe()
230 rfd, wfd = os.pipe()
226 pid = os.fork()
231 pid = os.fork()
227 if pid:
232 if pid:
228 os.close(wfd)
233 os.close(wfd)
229 for p in parent(os.fdopen(rfd, 'rb')):
234 for p in parent(os.fdopen(rfd, 'rb')):
230 yield p
235 yield p
231 ret = os.waitpid(pid, 0)[1]
236 ret = os.waitpid(pid, 0)[1]
232 if ret:
237 if ret:
233 raise util.Abort(_('get_log %s') % util.explain_exit(ret))
238 raise util.Abort(_('get_log %s') % util.explain_exit(ret))
234 else:
239 else:
235 os.close(rfd)
240 os.close(rfd)
236 child(os.fdopen(wfd, 'wb'))
241 child(os.fdopen(wfd, 'wb'))
237 os._exit(0)
242 os._exit(0)
238
243
239 def gettags(self):
244 def gettags(self):
240 tags = {}
245 tags = {}
241 start = self.revnum(self.head)
246 start = self.revnum(self.head)
242 try:
247 try:
243 for entry in self.get_log(['/tags'], 0, start):
248 for entry in self.get_log(['/tags'], 0, start):
244 orig_paths, revnum, author, date, message = entry
249 orig_paths, revnum, author, date, message = entry
245 for path in orig_paths:
250 for path in orig_paths:
246 if not path.startswith('/tags/'):
251 if not path.startswith('/tags/'):
247 continue
252 continue
248 ent = orig_paths[path]
253 ent = orig_paths[path]
249 source = ent.copyfrom_path
254 source = ent.copyfrom_path
250 rev = ent.copyfrom_rev
255 rev = ent.copyfrom_rev
251 tag = path.split('/', 2)[2]
256 tag = path.split('/', 2)[2]
252 tags[tag] = self.revid(rev, module=source)
257 tags[tag] = self.revid(rev, module=source)
253 except SubversionException, (_, num):
258 except SubversionException, (_, num):
254 self.ui.note('no tags found at revision %d\n' % start)
259 self.ui.note('no tags found at revision %d\n' % start)
255 return tags
260 return tags
256
261
257 # -- helper functions --
262 # -- helper functions --
258
263
259 def revid(self, revnum, module=None):
264 def revid(self, revnum, module=None):
260 if not module:
265 if not module:
261 module = self.module
266 module = self.module
262 return (u"svn:%s%s@%s" % (self.uuid, module, revnum)).decode(self.encoding)
267 return (u"svn:%s%s@%s" % (self.uuid, module, revnum)).decode(self.encoding)
263
268
264 def revnum(self, rev):
269 def revnum(self, rev):
265 return int(rev.split('@')[-1])
270 return int(rev.split('@')[-1])
266
271
267 def revsplit(self, rev):
272 def revsplit(self, rev):
268 url, revnum = rev.encode(self.encoding).split('@', 1)
273 url, revnum = rev.encode(self.encoding).split('@', 1)
269 revnum = int(revnum)
274 revnum = int(revnum)
270 parts = url.split('/', 1)
275 parts = url.split('/', 1)
271 uuid = parts.pop(0)[4:]
276 uuid = parts.pop(0)[4:]
272 mod = ''
277 mod = ''
273 if parts:
278 if parts:
274 mod = '/' + parts[0]
279 mod = '/' + parts[0]
275 return uuid, mod, revnum
280 return uuid, mod, revnum
276
281
277 def latest(self, path, stop=0):
282 def latest(self, path, stop=0):
278 'find the latest revision affecting path, up to stop'
283 'find the latest revision affecting path, up to stop'
279 if not stop:
284 if not stop:
280 stop = svn.ra.get_latest_revnum(self.ra)
285 stop = svn.ra.get_latest_revnum(self.ra)
281 try:
286 try:
282 self.reparent('')
287 self.reparent('')
283 dirent = svn.ra.stat(self.ra, path.strip('/'), stop)
288 dirent = svn.ra.stat(self.ra, path.strip('/'), stop)
284 self.reparent(self.module)
289 self.reparent(self.module)
285 except SubversionException:
290 except SubversionException:
286 dirent = None
291 dirent = None
287 if not dirent:
292 if not dirent:
288 print self.base, path
293 print self.base, path
289 raise util.Abort('%s not found up to revision %d' % (path, stop))
294 raise util.Abort('%s not found up to revision %d' % (path, stop))
290
295
291 return dirent.created_rev
296 return dirent.created_rev
292
297
293 def get_blacklist(self):
298 def get_blacklist(self):
294 """Avoid certain revision numbers.
299 """Avoid certain revision numbers.
295 It is not uncommon for two nearby revisions to cancel each other
300 It is not uncommon for two nearby revisions to cancel each other
296 out, e.g. 'I copied trunk into a subdirectory of itself instead
301 out, e.g. 'I copied trunk into a subdirectory of itself instead
297 of making a branch'. The converted repository is significantly
302 of making a branch'. The converted repository is significantly
298 smaller if we ignore such revisions."""
303 smaller if we ignore such revisions."""
299 self.blacklist = set()
304 self.blacklist = set()
300 blacklist = self.blacklist
305 blacklist = self.blacklist
301 for line in file("blacklist.txt", "r"):
306 for line in file("blacklist.txt", "r"):
302 if not line.startswith("#"):
307 if not line.startswith("#"):
303 try:
308 try:
304 svn_rev = int(line.strip())
309 svn_rev = int(line.strip())
305 blacklist.add(svn_rev)
310 blacklist.add(svn_rev)
306 except ValueError, e:
311 except ValueError, e:
307 pass # not an integer or a comment
312 pass # not an integer or a comment
308
313
309 def is_blacklisted(self, svn_rev):
314 def is_blacklisted(self, svn_rev):
310 return svn_rev in self.blacklist
315 return svn_rev in self.blacklist
311
316
312 def reparent(self, module):
317 def reparent(self, module):
313 svn_url = self.base + module
318 svn_url = self.base + module
314 self.ui.debug("reparent to %s\n" % svn_url.encode(self.encoding))
319 self.ui.debug("reparent to %s\n" % svn_url.encode(self.encoding))
315 svn.ra.reparent(self.ra, svn_url.encode(self.encoding))
320 svn.ra.reparent(self.ra, svn_url.encode(self.encoding))
316
321
317 def _fetch_revisions(self, from_revnum = 0, to_revnum = 347):
322 def _fetch_revisions(self, from_revnum = 0, to_revnum = 347):
318 def get_entry_from_path(path, module=self.module):
323 def get_entry_from_path(path, module=self.module):
319 # Given the repository url of this wc, say
324 # Given the repository url of this wc, say
320 # "http://server/plone/CMFPlone/branches/Plone-2_0-branch"
325 # "http://server/plone/CMFPlone/branches/Plone-2_0-branch"
321 # extract the "entry" portion (a relative path) from what
326 # extract the "entry" portion (a relative path) from what
322 # svn log --xml says, ie
327 # svn log --xml says, ie
323 # "/CMFPlone/branches/Plone-2_0-branch/tests/PloneTestCase.py"
328 # "/CMFPlone/branches/Plone-2_0-branch/tests/PloneTestCase.py"
324 # that is to say "tests/PloneTestCase.py"
329 # that is to say "tests/PloneTestCase.py"
325
330
326 if path.startswith(module):
331 if path.startswith(module):
327 relative = path[len(module):]
332 relative = path[len(module):]
328 if relative.startswith('/'):
333 if relative.startswith('/'):
329 return relative[1:]
334 return relative[1:]
330 else:
335 else:
331 return relative
336 return relative
332
337
333 # The path is outside our tracked tree...
338 # The path is outside our tracked tree...
334 self.ui.debug('Ignoring %r since it is not under %r\n' % (path, module))
339 self.ui.debug('Ignoring %r since it is not under %r\n' % (path, module))
335 return None
340 return None
336
341
337 self.child_cset = None
342 self.child_cset = None
338 def parselogentry(orig_paths, revnum, author, date, message):
343 def parselogentry(orig_paths, revnum, author, date, message):
339 self.ui.debug("parsing revision %d (%d changes)\n" %
344 self.ui.debug("parsing revision %d (%d changes)\n" %
340 (revnum, len(orig_paths)))
345 (revnum, len(orig_paths)))
341
346
342 if revnum in self.modulemap:
347 if revnum in self.modulemap:
343 new_module = self.modulemap[revnum]
348 new_module = self.modulemap[revnum]
344 if new_module != self.module:
349 if new_module != self.module:
345 self.module = new_module
350 self.module = new_module
346 self.reparent(self.module)
351 self.reparent(self.module)
347
352
348 copyfrom = {} # Map of entrypath, revision for finding source of deleted revisions.
353 copyfrom = {} # Map of entrypath, revision for finding source of deleted revisions.
349 copies = {}
354 copies = {}
350 entries = []
355 entries = []
351 rev = self.revid(revnum)
356 rev = self.revid(revnum)
352 parents = []
357 parents = []
353
358
354 # branch log might return entries for a parent we already have
359 # branch log might return entries for a parent we already have
355 if (rev in self.commits or
360 if (rev in self.commits or
356 (revnum < self.lastrevs.get(self.module, 0))):
361 (revnum < self.lastrevs.get(self.module, 0))):
357 return
362 return
358
363
359 try:
364 try:
360 branch = self.module.split("/")[-1]
365 branch = self.module.split("/")[-1]
361 if branch == 'trunk':
366 if branch == 'trunk':
362 branch = ''
367 branch = ''
363 except IndexError:
368 except IndexError:
364 branch = None
369 branch = None
365
370
366 orig_paths = orig_paths.items()
371 orig_paths = orig_paths.items()
367 orig_paths.sort()
372 orig_paths.sort()
368 for path, ent in orig_paths:
373 for path, ent in orig_paths:
369 # self.ui.write("path %s\n" % path)
374 # self.ui.write("path %s\n" % path)
370 if path == self.module: # Follow branching back in history
375 if path == self.module: # Follow branching back in history
371 if ent:
376 if ent:
372 if ent.copyfrom_path:
377 if ent.copyfrom_path:
373 # ent.copyfrom_rev may not be the actual last revision
378 # ent.copyfrom_rev may not be the actual last revision
374 prev = self.latest(ent.copyfrom_path, ent.copyfrom_rev)
379 prev = self.latest(ent.copyfrom_path, ent.copyfrom_rev)
375 self.modulemap[prev] = ent.copyfrom_path
380 self.modulemap[prev] = ent.copyfrom_path
376 parents = [self.revid(prev, ent.copyfrom_path)]
381 parents = [self.revid(prev, ent.copyfrom_path)]
377 self.ui.note('found parent of branch %s at %d: %s\n' % \
382 self.ui.note('found parent of branch %s at %d: %s\n' % \
378 (self.module, prev, ent.copyfrom_path))
383 (self.module, prev, ent.copyfrom_path))
379 else:
384 else:
380 self.ui.debug("No copyfrom path, don't know what to do.\n")
385 self.ui.debug("No copyfrom path, don't know what to do.\n")
381 # Maybe it was added and there is no more history.
386 # Maybe it was added and there is no more history.
382 entrypath = get_entry_from_path(path, module=self.module)
387 entrypath = get_entry_from_path(path, module=self.module)
383 # self.ui.write("entrypath %s\n" % entrypath)
388 # self.ui.write("entrypath %s\n" % entrypath)
384 if entrypath is None:
389 if entrypath is None:
385 # Outside our area of interest
390 # Outside our area of interest
386 self.ui.debug("boring@%s: %s\n" % (revnum, path))
391 self.ui.debug("boring@%s: %s\n" % (revnum, path))
387 continue
392 continue
388 entry = entrypath.decode(self.encoding)
393 entry = entrypath.decode(self.encoding)
389
394
390 kind = svn.ra.check_path(self.ra, entrypath, revnum)
395 kind = svn.ra.check_path(self.ra, entrypath, revnum)
391 if kind == svn.core.svn_node_file:
396 if kind == svn.core.svn_node_file:
392 if ent.copyfrom_path:
397 if ent.copyfrom_path:
393 copyfrom_path = get_entry_from_path(ent.copyfrom_path)
398 copyfrom_path = get_entry_from_path(ent.copyfrom_path)
394 if copyfrom_path:
399 if copyfrom_path:
395 self.ui.debug("Copied to %s from %s@%s\n" % (entry, copyfrom_path, ent.copyfrom_rev))
400 self.ui.debug("Copied to %s from %s@%s\n" % (entry, copyfrom_path, ent.copyfrom_rev))
396 # It's probably important for hg that the source
401 # It's probably important for hg that the source
397 # exists in the revision's parent, not just the
402 # exists in the revision's parent, not just the
398 # ent.copyfrom_rev
403 # ent.copyfrom_rev
399 fromkind = svn.ra.check_path(self.ra, copyfrom_path, ent.copyfrom_rev)
404 fromkind = svn.ra.check_path(self.ra, copyfrom_path, ent.copyfrom_rev)
400 if fromkind != 0:
405 if fromkind != 0:
401 copies[self.recode(entry)] = self.recode(copyfrom_path)
406 copies[self.recode(entry)] = self.recode(copyfrom_path)
402 entries.append(self.recode(entry))
407 entries.append(self.recode(entry))
403 elif kind == 0: # gone, but had better be a deleted *file*
408 elif kind == 0: # gone, but had better be a deleted *file*
404 self.ui.debug("gone from %s\n" % ent.copyfrom_rev)
409 self.ui.debug("gone from %s\n" % ent.copyfrom_rev)
405
410
406 # if a branch is created but entries are removed in the same
411 # if a branch is created but entries are removed in the same
407 # changeset, get the right fromrev
412 # changeset, get the right fromrev
408 if parents:
413 if parents:
409 uuid, old_module, fromrev = self.revsplit(parents[0])
414 uuid, old_module, fromrev = self.revsplit(parents[0])
410 else:
415 else:
411 fromrev = revnum - 1
416 fromrev = revnum - 1
412 # might always need to be revnum - 1 in these 3 lines?
417 # might always need to be revnum - 1 in these 3 lines?
413 old_module = self.modulemap.get(fromrev, self.module)
418 old_module = self.modulemap.get(fromrev, self.module)
414
419
415 basepath = old_module + "/" + get_entry_from_path(path, module=self.module)
420 basepath = old_module + "/" + get_entry_from_path(path, module=self.module)
416 entrypath = old_module + "/" + get_entry_from_path(path, module=self.module)
421 entrypath = old_module + "/" + get_entry_from_path(path, module=self.module)
417
422
418 def lookup_parts(p):
423 def lookup_parts(p):
419 rc = None
424 rc = None
420 parts = p.split("/")
425 parts = p.split("/")
421 for i in range(len(parts)):
426 for i in range(len(parts)):
422 part = "/".join(parts[:i])
427 part = "/".join(parts[:i])
423 info = part, copyfrom.get(part, None)
428 info = part, copyfrom.get(part, None)
424 if info[1] is not None:
429 if info[1] is not None:
425 self.ui.debug("Found parent directory %s\n" % info[1])
430 self.ui.debug("Found parent directory %s\n" % info[1])
426 rc = info
431 rc = info
427 return rc
432 return rc
428
433
429 self.ui.debug("base, entry %s %s\n" % (basepath, entrypath))
434 self.ui.debug("base, entry %s %s\n" % (basepath, entrypath))
430
435
431 frompath, froment = lookup_parts(entrypath) or (None, revnum - 1)
436 frompath, froment = lookup_parts(entrypath) or (None, revnum - 1)
432
437
433 # need to remove fragment from lookup_parts and replace with copyfrom_path
438 # need to remove fragment from lookup_parts and replace with copyfrom_path
434 if frompath is not None:
439 if frompath is not None:
435 self.ui.debug("munge-o-matic\n")
440 self.ui.debug("munge-o-matic\n")
436 self.ui.debug(entrypath + '\n')
441 self.ui.debug(entrypath + '\n')
437 self.ui.debug(entrypath[len(frompath):] + '\n')
442 self.ui.debug(entrypath[len(frompath):] + '\n')
438 entrypath = froment.copyfrom_path + entrypath[len(frompath):]
443 entrypath = froment.copyfrom_path + entrypath[len(frompath):]
439 fromrev = froment.copyfrom_rev
444 fromrev = froment.copyfrom_rev
440 self.ui.debug("Info: %s %s %s %s\n" % (frompath, froment, ent, entrypath))
445 self.ui.debug("Info: %s %s %s %s\n" % (frompath, froment, ent, entrypath))
441
446
442 fromkind = svn.ra.check_path(self.ra, entrypath, fromrev)
447 fromkind = svn.ra.check_path(self.ra, entrypath, fromrev)
443 if fromkind == svn.core.svn_node_file: # a deleted file
448 if fromkind == svn.core.svn_node_file: # a deleted file
444 entries.append(self.recode(entry))
449 entries.append(self.recode(entry))
445 elif fromkind == svn.core.svn_node_dir:
450 elif fromkind == svn.core.svn_node_dir:
446 # print "Deleted/moved non-file:", revnum, path, ent
451 # print "Deleted/moved non-file:", revnum, path, ent
447 # children = self._find_children(path, revnum - 1)
452 # children = self._find_children(path, revnum - 1)
448 # print "find children %s@%d from %d action %s" % (path, revnum, ent.copyfrom_rev, ent.action)
453 # print "find children %s@%d from %d action %s" % (path, revnum, ent.copyfrom_rev, ent.action)
449 # Sometimes this is tricky. For example: in
454 # Sometimes this is tricky. For example: in
450 # The Subversion Repository revision 6940 a dir
455 # The Subversion Repository revision 6940 a dir
451 # was copied and one of its files was deleted
456 # was copied and one of its files was deleted
452 # from the new location in the same commit. This
457 # from the new location in the same commit. This
453 # code can't deal with that yet.
458 # code can't deal with that yet.
454 if ent.action == 'C':
459 if ent.action == 'C':
455 children = self._find_children(path, fromrev)
460 children = self._find_children(path, fromrev)
456 else:
461 else:
457 oroot = entrypath.strip('/')
462 oroot = entrypath.strip('/')
458 nroot = path.strip('/')
463 nroot = path.strip('/')
459 children = self._find_children(oroot, fromrev)
464 children = self._find_children(oroot, fromrev)
460 children = [s.replace(oroot,nroot) for s in children]
465 children = [s.replace(oroot,nroot) for s in children]
461 # Mark all [files, not directories] as deleted.
466 # Mark all [files, not directories] as deleted.
462 for child in children:
467 for child in children:
463 # Can we move a child directory and its
468 # Can we move a child directory and its
464 # parent in the same commit? (probably can). Could
469 # parent in the same commit? (probably can). Could
465 # cause problems if instead of revnum -1,
470 # cause problems if instead of revnum -1,
466 # we have to look in (copyfrom_path, revnum - 1)
471 # we have to look in (copyfrom_path, revnum - 1)
467 entrypath = get_entry_from_path("/" + child, module=old_module)
472 entrypath = get_entry_from_path("/" + child, module=old_module)
468 if entrypath:
473 if entrypath:
469 entry = self.recode(entrypath.decode(self.encoding))
474 entry = self.recode(entrypath.decode(self.encoding))
470 if entry in copies:
475 if entry in copies:
471 # deleted file within a copy
476 # deleted file within a copy
472 del copies[entry]
477 del copies[entry]
473 else:
478 else:
474 entries.append(entry)
479 entries.append(entry)
475 else:
480 else:
476 self.ui.debug('unknown path in revision %d: %s\n' % \
481 self.ui.debug('unknown path in revision %d: %s\n' % \
477 (revnum, path))
482 (revnum, path))
478 elif kind == svn.core.svn_node_dir:
483 elif kind == svn.core.svn_node_dir:
479 # Should probably synthesize normal file entries
484 # Should probably synthesize normal file entries
480 # and handle as above to clean up copy/rename handling.
485 # and handle as above to clean up copy/rename handling.
481
486
482 # If the directory just had a prop change,
487 # If the directory just had a prop change,
483 # then we shouldn't need to look for its children.
488 # then we shouldn't need to look for its children.
484 # Also this could create duplicate entries. Not sure
489 # Also this could create duplicate entries. Not sure
485 # whether this will matter. Maybe should make entries a set.
490 # whether this will matter. Maybe should make entries a set.
486 # print "Changed directory", revnum, path, ent.action, ent.copyfrom_path, ent.copyfrom_rev
491 # print "Changed directory", revnum, path, ent.action, ent.copyfrom_path, ent.copyfrom_rev
487 # This will fail if a directory was copied
492 # This will fail if a directory was copied
488 # from another branch and then some of its files
493 # from another branch and then some of its files
489 # were deleted in the same transaction.
494 # were deleted in the same transaction.
490 children = self._find_children(path, revnum)
495 children = self._find_children(path, revnum)
491 children.sort()
496 children.sort()
492 for child in children:
497 for child in children:
493 # Can we move a child directory and its
498 # Can we move a child directory and its
494 # parent in the same commit? (probably can). Could
499 # parent in the same commit? (probably can). Could
495 # cause problems if instead of revnum -1,
500 # cause problems if instead of revnum -1,
496 # we have to look in (copyfrom_path, revnum - 1)
501 # we have to look in (copyfrom_path, revnum - 1)
497 entrypath = get_entry_from_path("/" + child, module=self.module)
502 entrypath = get_entry_from_path("/" + child, module=self.module)
498 # print child, self.module, entrypath
503 # print child, self.module, entrypath
499 if entrypath:
504 if entrypath:
500 # Need to filter out directories here...
505 # Need to filter out directories here...
501 kind = svn.ra.check_path(self.ra, entrypath, revnum)
506 kind = svn.ra.check_path(self.ra, entrypath, revnum)
502 if kind != svn.core.svn_node_dir:
507 if kind != svn.core.svn_node_dir:
503 entries.append(self.recode(entrypath))
508 entries.append(self.recode(entrypath))
504
509
505 # Copies here (must copy all from source)
510 # Copies here (must copy all from source)
506 # Probably not a real problem for us if
511 # Probably not a real problem for us if
507 # source does not exist
512 # source does not exist
508
513
509 # Can do this with the copy command "hg copy"
514 # Can do this with the copy command "hg copy"
510 # if ent.copyfrom_path:
515 # if ent.copyfrom_path:
511 # copyfrom_entry = get_entry_from_path(ent.copyfrom_path.decode(self.encoding),
516 # copyfrom_entry = get_entry_from_path(ent.copyfrom_path.decode(self.encoding),
512 # module=self.module)
517 # module=self.module)
513 # copyto_entry = entrypath
518 # copyto_entry = entrypath
514 #
519 #
515 # print "copy directory", copyfrom_entry, 'to', copyto_entry
520 # print "copy directory", copyfrom_entry, 'to', copyto_entry
516 #
521 #
517 # copies.append((copyfrom_entry, copyto_entry))
522 # copies.append((copyfrom_entry, copyto_entry))
518
523
519 if ent.copyfrom_path:
524 if ent.copyfrom_path:
520 copyfrom_path = ent.copyfrom_path.decode(self.encoding)
525 copyfrom_path = ent.copyfrom_path.decode(self.encoding)
521 copyfrom_entry = get_entry_from_path(copyfrom_path, module=self.module)
526 copyfrom_entry = get_entry_from_path(copyfrom_path, module=self.module)
522 if copyfrom_entry:
527 if copyfrom_entry:
523 copyfrom[path] = ent
528 copyfrom[path] = ent
524 self.ui.debug("mark %s came from %s\n" % (path, copyfrom[path]))
529 self.ui.debug("mark %s came from %s\n" % (path, copyfrom[path]))
525
530
526 # Good, /probably/ a regular copy. Really should check
531 # Good, /probably/ a regular copy. Really should check
527 # to see whether the parent revision actually contains
532 # to see whether the parent revision actually contains
528 # the directory in question.
533 # the directory in question.
529 children = self._find_children(self.recode(copyfrom_path), ent.copyfrom_rev)
534 children = self._find_children(self.recode(copyfrom_path), ent.copyfrom_rev)
530 children.sort()
535 children.sort()
531 for child in children:
536 for child in children:
532 entrypath = get_entry_from_path("/" + child, module=self.module)
537 entrypath = get_entry_from_path("/" + child, module=self.module)
533 if entrypath:
538 if entrypath:
534 entry = entrypath.decode(self.encoding)
539 entry = entrypath.decode(self.encoding)
535 # print "COPY COPY From", copyfrom_entry, entry
540 # print "COPY COPY From", copyfrom_entry, entry
536 copyto_path = path + entry[len(copyfrom_entry):]
541 copyto_path = path + entry[len(copyfrom_entry):]
537 copyto_entry = get_entry_from_path(copyto_path, module=self.module)
542 copyto_entry = get_entry_from_path(copyto_path, module=self.module)
538 # print "COPY", entry, "COPY To", copyto_entry
543 # print "COPY", entry, "COPY To", copyto_entry
539 copies[self.recode(copyto_entry)] = self.recode(entry)
544 copies[self.recode(copyto_entry)] = self.recode(entry)
540 # copy from quux splort/quuxfile
545 # copy from quux splort/quuxfile
541
546
542 self.modulemap[revnum] = self.module # track backwards in time
547 self.modulemap[revnum] = self.module # track backwards in time
543 # a list of (filename, id) where id lets us retrieve the file.
548 # a list of (filename, id) where id lets us retrieve the file.
544 # eg in git, id is the object hash. for svn it'll be the
549 # eg in git, id is the object hash. for svn it'll be the
545 self.files[rev] = zip(entries, [rev] * len(entries))
550 self.files[rev] = zip(entries, [rev] * len(entries))
546 if not entries:
551 if not entries:
547 return
552 return
548
553
549 # Example SVN datetime. Includes microseconds.
554 # Example SVN datetime. Includes microseconds.
550 # ISO-8601 conformant
555 # ISO-8601 conformant
551 # '2007-01-04T17:35:00.902377Z'
556 # '2007-01-04T17:35:00.902377Z'
552 date = util.parsedate(date[:18] + " UTC", ["%Y-%m-%dT%H:%M:%S"])
557 date = util.parsedate(date[:18] + " UTC", ["%Y-%m-%dT%H:%M:%S"])
553
558
554 log = message and self.recode(message)
559 log = message and self.recode(message)
555 author = author and self.recode(author) or ''
560 author = author and self.recode(author) or ''
556
561
557 cset = commit(author=author,
562 cset = commit(author=author,
558 date=util.datestr(date),
563 date=util.datestr(date),
559 desc=log,
564 desc=log,
560 parents=parents,
565 parents=parents,
561 copies=copies,
566 copies=copies,
562 branch=branch,
567 branch=branch,
563 rev=rev.encode('utf-8'))
568 rev=rev.encode('utf-8'))
564
569
565 self.commits[rev] = cset
570 self.commits[rev] = cset
566 if self.child_cset and not self.child_cset.parents:
571 if self.child_cset and not self.child_cset.parents:
567 self.child_cset.parents = [rev]
572 self.child_cset.parents = [rev]
568 self.child_cset = cset
573 self.child_cset = cset
569
574
570 self.ui.note('fetching revision log for "%s" from %d to %d\n' %
575 self.ui.note('fetching revision log for "%s" from %d to %d\n' %
571 (self.module, from_revnum, to_revnum))
576 (self.module, from_revnum, to_revnum))
572
577
573 try:
578 try:
574 discover_changed_paths = True
579 discover_changed_paths = True
575 strict_node_history = False
580 strict_node_history = False
576 for entry in self.get_log([self.module], from_revnum, to_revnum):
581 for entry in self.get_log([self.module], from_revnum, to_revnum):
577 orig_paths, revnum, author, date, message = entry
582 orig_paths, revnum, author, date, message = entry
578 if self.is_blacklisted(revnum):
583 if self.is_blacklisted(revnum):
579 self.ui.note('skipping blacklisted revision %d\n' % revnum)
584 self.ui.note('skipping blacklisted revision %d\n' % revnum)
580 continue
585 continue
581 if orig_paths is None:
586 if orig_paths is None:
582 self.ui.debug('revision %d has no entries\n' % revnum)
587 self.ui.debug('revision %d has no entries\n' % revnum)
583 continue
588 continue
584 parselogentry(orig_paths, revnum, author, date, message)
589 parselogentry(orig_paths, revnum, author, date, message)
585 except SubversionException, (_, num):
590 except SubversionException, (_, num):
586 if num == svn.core.SVN_ERR_FS_NO_SUCH_REVISION:
591 if num == svn.core.SVN_ERR_FS_NO_SUCH_REVISION:
587 raise NoSuchRevision(branch=self,
592 raise NoSuchRevision(branch=self,
588 revision="Revision number %d" % to_revnum)
593 revision="Revision number %d" % to_revnum)
589 raise
594 raise
590
595
591 def _getfile(self, file, rev):
596 def _getfile(self, file, rev):
592 io = StringIO()
597 io = StringIO()
593 # TODO: ra.get_file transmits the whole file instead of diffs.
598 # TODO: ra.get_file transmits the whole file instead of diffs.
594 mode = ''
599 mode = ''
595 try:
600 try:
596 revnum = self.revnum(rev)
601 revnum = self.revnum(rev)
597 if self.module != self.modulemap[revnum]:
602 if self.module != self.modulemap[revnum]:
598 self.module = self.modulemap[revnum]
603 self.module = self.modulemap[revnum]
599 self.reparent(self.module)
604 self.reparent(self.module)
600 info = svn.ra.get_file(self.ra, file, revnum, io)
605 info = svn.ra.get_file(self.ra, file, revnum, io)
601 if isinstance(info, list):
606 if isinstance(info, list):
602 info = info[-1]
607 info = info[-1]
603 mode = ("svn:executable" in info) and 'x' or ''
608 mode = ("svn:executable" in info) and 'x' or ''
604 mode = ("svn:special" in info) and 'l' or mode
609 mode = ("svn:special" in info) and 'l' or mode
605 except SubversionException, e:
610 except SubversionException, e:
606 notfound = (svn.core.SVN_ERR_FS_NOT_FOUND,
611 notfound = (svn.core.SVN_ERR_FS_NOT_FOUND,
607 svn.core.SVN_ERR_RA_DAV_PATH_NOT_FOUND)
612 svn.core.SVN_ERR_RA_DAV_PATH_NOT_FOUND)
608 if e.apr_err in notfound: # File not found
613 if e.apr_err in notfound: # File not found
609 raise IOError()
614 raise IOError()
610 raise
615 raise
611 data = io.getvalue()
616 data = io.getvalue()
612 if mode == 'l':
617 if mode == 'l':
613 link_prefix = "link "
618 link_prefix = "link "
614 if data.startswith(link_prefix):
619 if data.startswith(link_prefix):
615 data = data[len(link_prefix):]
620 data = data[len(link_prefix):]
616 return data, mode
621 return data, mode
617
622
618 def _find_children(self, path, revnum):
623 def _find_children(self, path, revnum):
619 path = path.strip("/")
624 path = path.strip("/")
620
625
621 def _find_children_fallback(path, revnum):
626 def _find_children_fallback(path, revnum):
622 # SWIG python bindings for getdir are broken up to at least 1.4.3
627 # SWIG python bindings for getdir are broken up to at least 1.4.3
623 pool = Pool()
628 pool = Pool()
624 optrev = svn.core.svn_opt_revision_t()
629 optrev = svn.core.svn_opt_revision_t()
625 optrev.kind = svn.core.svn_opt_revision_number
630 optrev.kind = svn.core.svn_opt_revision_number
626 optrev.value.number = revnum
631 optrev.value.number = revnum
627 rpath = '/'.join([self.base, path]).strip('/')
632 rpath = '/'.join([self.base, path]).strip('/')
628 return ['%s/%s' % (path, x) for x in svn.client.ls(rpath, optrev, True, self.ctx, pool).keys()]
633 return ['%s/%s' % (path, x) for x in svn.client.ls(rpath, optrev, True, self.ctx, pool).keys()]
629
634
630 if hasattr(self, '_find_children_fallback'):
635 if hasattr(self, '_find_children_fallback'):
631 return _find_children_fallback(path, revnum)
636 return _find_children_fallback(path, revnum)
632
637
633 self.reparent("/" + path)
638 self.reparent("/" + path)
634 pool = Pool()
639 pool = Pool()
635
640
636 children = []
641 children = []
637 def find_children_inner(children, path, revnum = revnum):
642 def find_children_inner(children, path, revnum = revnum):
638 if hasattr(svn.ra, 'get_dir2'): # Since SVN 1.4
643 if hasattr(svn.ra, 'get_dir2'): # Since SVN 1.4
639 fields = 0xffffffff # Binding does not provide SVN_DIRENT_ALL
644 fields = 0xffffffff # Binding does not provide SVN_DIRENT_ALL
640 getdir = svn.ra.get_dir2(self.ra, path, revnum, fields, pool)
645 getdir = svn.ra.get_dir2(self.ra, path, revnum, fields, pool)
641 else:
646 else:
642 getdir = svn.ra.get_dir(self.ra, path, revnum, pool)
647 getdir = svn.ra.get_dir(self.ra, path, revnum, pool)
643 if type(getdir) == dict:
648 if type(getdir) == dict:
644 # python binding for getdir is broken up to at least 1.4.3
649 # python binding for getdir is broken up to at least 1.4.3
645 raise CompatibilityException()
650 raise CompatibilityException()
646 dirents = getdir[0]
651 dirents = getdir[0]
647 if type(dirents) == int:
652 if type(dirents) == int:
648 # got here once due to infinite recursion bug
653 # got here once due to infinite recursion bug
649 return
654 return
650 c = dirents.keys()
655 c = dirents.keys()
651 c.sort()
656 c.sort()
652 for child in c:
657 for child in c:
653 dirent = dirents[child]
658 dirent = dirents[child]
654 if dirent.kind == svn.core.svn_node_dir:
659 if dirent.kind == svn.core.svn_node_dir:
655 find_children_inner(children, (path + "/" + child).strip("/"))
660 find_children_inner(children, (path + "/" + child).strip("/"))
656 else:
661 else:
657 children.append((path + "/" + child).strip("/"))
662 children.append((path + "/" + child).strip("/"))
658
663
659 try:
664 try:
660 find_children_inner(children, "")
665 find_children_inner(children, "")
661 except CompatibilityException:
666 except CompatibilityException:
662 self._find_children_fallback = True
667 self._find_children_fallback = True
663 self.reparent(self.module)
668 self.reparent(self.module)
664 return _find_children_fallback(path, revnum)
669 return _find_children_fallback(path, revnum)
665
670
666 self.reparent(self.module)
671 self.reparent(self.module)
667 return [path + "/" + c for c in children]
672 return [path + "/" + c for c in children]
General Comments 0
You need to be logged in to leave comments. Login now