1# journal.py 2# 3# Copyright 2014-2016 Facebook, Inc. 4# 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. 7"""track previous positions of bookmarks (EXPERIMENTAL) 8 9This extension adds a new command: `hg journal`, which shows you where 10bookmarks were previously located. 11 12""" 13 14from __future__ import absolute_import 15 16import collections 17import errno 18import os 19import weakref 20 21from mercurial.i18n import _ 22from mercurial.node import ( 23 bin, 24 hex, 25) 26 27from mercurial import ( 28 bookmarks, 29 cmdutil, 30 dispatch, 31 encoding, 32 error, 33 extensions, 34 hg, 35 localrepo, 36 lock, 37 logcmdutil, 38 pycompat, 39 registrar, 40 util, 41) 42from mercurial.utils import ( 43 dateutil, 44 procutil, 45 stringutil, 46) 47 48cmdtable = {} 49command = registrar.command(cmdtable) 50 51# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for 52# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should 53# be specifying the version(s) of Mercurial they are tested with, or 54# leave the attribute unspecified. 55testedwith = b'ships-with-hg-core' 56 57# storage format version; increment when the format changes 58storageversion = 0 59 60# namespaces 61bookmarktype = b'bookmark' 62wdirparenttype = b'wdirparent' 63# In a shared repository, what shared feature name is used 64# to indicate this namespace is shared with the source? 65sharednamespaces = { 66 bookmarktype: hg.sharedbookmarks, 67} 68 69# Journal recording, register hooks and storage object 70def extsetup(ui): 71 extensions.wrapfunction(dispatch, b'runcommand', runcommand) 72 extensions.wrapfunction(bookmarks.bmstore, b'_write', recordbookmarks) 73 extensions.wrapfilecache( 74 localrepo.localrepository, b'dirstate', wrapdirstate 75 ) 76 extensions.wrapfunction(hg, b'postshare', wrappostshare) 77 extensions.wrapfunction(hg, b'copystore', unsharejournal) 78 79 80def reposetup(ui, repo): 81 if repo.local(): 82 repo.journal = journalstorage(repo) 83 repo._wlockfreeprefix.add(b'namejournal') 84 85 dirstate, cached = localrepo.isfilecached(repo, b'dirstate') 86 if cached: 87 # already instantiated dirstate isn't yet marked as 88 # "journal"-ing, even though repo.dirstate() was already 89 # wrapped by own wrapdirstate() 90 _setupdirstate(repo, dirstate) 91 92 93def runcommand(orig, lui, repo, cmd, fullargs, *args): 94 """Track the command line options for recording in the journal""" 95 journalstorage.recordcommand(*fullargs) 96 return orig(lui, repo, cmd, fullargs, *args) 97 98 99def _setupdirstate(repo, dirstate): 100 dirstate.journalstorage = repo.journal 101 dirstate.addparentchangecallback(b'journal', recorddirstateparents) 102 103 104# hooks to record dirstate changes 105def wrapdirstate(orig, repo): 106 """Make journal storage available to the dirstate object""" 107 dirstate = orig(repo) 108 if util.safehasattr(repo, 'journal'): 109 _setupdirstate(repo, dirstate) 110 return dirstate 111 112 113def recorddirstateparents(dirstate, old, new): 114 """Records all dirstate parent changes in the journal.""" 115 old = list(old) 116 new = list(new) 117 if util.safehasattr(dirstate, 'journalstorage'): 118 # only record two hashes if there was a merge 119 oldhashes = old[:1] if old[1] == dirstate._nodeconstants.nullid else old 120 newhashes = new[:1] if new[1] == dirstate._nodeconstants.nullid else new 121 dirstate.journalstorage.record( 122 wdirparenttype, b'.', oldhashes, newhashes 123 ) 124 125 126# hooks to record bookmark changes (both local and remote) 127def recordbookmarks(orig, store, fp): 128 """Records all bookmark changes in the journal.""" 129 repo = store._repo 130 if util.safehasattr(repo, 'journal'): 131 oldmarks = bookmarks.bmstore(repo) 132 for mark, value in pycompat.iteritems(store): 133 oldvalue = oldmarks.get(mark, repo.nullid) 134 if value != oldvalue: 135 repo.journal.record(bookmarktype, mark, oldvalue, value) 136 return orig(store, fp) 137 138 139# shared repository support 140def _readsharedfeatures(repo): 141 """A set of shared features for this repository""" 142 try: 143 return set(repo.vfs.read(b'shared').splitlines()) 144 except IOError as inst: 145 if inst.errno != errno.ENOENT: 146 raise 147 return set() 148 149 150def _mergeentriesiter(*iterables, **kwargs): 151 """Given a set of sorted iterables, yield the next entry in merged order 152 153 Note that by default entries go from most recent to oldest. 154 """ 155 order = kwargs.pop('order', max) 156 iterables = [iter(it) for it in iterables] 157 # this tracks still active iterables; iterables are deleted as they are 158 # exhausted, which is why this is a dictionary and why each entry also 159 # stores the key. Entries are mutable so we can store the next value each 160 # time. 161 iterable_map = {} 162 for key, it in enumerate(iterables): 163 try: 164 iterable_map[key] = [next(it), key, it] 165 except StopIteration: 166 # empty entry, can be ignored 167 pass 168 169 while iterable_map: 170 value, key, it = order(pycompat.itervalues(iterable_map)) 171 yield value 172 try: 173 iterable_map[key][0] = next(it) 174 except StopIteration: 175 # this iterable is empty, remove it from consideration 176 del iterable_map[key] 177 178 179def wrappostshare(orig, sourcerepo, destrepo, **kwargs): 180 """Mark this shared working copy as sharing journal information""" 181 with destrepo.wlock(): 182 orig(sourcerepo, destrepo, **kwargs) 183 with destrepo.vfs(b'shared', b'a') as fp: 184 fp.write(b'journal\n') 185 186 187def unsharejournal(orig, ui, repo, repopath): 188 """Copy shared journal entries into this repo when unsharing""" 189 if ( 190 repo.path == repopath 191 and repo.shared() 192 and util.safehasattr(repo, 'journal') 193 ): 194 sharedrepo = hg.sharedreposource(repo) 195 sharedfeatures = _readsharedfeatures(repo) 196 if sharedrepo and sharedfeatures > {b'journal'}: 197 # there is a shared repository and there are shared journal entries 198 # to copy. move shared date over from source to destination but 199 # move the local file first 200 if repo.vfs.exists(b'namejournal'): 201 journalpath = repo.vfs.join(b'namejournal') 202 util.rename(journalpath, journalpath + b'.bak') 203 storage = repo.journal 204 local = storage._open( 205 repo.vfs, filename=b'namejournal.bak', _newestfirst=False 206 ) 207 shared = ( 208 e 209 for e in storage._open(sharedrepo.vfs, _newestfirst=False) 210 if sharednamespaces.get(e.namespace) in sharedfeatures 211 ) 212 for entry in _mergeentriesiter(local, shared, order=min): 213 storage._write(repo.vfs, entry) 214 215 return orig(ui, repo, repopath) 216 217 218class journalentry( 219 collections.namedtuple( 220 'journalentry', 221 'timestamp user command namespace name oldhashes newhashes', 222 ) 223): 224 """Individual journal entry 225 226 * timestamp: a mercurial (time, timezone) tuple 227 * user: the username that ran the command 228 * namespace: the entry namespace, an opaque string 229 * name: the name of the changed item, opaque string with meaning in the 230 namespace 231 * command: the hg command that triggered this record 232 * oldhashes: a tuple of one or more binary hashes for the old location 233 * newhashes: a tuple of one or more binary hashes for the new location 234 235 Handles serialisation from and to the storage format. Fields are 236 separated by newlines, hashes are written out in hex separated by commas, 237 timestamp and timezone are separated by a space. 238 239 """ 240 241 @classmethod 242 def fromstorage(cls, line): 243 ( 244 time, 245 user, 246 command, 247 namespace, 248 name, 249 oldhashes, 250 newhashes, 251 ) = line.split(b'\n') 252 timestamp, tz = time.split() 253 timestamp, tz = float(timestamp), int(tz) 254 oldhashes = tuple(bin(hash) for hash in oldhashes.split(b',')) 255 newhashes = tuple(bin(hash) for hash in newhashes.split(b',')) 256 return cls( 257 (timestamp, tz), 258 user, 259 command, 260 namespace, 261 name, 262 oldhashes, 263 newhashes, 264 ) 265 266 def __bytes__(self): 267 """bytes representation for storage""" 268 time = b' '.join(map(pycompat.bytestr, self.timestamp)) 269 oldhashes = b','.join([hex(hash) for hash in self.oldhashes]) 270 newhashes = b','.join([hex(hash) for hash in self.newhashes]) 271 return b'\n'.join( 272 ( 273 time, 274 self.user, 275 self.command, 276 self.namespace, 277 self.name, 278 oldhashes, 279 newhashes, 280 ) 281 ) 282 283 __str__ = encoding.strmethod(__bytes__) 284 285 286class journalstorage(object): 287 """Storage for journal entries 288 289 Entries are divided over two files; one with entries that pertain to the 290 local working copy *only*, and one with entries that are shared across 291 multiple working copies when shared using the share extension. 292 293 Entries are stored with NUL bytes as separators. See the journalentry 294 class for the per-entry structure. 295 296 The file format starts with an integer version, delimited by a NUL. 297 298 This storage uses a dedicated lock; this makes it easier to avoid issues 299 with adding entries that added when the regular wlock is unlocked (e.g. 300 the dirstate). 301 302 """ 303 304 _currentcommand = () 305 _lockref = None 306 307 def __init__(self, repo): 308 self.user = procutil.getuser() 309 self.ui = repo.ui 310 self.vfs = repo.vfs 311 312 # is this working copy using a shared storage? 313 self.sharedfeatures = self.sharedvfs = None 314 if repo.shared(): 315 features = _readsharedfeatures(repo) 316 sharedrepo = hg.sharedreposource(repo) 317 if sharedrepo is not None and b'journal' in features: 318 self.sharedvfs = sharedrepo.vfs 319 self.sharedfeatures = features 320 321 # track the current command for recording in journal entries 322 @property 323 def command(self): 324 commandstr = b' '.join( 325 map(procutil.shellquote, journalstorage._currentcommand) 326 ) 327 if b'\n' in commandstr: 328 # truncate multi-line commands 329 commandstr = commandstr.partition(b'\n')[0] + b' ...' 330 return commandstr 331 332 @classmethod 333 def recordcommand(cls, *fullargs): 334 """Set the current hg arguments, stored with recorded entries""" 335 # Set the current command on the class because we may have started 336 # with a non-local repo (cloning for example). 337 cls._currentcommand = fullargs 338 339 def _currentlock(self, lockref): 340 """Returns the lock if it's held, or None if it's not. 341 342 (This is copied from the localrepo class) 343 """ 344 if lockref is None: 345 return None 346 l = lockref() 347 if l is None or not l.held: 348 return None 349 return l 350 351 def jlock(self, vfs): 352 """Create a lock for the journal file""" 353 if self._currentlock(self._lockref) is not None: 354 raise error.Abort(_(b'journal lock does not support nesting')) 355 desc = _(b'journal of %s') % vfs.base 356 try: 357 l = lock.lock(vfs, b'namejournal.lock', 0, desc=desc) 358 except error.LockHeld as inst: 359 self.ui.warn( 360 _(b"waiting for lock on %s held by %r\n") % (desc, inst.locker) 361 ) 362 # default to 600 seconds timeout 363 l = lock.lock( 364 vfs, 365 b'namejournal.lock', 366 self.ui.configint(b"ui", b"timeout"), 367 desc=desc, 368 ) 369 self.ui.warn(_(b"got lock after %s seconds\n") % l.delay) 370 self._lockref = weakref.ref(l) 371 return l 372 373 def record(self, namespace, name, oldhashes, newhashes): 374 """Record a new journal entry 375 376 * namespace: an opaque string; this can be used to filter on the type 377 of recorded entries. 378 * name: the name defining this entry; for bookmarks, this is the 379 bookmark name. Can be filtered on when retrieving entries. 380 * oldhashes and newhashes: each a single binary hash, or a list of 381 binary hashes. These represent the old and new position of the named 382 item. 383 384 """ 385 if not isinstance(oldhashes, list): 386 oldhashes = [oldhashes] 387 if not isinstance(newhashes, list): 388 newhashes = [newhashes] 389 390 entry = journalentry( 391 dateutil.makedate(), 392 self.user, 393 self.command, 394 namespace, 395 name, 396 oldhashes, 397 newhashes, 398 ) 399 400 vfs = self.vfs 401 if self.sharedvfs is not None: 402 # write to the shared repository if this feature is being 403 # shared between working copies. 404 if sharednamespaces.get(namespace) in self.sharedfeatures: 405 vfs = self.sharedvfs 406 407 self._write(vfs, entry) 408 409 def _write(self, vfs, entry): 410 with self.jlock(vfs): 411 # open file in amend mode to ensure it is created if missing 412 with vfs(b'namejournal', mode=b'a+b') as f: 413 f.seek(0, os.SEEK_SET) 414 # Read just enough bytes to get a version number (up to 2 415 # digits plus separator) 416 version = f.read(3).partition(b'\0')[0] 417 if version and version != b"%d" % storageversion: 418 # different version of the storage. Exit early (and not 419 # write anything) if this is not a version we can handle or 420 # the file is corrupt. In future, perhaps rotate the file 421 # instead? 422 self.ui.warn( 423 _(b"unsupported journal file version '%s'\n") % version 424 ) 425 return 426 if not version: 427 # empty file, write version first 428 f.write((b"%d" % storageversion) + b'\0') 429 f.seek(0, os.SEEK_END) 430 f.write(bytes(entry) + b'\0') 431 432 def filtered(self, namespace=None, name=None): 433 """Yield all journal entries with the given namespace or name 434 435 Both the namespace and the name are optional; if neither is given all 436 entries in the journal are produced. 437 438 Matching supports regular expressions by using the `re:` prefix 439 (use `literal:` to match names or namespaces that start with `re:`) 440 441 """ 442 if namespace is not None: 443 namespace = stringutil.stringmatcher(namespace)[-1] 444 if name is not None: 445 name = stringutil.stringmatcher(name)[-1] 446 for entry in self: 447 if namespace is not None and not namespace(entry.namespace): 448 continue 449 if name is not None and not name(entry.name): 450 continue 451 yield entry 452 453 def __iter__(self): 454 """Iterate over the storage 455 456 Yields journalentry instances for each contained journal record. 457 458 """ 459 local = self._open(self.vfs) 460 461 if self.sharedvfs is None: 462 return local 463 464 # iterate over both local and shared entries, but only those 465 # shared entries that are among the currently shared features 466 shared = ( 467 e 468 for e in self._open(self.sharedvfs) 469 if sharednamespaces.get(e.namespace) in self.sharedfeatures 470 ) 471 return _mergeentriesiter(local, shared) 472 473 def _open(self, vfs, filename=b'namejournal', _newestfirst=True): 474 if not vfs.exists(filename): 475 return 476 477 with vfs(filename) as f: 478 raw = f.read() 479 480 lines = raw.split(b'\0') 481 version = lines and lines[0] 482 if version != b"%d" % storageversion: 483 version = version or _(b'not available') 484 raise error.Abort(_(b"unknown journal file version '%s'") % version) 485 486 # Skip the first line, it's a version number. Normally we iterate over 487 # these in reverse order to list newest first; only when copying across 488 # a shared storage do we forgo reversing. 489 lines = lines[1:] 490 if _newestfirst: 491 lines = reversed(lines) 492 for line in lines: 493 if not line: 494 continue 495 yield journalentry.fromstorage(line) 496 497 498# journal reading 499# log options that don't make sense for journal 500_ignoreopts = (b'no-merges', b'graph') 501 502 503@command( 504 b'journal', 505 [ 506 (b'', b'all', None, b'show history for all names'), 507 (b'c', b'commits', None, b'show commit metadata'), 508 ] 509 + [opt for opt in cmdutil.logopts if opt[1] not in _ignoreopts], 510 b'[OPTION]... [BOOKMARKNAME]', 511 helpcategory=command.CATEGORY_CHANGE_ORGANIZATION, 512) 513def journal(ui, repo, *args, **opts): 514 """show the previous position of bookmarks and the working copy 515 516 The journal is used to see the previous commits that bookmarks and the 517 working copy pointed to. By default the previous locations for the working 518 copy. Passing a bookmark name will show all the previous positions of 519 that bookmark. Use the --all switch to show previous locations for all 520 bookmarks and the working copy; each line will then include the bookmark 521 name, or '.' for the working copy, as well. 522 523 If `name` starts with `re:`, the remainder of the name is treated as 524 a regular expression. To match a name that actually starts with `re:`, 525 use the prefix `literal:`. 526 527 By default hg journal only shows the commit hash and the command that was 528 running at that time. -v/--verbose will show the prior hash, the user, and 529 the time at which it happened. 530 531 Use -c/--commits to output log information on each commit hash; at this 532 point you can use the usual `--patch`, `--git`, `--stat` and `--template` 533 switches to alter the log output for these. 534 535 `hg journal -T json` can be used to produce machine readable output. 536 537 """ 538 opts = pycompat.byteskwargs(opts) 539 name = b'.' 540 if opts.get(b'all'): 541 if args: 542 raise error.Abort( 543 _(b"You can't combine --all and filtering on a name") 544 ) 545 name = None 546 if args: 547 name = args[0] 548 549 fm = ui.formatter(b'journal', opts) 550 551 def formatnodes(nodes): 552 return fm.formatlist(map(fm.hexfunc, nodes), name=b'node', sep=b',') 553 554 if opts.get(b"template") != b"json": 555 if name is None: 556 displayname = _(b'the working copy and bookmarks') 557 else: 558 displayname = b"'%s'" % name 559 ui.status(_(b"previous locations of %s:\n") % displayname) 560 561 limit = logcmdutil.getlimit(opts) 562 entry = None 563 ui.pager(b'journal') 564 for count, entry in enumerate(repo.journal.filtered(name=name)): 565 if count == limit: 566 break 567 568 fm.startitem() 569 fm.condwrite( 570 ui.verbose, b'oldnodes', b'%s -> ', formatnodes(entry.oldhashes) 571 ) 572 fm.write(b'newnodes', b'%s', formatnodes(entry.newhashes)) 573 fm.condwrite(ui.verbose, b'user', b' %-8s', entry.user) 574 fm.condwrite( 575 opts.get(b'all') or name.startswith(b're:'), 576 b'name', 577 b' %-8s', 578 entry.name, 579 ) 580 581 fm.condwrite( 582 ui.verbose, 583 b'date', 584 b' %s', 585 fm.formatdate(entry.timestamp, b'%Y-%m-%d %H:%M %1%2'), 586 ) 587 fm.write(b'command', b' %s\n', entry.command) 588 589 if opts.get(b"commits"): 590 if fm.isplain(): 591 displayer = logcmdutil.changesetdisplayer(ui, repo, opts) 592 else: 593 displayer = logcmdutil.changesetformatter( 594 ui, repo, fm.nested(b'changesets'), diffopts=opts 595 ) 596 for hash in entry.newhashes: 597 try: 598 ctx = repo[hash] 599 displayer.show(ctx) 600 except error.RepoLookupError as e: 601 fm.plain(b"%s\n\n" % pycompat.bytestr(e)) 602 displayer.close() 603 604 fm.end() 605 606 if entry is None: 607 ui.status(_(b"no recorded locations\n")) 608