##// END OF EJS Templates
changelog: rework the delayupdate mechanism...
Pierre-Yves David -
r23201:7e97bf6e default
parent child Browse files
Show More
@@ -1,358 +1,374 b''
1 # changelog.py - changelog class for mercurial
1 # changelog.py - changelog class for mercurial
2 #
2 #
3 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
3 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
6 # GNU General Public License version 2 or any later version.
7
7
8 from node import bin, hex, nullid
8 from node import bin, hex, nullid
9 from i18n import _
9 from i18n import _
10 import util, error, revlog, encoding
10 import util, error, revlog, encoding
11
11
12 _defaultextra = {'branch': 'default'}
12 _defaultextra = {'branch': 'default'}
13
13
14 def _string_escape(text):
14 def _string_escape(text):
15 """
15 """
16 >>> d = {'nl': chr(10), 'bs': chr(92), 'cr': chr(13), 'nul': chr(0)}
16 >>> d = {'nl': chr(10), 'bs': chr(92), 'cr': chr(13), 'nul': chr(0)}
17 >>> s = "ab%(nl)scd%(bs)s%(bs)sn%(nul)sab%(cr)scd%(bs)s%(nl)s" % d
17 >>> s = "ab%(nl)scd%(bs)s%(bs)sn%(nul)sab%(cr)scd%(bs)s%(nl)s" % d
18 >>> s
18 >>> s
19 'ab\\ncd\\\\\\\\n\\x00ab\\rcd\\\\\\n'
19 'ab\\ncd\\\\\\\\n\\x00ab\\rcd\\\\\\n'
20 >>> res = _string_escape(s)
20 >>> res = _string_escape(s)
21 >>> s == res.decode('string_escape')
21 >>> s == res.decode('string_escape')
22 True
22 True
23 """
23 """
24 # subset of the string_escape codec
24 # subset of the string_escape codec
25 text = text.replace('\\', '\\\\').replace('\n', '\\n').replace('\r', '\\r')
25 text = text.replace('\\', '\\\\').replace('\n', '\\n').replace('\r', '\\r')
26 return text.replace('\0', '\\0')
26 return text.replace('\0', '\\0')
27
27
28 def decodeextra(text):
28 def decodeextra(text):
29 """
29 """
30 >>> sorted(decodeextra(encodeextra({'foo': 'bar', 'baz': chr(0) + '2'})
30 >>> sorted(decodeextra(encodeextra({'foo': 'bar', 'baz': chr(0) + '2'})
31 ... ).iteritems())
31 ... ).iteritems())
32 [('baz', '\\x002'), ('branch', 'default'), ('foo', 'bar')]
32 [('baz', '\\x002'), ('branch', 'default'), ('foo', 'bar')]
33 >>> sorted(decodeextra(encodeextra({'foo': 'bar',
33 >>> sorted(decodeextra(encodeextra({'foo': 'bar',
34 ... 'baz': chr(92) + chr(0) + '2'})
34 ... 'baz': chr(92) + chr(0) + '2'})
35 ... ).iteritems())
35 ... ).iteritems())
36 [('baz', '\\\\\\x002'), ('branch', 'default'), ('foo', 'bar')]
36 [('baz', '\\\\\\x002'), ('branch', 'default'), ('foo', 'bar')]
37 """
37 """
38 extra = _defaultextra.copy()
38 extra = _defaultextra.copy()
39 for l in text.split('\0'):
39 for l in text.split('\0'):
40 if l:
40 if l:
41 if '\\0' in l:
41 if '\\0' in l:
42 # fix up \0 without getting into trouble with \\0
42 # fix up \0 without getting into trouble with \\0
43 l = l.replace('\\\\', '\\\\\n')
43 l = l.replace('\\\\', '\\\\\n')
44 l = l.replace('\\0', '\0')
44 l = l.replace('\\0', '\0')
45 l = l.replace('\n', '')
45 l = l.replace('\n', '')
46 k, v = l.decode('string_escape').split(':', 1)
46 k, v = l.decode('string_escape').split(':', 1)
47 extra[k] = v
47 extra[k] = v
48 return extra
48 return extra
49
49
50 def encodeextra(d):
50 def encodeextra(d):
51 # keys must be sorted to produce a deterministic changelog entry
51 # keys must be sorted to produce a deterministic changelog entry
52 items = [_string_escape('%s:%s' % (k, d[k])) for k in sorted(d)]
52 items = [_string_escape('%s:%s' % (k, d[k])) for k in sorted(d)]
53 return "\0".join(items)
53 return "\0".join(items)
54
54
55 def stripdesc(desc):
55 def stripdesc(desc):
56 """strip trailing whitespace and leading and trailing empty lines"""
56 """strip trailing whitespace and leading and trailing empty lines"""
57 return '\n'.join([l.rstrip() for l in desc.splitlines()]).strip('\n')
57 return '\n'.join([l.rstrip() for l in desc.splitlines()]).strip('\n')
58
58
59 class appender(object):
59 class appender(object):
60 '''the changelog index must be updated last on disk, so we use this class
60 '''the changelog index must be updated last on disk, so we use this class
61 to delay writes to it'''
61 to delay writes to it'''
62 def __init__(self, vfs, name, mode, buf):
62 def __init__(self, vfs, name, mode, buf):
63 self.data = buf
63 self.data = buf
64 fp = vfs(name, mode)
64 fp = vfs(name, mode)
65 self.fp = fp
65 self.fp = fp
66 self.offset = fp.tell()
66 self.offset = fp.tell()
67 self.size = vfs.fstat(fp).st_size
67 self.size = vfs.fstat(fp).st_size
68
68
69 def end(self):
69 def end(self):
70 return self.size + len("".join(self.data))
70 return self.size + len("".join(self.data))
71 def tell(self):
71 def tell(self):
72 return self.offset
72 return self.offset
73 def flush(self):
73 def flush(self):
74 pass
74 pass
75 def close(self):
75 def close(self):
76 self.fp.close()
76 self.fp.close()
77
77
78 def seek(self, offset, whence=0):
78 def seek(self, offset, whence=0):
79 '''virtual file offset spans real file and data'''
79 '''virtual file offset spans real file and data'''
80 if whence == 0:
80 if whence == 0:
81 self.offset = offset
81 self.offset = offset
82 elif whence == 1:
82 elif whence == 1:
83 self.offset += offset
83 self.offset += offset
84 elif whence == 2:
84 elif whence == 2:
85 self.offset = self.end() + offset
85 self.offset = self.end() + offset
86 if self.offset < self.size:
86 if self.offset < self.size:
87 self.fp.seek(self.offset)
87 self.fp.seek(self.offset)
88
88
89 def read(self, count=-1):
89 def read(self, count=-1):
90 '''only trick here is reads that span real file and data'''
90 '''only trick here is reads that span real file and data'''
91 ret = ""
91 ret = ""
92 if self.offset < self.size:
92 if self.offset < self.size:
93 s = self.fp.read(count)
93 s = self.fp.read(count)
94 ret = s
94 ret = s
95 self.offset += len(s)
95 self.offset += len(s)
96 if count > 0:
96 if count > 0:
97 count -= len(s)
97 count -= len(s)
98 if count != 0:
98 if count != 0:
99 doff = self.offset - self.size
99 doff = self.offset - self.size
100 self.data.insert(0, "".join(self.data))
100 self.data.insert(0, "".join(self.data))
101 del self.data[1:]
101 del self.data[1:]
102 s = self.data[0][doff:doff + count]
102 s = self.data[0][doff:doff + count]
103 self.offset += len(s)
103 self.offset += len(s)
104 ret += s
104 ret += s
105 return ret
105 return ret
106
106
107 def write(self, s):
107 def write(self, s):
108 self.data.append(str(s))
108 self.data.append(str(s))
109 self.offset += len(s)
109 self.offset += len(s)
110
110
111 def delayopener(opener, target, divert, buf):
111 def _divertopener(opener, target):
112 def o(name, mode='r'):
112 """build an opener that writes in 'target.a' instead of 'target'"""
113 def _divert(name, mode='r'):
113 if name != target:
114 if name != target:
114 return opener(name, mode)
115 return opener(name, mode)
115 if divert:
116 return opener(name + ".a", mode)
116 return opener(name + ".a", mode.replace('a', 'w'))
117 return _divert
117 # otherwise, divert to memory
118
119 def _delayopener(opener, target, buf):
120 """build an opener that stores chunks in 'buf' instead of 'target'"""
121 def _delay(name, mode='r'):
122 if name != target:
123 return opener(name, mode)
118 return appender(opener, name, mode, buf)
124 return appender(opener, name, mode, buf)
119 return o
125 return _delay
120
126
121 class changelog(revlog.revlog):
127 class changelog(revlog.revlog):
122 def __init__(self, opener):
128 def __init__(self, opener):
123 revlog.revlog.__init__(self, opener, "00changelog.i")
129 revlog.revlog.__init__(self, opener, "00changelog.i")
124 if self._initempty:
130 if self._initempty:
125 # changelogs don't benefit from generaldelta
131 # changelogs don't benefit from generaldelta
126 self.version &= ~revlog.REVLOGGENERALDELTA
132 self.version &= ~revlog.REVLOGGENERALDELTA
127 self._generaldelta = False
133 self._generaldelta = False
128 self._realopener = opener
134 self._realopener = opener
129 self._delayed = False
135 self._delayed = False
130 self._delaybuf = []
136 self._delaybuf = None
131 self._divert = False
137 self._divert = False
132 self.filteredrevs = frozenset()
138 self.filteredrevs = frozenset()
133
139
134 def tip(self):
140 def tip(self):
135 """filtered version of revlog.tip"""
141 """filtered version of revlog.tip"""
136 for i in xrange(len(self) -1, -2, -1):
142 for i in xrange(len(self) -1, -2, -1):
137 if i not in self.filteredrevs:
143 if i not in self.filteredrevs:
138 return self.node(i)
144 return self.node(i)
139
145
140 def __iter__(self):
146 def __iter__(self):
141 """filtered version of revlog.__iter__"""
147 """filtered version of revlog.__iter__"""
142 if len(self.filteredrevs) == 0:
148 if len(self.filteredrevs) == 0:
143 return revlog.revlog.__iter__(self)
149 return revlog.revlog.__iter__(self)
144
150
145 def filterediter():
151 def filterediter():
146 for i in xrange(len(self)):
152 for i in xrange(len(self)):
147 if i not in self.filteredrevs:
153 if i not in self.filteredrevs:
148 yield i
154 yield i
149
155
150 return filterediter()
156 return filterediter()
151
157
152 def revs(self, start=0, stop=None):
158 def revs(self, start=0, stop=None):
153 """filtered version of revlog.revs"""
159 """filtered version of revlog.revs"""
154 for i in super(changelog, self).revs(start, stop):
160 for i in super(changelog, self).revs(start, stop):
155 if i not in self.filteredrevs:
161 if i not in self.filteredrevs:
156 yield i
162 yield i
157
163
158 @util.propertycache
164 @util.propertycache
159 def nodemap(self):
165 def nodemap(self):
160 # XXX need filtering too
166 # XXX need filtering too
161 self.rev(self.node(0))
167 self.rev(self.node(0))
162 return self._nodecache
168 return self._nodecache
163
169
164 def hasnode(self, node):
170 def hasnode(self, node):
165 """filtered version of revlog.hasnode"""
171 """filtered version of revlog.hasnode"""
166 try:
172 try:
167 i = self.rev(node)
173 i = self.rev(node)
168 return i not in self.filteredrevs
174 return i not in self.filteredrevs
169 except KeyError:
175 except KeyError:
170 return False
176 return False
171
177
172 def headrevs(self):
178 def headrevs(self):
173 if self.filteredrevs:
179 if self.filteredrevs:
174 try:
180 try:
175 return self.index.headrevsfiltered(self.filteredrevs)
181 return self.index.headrevsfiltered(self.filteredrevs)
176 # AttributeError covers non-c-extension environments and
182 # AttributeError covers non-c-extension environments and
177 # old c extensions without filter handling.
183 # old c extensions without filter handling.
178 except AttributeError:
184 except AttributeError:
179 return self._headrevs()
185 return self._headrevs()
180
186
181 return super(changelog, self).headrevs()
187 return super(changelog, self).headrevs()
182
188
183 def strip(self, *args, **kwargs):
189 def strip(self, *args, **kwargs):
184 # XXX make something better than assert
190 # XXX make something better than assert
185 # We can't expect proper strip behavior if we are filtered.
191 # We can't expect proper strip behavior if we are filtered.
186 assert not self.filteredrevs
192 assert not self.filteredrevs
187 super(changelog, self).strip(*args, **kwargs)
193 super(changelog, self).strip(*args, **kwargs)
188
194
189 def rev(self, node):
195 def rev(self, node):
190 """filtered version of revlog.rev"""
196 """filtered version of revlog.rev"""
191 r = super(changelog, self).rev(node)
197 r = super(changelog, self).rev(node)
192 if r in self.filteredrevs:
198 if r in self.filteredrevs:
193 raise error.FilteredLookupError(hex(node), self.indexfile,
199 raise error.FilteredLookupError(hex(node), self.indexfile,
194 _('filtered node'))
200 _('filtered node'))
195 return r
201 return r
196
202
197 def node(self, rev):
203 def node(self, rev):
198 """filtered version of revlog.node"""
204 """filtered version of revlog.node"""
199 if rev in self.filteredrevs:
205 if rev in self.filteredrevs:
200 raise error.FilteredIndexError(rev)
206 raise error.FilteredIndexError(rev)
201 return super(changelog, self).node(rev)
207 return super(changelog, self).node(rev)
202
208
203 def linkrev(self, rev):
209 def linkrev(self, rev):
204 """filtered version of revlog.linkrev"""
210 """filtered version of revlog.linkrev"""
205 if rev in self.filteredrevs:
211 if rev in self.filteredrevs:
206 raise error.FilteredIndexError(rev)
212 raise error.FilteredIndexError(rev)
207 return super(changelog, self).linkrev(rev)
213 return super(changelog, self).linkrev(rev)
208
214
209 def parentrevs(self, rev):
215 def parentrevs(self, rev):
210 """filtered version of revlog.parentrevs"""
216 """filtered version of revlog.parentrevs"""
211 if rev in self.filteredrevs:
217 if rev in self.filteredrevs:
212 raise error.FilteredIndexError(rev)
218 raise error.FilteredIndexError(rev)
213 return super(changelog, self).parentrevs(rev)
219 return super(changelog, self).parentrevs(rev)
214
220
215 def flags(self, rev):
221 def flags(self, rev):
216 """filtered version of revlog.flags"""
222 """filtered version of revlog.flags"""
217 if rev in self.filteredrevs:
223 if rev in self.filteredrevs:
218 raise error.FilteredIndexError(rev)
224 raise error.FilteredIndexError(rev)
219 return super(changelog, self).flags(rev)
225 return super(changelog, self).flags(rev)
220
226
221 def delayupdate(self):
227 def delayupdate(self):
222 "delay visibility of index updates to other readers"
228 "delay visibility of index updates to other readers"
229
230 if not self._delayed:
231 if len(self) == 0:
232 self._divert = True
233 if self._realopener.exists(self.indexfile + '.a'):
234 self._realopener.unlink(self.indexfile + '.a')
235 self.opener = _divertopener(self._realopener, self.indexfile)
236 else:
237 self._delaybuf = []
238 self.opener = _delayopener(self._realopener, self.indexfile,
239 self._delaybuf)
223 self._delayed = True
240 self._delayed = True
224 self._divert = (len(self) == 0)
225 self._delaybuf = []
226 self.opener = delayopener(self._realopener, self.indexfile,
227 self._divert, self._delaybuf)
228
241
229 def finalize(self, tr):
242 def finalize(self, tr):
230 "finalize index updates"
243 "finalize index updates"
231 self._delayed = False
244 self._delayed = False
232 self.opener = self._realopener
245 self.opener = self._realopener
233 # move redirected index data back into place
246 # move redirected index data back into place
234 if self._divert:
247 if self._divert:
248 assert not self._delaybuf
235 tmpname = self.indexfile + ".a"
249 tmpname = self.indexfile + ".a"
236 nfile = self.opener.open(tmpname)
250 nfile = self.opener.open(tmpname)
237 nfile.close()
251 nfile.close()
238 self.opener.rename(tmpname, self.indexfile)
252 self.opener.rename(tmpname, self.indexfile)
239 elif self._delaybuf:
253 elif self._delaybuf:
240 fp = self.opener(self.indexfile, 'a')
254 fp = self.opener(self.indexfile, 'a')
241 fp.write("".join(self._delaybuf))
255 fp.write("".join(self._delaybuf))
242 fp.close()
256 fp.close()
243 self._delaybuf = []
257 self._delaybuf = None
258 self._divert = False
244 # split when we're done
259 # split when we're done
245 self.checkinlinesize(tr)
260 self.checkinlinesize(tr)
246
261
247 def readpending(self, file):
262 def readpending(self, file):
248 r = revlog.revlog(self.opener, file)
263 r = revlog.revlog(self.opener, file)
249 self.index = r.index
264 self.index = r.index
250 self.nodemap = r.nodemap
265 self.nodemap = r.nodemap
251 self._nodecache = r._nodecache
266 self._nodecache = r._nodecache
252 self._chunkcache = r._chunkcache
267 self._chunkcache = r._chunkcache
253
268
254 def writepending(self):
269 def writepending(self):
255 "create a file containing the unfinalized state for pretxnchangegroup"
270 "create a file containing the unfinalized state for pretxnchangegroup"
256 if self._delaybuf:
271 if self._delaybuf:
257 # make a temporary copy of the index
272 # make a temporary copy of the index
258 fp1 = self._realopener(self.indexfile)
273 fp1 = self._realopener(self.indexfile)
259 fp2 = self._realopener(self.indexfile + ".a", "w")
274 fp2 = self._realopener(self.indexfile + ".a", "w")
260 fp2.write(fp1.read())
275 fp2.write(fp1.read())
261 # add pending data
276 # add pending data
262 fp2.write("".join(self._delaybuf))
277 fp2.write("".join(self._delaybuf))
263 fp2.close()
278 fp2.close()
264 # switch modes so finalize can simply rename
279 # switch modes so finalize can simply rename
265 self._delaybuf = []
280 self._delaybuf = None
266 self._divert = True
281 self._divert = True
282 self.opener = _divertopener(self._realopener, self.indexfile)
267
283
268 if self._divert:
284 if self._divert:
269 return True
285 return True
270
286
271 return False
287 return False
272
288
273 def checkinlinesize(self, tr, fp=None):
289 def checkinlinesize(self, tr, fp=None):
274 if not self._delayed:
290 if not self._delayed:
275 revlog.revlog.checkinlinesize(self, tr, fp)
291 revlog.revlog.checkinlinesize(self, tr, fp)
276
292
277 def read(self, node):
293 def read(self, node):
278 """
294 """
279 format used:
295 format used:
280 nodeid\n : manifest node in ascii
296 nodeid\n : manifest node in ascii
281 user\n : user, no \n or \r allowed
297 user\n : user, no \n or \r allowed
282 time tz extra\n : date (time is int or float, timezone is int)
298 time tz extra\n : date (time is int or float, timezone is int)
283 : extra is metadata, encoded and separated by '\0'
299 : extra is metadata, encoded and separated by '\0'
284 : older versions ignore it
300 : older versions ignore it
285 files\n\n : files modified by the cset, no \n or \r allowed
301 files\n\n : files modified by the cset, no \n or \r allowed
286 (.*) : comment (free text, ideally utf-8)
302 (.*) : comment (free text, ideally utf-8)
287
303
288 changelog v0 doesn't use extra
304 changelog v0 doesn't use extra
289 """
305 """
290 text = self.revision(node)
306 text = self.revision(node)
291 if not text:
307 if not text:
292 return (nullid, "", (0, 0), [], "", _defaultextra)
308 return (nullid, "", (0, 0), [], "", _defaultextra)
293 last = text.index("\n\n")
309 last = text.index("\n\n")
294 desc = encoding.tolocal(text[last + 2:])
310 desc = encoding.tolocal(text[last + 2:])
295 l = text[:last].split('\n')
311 l = text[:last].split('\n')
296 manifest = bin(l[0])
312 manifest = bin(l[0])
297 user = encoding.tolocal(l[1])
313 user = encoding.tolocal(l[1])
298
314
299 tdata = l[2].split(' ', 2)
315 tdata = l[2].split(' ', 2)
300 if len(tdata) != 3:
316 if len(tdata) != 3:
301 time = float(tdata[0])
317 time = float(tdata[0])
302 try:
318 try:
303 # various tools did silly things with the time zone field.
319 # various tools did silly things with the time zone field.
304 timezone = int(tdata[1])
320 timezone = int(tdata[1])
305 except ValueError:
321 except ValueError:
306 timezone = 0
322 timezone = 0
307 extra = _defaultextra
323 extra = _defaultextra
308 else:
324 else:
309 time, timezone = float(tdata[0]), int(tdata[1])
325 time, timezone = float(tdata[0]), int(tdata[1])
310 extra = decodeextra(tdata[2])
326 extra = decodeextra(tdata[2])
311
327
312 files = l[3:]
328 files = l[3:]
313 return (manifest, user, (time, timezone), files, desc, extra)
329 return (manifest, user, (time, timezone), files, desc, extra)
314
330
315 def add(self, manifest, files, desc, transaction, p1, p2,
331 def add(self, manifest, files, desc, transaction, p1, p2,
316 user, date=None, extra=None):
332 user, date=None, extra=None):
317 # Convert to UTF-8 encoded bytestrings as the very first
333 # Convert to UTF-8 encoded bytestrings as the very first
318 # thing: calling any method on a localstr object will turn it
334 # thing: calling any method on a localstr object will turn it
319 # into a str object and the cached UTF-8 string is thus lost.
335 # into a str object and the cached UTF-8 string is thus lost.
320 user, desc = encoding.fromlocal(user), encoding.fromlocal(desc)
336 user, desc = encoding.fromlocal(user), encoding.fromlocal(desc)
321
337
322 user = user.strip()
338 user = user.strip()
323 # An empty username or a username with a "\n" will make the
339 # An empty username or a username with a "\n" will make the
324 # revision text contain two "\n\n" sequences -> corrupt
340 # revision text contain two "\n\n" sequences -> corrupt
325 # repository since read cannot unpack the revision.
341 # repository since read cannot unpack the revision.
326 if not user:
342 if not user:
327 raise error.RevlogError(_("empty username"))
343 raise error.RevlogError(_("empty username"))
328 if "\n" in user:
344 if "\n" in user:
329 raise error.RevlogError(_("username %s contains a newline")
345 raise error.RevlogError(_("username %s contains a newline")
330 % repr(user))
346 % repr(user))
331
347
332 desc = stripdesc(desc)
348 desc = stripdesc(desc)
333
349
334 if date:
350 if date:
335 parseddate = "%d %d" % util.parsedate(date)
351 parseddate = "%d %d" % util.parsedate(date)
336 else:
352 else:
337 parseddate = "%d %d" % util.makedate()
353 parseddate = "%d %d" % util.makedate()
338 if extra:
354 if extra:
339 branch = extra.get("branch")
355 branch = extra.get("branch")
340 if branch in ("default", ""):
356 if branch in ("default", ""):
341 del extra["branch"]
357 del extra["branch"]
342 elif branch in (".", "null", "tip"):
358 elif branch in (".", "null", "tip"):
343 raise error.RevlogError(_('the name \'%s\' is reserved')
359 raise error.RevlogError(_('the name \'%s\' is reserved')
344 % branch)
360 % branch)
345 if extra:
361 if extra:
346 extra = encodeextra(extra)
362 extra = encodeextra(extra)
347 parseddate = "%s %s" % (parseddate, extra)
363 parseddate = "%s %s" % (parseddate, extra)
348 l = [hex(manifest), user, parseddate] + sorted(files) + ["", desc]
364 l = [hex(manifest), user, parseddate] + sorted(files) + ["", desc]
349 text = "\n".join(l)
365 text = "\n".join(l)
350 return self.addrevision(text, transaction, len(self), p1, p2)
366 return self.addrevision(text, transaction, len(self), p1, p2)
351
367
352 def branchinfo(self, rev):
368 def branchinfo(self, rev):
353 """return the branch name and open/close state of a revision
369 """return the branch name and open/close state of a revision
354
370
355 This function exists because creating a changectx object
371 This function exists because creating a changectx object
356 just to access this is costly."""
372 just to access this is costly."""
357 extra = self.read(rev)[5]
373 extra = self.read(rev)[5]
358 return encoding.tolocal(extra.get("branch")), 'close' in extra
374 return encoding.tolocal(extra.get("branch")), 'close' in extra
General Comments 0
You need to be logged in to leave comments. Login now