##// END OF EJS Templates
journal: new experimental extension...
Martijn Pieters -
r29443:cf092a3d default
parent child Browse files
Show More
@@ -0,0 +1,296 b''
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"))
@@ -0,0 +1,148 b''
1 Tests for the journal extension; records bookmark locations.
2
3 $ cat >> testmocks.py << EOF
4 > # mock out util.getuser() and util.makedate() to supply testable values
5 > import os
6 > from mercurial import util
7 > def mockgetuser():
8 > return 'foobar'
9 >
10 > def mockmakedate():
11 > filename = os.path.join(os.environ['TESTTMP'], 'testtime')
12 > try:
13 > with open(filename, 'rb') as timef:
14 > time = float(timef.read()) + 1
15 > except IOError:
16 > time = 0.0
17 > with open(filename, 'wb') as timef:
18 > timef.write(str(time))
19 > return (time, 0)
20 >
21 > util.getuser = mockgetuser
22 > util.makedate = mockmakedate
23 > EOF
24
25 $ cat >> $HGRCPATH << EOF
26 > [extensions]
27 > journal=
28 > testmocks=`pwd`/testmocks.py
29 > EOF
30
31 Setup repo
32
33 $ hg init repo
34 $ cd repo
35 $ echo a > a
36 $ hg commit -Aqm a
37 $ echo b > a
38 $ hg commit -Aqm b
39 $ hg up 0
40 1 files updated, 0 files merged, 0 files removed, 0 files unresolved
41
42 Test empty journal
43
44 $ hg journal
45 previous locations of all bookmarks:
46 no recorded locations
47 $ hg journal foo
48 previous locations of 'foo':
49 no recorded locations
50
51 Test that bookmarks are tracked
52
53 $ hg book -r tip bar
54 $ hg journal bar
55 previous locations of 'bar':
56 1e6c11564562 book -r tip bar
57 $ hg book -f bar
58 $ hg journal bar
59 previous locations of 'bar':
60 cb9a9f314b8b book -f bar
61 1e6c11564562 book -r tip bar
62 $ hg up
63 1 files updated, 0 files merged, 0 files removed, 0 files unresolved
64 updating bookmark bar
65 $ hg journal bar
66 previous locations of 'bar':
67 1e6c11564562 up
68 cb9a9f314b8b book -f bar
69 1e6c11564562 book -r tip bar
70
71 Test that you can list all bookmarks as well as limit the list or filter on them
72
73 $ hg book -r tip baz
74 $ hg journal
75 previous locations of all bookmarks:
76 1e6c11564562 book -r tip baz
77 1e6c11564562 up
78 cb9a9f314b8b book -f bar
79 1e6c11564562 book -r tip bar
80 $ hg journal --limit 2
81 previous locations of all bookmarks:
82 1e6c11564562 book -r tip baz
83 1e6c11564562 up
84 $ hg journal baz
85 previous locations of 'baz':
86 1e6c11564562 book -r tip baz
87 $ hg journal bar
88 previous locations of 'bar':
89 1e6c11564562 up
90 cb9a9f314b8b book -f bar
91 1e6c11564562 book -r tip bar
92 $ hg journal foo
93 previous locations of 'foo':
94 no recorded locations
95
96 Test that verbose and commit output work
97
98 $ hg journal --verbose
99 previous locations of all bookmarks:
100 000000000000 -> 1e6c11564562 foobar 1970-01-01 00:00 +0000 book -r tip baz
101 cb9a9f314b8b -> 1e6c11564562 foobar 1970-01-01 00:00 +0000 up
102 1e6c11564562 -> cb9a9f314b8b foobar 1970-01-01 00:00 +0000 book -f bar
103 000000000000 -> 1e6c11564562 foobar 1970-01-01 00:00 +0000 book -r tip bar
104 $ hg journal --commit
105 previous locations of all bookmarks:
106 1e6c11564562 book -r tip baz
107 changeset: 1:1e6c11564562
108 bookmark: bar
109 bookmark: baz
110 tag: tip
111 user: test
112 date: Thu Jan 01 00:00:00 1970 +0000
113 summary: b
114
115 1e6c11564562 up
116 changeset: 1:1e6c11564562
117 bookmark: bar
118 bookmark: baz
119 tag: tip
120 user: test
121 date: Thu Jan 01 00:00:00 1970 +0000
122 summary: b
123
124 cb9a9f314b8b book -f bar
125 changeset: 0:cb9a9f314b8b
126 user: test
127 date: Thu Jan 01 00:00:00 1970 +0000
128 summary: a
129
130 1e6c11564562 book -r tip bar
131 changeset: 1:1e6c11564562
132 bookmark: bar
133 bookmark: baz
134 tag: tip
135 user: test
136 date: Thu Jan 01 00:00:00 1970 +0000
137 summary: b
138
139
140 Test for behaviour on unexpected storage version information
141
142 $ printf '42\0' > .hg/journal
143 $ hg journal
144 previous locations of all bookmarks:
145 abort: unknown journal file version '42'
146 [255]
147 $ hg book -r tip doomed
148 unsupported journal file version '42'
General Comments 0
You need to be logged in to leave comments. Login now