Mercurial > hg
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")) |