comparison hgext/journal.py @ 29443:cf092a3d202a

journal: new experimental extension Records bookmark locations and shows you where bookmarks were located in the past. This is the first in a planned series of locations to be recorded; a future patch will add working copy (dirstate) tracking, and remote bookmarks will be supported as well, so the journal storage format should be fairly generic to support those use-cases.
author Martijn Pieters <mjpieters@fb.com>
date Fri, 24 Jun 2016 16:12:05 +0100
parents
children 8361131b4768
comparison
equal deleted inserted replaced
29442:456609cbd840 29443:cf092a3d202a
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
9 This extension adds a new command: `hg journal`, which shows you where
10 bookmarks were previously located.
11
12 """
13
14 from __future__ import absolute_import
15
16 import collections
17 import os
18
19 from mercurial.i18n import _
20
21 from mercurial import (
22 bookmarks,
23 cmdutil,
24 commands,
25 dispatch,
26 error,
27 extensions,
28 node,
29 util,
30 )
31
32 cmdtable = {}
33 command = cmdutil.command(cmdtable)
34
35 # Note for extension authors: ONLY specify testedwith = 'internal' for
36 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
37 # be specifying the version(s) of Mercurial they are tested with, or
38 # leave the attribute unspecified.
39 testedwith = 'internal'
40
41 # storage format version; increment when the format changes
42 storageversion = 0
43
44 # namespaces
45 bookmarktype = 'bookmark'
46
47 # Journal recording, register hooks and storage object
48 def extsetup(ui):
49 extensions.wrapfunction(dispatch, 'runcommand', runcommand)
50 extensions.wrapfunction(bookmarks.bmstore, '_write', recordbookmarks)
51
52 def reposetup(ui, repo):
53 if repo.local():
54 repo.journal = journalstorage(repo)
55
56 def runcommand(orig, lui, repo, cmd, fullargs, *args):
57 """Track the command line options for recording in the journal"""
58 journalstorage.recordcommand(*fullargs)
59 return orig(lui, repo, cmd, fullargs, *args)
60
61 def recordbookmarks(orig, store, fp):
62 """Records all bookmark changes in the journal."""
63 repo = store._repo
64 if util.safehasattr(repo, 'journal'):
65 oldmarks = bookmarks.bmstore(repo)
66 for mark, value in store.iteritems():
67 oldvalue = oldmarks.get(mark, node.nullid)
68 if value != oldvalue:
69 repo.journal.record(bookmarktype, mark, oldvalue, value)
70 return orig(store, fp)
71
72 class journalentry(collections.namedtuple(
73 'journalentry',
74 'timestamp user command namespace name oldhashes newhashes')):
75 """Individual journal entry
76
77 * timestamp: a mercurial (time, timezone) tuple
78 * user: the username that ran the command
79 * namespace: the entry namespace, an opaque string
80 * name: the name of the changed item, opaque string with meaning in the
81 namespace
82 * command: the hg command that triggered this record
83 * oldhashes: a tuple of one or more binary hashes for the old location
84 * newhashes: a tuple of one or more binary hashes for the new location
85
86 Handles serialisation from and to the storage format. Fields are
87 separated by newlines, hashes are written out in hex separated by commas,
88 timestamp and timezone are separated by a space.
89
90 """
91 @classmethod
92 def fromstorage(cls, line):
93 (time, user, command, namespace, name,
94 oldhashes, newhashes) = line.split('\n')
95 timestamp, tz = time.split()
96 timestamp, tz = float(timestamp), int(tz)
97 oldhashes = tuple(node.bin(hash) for hash in oldhashes.split(','))
98 newhashes = tuple(node.bin(hash) for hash in newhashes.split(','))
99 return cls(
100 (timestamp, tz), user, command, namespace, name,
101 oldhashes, newhashes)
102
103 def __str__(self):
104 """String representation for storage"""
105 time = ' '.join(map(str, self.timestamp))
106 oldhashes = ','.join([node.hex(hash) for hash in self.oldhashes])
107 newhashes = ','.join([node.hex(hash) for hash in self.newhashes])
108 return '\n'.join((
109 time, self.user, self.command, self.namespace, self.name,
110 oldhashes, newhashes))
111
112 class journalstorage(object):
113 """Storage for journal entries
114
115 Entries are stored with NUL bytes as separators. See the journalentry
116 class for the per-entry structure.
117
118 The file format starts with an integer version, delimited by a NUL.
119
120 """
121 _currentcommand = ()
122
123 def __init__(self, repo):
124 self.repo = repo
125 self.user = util.getuser()
126 self.vfs = repo.vfs
127
128 # track the current command for recording in journal entries
129 @property
130 def command(self):
131 commandstr = ' '.join(
132 map(util.shellquote, journalstorage._currentcommand))
133 if '\n' in commandstr:
134 # truncate multi-line commands
135 commandstr = commandstr.partition('\n')[0] + ' ...'
136 return commandstr
137
138 @classmethod
139 def recordcommand(cls, *fullargs):
140 """Set the current hg arguments, stored with recorded entries"""
141 # Set the current command on the class because we may have started
142 # with a non-local repo (cloning for example).
143 cls._currentcommand = fullargs
144
145 def record(self, namespace, name, oldhashes, newhashes):
146 """Record a new journal entry
147
148 * namespace: an opaque string; this can be used to filter on the type
149 of recorded entries.
150 * name: the name defining this entry; for bookmarks, this is the
151 bookmark name. Can be filtered on when retrieving entries.
152 * oldhashes and newhashes: each a single binary hash, or a list of
153 binary hashes. These represent the old and new position of the named
154 item.
155
156 """
157 if not isinstance(oldhashes, list):
158 oldhashes = [oldhashes]
159 if not isinstance(newhashes, list):
160 newhashes = [newhashes]
161
162 entry = journalentry(
163 util.makedate(), self.user, self.command, namespace, name,
164 oldhashes, newhashes)
165
166 with self.repo.wlock():
167 version = None
168 # open file in amend mode to ensure it is created if missing
169 with self.vfs('journal', mode='a+b', atomictemp=True) as f:
170 f.seek(0, os.SEEK_SET)
171 # Read just enough bytes to get a version number (up to 2
172 # digits plus separator)
173 version = f.read(3).partition('\0')[0]
174 if version and version != str(storageversion):
175 # different version of the storage. Exit early (and not
176 # write anything) if this is not a version we can handle or
177 # the file is corrupt. In future, perhaps rotate the file
178 # instead?
179 self.repo.ui.warn(
180 _("unsupported journal file version '%s'\n") % version)
181 return
182 if not version:
183 # empty file, write version first
184 f.write(str(storageversion) + '\0')
185 f.seek(0, os.SEEK_END)
186 f.write(str(entry) + '\0')
187
188 def filtered(self, namespace=None, name=None):
189 """Yield all journal entries with the given namespace or name
190
191 Both the namespace and the name are optional; if neither is given all
192 entries in the journal are produced.
193
194 """
195 for entry in self:
196 if namespace is not None and entry.namespace != namespace:
197 continue
198 if name is not None and entry.name != name:
199 continue
200 yield entry
201
202 def __iter__(self):
203 """Iterate over the storage
204
205 Yields journalentry instances for each contained journal record.
206
207 """
208 if not self.vfs.exists('journal'):
209 return
210
211 with self.vfs('journal') as f:
212 raw = f.read()
213
214 lines = raw.split('\0')
215 version = lines and lines[0]
216 if version != str(storageversion):
217 version = version or _('not available')
218 raise error.Abort(_("unknown journal file version '%s'") % version)
219
220 # Skip the first line, it's a version number. Reverse the rest.
221 lines = reversed(lines[1:])
222 for line in lines:
223 if not line:
224 continue
225 yield journalentry.fromstorage(line)
226
227 # journal reading
228 # log options that don't make sense for journal
229 _ignoreopts = ('no-merges', 'graph')
230 @command(
231 'journal', [
232 ('c', 'commits', None, 'show commit metadata'),
233 ] + [opt for opt in commands.logopts if opt[1] not in _ignoreopts],
234 '[OPTION]... [BOOKMARKNAME]')
235 def journal(ui, repo, *args, **opts):
236 """show the previous position of bookmarks
237
238 The journal is used to see the previous commits of bookmarks. By default
239 the previous locations for all bookmarks are shown. Passing a bookmark
240 name will show all the previous positions of that bookmark.
241
242 By default hg journal only shows the commit hash and the command that was
243 running at that time. -v/--verbose will show the prior hash, the user, and
244 the time at which it happened.
245
246 Use -c/--commits to output log information on each commit hash; at this
247 point you can use the usual `--patch`, `--git`, `--stat` and `--template`
248 switches to alter the log output for these.
249
250 `hg journal -T json` can be used to produce machine readable output.
251
252 """
253 bookmarkname = None
254 if args:
255 bookmarkname = args[0]
256
257 fm = ui.formatter('journal', opts)
258
259 if opts.get("template") != "json":
260 if bookmarkname is None:
261 name = _('all bookmarks')
262 else:
263 name = "'%s'" % bookmarkname
264 ui.status(_("previous locations of %s:\n") % name)
265
266 limit = cmdutil.loglimit(opts)
267 entry = None
268 for count, entry in enumerate(repo.journal.filtered(name=bookmarkname)):
269 if count == limit:
270 break
271 newhashesstr = ','.join([node.short(hash) for hash in entry.newhashes])
272 oldhashesstr = ','.join([node.short(hash) for hash in entry.oldhashes])
273
274 fm.startitem()
275 fm.condwrite(ui.verbose, 'oldhashes', '%s -> ', oldhashesstr)
276 fm.write('newhashes', '%s', newhashesstr)
277 fm.condwrite(ui.verbose, 'user', ' %s', entry.user.ljust(8))
278
279 timestring = util.datestr(entry.timestamp, '%Y-%m-%d %H:%M %1%2')
280 fm.condwrite(ui.verbose, 'date', ' %s', timestring)
281 fm.write('command', ' %s\n', entry.command)
282
283 if opts.get("commits"):
284 displayer = cmdutil.show_changeset(ui, repo, opts, buffered=False)
285 for hash in entry.newhashes:
286 try:
287 ctx = repo[hash]
288 displayer.show(ctx)
289 except error.RepoLookupError as e:
290 fm.write('repolookuperror', "%s\n\n", str(e))
291 displayer.close()
292
293 fm.end()
294
295 if entry is None:
296 ui.status(_("no recorded locations\n"))