##// END OF EJS Templates
dirstate: factor the identity getting/setting code in the dirstate map...
marmoute -
r51406:85746485 default
parent child Browse files
Show More
@@ -1,705 +1,706 b''
1 1 # dirstatemap.py
2 2 #
3 3 # This software may be used and distributed according to the terms of the
4 4 # GNU General Public License version 2 or any later version.
5 5
6 6
7 7 from .i18n import _
8 8
9 9 from . import (
10 10 error,
11 11 pathutil,
12 12 policy,
13 13 txnutil,
14 14 util,
15 15 )
16 16
17 17 from .dirstateutils import (
18 18 docket as docketmod,
19 19 v2,
20 20 )
21 21
22 22 parsers = policy.importmod('parsers')
23 23 rustmod = policy.importrust('dirstate')
24 24
25 25 propertycache = util.propertycache
26 26
27 27 if rustmod is None:
28 28 DirstateItem = parsers.DirstateItem
29 29 else:
30 30 DirstateItem = rustmod.DirstateItem
31 31
32 32 rangemask = 0x7FFFFFFF
33 33
34 34
35 35 class _dirstatemapcommon:
36 36 """
37 37 Methods that are identical for both implementations of the dirstatemap
38 38 class, with and without Rust extensions enabled.
39 39 """
40 40
41 41 # please pytype
42 42
43 43 _map = None
44 44 copymap = None
45 45
46 46 def __init__(self, ui, opener, root, nodeconstants, use_dirstate_v2):
47 47 self._use_dirstate_v2 = use_dirstate_v2
48 48 self._nodeconstants = nodeconstants
49 49 self._ui = ui
50 50 self._opener = opener
51 51 self._root = root
52 52 self._filename = b'dirstate'
53 53 self._nodelen = 20 # Also update Rust code when changing this!
54 54 self._parents = None
55 55 self._dirtyparents = False
56 56 self._docket = None
57 57
58 58 # for consistent view between _pl() and _read() invocations
59 59 self._pendingmode = None
60 60
61 def _set_identity(self):
62 self.identity = self._get_current_identity()
63
64 def _get_current_identity(self):
65 try:
66 return util.cachestat(self._opener.join(self._filename))
67 except FileNotFoundError:
68 return None
69
61 70 def preload(self):
62 71 """Loads the underlying data, if it's not already loaded"""
63 72 self._map
64 73
65 74 def get(self, key, default=None):
66 75 return self._map.get(key, default)
67 76
68 77 def __len__(self):
69 78 return len(self._map)
70 79
71 80 def __iter__(self):
72 81 return iter(self._map)
73 82
74 83 def __contains__(self, key):
75 84 return key in self._map
76 85
77 86 def __getitem__(self, item):
78 87 return self._map[item]
79 88
80 89 ### disk interaction
81 90
82 91 def _opendirstatefile(self):
83 92 fp, mode = txnutil.trypending(self._root, self._opener, self._filename)
84 93 if self._pendingmode is not None and self._pendingmode != mode:
85 94 fp.close()
86 95 raise error.Abort(
87 96 _(b'working directory state may be changed parallelly')
88 97 )
89 98 self._pendingmode = mode
90 99 return fp
91 100
92 101 def _readdirstatefile(self, size=-1):
93 102 try:
94 103 with self._opendirstatefile() as fp:
95 104 return fp.read(size)
96 105 except FileNotFoundError:
97 106 # File doesn't exist, so the current state is empty
98 107 return b''
99 108
100 109 @property
101 110 def docket(self):
102 111 if not self._docket:
103 112 if not self._use_dirstate_v2:
104 113 raise error.ProgrammingError(
105 114 b'dirstate only has a docket in v2 format'
106 115 )
107 116 self._docket = docketmod.DirstateDocket.parse(
108 117 self._readdirstatefile(), self._nodeconstants
109 118 )
110 119 return self._docket
111 120
112 121 def write_v2_no_append(self, tr, st, meta, packed):
113 122 old_docket = self.docket
114 123 new_docket = docketmod.DirstateDocket.with_new_uuid(
115 124 self.parents(), len(packed), meta
116 125 )
117 126 if old_docket.uuid == new_docket.uuid:
118 127 raise error.ProgrammingError(b'dirstate docket name collision')
119 128 data_filename = new_docket.data_filename()
120 129 self._opener.write(data_filename, packed)
121 130 # tell the transaction that we are adding a new file
122 131 if tr is not None:
123 132 tr.addbackup(data_filename, location=b'plain')
124 133 # Write the new docket after the new data file has been
125 134 # written. Because `st` was opened with `atomictemp=True`,
126 135 # the actual `.hg/dirstate` file is only affected on close.
127 136 st.write(new_docket.serialize())
128 137 st.close()
129 138 # Remove the old data file after the new docket pointing to
130 139 # the new data file was written.
131 140 if old_docket.uuid:
132 141 data_filename = old_docket.data_filename()
133 142 if tr is not None:
134 143 tr.addbackup(data_filename, location=b'plain')
135 144 unlink = lambda _tr=None: self._opener.unlink(data_filename)
136 145 if tr:
137 146 category = b"dirstate-v2-clean-" + old_docket.uuid
138 147 tr.addpostclose(category, unlink)
139 148 else:
140 149 unlink()
141 150 self._docket = new_docket
142 151
143 152 ### reading/setting parents
144 153
145 154 def parents(self):
146 155 if not self._parents:
147 156 if self._use_dirstate_v2:
148 157 self._parents = self.docket.parents
149 158 else:
150 159 read_len = self._nodelen * 2
151 160 st = self._readdirstatefile(read_len)
152 161 l = len(st)
153 162 if l == read_len:
154 163 self._parents = (
155 164 st[: self._nodelen],
156 165 st[self._nodelen : 2 * self._nodelen],
157 166 )
158 167 elif l == 0:
159 168 self._parents = (
160 169 self._nodeconstants.nullid,
161 170 self._nodeconstants.nullid,
162 171 )
163 172 else:
164 173 raise error.Abort(
165 174 _(b'working directory state appears damaged!')
166 175 )
167 176
168 177 return self._parents
169 178
170 179
171 180 class dirstatemap(_dirstatemapcommon):
172 181 """Map encapsulating the dirstate's contents.
173 182
174 183 The dirstate contains the following state:
175 184
176 185 - `identity` is the identity of the dirstate file, which can be used to
177 186 detect when changes have occurred to the dirstate file.
178 187
179 188 - `parents` is a pair containing the parents of the working copy. The
180 189 parents are updated by calling `setparents`.
181 190
182 191 - the state map maps filenames to tuples of (state, mode, size, mtime),
183 192 where state is a single character representing 'normal', 'added',
184 193 'removed', or 'merged'. It is read by treating the dirstate as a
185 194 dict. File state is updated by calling various methods (see each
186 195 documentation for details):
187 196
188 197 - `reset_state`,
189 198 - `set_tracked`
190 199 - `set_untracked`
191 200 - `set_clean`
192 201 - `set_possibly_dirty`
193 202
194 203 - `copymap` maps destination filenames to their source filename.
195 204
196 205 The dirstate also provides the following views onto the state:
197 206
198 207 - `filefoldmap` is a dict mapping normalized filenames to the denormalized
199 208 form that they appear as in the dirstate.
200 209
201 210 - `dirfoldmap` is a dict mapping normalized directory names to the
202 211 denormalized form that they appear as in the dirstate.
203 212 """
204 213
205 214 ### Core data storage and access
206 215
207 216 @propertycache
208 217 def _map(self):
209 218 self._map = {}
210 219 self.read()
211 220 return self._map
212 221
213 222 @propertycache
214 223 def copymap(self):
215 224 self.copymap = {}
216 225 self._map
217 226 return self.copymap
218 227
219 228 def clear(self):
220 229 self._map.clear()
221 230 self.copymap.clear()
222 231 self.setparents(self._nodeconstants.nullid, self._nodeconstants.nullid)
223 232 util.clearcachedproperty(self, b"_dirs")
224 233 util.clearcachedproperty(self, b"_alldirs")
225 234 util.clearcachedproperty(self, b"filefoldmap")
226 235 util.clearcachedproperty(self, b"dirfoldmap")
227 236
228 237 def items(self):
229 238 return self._map.items()
230 239
231 240 # forward for python2,3 compat
232 241 iteritems = items
233 242
234 243 def debug_iter(self, all):
235 244 """
236 245 Return an iterator of (filename, state, mode, size, mtime) tuples
237 246
238 247 `all` is unused when Rust is not enabled
239 248 """
240 249 for (filename, item) in self.items():
241 250 yield (filename, item.state, item.mode, item.size, item.mtime)
242 251
243 252 def keys(self):
244 253 return self._map.keys()
245 254
246 255 ### reading/setting parents
247 256
248 257 def setparents(self, p1, p2, fold_p2=False):
249 258 self._parents = (p1, p2)
250 259 self._dirtyparents = True
251 260 copies = {}
252 261 if fold_p2:
253 262 for f, s in self._map.items():
254 263 # Discard "merged" markers when moving away from a merge state
255 264 if s.p2_info:
256 265 source = self.copymap.pop(f, None)
257 266 if source:
258 267 copies[f] = source
259 268 s.drop_merge_data()
260 269 return copies
261 270
262 271 ### disk interaction
263 272
264 273 def read(self):
265 274 # ignore HG_PENDING because identity is used only for writing
266 try:
267 self.identity = util.cachestat(self._opener.join(self._filename))
268 except FileNotFoundError:
269 self.identity = None
275 self._set_identity()
270 276
271 277 if self._use_dirstate_v2:
272 278 if not self.docket.uuid:
273 279 return
274 280 st = self._opener.read(self.docket.data_filename())
275 281 else:
276 282 st = self._readdirstatefile()
277 283
278 284 if not st:
279 285 return
280 286
281 287 # TODO: adjust this estimate for dirstate-v2
282 288 if util.safehasattr(parsers, b'dict_new_presized'):
283 289 # Make an estimate of the number of files in the dirstate based on
284 290 # its size. This trades wasting some memory for avoiding costly
285 291 # resizes. Each entry have a prefix of 17 bytes followed by one or
286 292 # two path names. Studies on various large-scale real-world repositories
287 293 # found 54 bytes a reasonable upper limit for the average path names.
288 294 # Copy entries are ignored for the sake of this estimate.
289 295 self._map = parsers.dict_new_presized(len(st) // 71)
290 296
291 297 # Python's garbage collector triggers a GC each time a certain number
292 298 # of container objects (the number being defined by
293 299 # gc.get_threshold()) are allocated. parse_dirstate creates a tuple
294 300 # for each file in the dirstate. The C version then immediately marks
295 301 # them as not to be tracked by the collector. However, this has no
296 302 # effect on when GCs are triggered, only on what objects the GC looks
297 303 # into. This means that O(number of files) GCs are unavoidable.
298 304 # Depending on when in the process's lifetime the dirstate is parsed,
299 305 # this can get very expensive. As a workaround, disable GC while
300 306 # parsing the dirstate.
301 307 #
302 308 # (we cannot decorate the function directly since it is in a C module)
303 309 if self._use_dirstate_v2:
304 310 p = self.docket.parents
305 311 meta = self.docket.tree_metadata
306 312 parse_dirstate = util.nogc(v2.parse_dirstate)
307 313 parse_dirstate(self._map, self.copymap, st, meta)
308 314 else:
309 315 parse_dirstate = util.nogc(parsers.parse_dirstate)
310 316 p = parse_dirstate(self._map, self.copymap, st)
311 317 if not self._dirtyparents:
312 318 self.setparents(*p)
313 319
314 320 # Avoid excess attribute lookups by fast pathing certain checks
315 321 self.__contains__ = self._map.__contains__
316 322 self.__getitem__ = self._map.__getitem__
317 323 self.get = self._map.get
318 324
319 325 def write(self, tr, st):
320 326 if self._use_dirstate_v2:
321 327 packed, meta = v2.pack_dirstate(self._map, self.copymap)
322 328 self.write_v2_no_append(tr, st, meta, packed)
323 329 else:
324 330 packed = parsers.pack_dirstate(
325 331 self._map, self.copymap, self.parents()
326 332 )
327 333 st.write(packed)
328 334 st.close()
329 335 self._dirtyparents = False
330 336
331 337 @propertycache
332 338 def identity(self):
333 339 self._map
334 340 return self.identity
335 341
336 342 ### code related to maintaining and accessing "extra" property
337 343 # (e.g. "has_dir")
338 344
339 345 def _dirs_incr(self, filename, old_entry=None):
340 346 """increment the dirstate counter if applicable"""
341 347 if (
342 348 old_entry is None or old_entry.removed
343 349 ) and "_dirs" in self.__dict__:
344 350 self._dirs.addpath(filename)
345 351 if old_entry is None and "_alldirs" in self.__dict__:
346 352 self._alldirs.addpath(filename)
347 353
348 354 def _dirs_decr(self, filename, old_entry=None, remove_variant=False):
349 355 """decrement the dirstate counter if applicable"""
350 356 if old_entry is not None:
351 357 if "_dirs" in self.__dict__ and not old_entry.removed:
352 358 self._dirs.delpath(filename)
353 359 if "_alldirs" in self.__dict__ and not remove_variant:
354 360 self._alldirs.delpath(filename)
355 361 elif remove_variant and "_alldirs" in self.__dict__:
356 362 self._alldirs.addpath(filename)
357 363 if "filefoldmap" in self.__dict__:
358 364 normed = util.normcase(filename)
359 365 self.filefoldmap.pop(normed, None)
360 366
361 367 @propertycache
362 368 def filefoldmap(self):
363 369 """Returns a dictionary mapping normalized case paths to their
364 370 non-normalized versions.
365 371 """
366 372 try:
367 373 makefilefoldmap = parsers.make_file_foldmap
368 374 except AttributeError:
369 375 pass
370 376 else:
371 377 return makefilefoldmap(
372 378 self._map, util.normcasespec, util.normcasefallback
373 379 )
374 380
375 381 f = {}
376 382 normcase = util.normcase
377 383 for name, s in self._map.items():
378 384 if not s.removed:
379 385 f[normcase(name)] = name
380 386 f[b'.'] = b'.' # prevents useless util.fspath() invocation
381 387 return f
382 388
383 389 @propertycache
384 390 def dirfoldmap(self):
385 391 f = {}
386 392 normcase = util.normcase
387 393 for name in self._dirs:
388 394 f[normcase(name)] = name
389 395 return f
390 396
391 397 def hastrackeddir(self, d):
392 398 """
393 399 Returns True if the dirstate contains a tracked (not removed) file
394 400 in this directory.
395 401 """
396 402 return d in self._dirs
397 403
398 404 def hasdir(self, d):
399 405 """
400 406 Returns True if the dirstate contains a file (tracked or removed)
401 407 in this directory.
402 408 """
403 409 return d in self._alldirs
404 410
405 411 @propertycache
406 412 def _dirs(self):
407 413 return pathutil.dirs(self._map, only_tracked=True)
408 414
409 415 @propertycache
410 416 def _alldirs(self):
411 417 return pathutil.dirs(self._map)
412 418
413 419 ### code related to manipulation of entries and copy-sources
414 420
415 421 def reset_state(
416 422 self,
417 423 filename,
418 424 wc_tracked=False,
419 425 p1_tracked=False,
420 426 p2_info=False,
421 427 has_meaningful_mtime=True,
422 428 parentfiledata=None,
423 429 ):
424 430 """Set a entry to a given state, diregarding all previous state
425 431
426 432 This is to be used by the part of the dirstate API dedicated to
427 433 adjusting the dirstate after a update/merge.
428 434
429 435 note: calling this might result to no entry existing at all if the
430 436 dirstate map does not see any point at having one for this file
431 437 anymore.
432 438 """
433 439 # copy information are now outdated
434 440 # (maybe new information should be in directly passed to this function)
435 441 self.copymap.pop(filename, None)
436 442
437 443 if not (p1_tracked or p2_info or wc_tracked):
438 444 old_entry = self._map.get(filename)
439 445 self._drop_entry(filename)
440 446 self._dirs_decr(filename, old_entry=old_entry)
441 447 return
442 448
443 449 old_entry = self._map.get(filename)
444 450 self._dirs_incr(filename, old_entry)
445 451 entry = DirstateItem(
446 452 wc_tracked=wc_tracked,
447 453 p1_tracked=p1_tracked,
448 454 p2_info=p2_info,
449 455 has_meaningful_mtime=has_meaningful_mtime,
450 456 parentfiledata=parentfiledata,
451 457 )
452 458 self._map[filename] = entry
453 459
454 460 def set_tracked(self, filename):
455 461 new = False
456 462 entry = self.get(filename)
457 463 if entry is None:
458 464 self._dirs_incr(filename)
459 465 entry = DirstateItem(
460 466 wc_tracked=True,
461 467 )
462 468
463 469 self._map[filename] = entry
464 470 new = True
465 471 elif not entry.tracked:
466 472 self._dirs_incr(filename, entry)
467 473 entry.set_tracked()
468 474 self._refresh_entry(filename, entry)
469 475 new = True
470 476 else:
471 477 # XXX This is probably overkill for more case, but we need this to
472 478 # fully replace the `normallookup` call with `set_tracked` one.
473 479 # Consider smoothing this in the future.
474 480 entry.set_possibly_dirty()
475 481 self._refresh_entry(filename, entry)
476 482 return new
477 483
478 484 def set_untracked(self, f):
479 485 """Mark a file as no longer tracked in the dirstate map"""
480 486 entry = self.get(f)
481 487 if entry is None:
482 488 return False
483 489 else:
484 490 self._dirs_decr(f, old_entry=entry, remove_variant=not entry.added)
485 491 if not entry.p2_info:
486 492 self.copymap.pop(f, None)
487 493 entry.set_untracked()
488 494 self._refresh_entry(f, entry)
489 495 return True
490 496
491 497 def set_clean(self, filename, mode, size, mtime):
492 498 """mark a file as back to a clean state"""
493 499 entry = self[filename]
494 500 size = size & rangemask
495 501 entry.set_clean(mode, size, mtime)
496 502 self._refresh_entry(filename, entry)
497 503 self.copymap.pop(filename, None)
498 504
499 505 def set_possibly_dirty(self, filename):
500 506 """record that the current state of the file on disk is unknown"""
501 507 entry = self[filename]
502 508 entry.set_possibly_dirty()
503 509 self._refresh_entry(filename, entry)
504 510
505 511 def _refresh_entry(self, f, entry):
506 512 """record updated state of an entry"""
507 513 if not entry.any_tracked:
508 514 self._map.pop(f, None)
509 515
510 516 def _drop_entry(self, f):
511 517 """remove any entry for file f
512 518
513 519 This should also drop associated copy information
514 520
515 521 The fact we actually need to drop it is the responsability of the caller"""
516 522 self._map.pop(f, None)
517 523 self.copymap.pop(f, None)
518 524
519 525
520 526 if rustmod is not None:
521 527
522 528 class dirstatemap(_dirstatemapcommon):
523 529
524 530 ### Core data storage and access
525 531
526 532 @propertycache
527 533 def _map(self):
528 534 """
529 535 Fills the Dirstatemap when called.
530 536 """
531 537 # ignore HG_PENDING because identity is used only for writing
532 try:
533 self.identity = util.cachestat(
534 self._opener.join(self._filename)
535 )
536 except FileNotFoundError:
537 self.identity = None
538 self._set_identity()
538 539
539 540 if self._use_dirstate_v2:
540 541 if self.docket.uuid:
541 542 # TODO: use mmap when possible
542 543 data = self._opener.read(self.docket.data_filename())
543 544 else:
544 545 data = b''
545 546 self._map = rustmod.DirstateMap.new_v2(
546 547 data, self.docket.data_size, self.docket.tree_metadata
547 548 )
548 549 parents = self.docket.parents
549 550 else:
550 551 self._map, parents = rustmod.DirstateMap.new_v1(
551 552 self._readdirstatefile()
552 553 )
553 554
554 555 if parents and not self._dirtyparents:
555 556 self.setparents(*parents)
556 557
557 558 self.__contains__ = self._map.__contains__
558 559 self.__getitem__ = self._map.__getitem__
559 560 self.get = self._map.get
560 561 return self._map
561 562
562 563 @property
563 564 def copymap(self):
564 565 return self._map.copymap()
565 566
566 567 def debug_iter(self, all):
567 568 """
568 569 Return an iterator of (filename, state, mode, size, mtime) tuples
569 570
570 571 `all`: also include with `state == b' '` dirstate tree nodes that
571 572 don't have an associated `DirstateItem`.
572 573
573 574 """
574 575 return self._map.debug_iter(all)
575 576
576 577 def clear(self):
577 578 self._map.clear()
578 579 self.setparents(
579 580 self._nodeconstants.nullid, self._nodeconstants.nullid
580 581 )
581 582 util.clearcachedproperty(self, b"_dirs")
582 583 util.clearcachedproperty(self, b"_alldirs")
583 584 util.clearcachedproperty(self, b"dirfoldmap")
584 585
585 586 def items(self):
586 587 return self._map.items()
587 588
588 589 # forward for python2,3 compat
589 590 iteritems = items
590 591
591 592 def keys(self):
592 593 return iter(self._map)
593 594
594 595 ### reading/setting parents
595 596
596 597 def setparents(self, p1, p2, fold_p2=False):
597 598 self._parents = (p1, p2)
598 599 self._dirtyparents = True
599 600 copies = {}
600 601 if fold_p2:
601 602 copies = self._map.setparents_fixup()
602 603 return copies
603 604
604 605 ### disk interaction
605 606
606 607 @propertycache
607 608 def identity(self):
608 609 self._map
609 610 return self.identity
610 611
611 612 def write(self, tr, st):
612 613 if not self._use_dirstate_v2:
613 614 p1, p2 = self.parents()
614 615 packed = self._map.write_v1(p1, p2)
615 616 st.write(packed)
616 617 st.close()
617 618 self._dirtyparents = False
618 619 return
619 620
620 621 # We can only append to an existing data file if there is one
621 622 can_append = self.docket.uuid is not None
622 623 packed, meta, append = self._map.write_v2(can_append)
623 624 if append:
624 625 docket = self.docket
625 626 data_filename = docket.data_filename()
626 627 # We mark it for backup to make sure a future `hg rollback` (or
627 628 # `hg recover`?) call find the data it needs to restore a
628 629 # working repository.
629 630 #
630 631 # The backup can use a hardlink because the format is resistant
631 632 # to trailing "dead" data.
632 633 if tr is not None:
633 634 tr.addbackup(data_filename, location=b'plain')
634 635 with self._opener(data_filename, b'r+b') as fp:
635 636 fp.seek(docket.data_size)
636 637 assert fp.tell() == docket.data_size
637 638 written = fp.write(packed)
638 639 if written is not None: # py2 may return None
639 640 assert written == len(packed), (written, len(packed))
640 641 docket.data_size += len(packed)
641 642 docket.parents = self.parents()
642 643 docket.tree_metadata = meta
643 644 st.write(docket.serialize())
644 645 st.close()
645 646 else:
646 647 self.write_v2_no_append(tr, st, meta, packed)
647 648 # Reload from the newly-written file
648 649 util.clearcachedproperty(self, b"_map")
649 650 self._dirtyparents = False
650 651
651 652 ### code related to maintaining and accessing "extra" property
652 653 # (e.g. "has_dir")
653 654
654 655 @propertycache
655 656 def filefoldmap(self):
656 657 """Returns a dictionary mapping normalized case paths to their
657 658 non-normalized versions.
658 659 """
659 660 return self._map.filefoldmapasdict()
660 661
661 662 def hastrackeddir(self, d):
662 663 return self._map.hastrackeddir(d)
663 664
664 665 def hasdir(self, d):
665 666 return self._map.hasdir(d)
666 667
667 668 @propertycache
668 669 def dirfoldmap(self):
669 670 f = {}
670 671 normcase = util.normcase
671 672 for name in self._map.tracked_dirs():
672 673 f[normcase(name)] = name
673 674 return f
674 675
675 676 ### code related to manipulation of entries and copy-sources
676 677
677 678 def set_tracked(self, f):
678 679 return self._map.set_tracked(f)
679 680
680 681 def set_untracked(self, f):
681 682 return self._map.set_untracked(f)
682 683
683 684 def set_clean(self, filename, mode, size, mtime):
684 685 self._map.set_clean(filename, mode, size, mtime)
685 686
686 687 def set_possibly_dirty(self, f):
687 688 self._map.set_possibly_dirty(f)
688 689
689 690 def reset_state(
690 691 self,
691 692 filename,
692 693 wc_tracked=False,
693 694 p1_tracked=False,
694 695 p2_info=False,
695 696 has_meaningful_mtime=True,
696 697 parentfiledata=None,
697 698 ):
698 699 return self._map.reset_state(
699 700 filename,
700 701 wc_tracked,
701 702 p1_tracked,
702 703 p2_info,
703 704 has_meaningful_mtime,
704 705 parentfiledata,
705 706 )
General Comments 0
You need to be logged in to leave comments. Login now