##// END OF EJS Templates
removed exception args indexing (not supported by py3k)...
Renato Cunha -
r11567:34cc8b84 default
parent child Browse files
Show More
@@ -1,441 +1,441
1 # bugzilla.py - bugzilla integration for mercurial
1 # bugzilla.py - bugzilla integration for mercurial
2 #
2 #
3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
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.
6 # GNU General Public License version 2 or any later version.
7
7
8 '''hooks for integrating with the Bugzilla bug tracker
8 '''hooks for integrating with the Bugzilla bug tracker
9
9
10 This hook extension adds comments on bugs in Bugzilla when changesets
10 This hook extension adds comments on bugs in Bugzilla when changesets
11 that refer to bugs by Bugzilla ID are seen. The hook does not change
11 that refer to bugs by Bugzilla ID are seen. The hook does not change
12 bug status.
12 bug status.
13
13
14 The hook updates the Bugzilla database directly. Only Bugzilla
14 The hook updates the Bugzilla database directly. Only Bugzilla
15 installations using MySQL are supported.
15 installations using MySQL are supported.
16
16
17 The hook relies on a Bugzilla script to send bug change notification
17 The hook relies on a Bugzilla script to send bug change notification
18 emails. That script changes between Bugzilla versions; the
18 emails. That script changes between Bugzilla versions; the
19 'processmail' script used prior to 2.18 is replaced in 2.18 and
19 'processmail' script used prior to 2.18 is replaced in 2.18 and
20 subsequent versions by 'config/sendbugmail.pl'. Note that these will
20 subsequent versions by 'config/sendbugmail.pl'. Note that these will
21 be run by Mercurial as the user pushing the change; you will need to
21 be run by Mercurial as the user pushing the change; you will need to
22 ensure the Bugzilla install file permissions are set appropriately.
22 ensure the Bugzilla install file permissions are set appropriately.
23
23
24 The extension is configured through three different configuration
24 The extension is configured through three different configuration
25 sections. These keys are recognized in the [bugzilla] section:
25 sections. These keys are recognized in the [bugzilla] section:
26
26
27 host
27 host
28 Hostname of the MySQL server holding the Bugzilla database.
28 Hostname of the MySQL server holding the Bugzilla database.
29
29
30 db
30 db
31 Name of the Bugzilla database in MySQL. Default 'bugs'.
31 Name of the Bugzilla database in MySQL. Default 'bugs'.
32
32
33 user
33 user
34 Username to use to access MySQL server. Default 'bugs'.
34 Username to use to access MySQL server. Default 'bugs'.
35
35
36 password
36 password
37 Password to use to access MySQL server.
37 Password to use to access MySQL server.
38
38
39 timeout
39 timeout
40 Database connection timeout (seconds). Default 5.
40 Database connection timeout (seconds). Default 5.
41
41
42 version
42 version
43 Bugzilla version. Specify '3.0' for Bugzilla versions 3.0 and later,
43 Bugzilla version. Specify '3.0' for Bugzilla versions 3.0 and later,
44 '2.18' for Bugzilla versions from 2.18 and '2.16' for versions prior
44 '2.18' for Bugzilla versions from 2.18 and '2.16' for versions prior
45 to 2.18.
45 to 2.18.
46
46
47 bzuser
47 bzuser
48 Fallback Bugzilla user name to record comments with, if changeset
48 Fallback Bugzilla user name to record comments with, if changeset
49 committer cannot be found as a Bugzilla user.
49 committer cannot be found as a Bugzilla user.
50
50
51 bzdir
51 bzdir
52 Bugzilla install directory. Used by default notify. Default
52 Bugzilla install directory. Used by default notify. Default
53 '/var/www/html/bugzilla'.
53 '/var/www/html/bugzilla'.
54
54
55 notify
55 notify
56 The command to run to get Bugzilla to send bug change notification
56 The command to run to get Bugzilla to send bug change notification
57 emails. Substitutes from a map with 3 keys, 'bzdir', 'id' (bug id)
57 emails. Substitutes from a map with 3 keys, 'bzdir', 'id' (bug id)
58 and 'user' (committer bugzilla email). Default depends on version;
58 and 'user' (committer bugzilla email). Default depends on version;
59 from 2.18 it is "cd %(bzdir)s && perl -T contrib/sendbugmail.pl
59 from 2.18 it is "cd %(bzdir)s && perl -T contrib/sendbugmail.pl
60 %(id)s %(user)s".
60 %(id)s %(user)s".
61
61
62 regexp
62 regexp
63 Regular expression to match bug IDs in changeset commit message.
63 Regular expression to match bug IDs in changeset commit message.
64 Must contain one "()" group. The default expression matches 'Bug
64 Must contain one "()" group. The default expression matches 'Bug
65 1234', 'Bug no. 1234', 'Bug number 1234', 'Bugs 1234,5678', 'Bug
65 1234', 'Bug no. 1234', 'Bug number 1234', 'Bugs 1234,5678', 'Bug
66 1234 and 5678' and variations thereof. Matching is case insensitive.
66 1234 and 5678' and variations thereof. Matching is case insensitive.
67
67
68 style
68 style
69 The style file to use when formatting comments.
69 The style file to use when formatting comments.
70
70
71 template
71 template
72 Template to use when formatting comments. Overrides style if
72 Template to use when formatting comments. Overrides style if
73 specified. In addition to the usual Mercurial keywords, the
73 specified. In addition to the usual Mercurial keywords, the
74 extension specifies::
74 extension specifies::
75
75
76 {bug} The Bugzilla bug ID.
76 {bug} The Bugzilla bug ID.
77 {root} The full pathname of the Mercurial repository.
77 {root} The full pathname of the Mercurial repository.
78 {webroot} Stripped pathname of the Mercurial repository.
78 {webroot} Stripped pathname of the Mercurial repository.
79 {hgweb} Base URL for browsing Mercurial repositories.
79 {hgweb} Base URL for browsing Mercurial repositories.
80
80
81 Default 'changeset {node|short} in repo {root} refers '
81 Default 'changeset {node|short} in repo {root} refers '
82 'to bug {bug}.\\ndetails:\\n\\t{desc|tabindent}'
82 'to bug {bug}.\\ndetails:\\n\\t{desc|tabindent}'
83
83
84 strip
84 strip
85 The number of slashes to strip from the front of {root} to produce
85 The number of slashes to strip from the front of {root} to produce
86 {webroot}. Default 0.
86 {webroot}. Default 0.
87
87
88 usermap
88 usermap
89 Path of file containing Mercurial committer ID to Bugzilla user ID
89 Path of file containing Mercurial committer ID to Bugzilla user ID
90 mappings. If specified, the file should contain one mapping per
90 mappings. If specified, the file should contain one mapping per
91 line, "committer"="Bugzilla user". See also the [usermap] section.
91 line, "committer"="Bugzilla user". See also the [usermap] section.
92
92
93 The [usermap] section is used to specify mappings of Mercurial
93 The [usermap] section is used to specify mappings of Mercurial
94 committer ID to Bugzilla user ID. See also [bugzilla].usermap.
94 committer ID to Bugzilla user ID. See also [bugzilla].usermap.
95 "committer"="Bugzilla user"
95 "committer"="Bugzilla user"
96
96
97 Finally, the [web] section supports one entry:
97 Finally, the [web] section supports one entry:
98
98
99 baseurl
99 baseurl
100 Base URL for browsing Mercurial repositories. Reference from
100 Base URL for browsing Mercurial repositories. Reference from
101 templates as {hgweb}.
101 templates as {hgweb}.
102
102
103 Activating the extension::
103 Activating the extension::
104
104
105 [extensions]
105 [extensions]
106 bugzilla =
106 bugzilla =
107
107
108 [hooks]
108 [hooks]
109 # run bugzilla hook on every change pulled or pushed in here
109 # run bugzilla hook on every change pulled or pushed in here
110 incoming.bugzilla = python:hgext.bugzilla.hook
110 incoming.bugzilla = python:hgext.bugzilla.hook
111
111
112 Example configuration:
112 Example configuration:
113
113
114 This example configuration is for a collection of Mercurial
114 This example configuration is for a collection of Mercurial
115 repositories in /var/local/hg/repos/ used with a local Bugzilla 3.2
115 repositories in /var/local/hg/repos/ used with a local Bugzilla 3.2
116 installation in /opt/bugzilla-3.2. ::
116 installation in /opt/bugzilla-3.2. ::
117
117
118 [bugzilla]
118 [bugzilla]
119 host=localhost
119 host=localhost
120 password=XYZZY
120 password=XYZZY
121 version=3.0
121 version=3.0
122 bzuser=unknown@domain.com
122 bzuser=unknown@domain.com
123 bzdir=/opt/bugzilla-3.2
123 bzdir=/opt/bugzilla-3.2
124 template=Changeset {node|short} in {root|basename}.
124 template=Changeset {node|short} in {root|basename}.
125 {hgweb}/{webroot}/rev/{node|short}\\n
125 {hgweb}/{webroot}/rev/{node|short}\\n
126 {desc}\\n
126 {desc}\\n
127 strip=5
127 strip=5
128
128
129 [web]
129 [web]
130 baseurl=http://dev.domain.com/hg
130 baseurl=http://dev.domain.com/hg
131
131
132 [usermap]
132 [usermap]
133 user@emaildomain.com=user.name@bugzilladomain.com
133 user@emaildomain.com=user.name@bugzilladomain.com
134
134
135 Commits add a comment to the Bugzilla bug record of the form::
135 Commits add a comment to the Bugzilla bug record of the form::
136
136
137 Changeset 3b16791d6642 in repository-name.
137 Changeset 3b16791d6642 in repository-name.
138 http://dev.domain.com/hg/repository-name/rev/3b16791d6642
138 http://dev.domain.com/hg/repository-name/rev/3b16791d6642
139
139
140 Changeset commit comment. Bug 1234.
140 Changeset commit comment. Bug 1234.
141 '''
141 '''
142
142
143 from mercurial.i18n import _
143 from mercurial.i18n import _
144 from mercurial.node import short
144 from mercurial.node import short
145 from mercurial import cmdutil, templater, util
145 from mercurial import cmdutil, templater, util
146 import re, time
146 import re, time
147
147
148 MySQLdb = None
148 MySQLdb = None
149
149
150 def buglist(ids):
150 def buglist(ids):
151 return '(' + ','.join(map(str, ids)) + ')'
151 return '(' + ','.join(map(str, ids)) + ')'
152
152
153 class bugzilla_2_16(object):
153 class bugzilla_2_16(object):
154 '''support for bugzilla version 2.16.'''
154 '''support for bugzilla version 2.16.'''
155
155
156 def __init__(self, ui):
156 def __init__(self, ui):
157 self.ui = ui
157 self.ui = ui
158 host = self.ui.config('bugzilla', 'host', 'localhost')
158 host = self.ui.config('bugzilla', 'host', 'localhost')
159 user = self.ui.config('bugzilla', 'user', 'bugs')
159 user = self.ui.config('bugzilla', 'user', 'bugs')
160 passwd = self.ui.config('bugzilla', 'password')
160 passwd = self.ui.config('bugzilla', 'password')
161 db = self.ui.config('bugzilla', 'db', 'bugs')
161 db = self.ui.config('bugzilla', 'db', 'bugs')
162 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
162 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
163 usermap = self.ui.config('bugzilla', 'usermap')
163 usermap = self.ui.config('bugzilla', 'usermap')
164 if usermap:
164 if usermap:
165 self.ui.readconfig(usermap, sections=['usermap'])
165 self.ui.readconfig(usermap, sections=['usermap'])
166 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
166 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
167 (host, db, user, '*' * len(passwd)))
167 (host, db, user, '*' * len(passwd)))
168 self.conn = MySQLdb.connect(host=host, user=user, passwd=passwd,
168 self.conn = MySQLdb.connect(host=host, user=user, passwd=passwd,
169 db=db, connect_timeout=timeout)
169 db=db, connect_timeout=timeout)
170 self.cursor = self.conn.cursor()
170 self.cursor = self.conn.cursor()
171 self.longdesc_id = self.get_longdesc_id()
171 self.longdesc_id = self.get_longdesc_id()
172 self.user_ids = {}
172 self.user_ids = {}
173 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
173 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
174
174
175 def run(self, *args, **kwargs):
175 def run(self, *args, **kwargs):
176 '''run a query.'''
176 '''run a query.'''
177 self.ui.note(_('query: %s %s\n') % (args, kwargs))
177 self.ui.note(_('query: %s %s\n') % (args, kwargs))
178 try:
178 try:
179 self.cursor.execute(*args, **kwargs)
179 self.cursor.execute(*args, **kwargs)
180 except MySQLdb.MySQLError:
180 except MySQLdb.MySQLError:
181 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
181 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
182 raise
182 raise
183
183
184 def get_longdesc_id(self):
184 def get_longdesc_id(self):
185 '''get identity of longdesc field'''
185 '''get identity of longdesc field'''
186 self.run('select fieldid from fielddefs where name = "longdesc"')
186 self.run('select fieldid from fielddefs where name = "longdesc"')
187 ids = self.cursor.fetchall()
187 ids = self.cursor.fetchall()
188 if len(ids) != 1:
188 if len(ids) != 1:
189 raise util.Abort(_('unknown database schema'))
189 raise util.Abort(_('unknown database schema'))
190 return ids[0][0]
190 return ids[0][0]
191
191
192 def filter_real_bug_ids(self, ids):
192 def filter_real_bug_ids(self, ids):
193 '''filter not-existing bug ids from list.'''
193 '''filter not-existing bug ids from list.'''
194 self.run('select bug_id from bugs where bug_id in %s' % buglist(ids))
194 self.run('select bug_id from bugs where bug_id in %s' % buglist(ids))
195 return sorted([c[0] for c in self.cursor.fetchall()])
195 return sorted([c[0] for c in self.cursor.fetchall()])
196
196
197 def filter_unknown_bug_ids(self, node, ids):
197 def filter_unknown_bug_ids(self, node, ids):
198 '''filter bug ids from list that already refer to this changeset.'''
198 '''filter bug ids from list that already refer to this changeset.'''
199
199
200 self.run('''select bug_id from longdescs where
200 self.run('''select bug_id from longdescs where
201 bug_id in %s and thetext like "%%%s%%"''' %
201 bug_id in %s and thetext like "%%%s%%"''' %
202 (buglist(ids), short(node)))
202 (buglist(ids), short(node)))
203 unknown = set(ids)
203 unknown = set(ids)
204 for (id,) in self.cursor.fetchall():
204 for (id,) in self.cursor.fetchall():
205 self.ui.status(_('bug %d already knows about changeset %s\n') %
205 self.ui.status(_('bug %d already knows about changeset %s\n') %
206 (id, short(node)))
206 (id, short(node)))
207 unknown.discard(id)
207 unknown.discard(id)
208 return sorted(unknown)
208 return sorted(unknown)
209
209
210 def notify(self, ids, committer):
210 def notify(self, ids, committer):
211 '''tell bugzilla to send mail.'''
211 '''tell bugzilla to send mail.'''
212
212
213 self.ui.status(_('telling bugzilla to send mail:\n'))
213 self.ui.status(_('telling bugzilla to send mail:\n'))
214 (user, userid) = self.get_bugzilla_user(committer)
214 (user, userid) = self.get_bugzilla_user(committer)
215 for id in ids:
215 for id in ids:
216 self.ui.status(_(' bug %s\n') % id)
216 self.ui.status(_(' bug %s\n') % id)
217 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
217 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
218 bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
218 bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
219 try:
219 try:
220 # Backwards-compatible with old notify string, which
220 # Backwards-compatible with old notify string, which
221 # took one string. This will throw with a new format
221 # took one string. This will throw with a new format
222 # string.
222 # string.
223 cmd = cmdfmt % id
223 cmd = cmdfmt % id
224 except TypeError:
224 except TypeError:
225 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
225 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
226 self.ui.note(_('running notify command %s\n') % cmd)
226 self.ui.note(_('running notify command %s\n') % cmd)
227 fp = util.popen('(%s) 2>&1' % cmd)
227 fp = util.popen('(%s) 2>&1' % cmd)
228 out = fp.read()
228 out = fp.read()
229 ret = fp.close()
229 ret = fp.close()
230 if ret:
230 if ret:
231 self.ui.warn(out)
231 self.ui.warn(out)
232 raise util.Abort(_('bugzilla notify command %s') %
232 raise util.Abort(_('bugzilla notify command %s') %
233 util.explain_exit(ret)[0])
233 util.explain_exit(ret)[0])
234 self.ui.status(_('done\n'))
234 self.ui.status(_('done\n'))
235
235
236 def get_user_id(self, user):
236 def get_user_id(self, user):
237 '''look up numeric bugzilla user id.'''
237 '''look up numeric bugzilla user id.'''
238 try:
238 try:
239 return self.user_ids[user]
239 return self.user_ids[user]
240 except KeyError:
240 except KeyError:
241 try:
241 try:
242 userid = int(user)
242 userid = int(user)
243 except ValueError:
243 except ValueError:
244 self.ui.note(_('looking up user %s\n') % user)
244 self.ui.note(_('looking up user %s\n') % user)
245 self.run('''select userid from profiles
245 self.run('''select userid from profiles
246 where login_name like %s''', user)
246 where login_name like %s''', user)
247 all = self.cursor.fetchall()
247 all = self.cursor.fetchall()
248 if len(all) != 1:
248 if len(all) != 1:
249 raise KeyError(user)
249 raise KeyError(user)
250 userid = int(all[0][0])
250 userid = int(all[0][0])
251 self.user_ids[user] = userid
251 self.user_ids[user] = userid
252 return userid
252 return userid
253
253
254 def map_committer(self, user):
254 def map_committer(self, user):
255 '''map name of committer to bugzilla user name.'''
255 '''map name of committer to bugzilla user name.'''
256 for committer, bzuser in self.ui.configitems('usermap'):
256 for committer, bzuser in self.ui.configitems('usermap'):
257 if committer.lower() == user.lower():
257 if committer.lower() == user.lower():
258 return bzuser
258 return bzuser
259 return user
259 return user
260
260
261 def get_bugzilla_user(self, committer):
261 def get_bugzilla_user(self, committer):
262 '''see if committer is a registered bugzilla user. Return
262 '''see if committer is a registered bugzilla user. Return
263 bugzilla username and userid if so. If not, return default
263 bugzilla username and userid if so. If not, return default
264 bugzilla username and userid.'''
264 bugzilla username and userid.'''
265 user = self.map_committer(committer)
265 user = self.map_committer(committer)
266 try:
266 try:
267 userid = self.get_user_id(user)
267 userid = self.get_user_id(user)
268 except KeyError:
268 except KeyError:
269 try:
269 try:
270 defaultuser = self.ui.config('bugzilla', 'bzuser')
270 defaultuser = self.ui.config('bugzilla', 'bzuser')
271 if not defaultuser:
271 if not defaultuser:
272 raise util.Abort(_('cannot find bugzilla user id for %s') %
272 raise util.Abort(_('cannot find bugzilla user id for %s') %
273 user)
273 user)
274 userid = self.get_user_id(defaultuser)
274 userid = self.get_user_id(defaultuser)
275 user = defaultuser
275 user = defaultuser
276 except KeyError:
276 except KeyError:
277 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
277 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
278 (user, defaultuser))
278 (user, defaultuser))
279 return (user, userid)
279 return (user, userid)
280
280
281 def add_comment(self, bugid, text, committer):
281 def add_comment(self, bugid, text, committer):
282 '''add comment to bug. try adding comment as committer of
282 '''add comment to bug. try adding comment as committer of
283 changeset, otherwise as default bugzilla user.'''
283 changeset, otherwise as default bugzilla user.'''
284 (user, userid) = self.get_bugzilla_user(committer)
284 (user, userid) = self.get_bugzilla_user(committer)
285 now = time.strftime('%Y-%m-%d %H:%M:%S')
285 now = time.strftime('%Y-%m-%d %H:%M:%S')
286 self.run('''insert into longdescs
286 self.run('''insert into longdescs
287 (bug_id, who, bug_when, thetext)
287 (bug_id, who, bug_when, thetext)
288 values (%s, %s, %s, %s)''',
288 values (%s, %s, %s, %s)''',
289 (bugid, userid, now, text))
289 (bugid, userid, now, text))
290 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
290 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
291 values (%s, %s, %s, %s)''',
291 values (%s, %s, %s, %s)''',
292 (bugid, userid, now, self.longdesc_id))
292 (bugid, userid, now, self.longdesc_id))
293 self.conn.commit()
293 self.conn.commit()
294
294
295 class bugzilla_2_18(bugzilla_2_16):
295 class bugzilla_2_18(bugzilla_2_16):
296 '''support for bugzilla 2.18 series.'''
296 '''support for bugzilla 2.18 series.'''
297
297
298 def __init__(self, ui):
298 def __init__(self, ui):
299 bugzilla_2_16.__init__(self, ui)
299 bugzilla_2_16.__init__(self, ui)
300 self.default_notify = \
300 self.default_notify = \
301 "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
301 "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
302
302
303 class bugzilla_3_0(bugzilla_2_18):
303 class bugzilla_3_0(bugzilla_2_18):
304 '''support for bugzilla 3.0 series.'''
304 '''support for bugzilla 3.0 series.'''
305
305
306 def __init__(self, ui):
306 def __init__(self, ui):
307 bugzilla_2_18.__init__(self, ui)
307 bugzilla_2_18.__init__(self, ui)
308
308
309 def get_longdesc_id(self):
309 def get_longdesc_id(self):
310 '''get identity of longdesc field'''
310 '''get identity of longdesc field'''
311 self.run('select id from fielddefs where name = "longdesc"')
311 self.run('select id from fielddefs where name = "longdesc"')
312 ids = self.cursor.fetchall()
312 ids = self.cursor.fetchall()
313 if len(ids) != 1:
313 if len(ids) != 1:
314 raise util.Abort(_('unknown database schema'))
314 raise util.Abort(_('unknown database schema'))
315 return ids[0][0]
315 return ids[0][0]
316
316
317 class bugzilla(object):
317 class bugzilla(object):
318 # supported versions of bugzilla. different versions have
318 # supported versions of bugzilla. different versions have
319 # different schemas.
319 # different schemas.
320 _versions = {
320 _versions = {
321 '2.16': bugzilla_2_16,
321 '2.16': bugzilla_2_16,
322 '2.18': bugzilla_2_18,
322 '2.18': bugzilla_2_18,
323 '3.0': bugzilla_3_0
323 '3.0': bugzilla_3_0
324 }
324 }
325
325
326 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
326 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
327 r'((?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)')
327 r'((?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)')
328
328
329 _bz = None
329 _bz = None
330
330
331 def __init__(self, ui, repo):
331 def __init__(self, ui, repo):
332 self.ui = ui
332 self.ui = ui
333 self.repo = repo
333 self.repo = repo
334
334
335 def bz(self):
335 def bz(self):
336 '''return object that knows how to talk to bugzilla version in
336 '''return object that knows how to talk to bugzilla version in
337 use.'''
337 use.'''
338
338
339 if bugzilla._bz is None:
339 if bugzilla._bz is None:
340 bzversion = self.ui.config('bugzilla', 'version')
340 bzversion = self.ui.config('bugzilla', 'version')
341 try:
341 try:
342 bzclass = bugzilla._versions[bzversion]
342 bzclass = bugzilla._versions[bzversion]
343 except KeyError:
343 except KeyError:
344 raise util.Abort(_('bugzilla version %s not supported') %
344 raise util.Abort(_('bugzilla version %s not supported') %
345 bzversion)
345 bzversion)
346 bugzilla._bz = bzclass(self.ui)
346 bugzilla._bz = bzclass(self.ui)
347 return bugzilla._bz
347 return bugzilla._bz
348
348
349 def __getattr__(self, key):
349 def __getattr__(self, key):
350 return getattr(self.bz(), key)
350 return getattr(self.bz(), key)
351
351
352 _bug_re = None
352 _bug_re = None
353 _split_re = None
353 _split_re = None
354
354
355 def find_bug_ids(self, ctx):
355 def find_bug_ids(self, ctx):
356 '''find valid bug ids that are referred to in changeset
356 '''find valid bug ids that are referred to in changeset
357 comments and that do not already have references to this
357 comments and that do not already have references to this
358 changeset.'''
358 changeset.'''
359
359
360 if bugzilla._bug_re is None:
360 if bugzilla._bug_re is None:
361 bugzilla._bug_re = re.compile(
361 bugzilla._bug_re = re.compile(
362 self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re),
362 self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re),
363 re.IGNORECASE)
363 re.IGNORECASE)
364 bugzilla._split_re = re.compile(r'\D+')
364 bugzilla._split_re = re.compile(r'\D+')
365 start = 0
365 start = 0
366 ids = set()
366 ids = set()
367 while True:
367 while True:
368 m = bugzilla._bug_re.search(ctx.description(), start)
368 m = bugzilla._bug_re.search(ctx.description(), start)
369 if not m:
369 if not m:
370 break
370 break
371 start = m.end()
371 start = m.end()
372 for id in bugzilla._split_re.split(m.group(1)):
372 for id in bugzilla._split_re.split(m.group(1)):
373 if not id:
373 if not id:
374 continue
374 continue
375 ids.add(int(id))
375 ids.add(int(id))
376 if ids:
376 if ids:
377 ids = self.filter_real_bug_ids(ids)
377 ids = self.filter_real_bug_ids(ids)
378 if ids:
378 if ids:
379 ids = self.filter_unknown_bug_ids(ctx.node(), ids)
379 ids = self.filter_unknown_bug_ids(ctx.node(), ids)
380 return ids
380 return ids
381
381
382 def update(self, bugid, ctx):
382 def update(self, bugid, ctx):
383 '''update bugzilla bug with reference to changeset.'''
383 '''update bugzilla bug with reference to changeset.'''
384
384
385 def webroot(root):
385 def webroot(root):
386 '''strip leading prefix of repo root and turn into
386 '''strip leading prefix of repo root and turn into
387 url-safe path.'''
387 url-safe path.'''
388 count = int(self.ui.config('bugzilla', 'strip', 0))
388 count = int(self.ui.config('bugzilla', 'strip', 0))
389 root = util.pconvert(root)
389 root = util.pconvert(root)
390 while count > 0:
390 while count > 0:
391 c = root.find('/')
391 c = root.find('/')
392 if c == -1:
392 if c == -1:
393 break
393 break
394 root = root[c + 1:]
394 root = root[c + 1:]
395 count -= 1
395 count -= 1
396 return root
396 return root
397
397
398 mapfile = self.ui.config('bugzilla', 'style')
398 mapfile = self.ui.config('bugzilla', 'style')
399 tmpl = self.ui.config('bugzilla', 'template')
399 tmpl = self.ui.config('bugzilla', 'template')
400 t = cmdutil.changeset_templater(self.ui, self.repo,
400 t = cmdutil.changeset_templater(self.ui, self.repo,
401 False, None, mapfile, False)
401 False, None, mapfile, False)
402 if not mapfile and not tmpl:
402 if not mapfile and not tmpl:
403 tmpl = _('changeset {node|short} in repo {root} refers '
403 tmpl = _('changeset {node|short} in repo {root} refers '
404 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
404 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
405 if tmpl:
405 if tmpl:
406 tmpl = templater.parsestring(tmpl, quoted=False)
406 tmpl = templater.parsestring(tmpl, quoted=False)
407 t.use_template(tmpl)
407 t.use_template(tmpl)
408 self.ui.pushbuffer()
408 self.ui.pushbuffer()
409 t.show(ctx, changes=ctx.changeset(),
409 t.show(ctx, changes=ctx.changeset(),
410 bug=str(bugid),
410 bug=str(bugid),
411 hgweb=self.ui.config('web', 'baseurl'),
411 hgweb=self.ui.config('web', 'baseurl'),
412 root=self.repo.root,
412 root=self.repo.root,
413 webroot=webroot(self.repo.root))
413 webroot=webroot(self.repo.root))
414 data = self.ui.popbuffer()
414 data = self.ui.popbuffer()
415 self.add_comment(bugid, data, util.email(ctx.user()))
415 self.add_comment(bugid, data, util.email(ctx.user()))
416
416
417 def hook(ui, repo, hooktype, node=None, **kwargs):
417 def hook(ui, repo, hooktype, node=None, **kwargs):
418 '''add comment to bugzilla for each changeset that refers to a
418 '''add comment to bugzilla for each changeset that refers to a
419 bugzilla bug id. only add a comment once per bug, so same change
419 bugzilla bug id. only add a comment once per bug, so same change
420 seen multiple times does not fill bug with duplicate data.'''
420 seen multiple times does not fill bug with duplicate data.'''
421 try:
421 try:
422 import MySQLdb as mysql
422 import MySQLdb as mysql
423 global MySQLdb
423 global MySQLdb
424 MySQLdb = mysql
424 MySQLdb = mysql
425 except ImportError, err:
425 except ImportError, err:
426 raise util.Abort(_('python mysql support not available: %s') % err)
426 raise util.Abort(_('python mysql support not available: %s') % err)
427
427
428 if node is None:
428 if node is None:
429 raise util.Abort(_('hook type %s does not pass a changeset id') %
429 raise util.Abort(_('hook type %s does not pass a changeset id') %
430 hooktype)
430 hooktype)
431 try:
431 try:
432 bz = bugzilla(ui, repo)
432 bz = bugzilla(ui, repo)
433 ctx = repo[node]
433 ctx = repo[node]
434 ids = bz.find_bug_ids(ctx)
434 ids = bz.find_bug_ids(ctx)
435 if ids:
435 if ids:
436 for id in ids:
436 for id in ids:
437 bz.update(id, ctx)
437 bz.update(id, ctx)
438 bz.notify(ids, util.email(ctx.user()))
438 bz.notify(ids, util.email(ctx.user()))
439 except MySQLdb.MySQLError, err:
439 except MySQLdb.MySQLError, err:
440 raise util.Abort(_('database error: %s') % err[1])
440 raise util.Abort(_('database error: %s') % err.args[1])
441
441
@@ -1,174 +1,174
1 # client.py - inotify status client
1 # client.py - inotify status client
2 #
2 #
3 # Copyright 2006, 2007, 2008 Bryan O'Sullivan <bos@serpentine.com>
3 # Copyright 2006, 2007, 2008 Bryan O'Sullivan <bos@serpentine.com>
4 # Copyright 2007, 2008 Brendan Cully <brendan@kublai.com>
4 # Copyright 2007, 2008 Brendan Cully <brendan@kublai.com>
5 # Copyright 2009 Nicolas Dumazet <nicdumz@gmail.com>
5 # Copyright 2009 Nicolas Dumazet <nicdumz@gmail.com>
6 #
6 #
7 # This software may be used and distributed according to the terms of the
7 # This software may be used and distributed according to the terms of the
8 # GNU General Public License version 2 or any later version.
8 # GNU General Public License version 2 or any later version.
9
9
10 from mercurial.i18n import _
10 from mercurial.i18n import _
11 import common, server
11 import common, server
12 import errno, os, socket, struct
12 import errno, os, socket, struct
13
13
14 class QueryFailed(Exception):
14 class QueryFailed(Exception):
15 pass
15 pass
16
16
17 def start_server(function):
17 def start_server(function):
18 """
18 """
19 Decorator.
19 Decorator.
20 Tries to call function, if it fails, try to (re)start inotify server.
20 Tries to call function, if it fails, try to (re)start inotify server.
21 Raise QueryFailed if something went wrong
21 Raise QueryFailed if something went wrong
22 """
22 """
23 def decorated_function(self, *args):
23 def decorated_function(self, *args):
24 result = None
24 result = None
25 try:
25 try:
26 return function(self, *args)
26 return function(self, *args)
27 except (OSError, socket.error), err:
27 except (OSError, socket.error), err:
28 autostart = self.ui.configbool('inotify', 'autostart', True)
28 autostart = self.ui.configbool('inotify', 'autostart', True)
29
29
30 if err[0] == errno.ECONNREFUSED:
30 if err.args[0] == errno.ECONNREFUSED:
31 self.ui.warn(_('inotify-client: found dead inotify server '
31 self.ui.warn(_('inotify-client: found dead inotify server '
32 'socket; removing it\n'))
32 'socket; removing it\n'))
33 os.unlink(os.path.join(self.root, '.hg', 'inotify.sock'))
33 os.unlink(os.path.join(self.root, '.hg', 'inotify.sock'))
34 if err[0] in (errno.ECONNREFUSED, errno.ENOENT) and autostart:
34 if err.args[0] in (errno.ECONNREFUSED, errno.ENOENT) and autostart:
35 self.ui.debug('(starting inotify server)\n')
35 self.ui.debug('(starting inotify server)\n')
36 try:
36 try:
37 try:
37 try:
38 server.start(self.ui, self.dirstate, self.root,
38 server.start(self.ui, self.dirstate, self.root,
39 dict(daemon=True, daemon_pipefds=''))
39 dict(daemon=True, daemon_pipefds=''))
40 except server.AlreadyStartedException, inst:
40 except server.AlreadyStartedException, inst:
41 # another process may have started its own
41 # another process may have started its own
42 # inotify server while this one was starting.
42 # inotify server while this one was starting.
43 self.ui.debug(str(inst))
43 self.ui.debug(str(inst))
44 except Exception, inst:
44 except Exception, inst:
45 self.ui.warn(_('inotify-client: could not start inotify '
45 self.ui.warn(_('inotify-client: could not start inotify '
46 'server: %s\n') % inst)
46 'server: %s\n') % inst)
47 else:
47 else:
48 try:
48 try:
49 return function(self, *args)
49 return function(self, *args)
50 except socket.error, err:
50 except socket.error, err:
51 self.ui.warn(_('inotify-client: could not talk to new '
51 self.ui.warn(_('inotify-client: could not talk to new '
52 'inotify server: %s\n') % err[-1])
52 'inotify server: %s\n') % err.args[-1])
53 elif err[0] in (errno.ECONNREFUSED, errno.ENOENT):
53 elif err.args[0] in (errno.ECONNREFUSED, errno.ENOENT):
54 # silently ignore normal errors if autostart is False
54 # silently ignore normal errors if autostart is False
55 self.ui.debug('(inotify server not running)\n')
55 self.ui.debug('(inotify server not running)\n')
56 else:
56 else:
57 self.ui.warn(_('inotify-client: failed to contact inotify '
57 self.ui.warn(_('inotify-client: failed to contact inotify '
58 'server: %s\n') % err[-1])
58 'server: %s\n') % err.args[-1])
59
59
60 self.ui.traceback()
60 self.ui.traceback()
61 raise QueryFailed('inotify query failed')
61 raise QueryFailed('inotify query failed')
62
62
63 return decorated_function
63 return decorated_function
64
64
65
65
66 class client(object):
66 class client(object):
67 def __init__(self, ui, repo):
67 def __init__(self, ui, repo):
68 self.ui = ui
68 self.ui = ui
69 self.dirstate = repo.dirstate
69 self.dirstate = repo.dirstate
70 self.root = repo.root
70 self.root = repo.root
71 self.sock = socket.socket(socket.AF_UNIX)
71 self.sock = socket.socket(socket.AF_UNIX)
72
72
73 def _connect(self):
73 def _connect(self):
74 sockpath = os.path.join(self.root, '.hg', 'inotify.sock')
74 sockpath = os.path.join(self.root, '.hg', 'inotify.sock')
75 try:
75 try:
76 self.sock.connect(sockpath)
76 self.sock.connect(sockpath)
77 except socket.error, err:
77 except socket.error, err:
78 if err[0] == "AF_UNIX path too long":
78 if err.args[0] == "AF_UNIX path too long":
79 sockpath = os.readlink(sockpath)
79 sockpath = os.readlink(sockpath)
80 self.sock.connect(sockpath)
80 self.sock.connect(sockpath)
81 else:
81 else:
82 raise
82 raise
83
83
84 def _send(self, type, data):
84 def _send(self, type, data):
85 """Sends protocol version number, and the data"""
85 """Sends protocol version number, and the data"""
86 self.sock.sendall(chr(common.version) + type + data)
86 self.sock.sendall(chr(common.version) + type + data)
87
87
88 self.sock.shutdown(socket.SHUT_WR)
88 self.sock.shutdown(socket.SHUT_WR)
89
89
90 def _receive(self, type):
90 def _receive(self, type):
91 """
91 """
92 Read data, check version number, extract headers,
92 Read data, check version number, extract headers,
93 and returns a tuple (data descriptor, header)
93 and returns a tuple (data descriptor, header)
94 Raises QueryFailed on error
94 Raises QueryFailed on error
95 """
95 """
96 cs = common.recvcs(self.sock)
96 cs = common.recvcs(self.sock)
97 try:
97 try:
98 version = ord(cs.read(1))
98 version = ord(cs.read(1))
99 except TypeError:
99 except TypeError:
100 # empty answer, assume the server crashed
100 # empty answer, assume the server crashed
101 self.ui.warn(_('inotify-client: received empty answer from inotify '
101 self.ui.warn(_('inotify-client: received empty answer from inotify '
102 'server'))
102 'server'))
103 raise QueryFailed('server crashed')
103 raise QueryFailed('server crashed')
104
104
105 if version != common.version:
105 if version != common.version:
106 self.ui.warn(_('(inotify: received response from incompatible '
106 self.ui.warn(_('(inotify: received response from incompatible '
107 'server version %d)\n') % version)
107 'server version %d)\n') % version)
108 raise QueryFailed('incompatible server version')
108 raise QueryFailed('incompatible server version')
109
109
110 readtype = cs.read(4)
110 readtype = cs.read(4)
111 if readtype != type:
111 if readtype != type:
112 self.ui.warn(_('(inotify: received \'%s\' response when expecting'
112 self.ui.warn(_('(inotify: received \'%s\' response when expecting'
113 ' \'%s\')\n') % (readtype, type))
113 ' \'%s\')\n') % (readtype, type))
114 raise QueryFailed('wrong response type')
114 raise QueryFailed('wrong response type')
115
115
116 hdrfmt = common.resphdrfmts[type]
116 hdrfmt = common.resphdrfmts[type]
117 hdrsize = common.resphdrsizes[type]
117 hdrsize = common.resphdrsizes[type]
118 try:
118 try:
119 resphdr = struct.unpack(hdrfmt, cs.read(hdrsize))
119 resphdr = struct.unpack(hdrfmt, cs.read(hdrsize))
120 except struct.error:
120 except struct.error:
121 raise QueryFailed('unable to retrieve query response headers')
121 raise QueryFailed('unable to retrieve query response headers')
122
122
123 return cs, resphdr
123 return cs, resphdr
124
124
125 def query(self, type, req):
125 def query(self, type, req):
126 self._connect()
126 self._connect()
127
127
128 self._send(type, req)
128 self._send(type, req)
129
129
130 return self._receive(type)
130 return self._receive(type)
131
131
132 @start_server
132 @start_server
133 def statusquery(self, names, match, ignored, clean, unknown=True):
133 def statusquery(self, names, match, ignored, clean, unknown=True):
134
134
135 def genquery():
135 def genquery():
136 for n in names:
136 for n in names:
137 yield n
137 yield n
138 states = 'almrx!'
138 states = 'almrx!'
139 if ignored:
139 if ignored:
140 raise ValueError('this is insanity')
140 raise ValueError('this is insanity')
141 if clean:
141 if clean:
142 states += 'c'
142 states += 'c'
143 if unknown:
143 if unknown:
144 states += '?'
144 states += '?'
145 yield states
145 yield states
146
146
147 req = '\0'.join(genquery())
147 req = '\0'.join(genquery())
148
148
149 cs, resphdr = self.query('STAT', req)
149 cs, resphdr = self.query('STAT', req)
150
150
151 def readnames(nbytes):
151 def readnames(nbytes):
152 if nbytes:
152 if nbytes:
153 names = cs.read(nbytes)
153 names = cs.read(nbytes)
154 if names:
154 if names:
155 return filter(match, names.split('\0'))
155 return filter(match, names.split('\0'))
156 return []
156 return []
157 results = map(readnames, resphdr[:-1])
157 results = map(readnames, resphdr[:-1])
158
158
159 if names:
159 if names:
160 nbytes = resphdr[-1]
160 nbytes = resphdr[-1]
161 vdirs = cs.read(nbytes)
161 vdirs = cs.read(nbytes)
162 if vdirs:
162 if vdirs:
163 for vdir in vdirs.split('\0'):
163 for vdir in vdirs.split('\0'):
164 match.dir(vdir)
164 match.dir(vdir)
165
165
166 return results
166 return results
167
167
168 @start_server
168 @start_server
169 def debugquery(self):
169 def debugquery(self):
170 cs, resphdr = self.query('DBUG', '')
170 cs, resphdr = self.query('DBUG', '')
171
171
172 nbytes = resphdr[0]
172 nbytes = resphdr[0]
173 names = cs.read(nbytes)
173 names = cs.read(nbytes)
174 return names.split('\0')
174 return names.split('\0')
@@ -1,441 +1,441
1 # linuxserver.py - inotify status server for linux
1 # linuxserver.py - inotify status server for linux
2 #
2 #
3 # Copyright 2006, 2007, 2008 Bryan O'Sullivan <bos@serpentine.com>
3 # Copyright 2006, 2007, 2008 Bryan O'Sullivan <bos@serpentine.com>
4 # Copyright 2007, 2008 Brendan Cully <brendan@kublai.com>
4 # Copyright 2007, 2008 Brendan Cully <brendan@kublai.com>
5 #
5 #
6 # This software may be used and distributed according to the terms of the
6 # This software may be used and distributed according to the terms of the
7 # GNU General Public License version 2 or any later version.
7 # GNU General Public License version 2 or any later version.
8
8
9 from mercurial.i18n import _
9 from mercurial.i18n import _
10 from mercurial import osutil, util
10 from mercurial import osutil, util
11 import server
11 import server
12 import errno, os, select, stat, sys, time
12 import errno, os, select, stat, sys, time
13
13
14 try:
14 try:
15 import linux as inotify
15 import linux as inotify
16 from linux import watcher
16 from linux import watcher
17 except ImportError:
17 except ImportError:
18 raise
18 raise
19
19
20 def walkrepodirs(dirstate, absroot):
20 def walkrepodirs(dirstate, absroot):
21 '''Iterate over all subdirectories of this repo.
21 '''Iterate over all subdirectories of this repo.
22 Exclude the .hg directory, any nested repos, and ignored dirs.'''
22 Exclude the .hg directory, any nested repos, and ignored dirs.'''
23 def walkit(dirname, top):
23 def walkit(dirname, top):
24 fullpath = server.join(absroot, dirname)
24 fullpath = server.join(absroot, dirname)
25 try:
25 try:
26 for name, kind in osutil.listdir(fullpath):
26 for name, kind in osutil.listdir(fullpath):
27 if kind == stat.S_IFDIR:
27 if kind == stat.S_IFDIR:
28 if name == '.hg':
28 if name == '.hg':
29 if not top:
29 if not top:
30 return
30 return
31 else:
31 else:
32 d = server.join(dirname, name)
32 d = server.join(dirname, name)
33 if dirstate._ignore(d):
33 if dirstate._ignore(d):
34 continue
34 continue
35 for subdir in walkit(d, False):
35 for subdir in walkit(d, False):
36 yield subdir
36 yield subdir
37 except OSError, err:
37 except OSError, err:
38 if err.errno not in server.walk_ignored_errors:
38 if err.errno not in server.walk_ignored_errors:
39 raise
39 raise
40 yield fullpath
40 yield fullpath
41
41
42 return walkit('', True)
42 return walkit('', True)
43
43
44 def _explain_watch_limit(ui, dirstate, rootabs):
44 def _explain_watch_limit(ui, dirstate, rootabs):
45 path = '/proc/sys/fs/inotify/max_user_watches'
45 path = '/proc/sys/fs/inotify/max_user_watches'
46 try:
46 try:
47 limit = int(file(path).read())
47 limit = int(file(path).read())
48 except IOError, err:
48 except IOError, err:
49 if err.errno != errno.ENOENT:
49 if err.errno != errno.ENOENT:
50 raise
50 raise
51 raise util.Abort(_('this system does not seem to '
51 raise util.Abort(_('this system does not seem to '
52 'support inotify'))
52 'support inotify'))
53 ui.warn(_('*** the current per-user limit on the number '
53 ui.warn(_('*** the current per-user limit on the number '
54 'of inotify watches is %s\n') % limit)
54 'of inotify watches is %s\n') % limit)
55 ui.warn(_('*** this limit is too low to watch every '
55 ui.warn(_('*** this limit is too low to watch every '
56 'directory in this repository\n'))
56 'directory in this repository\n'))
57 ui.warn(_('*** counting directories: '))
57 ui.warn(_('*** counting directories: '))
58 ndirs = len(list(walkrepodirs(dirstate, rootabs)))
58 ndirs = len(list(walkrepodirs(dirstate, rootabs)))
59 ui.warn(_('found %d\n') % ndirs)
59 ui.warn(_('found %d\n') % ndirs)
60 newlimit = min(limit, 1024)
60 newlimit = min(limit, 1024)
61 while newlimit < ((limit + ndirs) * 1.1):
61 while newlimit < ((limit + ndirs) * 1.1):
62 newlimit *= 2
62 newlimit *= 2
63 ui.warn(_('*** to raise the limit from %d to %d (run as root):\n') %
63 ui.warn(_('*** to raise the limit from %d to %d (run as root):\n') %
64 (limit, newlimit))
64 (limit, newlimit))
65 ui.warn(_('*** echo %d > %s\n') % (newlimit, path))
65 ui.warn(_('*** echo %d > %s\n') % (newlimit, path))
66 raise util.Abort(_('cannot watch %s until inotify watch limit is raised')
66 raise util.Abort(_('cannot watch %s until inotify watch limit is raised')
67 % rootabs)
67 % rootabs)
68
68
69 class pollable(object):
69 class pollable(object):
70 """
70 """
71 Interface to support polling.
71 Interface to support polling.
72 The file descriptor returned by fileno() is registered to a polling
72 The file descriptor returned by fileno() is registered to a polling
73 object.
73 object.
74 Usage:
74 Usage:
75 Every tick, check if an event has happened since the last tick:
75 Every tick, check if an event has happened since the last tick:
76 * If yes, call handle_events
76 * If yes, call handle_events
77 * If no, call handle_timeout
77 * If no, call handle_timeout
78 """
78 """
79 poll_events = select.POLLIN
79 poll_events = select.POLLIN
80 instances = {}
80 instances = {}
81 poll = select.poll()
81 poll = select.poll()
82
82
83 def fileno(self):
83 def fileno(self):
84 raise NotImplementedError
84 raise NotImplementedError
85
85
86 def handle_events(self, events):
86 def handle_events(self, events):
87 raise NotImplementedError
87 raise NotImplementedError
88
88
89 def handle_timeout(self):
89 def handle_timeout(self):
90 raise NotImplementedError
90 raise NotImplementedError
91
91
92 def shutdown(self):
92 def shutdown(self):
93 raise NotImplementedError
93 raise NotImplementedError
94
94
95 def register(self, timeout):
95 def register(self, timeout):
96 fd = self.fileno()
96 fd = self.fileno()
97
97
98 pollable.poll.register(fd, pollable.poll_events)
98 pollable.poll.register(fd, pollable.poll_events)
99 pollable.instances[fd] = self
99 pollable.instances[fd] = self
100
100
101 self.registered = True
101 self.registered = True
102 self.timeout = timeout
102 self.timeout = timeout
103
103
104 def unregister(self):
104 def unregister(self):
105 pollable.poll.unregister(self)
105 pollable.poll.unregister(self)
106 self.registered = False
106 self.registered = False
107
107
108 @classmethod
108 @classmethod
109 def run(cls):
109 def run(cls):
110 while True:
110 while True:
111 timeout = None
111 timeout = None
112 timeobj = None
112 timeobj = None
113 for obj in cls.instances.itervalues():
113 for obj in cls.instances.itervalues():
114 if obj.timeout is not None and (timeout is None
114 if obj.timeout is not None and (timeout is None
115 or obj.timeout < timeout):
115 or obj.timeout < timeout):
116 timeout, timeobj = obj.timeout, obj
116 timeout, timeobj = obj.timeout, obj
117 try:
117 try:
118 events = cls.poll.poll(timeout)
118 events = cls.poll.poll(timeout)
119 except select.error, err:
119 except select.error, err:
120 if err[0] == errno.EINTR:
120 if err.args[0] == errno.EINTR:
121 continue
121 continue
122 raise
122 raise
123 if events:
123 if events:
124 by_fd = {}
124 by_fd = {}
125 for fd, event in events:
125 for fd, event in events:
126 by_fd.setdefault(fd, []).append(event)
126 by_fd.setdefault(fd, []).append(event)
127
127
128 for fd, events in by_fd.iteritems():
128 for fd, events in by_fd.iteritems():
129 cls.instances[fd].handle_pollevents(events)
129 cls.instances[fd].handle_pollevents(events)
130
130
131 elif timeobj:
131 elif timeobj:
132 timeobj.handle_timeout()
132 timeobj.handle_timeout()
133
133
134 def eventaction(code):
134 def eventaction(code):
135 """
135 """
136 Decorator to help handle events in repowatcher
136 Decorator to help handle events in repowatcher
137 """
137 """
138 def decorator(f):
138 def decorator(f):
139 def wrapper(self, wpath):
139 def wrapper(self, wpath):
140 if code == 'm' and wpath in self.lastevent and \
140 if code == 'm' and wpath in self.lastevent and \
141 self.lastevent[wpath] in 'cm':
141 self.lastevent[wpath] in 'cm':
142 return
142 return
143 self.lastevent[wpath] = code
143 self.lastevent[wpath] = code
144 self.timeout = 250
144 self.timeout = 250
145
145
146 f(self, wpath)
146 f(self, wpath)
147
147
148 wrapper.func_name = f.func_name
148 wrapper.func_name = f.func_name
149 return wrapper
149 return wrapper
150 return decorator
150 return decorator
151
151
152 class repowatcher(server.repowatcher, pollable):
152 class repowatcher(server.repowatcher, pollable):
153 """
153 """
154 Watches inotify events
154 Watches inotify events
155 """
155 """
156 mask = (
156 mask = (
157 inotify.IN_ATTRIB |
157 inotify.IN_ATTRIB |
158 inotify.IN_CREATE |
158 inotify.IN_CREATE |
159 inotify.IN_DELETE |
159 inotify.IN_DELETE |
160 inotify.IN_DELETE_SELF |
160 inotify.IN_DELETE_SELF |
161 inotify.IN_MODIFY |
161 inotify.IN_MODIFY |
162 inotify.IN_MOVED_FROM |
162 inotify.IN_MOVED_FROM |
163 inotify.IN_MOVED_TO |
163 inotify.IN_MOVED_TO |
164 inotify.IN_MOVE_SELF |
164 inotify.IN_MOVE_SELF |
165 inotify.IN_ONLYDIR |
165 inotify.IN_ONLYDIR |
166 inotify.IN_UNMOUNT |
166 inotify.IN_UNMOUNT |
167 0)
167 0)
168
168
169 def __init__(self, ui, dirstate, root):
169 def __init__(self, ui, dirstate, root):
170 server.repowatcher.__init__(self, ui, dirstate, root)
170 server.repowatcher.__init__(self, ui, dirstate, root)
171
171
172 self.lastevent = {}
172 self.lastevent = {}
173 self.dirty = False
173 self.dirty = False
174 try:
174 try:
175 self.watcher = watcher.watcher()
175 self.watcher = watcher.watcher()
176 except OSError, err:
176 except OSError, err:
177 raise util.Abort(_('inotify service not available: %s') %
177 raise util.Abort(_('inotify service not available: %s') %
178 err.strerror)
178 err.strerror)
179 self.threshold = watcher.threshold(self.watcher)
179 self.threshold = watcher.threshold(self.watcher)
180 self.fileno = self.watcher.fileno
180 self.fileno = self.watcher.fileno
181 self.register(timeout=None)
181 self.register(timeout=None)
182
182
183 self.handle_timeout()
183 self.handle_timeout()
184 self.scan()
184 self.scan()
185
185
186 def event_time(self):
186 def event_time(self):
187 last = self.last_event
187 last = self.last_event
188 now = time.time()
188 now = time.time()
189 self.last_event = now
189 self.last_event = now
190
190
191 if last is None:
191 if last is None:
192 return 'start'
192 return 'start'
193 delta = now - last
193 delta = now - last
194 if delta < 5:
194 if delta < 5:
195 return '+%.3f' % delta
195 return '+%.3f' % delta
196 if delta < 50:
196 if delta < 50:
197 return '+%.2f' % delta
197 return '+%.2f' % delta
198 return '+%.1f' % delta
198 return '+%.1f' % delta
199
199
200 def add_watch(self, path, mask):
200 def add_watch(self, path, mask):
201 if not path:
201 if not path:
202 return
202 return
203 if self.watcher.path(path) is None:
203 if self.watcher.path(path) is None:
204 if self.ui.debugflag:
204 if self.ui.debugflag:
205 self.ui.note(_('watching %r\n') % path[self.prefixlen:])
205 self.ui.note(_('watching %r\n') % path[self.prefixlen:])
206 try:
206 try:
207 self.watcher.add(path, mask)
207 self.watcher.add(path, mask)
208 except OSError, err:
208 except OSError, err:
209 if err.errno in (errno.ENOENT, errno.ENOTDIR):
209 if err.errno in (errno.ENOENT, errno.ENOTDIR):
210 return
210 return
211 if err.errno != errno.ENOSPC:
211 if err.errno != errno.ENOSPC:
212 raise
212 raise
213 _explain_watch_limit(self.ui, self.dirstate, self.wprefix)
213 _explain_watch_limit(self.ui, self.dirstate, self.wprefix)
214
214
215 def setup(self):
215 def setup(self):
216 self.ui.note(_('watching directories under %r\n') % self.wprefix)
216 self.ui.note(_('watching directories under %r\n') % self.wprefix)
217 self.add_watch(self.wprefix + '.hg', inotify.IN_DELETE)
217 self.add_watch(self.wprefix + '.hg', inotify.IN_DELETE)
218
218
219 def scan(self, topdir=''):
219 def scan(self, topdir=''):
220 ds = self.dirstate._map.copy()
220 ds = self.dirstate._map.copy()
221 self.add_watch(server.join(self.wprefix, topdir), self.mask)
221 self.add_watch(server.join(self.wprefix, topdir), self.mask)
222 for root, dirs, files in server.walk(self.dirstate, self.wprefix,
222 for root, dirs, files in server.walk(self.dirstate, self.wprefix,
223 topdir):
223 topdir):
224 for d in dirs:
224 for d in dirs:
225 self.add_watch(server.join(root, d), self.mask)
225 self.add_watch(server.join(root, d), self.mask)
226 wroot = root[self.prefixlen:]
226 wroot = root[self.prefixlen:]
227 for fn in files:
227 for fn in files:
228 wfn = server.join(wroot, fn)
228 wfn = server.join(wroot, fn)
229 self.updatefile(wfn, self.getstat(wfn))
229 self.updatefile(wfn, self.getstat(wfn))
230 ds.pop(wfn, None)
230 ds.pop(wfn, None)
231 wtopdir = topdir
231 wtopdir = topdir
232 if wtopdir and wtopdir[-1] != '/':
232 if wtopdir and wtopdir[-1] != '/':
233 wtopdir += '/'
233 wtopdir += '/'
234 for wfn, state in ds.iteritems():
234 for wfn, state in ds.iteritems():
235 if not wfn.startswith(wtopdir):
235 if not wfn.startswith(wtopdir):
236 continue
236 continue
237 try:
237 try:
238 st = self.stat(wfn)
238 st = self.stat(wfn)
239 except OSError:
239 except OSError:
240 status = state[0]
240 status = state[0]
241 self.deletefile(wfn, status)
241 self.deletefile(wfn, status)
242 else:
242 else:
243 self.updatefile(wfn, st)
243 self.updatefile(wfn, st)
244 self.check_deleted('!')
244 self.check_deleted('!')
245 self.check_deleted('r')
245 self.check_deleted('r')
246
246
247 @eventaction('c')
247 @eventaction('c')
248 def created(self, wpath):
248 def created(self, wpath):
249 if wpath == '.hgignore':
249 if wpath == '.hgignore':
250 self.update_hgignore()
250 self.update_hgignore()
251 try:
251 try:
252 st = self.stat(wpath)
252 st = self.stat(wpath)
253 if stat.S_ISREG(st[0]) or stat.S_ISLNK(st[0]):
253 if stat.S_ISREG(st[0]) or stat.S_ISLNK(st[0]):
254 self.updatefile(wpath, st)
254 self.updatefile(wpath, st)
255 except OSError:
255 except OSError:
256 pass
256 pass
257
257
258 @eventaction('m')
258 @eventaction('m')
259 def modified(self, wpath):
259 def modified(self, wpath):
260 if wpath == '.hgignore':
260 if wpath == '.hgignore':
261 self.update_hgignore()
261 self.update_hgignore()
262 try:
262 try:
263 st = self.stat(wpath)
263 st = self.stat(wpath)
264 if stat.S_ISREG(st[0]):
264 if stat.S_ISREG(st[0]):
265 if self.dirstate[wpath] in 'lmn':
265 if self.dirstate[wpath] in 'lmn':
266 self.updatefile(wpath, st)
266 self.updatefile(wpath, st)
267 except OSError:
267 except OSError:
268 pass
268 pass
269
269
270 @eventaction('d')
270 @eventaction('d')
271 def deleted(self, wpath):
271 def deleted(self, wpath):
272 if wpath == '.hgignore':
272 if wpath == '.hgignore':
273 self.update_hgignore()
273 self.update_hgignore()
274 elif wpath.startswith('.hg/'):
274 elif wpath.startswith('.hg/'):
275 return
275 return
276
276
277 self.deletefile(wpath, self.dirstate[wpath])
277 self.deletefile(wpath, self.dirstate[wpath])
278
278
279 def process_create(self, wpath, evt):
279 def process_create(self, wpath, evt):
280 if self.ui.debugflag:
280 if self.ui.debugflag:
281 self.ui.note(_('%s event: created %s\n') %
281 self.ui.note(_('%s event: created %s\n') %
282 (self.event_time(), wpath))
282 (self.event_time(), wpath))
283
283
284 if evt.mask & inotify.IN_ISDIR:
284 if evt.mask & inotify.IN_ISDIR:
285 self.scan(wpath)
285 self.scan(wpath)
286 else:
286 else:
287 self.created(wpath)
287 self.created(wpath)
288
288
289 def process_delete(self, wpath, evt):
289 def process_delete(self, wpath, evt):
290 if self.ui.debugflag:
290 if self.ui.debugflag:
291 self.ui.note(_('%s event: deleted %s\n') %
291 self.ui.note(_('%s event: deleted %s\n') %
292 (self.event_time(), wpath))
292 (self.event_time(), wpath))
293
293
294 if evt.mask & inotify.IN_ISDIR:
294 if evt.mask & inotify.IN_ISDIR:
295 tree = self.tree.dir(wpath)
295 tree = self.tree.dir(wpath)
296 todelete = [wfn for wfn, ignore in tree.walk('?')]
296 todelete = [wfn for wfn, ignore in tree.walk('?')]
297 for fn in todelete:
297 for fn in todelete:
298 self.deletefile(fn, '?')
298 self.deletefile(fn, '?')
299 self.scan(wpath)
299 self.scan(wpath)
300 else:
300 else:
301 self.deleted(wpath)
301 self.deleted(wpath)
302
302
303 def process_modify(self, wpath, evt):
303 def process_modify(self, wpath, evt):
304 if self.ui.debugflag:
304 if self.ui.debugflag:
305 self.ui.note(_('%s event: modified %s\n') %
305 self.ui.note(_('%s event: modified %s\n') %
306 (self.event_time(), wpath))
306 (self.event_time(), wpath))
307
307
308 if not (evt.mask & inotify.IN_ISDIR):
308 if not (evt.mask & inotify.IN_ISDIR):
309 self.modified(wpath)
309 self.modified(wpath)
310
310
311 def process_unmount(self, evt):
311 def process_unmount(self, evt):
312 self.ui.warn(_('filesystem containing %s was unmounted\n') %
312 self.ui.warn(_('filesystem containing %s was unmounted\n') %
313 evt.fullpath)
313 evt.fullpath)
314 sys.exit(0)
314 sys.exit(0)
315
315
316 def handle_pollevents(self, events):
316 def handle_pollevents(self, events):
317 if self.ui.debugflag:
317 if self.ui.debugflag:
318 self.ui.note(_('%s readable: %d bytes\n') %
318 self.ui.note(_('%s readable: %d bytes\n') %
319 (self.event_time(), self.threshold.readable()))
319 (self.event_time(), self.threshold.readable()))
320 if not self.threshold():
320 if not self.threshold():
321 if self.registered:
321 if self.registered:
322 if self.ui.debugflag:
322 if self.ui.debugflag:
323 self.ui.note(_('%s below threshold - unhooking\n') %
323 self.ui.note(_('%s below threshold - unhooking\n') %
324 (self.event_time()))
324 (self.event_time()))
325 self.unregister()
325 self.unregister()
326 self.timeout = 250
326 self.timeout = 250
327 else:
327 else:
328 self.read_events()
328 self.read_events()
329
329
330 def read_events(self, bufsize=None):
330 def read_events(self, bufsize=None):
331 events = self.watcher.read(bufsize)
331 events = self.watcher.read(bufsize)
332 if self.ui.debugflag:
332 if self.ui.debugflag:
333 self.ui.note(_('%s reading %d events\n') %
333 self.ui.note(_('%s reading %d events\n') %
334 (self.event_time(), len(events)))
334 (self.event_time(), len(events)))
335 for evt in events:
335 for evt in events:
336 if evt.fullpath == self.wprefix[:-1]:
336 if evt.fullpath == self.wprefix[:-1]:
337 # events on the root of the repository
337 # events on the root of the repository
338 # itself, e.g. permission changes or repository move
338 # itself, e.g. permission changes or repository move
339 continue
339 continue
340 assert evt.fullpath.startswith(self.wprefix)
340 assert evt.fullpath.startswith(self.wprefix)
341 wpath = evt.fullpath[self.prefixlen:]
341 wpath = evt.fullpath[self.prefixlen:]
342
342
343 # paths have been normalized, wpath never ends with a '/'
343 # paths have been normalized, wpath never ends with a '/'
344
344
345 if wpath.startswith('.hg/') and evt.mask & inotify.IN_ISDIR:
345 if wpath.startswith('.hg/') and evt.mask & inotify.IN_ISDIR:
346 # ignore subdirectories of .hg/ (merge, patches...)
346 # ignore subdirectories of .hg/ (merge, patches...)
347 continue
347 continue
348 if wpath == ".hg/wlock":
348 if wpath == ".hg/wlock":
349 if evt.mask & inotify.IN_DELETE:
349 if evt.mask & inotify.IN_DELETE:
350 self.dirstate.invalidate()
350 self.dirstate.invalidate()
351 self.dirty = False
351 self.dirty = False
352 self.scan()
352 self.scan()
353 elif evt.mask & inotify.IN_CREATE:
353 elif evt.mask & inotify.IN_CREATE:
354 self.dirty = True
354 self.dirty = True
355 else:
355 else:
356 if self.dirty:
356 if self.dirty:
357 continue
357 continue
358
358
359 if evt.mask & inotify.IN_UNMOUNT:
359 if evt.mask & inotify.IN_UNMOUNT:
360 self.process_unmount(wpath, evt)
360 self.process_unmount(wpath, evt)
361 elif evt.mask & (inotify.IN_MODIFY | inotify.IN_ATTRIB):
361 elif evt.mask & (inotify.IN_MODIFY | inotify.IN_ATTRIB):
362 self.process_modify(wpath, evt)
362 self.process_modify(wpath, evt)
363 elif evt.mask & (inotify.IN_DELETE | inotify.IN_DELETE_SELF |
363 elif evt.mask & (inotify.IN_DELETE | inotify.IN_DELETE_SELF |
364 inotify.IN_MOVED_FROM):
364 inotify.IN_MOVED_FROM):
365 self.process_delete(wpath, evt)
365 self.process_delete(wpath, evt)
366 elif evt.mask & (inotify.IN_CREATE | inotify.IN_MOVED_TO):
366 elif evt.mask & (inotify.IN_CREATE | inotify.IN_MOVED_TO):
367 self.process_create(wpath, evt)
367 self.process_create(wpath, evt)
368
368
369 self.lastevent.clear()
369 self.lastevent.clear()
370
370
371 def handle_timeout(self):
371 def handle_timeout(self):
372 if not self.registered:
372 if not self.registered:
373 if self.ui.debugflag:
373 if self.ui.debugflag:
374 self.ui.note(_('%s hooking back up with %d bytes readable\n') %
374 self.ui.note(_('%s hooking back up with %d bytes readable\n') %
375 (self.event_time(), self.threshold.readable()))
375 (self.event_time(), self.threshold.readable()))
376 self.read_events(0)
376 self.read_events(0)
377 self.register(timeout=None)
377 self.register(timeout=None)
378
378
379 self.timeout = None
379 self.timeout = None
380
380
381 def shutdown(self):
381 def shutdown(self):
382 self.watcher.close()
382 self.watcher.close()
383
383
384 def debug(self):
384 def debug(self):
385 """
385 """
386 Returns a sorted list of relatives paths currently watched,
386 Returns a sorted list of relatives paths currently watched,
387 for debugging purposes.
387 for debugging purposes.
388 """
388 """
389 return sorted(tuple[0][self.prefixlen:] for tuple in self.watcher)
389 return sorted(tuple[0][self.prefixlen:] for tuple in self.watcher)
390
390
391 class socketlistener(server.socketlistener, pollable):
391 class socketlistener(server.socketlistener, pollable):
392 """
392 """
393 Listens for client queries on unix socket inotify.sock
393 Listens for client queries on unix socket inotify.sock
394 """
394 """
395 def __init__(self, ui, root, repowatcher, timeout):
395 def __init__(self, ui, root, repowatcher, timeout):
396 server.socketlistener.__init__(self, ui, root, repowatcher, timeout)
396 server.socketlistener.__init__(self, ui, root, repowatcher, timeout)
397 self.register(timeout=timeout)
397 self.register(timeout=timeout)
398
398
399 def handle_timeout(self):
399 def handle_timeout(self):
400 raise server.TimeoutException
400 raise server.TimeoutException
401
401
402 def handle_pollevents(self, events):
402 def handle_pollevents(self, events):
403 for e in events:
403 for e in events:
404 self.accept_connection()
404 self.accept_connection()
405
405
406 def shutdown(self):
406 def shutdown(self):
407 self.sock.close()
407 self.sock.close()
408 try:
408 try:
409 os.unlink(self.sockpath)
409 os.unlink(self.sockpath)
410 if self.realsockpath:
410 if self.realsockpath:
411 os.unlink(self.realsockpath)
411 os.unlink(self.realsockpath)
412 os.rmdir(os.path.dirname(self.realsockpath))
412 os.rmdir(os.path.dirname(self.realsockpath))
413 except OSError, err:
413 except OSError, err:
414 if err.errno != errno.ENOENT:
414 if err.errno != errno.ENOENT:
415 raise
415 raise
416
416
417 def answer_stat_query(self, cs):
417 def answer_stat_query(self, cs):
418 if self.repowatcher.timeout:
418 if self.repowatcher.timeout:
419 # We got a query while a rescan is pending. Make sure we
419 # We got a query while a rescan is pending. Make sure we
420 # rescan before responding, or we could give back a wrong
420 # rescan before responding, or we could give back a wrong
421 # answer.
421 # answer.
422 self.repowatcher.handle_timeout()
422 self.repowatcher.handle_timeout()
423 return server.socketlistener.answer_stat_query(self, cs)
423 return server.socketlistener.answer_stat_query(self, cs)
424
424
425 class master(object):
425 class master(object):
426 def __init__(self, ui, dirstate, root, timeout=None):
426 def __init__(self, ui, dirstate, root, timeout=None):
427 self.ui = ui
427 self.ui = ui
428 self.repowatcher = repowatcher(ui, dirstate, root)
428 self.repowatcher = repowatcher(ui, dirstate, root)
429 self.socketlistener = socketlistener(ui, root, self.repowatcher,
429 self.socketlistener = socketlistener(ui, root, self.repowatcher,
430 timeout)
430 timeout)
431
431
432 def shutdown(self):
432 def shutdown(self):
433 for obj in pollable.instances.itervalues():
433 for obj in pollable.instances.itervalues():
434 obj.shutdown()
434 obj.shutdown()
435
435
436 def run(self):
436 def run(self):
437 self.repowatcher.setup()
437 self.repowatcher.setup()
438 self.ui.note(_('finished setup\n'))
438 self.ui.note(_('finished setup\n'))
439 if os.getenv('TIME_STARTUP'):
439 if os.getenv('TIME_STARTUP'):
440 sys.exit(0)
440 sys.exit(0)
441 pollable.run()
441 pollable.run()
@@ -1,488 +1,488
1 # server.py - common entry point for inotify status server
1 # server.py - common entry point for inotify status server
2 #
2 #
3 # Copyright 2009 Nicolas Dumazet <nicdumz@gmail.com>
3 # Copyright 2009 Nicolas Dumazet <nicdumz@gmail.com>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
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.
6 # GNU General Public License version 2 or any later version.
7
7
8 from mercurial.i18n import _
8 from mercurial.i18n import _
9 from mercurial import cmdutil, osutil, util
9 from mercurial import cmdutil, osutil, util
10 import common
10 import common
11
11
12 import errno
12 import errno
13 import os
13 import os
14 import socket
14 import socket
15 import stat
15 import stat
16 import struct
16 import struct
17 import sys
17 import sys
18 import tempfile
18 import tempfile
19
19
20 class AlreadyStartedException(Exception):
20 class AlreadyStartedException(Exception):
21 pass
21 pass
22 class TimeoutException(Exception):
22 class TimeoutException(Exception):
23 pass
23 pass
24
24
25 def join(a, b):
25 def join(a, b):
26 if a:
26 if a:
27 if a[-1] == '/':
27 if a[-1] == '/':
28 return a + b
28 return a + b
29 return a + '/' + b
29 return a + '/' + b
30 return b
30 return b
31
31
32 def split(path):
32 def split(path):
33 c = path.rfind('/')
33 c = path.rfind('/')
34 if c == -1:
34 if c == -1:
35 return '', path
35 return '', path
36 return path[:c], path[c + 1:]
36 return path[:c], path[c + 1:]
37
37
38 walk_ignored_errors = (errno.ENOENT, errno.ENAMETOOLONG)
38 walk_ignored_errors = (errno.ENOENT, errno.ENAMETOOLONG)
39
39
40 def walk(dirstate, absroot, root):
40 def walk(dirstate, absroot, root):
41 '''Like os.walk, but only yields regular files.'''
41 '''Like os.walk, but only yields regular files.'''
42
42
43 # This function is critical to performance during startup.
43 # This function is critical to performance during startup.
44
44
45 def walkit(root, reporoot):
45 def walkit(root, reporoot):
46 files, dirs = [], []
46 files, dirs = [], []
47
47
48 try:
48 try:
49 fullpath = join(absroot, root)
49 fullpath = join(absroot, root)
50 for name, kind in osutil.listdir(fullpath):
50 for name, kind in osutil.listdir(fullpath):
51 if kind == stat.S_IFDIR:
51 if kind == stat.S_IFDIR:
52 if name == '.hg':
52 if name == '.hg':
53 if not reporoot:
53 if not reporoot:
54 return
54 return
55 else:
55 else:
56 dirs.append(name)
56 dirs.append(name)
57 path = join(root, name)
57 path = join(root, name)
58 if dirstate._ignore(path):
58 if dirstate._ignore(path):
59 continue
59 continue
60 for result in walkit(path, False):
60 for result in walkit(path, False):
61 yield result
61 yield result
62 elif kind in (stat.S_IFREG, stat.S_IFLNK):
62 elif kind in (stat.S_IFREG, stat.S_IFLNK):
63 files.append(name)
63 files.append(name)
64 yield fullpath, dirs, files
64 yield fullpath, dirs, files
65
65
66 except OSError, err:
66 except OSError, err:
67 if err.errno == errno.ENOTDIR:
67 if err.errno == errno.ENOTDIR:
68 # fullpath was a directory, but has since been replaced
68 # fullpath was a directory, but has since been replaced
69 # by a file.
69 # by a file.
70 yield fullpath, dirs, files
70 yield fullpath, dirs, files
71 elif err.errno not in walk_ignored_errors:
71 elif err.errno not in walk_ignored_errors:
72 raise
72 raise
73
73
74 return walkit(root, root == '')
74 return walkit(root, root == '')
75
75
76 class directory(object):
76 class directory(object):
77 """
77 """
78 Representing a directory
78 Representing a directory
79
79
80 * path is the relative path from repo root to this directory
80 * path is the relative path from repo root to this directory
81 * files is a dict listing the files in this directory
81 * files is a dict listing the files in this directory
82 - keys are file names
82 - keys are file names
83 - values are file status
83 - values are file status
84 * dirs is a dict listing the subdirectories
84 * dirs is a dict listing the subdirectories
85 - key are subdirectories names
85 - key are subdirectories names
86 - values are directory objects
86 - values are directory objects
87 """
87 """
88 def __init__(self, relpath=''):
88 def __init__(self, relpath=''):
89 self.path = relpath
89 self.path = relpath
90 self.files = {}
90 self.files = {}
91 self.dirs = {}
91 self.dirs = {}
92
92
93 def dir(self, relpath):
93 def dir(self, relpath):
94 """
94 """
95 Returns the directory contained at the relative path relpath.
95 Returns the directory contained at the relative path relpath.
96 Creates the intermediate directories if necessary.
96 Creates the intermediate directories if necessary.
97 """
97 """
98 if not relpath:
98 if not relpath:
99 return self
99 return self
100 l = relpath.split('/')
100 l = relpath.split('/')
101 ret = self
101 ret = self
102 while l:
102 while l:
103 next = l.pop(0)
103 next = l.pop(0)
104 try:
104 try:
105 ret = ret.dirs[next]
105 ret = ret.dirs[next]
106 except KeyError:
106 except KeyError:
107 d = directory(join(ret.path, next))
107 d = directory(join(ret.path, next))
108 ret.dirs[next] = d
108 ret.dirs[next] = d
109 ret = d
109 ret = d
110 return ret
110 return ret
111
111
112 def walk(self, states, visited=None):
112 def walk(self, states, visited=None):
113 """
113 """
114 yield (filename, status) pairs for items in the trees
114 yield (filename, status) pairs for items in the trees
115 that have status in states.
115 that have status in states.
116 filenames are relative to the repo root
116 filenames are relative to the repo root
117 """
117 """
118 for file, st in self.files.iteritems():
118 for file, st in self.files.iteritems():
119 if st in states:
119 if st in states:
120 yield join(self.path, file), st
120 yield join(self.path, file), st
121 for dir in self.dirs.itervalues():
121 for dir in self.dirs.itervalues():
122 if visited is not None:
122 if visited is not None:
123 visited.add(dir.path)
123 visited.add(dir.path)
124 for e in dir.walk(states):
124 for e in dir.walk(states):
125 yield e
125 yield e
126
126
127 def lookup(self, states, path, visited):
127 def lookup(self, states, path, visited):
128 """
128 """
129 yield root-relative filenames that match path, and whose
129 yield root-relative filenames that match path, and whose
130 status are in states:
130 status are in states:
131 * if path is a file, yield path
131 * if path is a file, yield path
132 * if path is a directory, yield directory files
132 * if path is a directory, yield directory files
133 * if path is not tracked, yield nothing
133 * if path is not tracked, yield nothing
134 """
134 """
135 if path[-1] == '/':
135 if path[-1] == '/':
136 path = path[:-1]
136 path = path[:-1]
137
137
138 paths = path.split('/')
138 paths = path.split('/')
139
139
140 # we need to check separately for last node
140 # we need to check separately for last node
141 last = paths.pop()
141 last = paths.pop()
142
142
143 tree = self
143 tree = self
144 try:
144 try:
145 for dir in paths:
145 for dir in paths:
146 tree = tree.dirs[dir]
146 tree = tree.dirs[dir]
147 except KeyError:
147 except KeyError:
148 # path is not tracked
148 # path is not tracked
149 visited.add(tree.path)
149 visited.add(tree.path)
150 return
150 return
151
151
152 try:
152 try:
153 # if path is a directory, walk it
153 # if path is a directory, walk it
154 target = tree.dirs[last]
154 target = tree.dirs[last]
155 visited.add(target.path)
155 visited.add(target.path)
156 for file, st in target.walk(states, visited):
156 for file, st in target.walk(states, visited):
157 yield file
157 yield file
158 except KeyError:
158 except KeyError:
159 try:
159 try:
160 if tree.files[last] in states:
160 if tree.files[last] in states:
161 # path is a file
161 # path is a file
162 visited.add(tree.path)
162 visited.add(tree.path)
163 yield path
163 yield path
164 except KeyError:
164 except KeyError:
165 # path is not tracked
165 # path is not tracked
166 pass
166 pass
167
167
168 class repowatcher(object):
168 class repowatcher(object):
169 """
169 """
170 Watches inotify events
170 Watches inotify events
171 """
171 """
172 statuskeys = 'almr!?'
172 statuskeys = 'almr!?'
173
173
174 def __init__(self, ui, dirstate, root):
174 def __init__(self, ui, dirstate, root):
175 self.ui = ui
175 self.ui = ui
176 self.dirstate = dirstate
176 self.dirstate = dirstate
177
177
178 self.wprefix = join(root, '')
178 self.wprefix = join(root, '')
179 self.prefixlen = len(self.wprefix)
179 self.prefixlen = len(self.wprefix)
180
180
181 self.tree = directory()
181 self.tree = directory()
182 self.statcache = {}
182 self.statcache = {}
183 self.statustrees = dict([(s, directory()) for s in self.statuskeys])
183 self.statustrees = dict([(s, directory()) for s in self.statuskeys])
184
184
185 self.ds_info = self.dirstate_info()
185 self.ds_info = self.dirstate_info()
186
186
187 self.last_event = None
187 self.last_event = None
188
188
189
189
190 def handle_timeout(self):
190 def handle_timeout(self):
191 pass
191 pass
192
192
193 def dirstate_info(self):
193 def dirstate_info(self):
194 try:
194 try:
195 st = os.lstat(self.wprefix + '.hg/dirstate')
195 st = os.lstat(self.wprefix + '.hg/dirstate')
196 return st.st_mtime, st.st_ino
196 return st.st_mtime, st.st_ino
197 except OSError, err:
197 except OSError, err:
198 if err.errno != errno.ENOENT:
198 if err.errno != errno.ENOENT:
199 raise
199 raise
200 return 0, 0
200 return 0, 0
201
201
202 def filestatus(self, fn, st):
202 def filestatus(self, fn, st):
203 try:
203 try:
204 type_, mode, size, time = self.dirstate._map[fn][:4]
204 type_, mode, size, time = self.dirstate._map[fn][:4]
205 except KeyError:
205 except KeyError:
206 type_ = '?'
206 type_ = '?'
207 if type_ == 'n':
207 if type_ == 'n':
208 st_mode, st_size, st_mtime = st
208 st_mode, st_size, st_mtime = st
209 if size == -1:
209 if size == -1:
210 return 'l'
210 return 'l'
211 if size and (size != st_size or (mode ^ st_mode) & 0100):
211 if size and (size != st_size or (mode ^ st_mode) & 0100):
212 return 'm'
212 return 'm'
213 if time != int(st_mtime):
213 if time != int(st_mtime):
214 return 'l'
214 return 'l'
215 return 'n'
215 return 'n'
216 if type_ == '?' and self.dirstate._dirignore(fn):
216 if type_ == '?' and self.dirstate._dirignore(fn):
217 # we must check not only if the file is ignored, but if any part
217 # we must check not only if the file is ignored, but if any part
218 # of its path match an ignore pattern
218 # of its path match an ignore pattern
219 return 'i'
219 return 'i'
220 return type_
220 return type_
221
221
222 def updatefile(self, wfn, osstat):
222 def updatefile(self, wfn, osstat):
223 '''
223 '''
224 update the file entry of an existing file.
224 update the file entry of an existing file.
225
225
226 osstat: (mode, size, time) tuple, as returned by os.lstat(wfn)
226 osstat: (mode, size, time) tuple, as returned by os.lstat(wfn)
227 '''
227 '''
228
228
229 self._updatestatus(wfn, self.filestatus(wfn, osstat))
229 self._updatestatus(wfn, self.filestatus(wfn, osstat))
230
230
231 def deletefile(self, wfn, oldstatus):
231 def deletefile(self, wfn, oldstatus):
232 '''
232 '''
233 update the entry of a file which has been deleted.
233 update the entry of a file which has been deleted.
234
234
235 oldstatus: char in statuskeys, status of the file before deletion
235 oldstatus: char in statuskeys, status of the file before deletion
236 '''
236 '''
237 if oldstatus == 'r':
237 if oldstatus == 'r':
238 newstatus = 'r'
238 newstatus = 'r'
239 elif oldstatus in 'almn':
239 elif oldstatus in 'almn':
240 newstatus = '!'
240 newstatus = '!'
241 else:
241 else:
242 newstatus = None
242 newstatus = None
243
243
244 self.statcache.pop(wfn, None)
244 self.statcache.pop(wfn, None)
245 self._updatestatus(wfn, newstatus)
245 self._updatestatus(wfn, newstatus)
246
246
247 def _updatestatus(self, wfn, newstatus):
247 def _updatestatus(self, wfn, newstatus):
248 '''
248 '''
249 Update the stored status of a file.
249 Update the stored status of a file.
250
250
251 newstatus: - char in (statuskeys + 'ni'), new status to apply.
251 newstatus: - char in (statuskeys + 'ni'), new status to apply.
252 - or None, to stop tracking wfn
252 - or None, to stop tracking wfn
253 '''
253 '''
254 root, fn = split(wfn)
254 root, fn = split(wfn)
255 d = self.tree.dir(root)
255 d = self.tree.dir(root)
256
256
257 oldstatus = d.files.get(fn)
257 oldstatus = d.files.get(fn)
258 # oldstatus can be either:
258 # oldstatus can be either:
259 # - None : fn is new
259 # - None : fn is new
260 # - a char in statuskeys: fn is a (tracked) file
260 # - a char in statuskeys: fn is a (tracked) file
261
261
262 if self.ui.debugflag and oldstatus != newstatus:
262 if self.ui.debugflag and oldstatus != newstatus:
263 self.ui.note(_('status: %r %s -> %s\n') %
263 self.ui.note(_('status: %r %s -> %s\n') %
264 (wfn, oldstatus, newstatus))
264 (wfn, oldstatus, newstatus))
265
265
266 if oldstatus and oldstatus in self.statuskeys \
266 if oldstatus and oldstatus in self.statuskeys \
267 and oldstatus != newstatus:
267 and oldstatus != newstatus:
268 del self.statustrees[oldstatus].dir(root).files[fn]
268 del self.statustrees[oldstatus].dir(root).files[fn]
269
269
270 if newstatus in (None, 'i'):
270 if newstatus in (None, 'i'):
271 d.files.pop(fn, None)
271 d.files.pop(fn, None)
272 elif oldstatus != newstatus:
272 elif oldstatus != newstatus:
273 d.files[fn] = newstatus
273 d.files[fn] = newstatus
274 if newstatus != 'n':
274 if newstatus != 'n':
275 self.statustrees[newstatus].dir(root).files[fn] = newstatus
275 self.statustrees[newstatus].dir(root).files[fn] = newstatus
276
276
277 def check_deleted(self, key):
277 def check_deleted(self, key):
278 # Files that had been deleted but were present in the dirstate
278 # Files that had been deleted but were present in the dirstate
279 # may have vanished from the dirstate; we must clean them up.
279 # may have vanished from the dirstate; we must clean them up.
280 nuke = []
280 nuke = []
281 for wfn, ignore in self.statustrees[key].walk(key):
281 for wfn, ignore in self.statustrees[key].walk(key):
282 if wfn not in self.dirstate:
282 if wfn not in self.dirstate:
283 nuke.append(wfn)
283 nuke.append(wfn)
284 for wfn in nuke:
284 for wfn in nuke:
285 root, fn = split(wfn)
285 root, fn = split(wfn)
286 del self.statustrees[key].dir(root).files[fn]
286 del self.statustrees[key].dir(root).files[fn]
287 del self.tree.dir(root).files[fn]
287 del self.tree.dir(root).files[fn]
288
288
289 def update_hgignore(self):
289 def update_hgignore(self):
290 # An update of the ignore file can potentially change the
290 # An update of the ignore file can potentially change the
291 # states of all unknown and ignored files.
291 # states of all unknown and ignored files.
292
292
293 # XXX If the user has other ignore files outside the repo, or
293 # XXX If the user has other ignore files outside the repo, or
294 # changes their list of ignore files at run time, we'll
294 # changes their list of ignore files at run time, we'll
295 # potentially never see changes to them. We could get the
295 # potentially never see changes to them. We could get the
296 # client to report to us what ignore data they're using.
296 # client to report to us what ignore data they're using.
297 # But it's easier to do nothing than to open that can of
297 # But it's easier to do nothing than to open that can of
298 # worms.
298 # worms.
299
299
300 if '_ignore' in self.dirstate.__dict__:
300 if '_ignore' in self.dirstate.__dict__:
301 delattr(self.dirstate, '_ignore')
301 delattr(self.dirstate, '_ignore')
302 self.ui.note(_('rescanning due to .hgignore change\n'))
302 self.ui.note(_('rescanning due to .hgignore change\n'))
303 self.handle_timeout()
303 self.handle_timeout()
304 self.scan()
304 self.scan()
305
305
306 def getstat(self, wpath):
306 def getstat(self, wpath):
307 try:
307 try:
308 return self.statcache[wpath]
308 return self.statcache[wpath]
309 except KeyError:
309 except KeyError:
310 try:
310 try:
311 return self.stat(wpath)
311 return self.stat(wpath)
312 except OSError, err:
312 except OSError, err:
313 if err.errno != errno.ENOENT:
313 if err.errno != errno.ENOENT:
314 raise
314 raise
315
315
316 def stat(self, wpath):
316 def stat(self, wpath):
317 try:
317 try:
318 st = os.lstat(join(self.wprefix, wpath))
318 st = os.lstat(join(self.wprefix, wpath))
319 ret = st.st_mode, st.st_size, st.st_mtime
319 ret = st.st_mode, st.st_size, st.st_mtime
320 self.statcache[wpath] = ret
320 self.statcache[wpath] = ret
321 return ret
321 return ret
322 except OSError:
322 except OSError:
323 self.statcache.pop(wpath, None)
323 self.statcache.pop(wpath, None)
324 raise
324 raise
325
325
326 class socketlistener(object):
326 class socketlistener(object):
327 """
327 """
328 Listens for client queries on unix socket inotify.sock
328 Listens for client queries on unix socket inotify.sock
329 """
329 """
330 def __init__(self, ui, root, repowatcher, timeout):
330 def __init__(self, ui, root, repowatcher, timeout):
331 self.ui = ui
331 self.ui = ui
332 self.repowatcher = repowatcher
332 self.repowatcher = repowatcher
333 self.sock = socket.socket(socket.AF_UNIX)
333 self.sock = socket.socket(socket.AF_UNIX)
334 self.sockpath = join(root, '.hg/inotify.sock')
334 self.sockpath = join(root, '.hg/inotify.sock')
335 self.realsockpath = None
335 self.realsockpath = None
336 try:
336 try:
337 self.sock.bind(self.sockpath)
337 self.sock.bind(self.sockpath)
338 except socket.error, err:
338 except socket.error, err:
339 if err[0] == errno.EADDRINUSE:
339 if err.args[0] == errno.EADDRINUSE:
340 raise AlreadyStartedException(_('cannot start: socket is '
340 raise AlreadyStartedException(_('cannot start: socket is '
341 'already bound'))
341 'already bound'))
342 if err[0] == "AF_UNIX path too long":
342 if err.args[0] == "AF_UNIX path too long":
343 if os.path.islink(self.sockpath) and \
343 if os.path.islink(self.sockpath) and \
344 not os.path.exists(self.sockpath):
344 not os.path.exists(self.sockpath):
345 raise util.Abort('inotify-server: cannot start: '
345 raise util.Abort('inotify-server: cannot start: '
346 '.hg/inotify.sock is a broken symlink')
346 '.hg/inotify.sock is a broken symlink')
347 tempdir = tempfile.mkdtemp(prefix="hg-inotify-")
347 tempdir = tempfile.mkdtemp(prefix="hg-inotify-")
348 self.realsockpath = os.path.join(tempdir, "inotify.sock")
348 self.realsockpath = os.path.join(tempdir, "inotify.sock")
349 try:
349 try:
350 self.sock.bind(self.realsockpath)
350 self.sock.bind(self.realsockpath)
351 os.symlink(self.realsockpath, self.sockpath)
351 os.symlink(self.realsockpath, self.sockpath)
352 except (OSError, socket.error), inst:
352 except (OSError, socket.error), inst:
353 try:
353 try:
354 os.unlink(self.realsockpath)
354 os.unlink(self.realsockpath)
355 except:
355 except:
356 pass
356 pass
357 os.rmdir(tempdir)
357 os.rmdir(tempdir)
358 if inst.errno == errno.EEXIST:
358 if inst.errno == errno.EEXIST:
359 raise AlreadyStartedException(_('cannot start: tried '
359 raise AlreadyStartedException(_('cannot start: tried '
360 'linking .hg/inotify.sock to a temporary socket but'
360 'linking .hg/inotify.sock to a temporary socket but'
361 ' .hg/inotify.sock already exists'))
361 ' .hg/inotify.sock already exists'))
362 raise
362 raise
363 else:
363 else:
364 raise
364 raise
365 self.sock.listen(5)
365 self.sock.listen(5)
366 self.fileno = self.sock.fileno
366 self.fileno = self.sock.fileno
367
367
368 def answer_stat_query(self, cs):
368 def answer_stat_query(self, cs):
369 names = cs.read().split('\0')
369 names = cs.read().split('\0')
370
370
371 states = names.pop()
371 states = names.pop()
372
372
373 self.ui.note(_('answering query for %r\n') % states)
373 self.ui.note(_('answering query for %r\n') % states)
374
374
375 visited = set()
375 visited = set()
376 if not names:
376 if not names:
377 def genresult(states, tree):
377 def genresult(states, tree):
378 for fn, state in tree.walk(states):
378 for fn, state in tree.walk(states):
379 yield fn
379 yield fn
380 else:
380 else:
381 def genresult(states, tree):
381 def genresult(states, tree):
382 for fn in names:
382 for fn in names:
383 for f in tree.lookup(states, fn, visited):
383 for f in tree.lookup(states, fn, visited):
384 yield f
384 yield f
385
385
386 return ['\0'.join(r) for r in [
386 return ['\0'.join(r) for r in [
387 genresult('l', self.repowatcher.statustrees['l']),
387 genresult('l', self.repowatcher.statustrees['l']),
388 genresult('m', self.repowatcher.statustrees['m']),
388 genresult('m', self.repowatcher.statustrees['m']),
389 genresult('a', self.repowatcher.statustrees['a']),
389 genresult('a', self.repowatcher.statustrees['a']),
390 genresult('r', self.repowatcher.statustrees['r']),
390 genresult('r', self.repowatcher.statustrees['r']),
391 genresult('!', self.repowatcher.statustrees['!']),
391 genresult('!', self.repowatcher.statustrees['!']),
392 '?' in states
392 '?' in states
393 and genresult('?', self.repowatcher.statustrees['?'])
393 and genresult('?', self.repowatcher.statustrees['?'])
394 or [],
394 or [],
395 [],
395 [],
396 'c' in states and genresult('n', self.repowatcher.tree) or [],
396 'c' in states and genresult('n', self.repowatcher.tree) or [],
397 visited
397 visited
398 ]]
398 ]]
399
399
400 def answer_dbug_query(self):
400 def answer_dbug_query(self):
401 return ['\0'.join(self.repowatcher.debug())]
401 return ['\0'.join(self.repowatcher.debug())]
402
402
403 def accept_connection(self):
403 def accept_connection(self):
404 sock, addr = self.sock.accept()
404 sock, addr = self.sock.accept()
405
405
406 cs = common.recvcs(sock)
406 cs = common.recvcs(sock)
407 version = ord(cs.read(1))
407 version = ord(cs.read(1))
408
408
409 if version != common.version:
409 if version != common.version:
410 self.ui.warn(_('received query from incompatible client '
410 self.ui.warn(_('received query from incompatible client '
411 'version %d\n') % version)
411 'version %d\n') % version)
412 try:
412 try:
413 # try to send back our version to the client
413 # try to send back our version to the client
414 # this way, the client too is informed of the mismatch
414 # this way, the client too is informed of the mismatch
415 sock.sendall(chr(common.version))
415 sock.sendall(chr(common.version))
416 except:
416 except:
417 pass
417 pass
418 return
418 return
419
419
420 type = cs.read(4)
420 type = cs.read(4)
421
421
422 if type == 'STAT':
422 if type == 'STAT':
423 results = self.answer_stat_query(cs)
423 results = self.answer_stat_query(cs)
424 elif type == 'DBUG':
424 elif type == 'DBUG':
425 results = self.answer_dbug_query()
425 results = self.answer_dbug_query()
426 else:
426 else:
427 self.ui.warn(_('unrecognized query type: %s\n') % type)
427 self.ui.warn(_('unrecognized query type: %s\n') % type)
428 return
428 return
429
429
430 try:
430 try:
431 try:
431 try:
432 v = chr(common.version)
432 v = chr(common.version)
433
433
434 sock.sendall(v + type + struct.pack(common.resphdrfmts[type],
434 sock.sendall(v + type + struct.pack(common.resphdrfmts[type],
435 *map(len, results)))
435 *map(len, results)))
436 sock.sendall(''.join(results))
436 sock.sendall(''.join(results))
437 finally:
437 finally:
438 sock.shutdown(socket.SHUT_WR)
438 sock.shutdown(socket.SHUT_WR)
439 except socket.error, err:
439 except socket.error, err:
440 if err[0] != errno.EPIPE:
440 if err.args[0] != errno.EPIPE:
441 raise
441 raise
442
442
443 if sys.platform == 'linux2':
443 if sys.platform == 'linux2':
444 import linuxserver as _server
444 import linuxserver as _server
445 else:
445 else:
446 raise ImportError
446 raise ImportError
447
447
448 master = _server.master
448 master = _server.master
449
449
450 def start(ui, dirstate, root, opts):
450 def start(ui, dirstate, root, opts):
451 timeout = opts.get('idle_timeout')
451 timeout = opts.get('idle_timeout')
452 if timeout:
452 if timeout:
453 timeout = float(timeout) * 60000
453 timeout = float(timeout) * 60000
454 else:
454 else:
455 timeout = None
455 timeout = None
456
456
457 class service(object):
457 class service(object):
458 def init(self):
458 def init(self):
459 try:
459 try:
460 self.master = master(ui, dirstate, root, timeout)
460 self.master = master(ui, dirstate, root, timeout)
461 except AlreadyStartedException, inst:
461 except AlreadyStartedException, inst:
462 raise util.Abort("inotify-server: %s" % inst)
462 raise util.Abort("inotify-server: %s" % inst)
463
463
464 def run(self):
464 def run(self):
465 try:
465 try:
466 try:
466 try:
467 self.master.run()
467 self.master.run()
468 except TimeoutException:
468 except TimeoutException:
469 pass
469 pass
470 finally:
470 finally:
471 self.master.shutdown()
471 self.master.shutdown()
472
472
473 if 'inserve' not in sys.argv:
473 if 'inserve' not in sys.argv:
474 runargs = util.hgcmd() + ['inserve', '-R', root]
474 runargs = util.hgcmd() + ['inserve', '-R', root]
475 else:
475 else:
476 runargs = util.hgcmd() + sys.argv[1:]
476 runargs = util.hgcmd() + sys.argv[1:]
477
477
478 pidfile = ui.config('inotify', 'pidfile')
478 pidfile = ui.config('inotify', 'pidfile')
479 if opts['daemon'] and pidfile is not None and 'pid-file' not in runargs:
479 if opts['daemon'] and pidfile is not None and 'pid-file' not in runargs:
480 runargs.append("--pid-file=%s" % pidfile)
480 runargs.append("--pid-file=%s" % pidfile)
481
481
482 service = service()
482 service = service()
483 logfile = ui.config('inotify', 'log')
483 logfile = ui.config('inotify', 'log')
484
484
485 appendpid = ui.configbool('inotify', 'appendpid', False)
485 appendpid = ui.configbool('inotify', 'appendpid', False)
486
486
487 cmdutil.service(opts, initfn=service.init, runfn=service.run,
487 cmdutil.service(opts, initfn=service.init, runfn=service.run,
488 logfile=logfile, runargs=runargs, appendpid=appendpid)
488 logfile=logfile, runargs=runargs, appendpid=appendpid)
@@ -1,306 +1,306
1 # httprepo.py - HTTP repository proxy classes for mercurial
1 # httprepo.py - HTTP repository proxy classes for mercurial
2 #
2 #
3 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
3 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
4 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
5 #
5 #
6 # This software may be used and distributed according to the terms of the
6 # This software may be used and distributed according to the terms of the
7 # GNU General Public License version 2 or any later version.
7 # GNU General Public License version 2 or any later version.
8
8
9 from node import bin, hex, nullid
9 from node import bin, hex, nullid
10 from i18n import _
10 from i18n import _
11 import repo, changegroup, statichttprepo, error, url, util, pushkey
11 import repo, changegroup, statichttprepo, error, url, util, pushkey
12 import os, urllib, urllib2, urlparse, zlib, httplib
12 import os, urllib, urllib2, urlparse, zlib, httplib
13 import errno, socket
13 import errno, socket
14 import encoding
14 import encoding
15
15
16 def zgenerator(f):
16 def zgenerator(f):
17 zd = zlib.decompressobj()
17 zd = zlib.decompressobj()
18 try:
18 try:
19 for chunk in util.filechunkiter(f):
19 for chunk in util.filechunkiter(f):
20 yield zd.decompress(chunk)
20 yield zd.decompress(chunk)
21 except httplib.HTTPException:
21 except httplib.HTTPException:
22 raise IOError(None, _('connection ended unexpectedly'))
22 raise IOError(None, _('connection ended unexpectedly'))
23 yield zd.flush()
23 yield zd.flush()
24
24
25 class httprepository(repo.repository):
25 class httprepository(repo.repository):
26 def __init__(self, ui, path):
26 def __init__(self, ui, path):
27 self.path = path
27 self.path = path
28 self.caps = None
28 self.caps = None
29 self.handler = None
29 self.handler = None
30 scheme, netloc, urlpath, query, frag = urlparse.urlsplit(path)
30 scheme, netloc, urlpath, query, frag = urlparse.urlsplit(path)
31 if query or frag:
31 if query or frag:
32 raise util.Abort(_('unsupported URL component: "%s"') %
32 raise util.Abort(_('unsupported URL component: "%s"') %
33 (query or frag))
33 (query or frag))
34
34
35 # urllib cannot handle URLs with embedded user or passwd
35 # urllib cannot handle URLs with embedded user or passwd
36 self._url, authinfo = url.getauthinfo(path)
36 self._url, authinfo = url.getauthinfo(path)
37
37
38 self.ui = ui
38 self.ui = ui
39 self.ui.debug('using %s\n' % self._url)
39 self.ui.debug('using %s\n' % self._url)
40
40
41 self.urlopener = url.opener(ui, authinfo)
41 self.urlopener = url.opener(ui, authinfo)
42
42
43 def __del__(self):
43 def __del__(self):
44 for h in self.urlopener.handlers:
44 for h in self.urlopener.handlers:
45 h.close()
45 h.close()
46 if hasattr(h, "close_all"):
46 if hasattr(h, "close_all"):
47 h.close_all()
47 h.close_all()
48
48
49 def url(self):
49 def url(self):
50 return self.path
50 return self.path
51
51
52 # look up capabilities only when needed
52 # look up capabilities only when needed
53
53
54 def get_caps(self):
54 def get_caps(self):
55 if self.caps is None:
55 if self.caps is None:
56 try:
56 try:
57 self.caps = set(self.do_read('capabilities').split())
57 self.caps = set(self.do_read('capabilities').split())
58 except error.RepoError:
58 except error.RepoError:
59 self.caps = set()
59 self.caps = set()
60 self.ui.debug('capabilities: %s\n' %
60 self.ui.debug('capabilities: %s\n' %
61 (' '.join(self.caps or ['none'])))
61 (' '.join(self.caps or ['none'])))
62 return self.caps
62 return self.caps
63
63
64 capabilities = property(get_caps)
64 capabilities = property(get_caps)
65
65
66 def lock(self):
66 def lock(self):
67 raise util.Abort(_('operation not supported over http'))
67 raise util.Abort(_('operation not supported over http'))
68
68
69 def do_cmd(self, cmd, **args):
69 def do_cmd(self, cmd, **args):
70 data = args.pop('data', None)
70 data = args.pop('data', None)
71 headers = args.pop('headers', {})
71 headers = args.pop('headers', {})
72 self.ui.debug("sending %s command\n" % cmd)
72 self.ui.debug("sending %s command\n" % cmd)
73 q = {"cmd": cmd}
73 q = {"cmd": cmd}
74 q.update(args)
74 q.update(args)
75 qs = '?%s' % urllib.urlencode(q)
75 qs = '?%s' % urllib.urlencode(q)
76 cu = "%s%s" % (self._url, qs)
76 cu = "%s%s" % (self._url, qs)
77 req = urllib2.Request(cu, data, headers)
77 req = urllib2.Request(cu, data, headers)
78 if data is not None:
78 if data is not None:
79 # len(data) is broken if data doesn't fit into Py_ssize_t
79 # len(data) is broken if data doesn't fit into Py_ssize_t
80 # add the header ourself to avoid OverflowError
80 # add the header ourself to avoid OverflowError
81 size = data.__len__()
81 size = data.__len__()
82 self.ui.debug("sending %s bytes\n" % size)
82 self.ui.debug("sending %s bytes\n" % size)
83 req.add_unredirected_header('Content-Length', '%d' % size)
83 req.add_unredirected_header('Content-Length', '%d' % size)
84 try:
84 try:
85 resp = self.urlopener.open(req)
85 resp = self.urlopener.open(req)
86 except urllib2.HTTPError, inst:
86 except urllib2.HTTPError, inst:
87 if inst.code == 401:
87 if inst.code == 401:
88 raise util.Abort(_('authorization failed'))
88 raise util.Abort(_('authorization failed'))
89 raise
89 raise
90 except httplib.HTTPException, inst:
90 except httplib.HTTPException, inst:
91 self.ui.debug('http error while sending %s command\n' % cmd)
91 self.ui.debug('http error while sending %s command\n' % cmd)
92 self.ui.traceback()
92 self.ui.traceback()
93 raise IOError(None, inst)
93 raise IOError(None, inst)
94 except IndexError:
94 except IndexError:
95 # this only happens with Python 2.3, later versions raise URLError
95 # this only happens with Python 2.3, later versions raise URLError
96 raise util.Abort(_('http error, possibly caused by proxy setting'))
96 raise util.Abort(_('http error, possibly caused by proxy setting'))
97 # record the url we got redirected to
97 # record the url we got redirected to
98 resp_url = resp.geturl()
98 resp_url = resp.geturl()
99 if resp_url.endswith(qs):
99 if resp_url.endswith(qs):
100 resp_url = resp_url[:-len(qs)]
100 resp_url = resp_url[:-len(qs)]
101 if self._url.rstrip('/') != resp_url.rstrip('/'):
101 if self._url.rstrip('/') != resp_url.rstrip('/'):
102 self.ui.status(_('real URL is %s\n') % resp_url)
102 self.ui.status(_('real URL is %s\n') % resp_url)
103 self._url = resp_url
103 self._url = resp_url
104 try:
104 try:
105 proto = resp.getheader('content-type')
105 proto = resp.getheader('content-type')
106 except AttributeError:
106 except AttributeError:
107 proto = resp.headers['content-type']
107 proto = resp.headers['content-type']
108
108
109 safeurl = url.hidepassword(self._url)
109 safeurl = url.hidepassword(self._url)
110 # accept old "text/plain" and "application/hg-changegroup" for now
110 # accept old "text/plain" and "application/hg-changegroup" for now
111 if not (proto.startswith('application/mercurial-') or
111 if not (proto.startswith('application/mercurial-') or
112 proto.startswith('text/plain') or
112 proto.startswith('text/plain') or
113 proto.startswith('application/hg-changegroup')):
113 proto.startswith('application/hg-changegroup')):
114 self.ui.debug("requested URL: '%s'\n" % url.hidepassword(cu))
114 self.ui.debug("requested URL: '%s'\n" % url.hidepassword(cu))
115 raise error.RepoError(
115 raise error.RepoError(
116 _("'%s' does not appear to be an hg repository:\n"
116 _("'%s' does not appear to be an hg repository:\n"
117 "---%%<--- (%s)\n%s\n---%%<---\n")
117 "---%%<--- (%s)\n%s\n---%%<---\n")
118 % (safeurl, proto, resp.read()))
118 % (safeurl, proto, resp.read()))
119
119
120 if proto.startswith('application/mercurial-'):
120 if proto.startswith('application/mercurial-'):
121 try:
121 try:
122 version = proto.split('-', 1)[1]
122 version = proto.split('-', 1)[1]
123 version_info = tuple([int(n) for n in version.split('.')])
123 version_info = tuple([int(n) for n in version.split('.')])
124 except ValueError:
124 except ValueError:
125 raise error.RepoError(_("'%s' sent a broken Content-Type "
125 raise error.RepoError(_("'%s' sent a broken Content-Type "
126 "header (%s)") % (safeurl, proto))
126 "header (%s)") % (safeurl, proto))
127 if version_info > (0, 1):
127 if version_info > (0, 1):
128 raise error.RepoError(_("'%s' uses newer protocol %s") %
128 raise error.RepoError(_("'%s' uses newer protocol %s") %
129 (safeurl, version))
129 (safeurl, version))
130
130
131 return resp
131 return resp
132
132
133 def do_read(self, cmd, **args):
133 def do_read(self, cmd, **args):
134 fp = self.do_cmd(cmd, **args)
134 fp = self.do_cmd(cmd, **args)
135 try:
135 try:
136 return fp.read()
136 return fp.read()
137 finally:
137 finally:
138 # if using keepalive, allow connection to be reused
138 # if using keepalive, allow connection to be reused
139 fp.close()
139 fp.close()
140
140
141 def lookup(self, key):
141 def lookup(self, key):
142 self.requirecap('lookup', _('look up remote revision'))
142 self.requirecap('lookup', _('look up remote revision'))
143 d = self.do_cmd("lookup", key = key).read()
143 d = self.do_cmd("lookup", key = key).read()
144 success, data = d[:-1].split(' ', 1)
144 success, data = d[:-1].split(' ', 1)
145 if int(success):
145 if int(success):
146 return bin(data)
146 return bin(data)
147 raise error.RepoError(data)
147 raise error.RepoError(data)
148
148
149 def heads(self):
149 def heads(self):
150 d = self.do_read("heads")
150 d = self.do_read("heads")
151 try:
151 try:
152 return map(bin, d[:-1].split(" "))
152 return map(bin, d[:-1].split(" "))
153 except:
153 except:
154 raise error.ResponseError(_("unexpected response:"), d)
154 raise error.ResponseError(_("unexpected response:"), d)
155
155
156 def branchmap(self):
156 def branchmap(self):
157 d = self.do_read("branchmap")
157 d = self.do_read("branchmap")
158 try:
158 try:
159 branchmap = {}
159 branchmap = {}
160 for branchpart in d.splitlines():
160 for branchpart in d.splitlines():
161 branchheads = branchpart.split(' ')
161 branchheads = branchpart.split(' ')
162 branchname = urllib.unquote(branchheads[0])
162 branchname = urllib.unquote(branchheads[0])
163 # Earlier servers (1.3.x) send branch names in (their) local
163 # Earlier servers (1.3.x) send branch names in (their) local
164 # charset. The best we can do is assume it's identical to our
164 # charset. The best we can do is assume it's identical to our
165 # own local charset, in case it's not utf-8.
165 # own local charset, in case it's not utf-8.
166 try:
166 try:
167 branchname.decode('utf-8')
167 branchname.decode('utf-8')
168 except UnicodeDecodeError:
168 except UnicodeDecodeError:
169 branchname = encoding.fromlocal(branchname)
169 branchname = encoding.fromlocal(branchname)
170 branchheads = [bin(x) for x in branchheads[1:]]
170 branchheads = [bin(x) for x in branchheads[1:]]
171 branchmap[branchname] = branchheads
171 branchmap[branchname] = branchheads
172 return branchmap
172 return branchmap
173 except:
173 except:
174 raise error.ResponseError(_("unexpected response:"), d)
174 raise error.ResponseError(_("unexpected response:"), d)
175
175
176 def branches(self, nodes):
176 def branches(self, nodes):
177 n = " ".join(map(hex, nodes))
177 n = " ".join(map(hex, nodes))
178 d = self.do_read("branches", nodes=n)
178 d = self.do_read("branches", nodes=n)
179 try:
179 try:
180 br = [tuple(map(bin, b.split(" "))) for b in d.splitlines()]
180 br = [tuple(map(bin, b.split(" "))) for b in d.splitlines()]
181 return br
181 return br
182 except:
182 except:
183 raise error.ResponseError(_("unexpected response:"), d)
183 raise error.ResponseError(_("unexpected response:"), d)
184
184
185 def between(self, pairs):
185 def between(self, pairs):
186 batch = 8 # avoid giant requests
186 batch = 8 # avoid giant requests
187 r = []
187 r = []
188 for i in xrange(0, len(pairs), batch):
188 for i in xrange(0, len(pairs), batch):
189 n = " ".join(["-".join(map(hex, p)) for p in pairs[i:i + batch]])
189 n = " ".join(["-".join(map(hex, p)) for p in pairs[i:i + batch]])
190 d = self.do_read("between", pairs=n)
190 d = self.do_read("between", pairs=n)
191 try:
191 try:
192 r += [l and map(bin, l.split(" ")) or []
192 r += [l and map(bin, l.split(" ")) or []
193 for l in d.splitlines()]
193 for l in d.splitlines()]
194 except:
194 except:
195 raise error.ResponseError(_("unexpected response:"), d)
195 raise error.ResponseError(_("unexpected response:"), d)
196 return r
196 return r
197
197
198 def changegroup(self, nodes, kind):
198 def changegroup(self, nodes, kind):
199 n = " ".join(map(hex, nodes))
199 n = " ".join(map(hex, nodes))
200 f = self.do_cmd("changegroup", roots=n)
200 f = self.do_cmd("changegroup", roots=n)
201 return util.chunkbuffer(zgenerator(f))
201 return util.chunkbuffer(zgenerator(f))
202
202
203 def changegroupsubset(self, bases, heads, source):
203 def changegroupsubset(self, bases, heads, source):
204 self.requirecap('changegroupsubset', _('look up remote changes'))
204 self.requirecap('changegroupsubset', _('look up remote changes'))
205 baselst = " ".join([hex(n) for n in bases])
205 baselst = " ".join([hex(n) for n in bases])
206 headlst = " ".join([hex(n) for n in heads])
206 headlst = " ".join([hex(n) for n in heads])
207 f = self.do_cmd("changegroupsubset", bases=baselst, heads=headlst)
207 f = self.do_cmd("changegroupsubset", bases=baselst, heads=headlst)
208 return util.chunkbuffer(zgenerator(f))
208 return util.chunkbuffer(zgenerator(f))
209
209
210 def unbundle(self, cg, heads, source):
210 def unbundle(self, cg, heads, source):
211 '''Send cg (a readable file-like object representing the
211 '''Send cg (a readable file-like object representing the
212 changegroup to push, typically a chunkbuffer object) to the
212 changegroup to push, typically a chunkbuffer object) to the
213 remote server as a bundle. Return an integer response code:
213 remote server as a bundle. Return an integer response code:
214 non-zero indicates a successful push (see
214 non-zero indicates a successful push (see
215 localrepository.addchangegroup()), and zero indicates either
215 localrepository.addchangegroup()), and zero indicates either
216 error or nothing to push.'''
216 error or nothing to push.'''
217 # have to stream bundle to a temp file because we do not have
217 # have to stream bundle to a temp file because we do not have
218 # http 1.1 chunked transfer.
218 # http 1.1 chunked transfer.
219
219
220 type = ""
220 type = ""
221 types = self.capable('unbundle')
221 types = self.capable('unbundle')
222 # servers older than d1b16a746db6 will send 'unbundle' as a
222 # servers older than d1b16a746db6 will send 'unbundle' as a
223 # boolean capability
223 # boolean capability
224 try:
224 try:
225 types = types.split(',')
225 types = types.split(',')
226 except AttributeError:
226 except AttributeError:
227 types = [""]
227 types = [""]
228 if types:
228 if types:
229 for x in types:
229 for x in types:
230 if x in changegroup.bundletypes:
230 if x in changegroup.bundletypes:
231 type = x
231 type = x
232 break
232 break
233
233
234 tempname = changegroup.writebundle(cg, None, type)
234 tempname = changegroup.writebundle(cg, None, type)
235 fp = url.httpsendfile(tempname, "rb")
235 fp = url.httpsendfile(tempname, "rb")
236 try:
236 try:
237 try:
237 try:
238 resp = self.do_read(
238 resp = self.do_read(
239 'unbundle', data=fp,
239 'unbundle', data=fp,
240 headers={'Content-Type': 'application/mercurial-0.1'},
240 headers={'Content-Type': 'application/mercurial-0.1'},
241 heads=' '.join(map(hex, heads)))
241 heads=' '.join(map(hex, heads)))
242 resp_code, output = resp.split('\n', 1)
242 resp_code, output = resp.split('\n', 1)
243 try:
243 try:
244 ret = int(resp_code)
244 ret = int(resp_code)
245 except ValueError, err:
245 except ValueError, err:
246 raise error.ResponseError(
246 raise error.ResponseError(
247 _('push failed (unexpected response):'), resp)
247 _('push failed (unexpected response):'), resp)
248 for l in output.splitlines(True):
248 for l in output.splitlines(True):
249 self.ui.status(_('remote: '), l)
249 self.ui.status(_('remote: '), l)
250 return ret
250 return ret
251 except socket.error, err:
251 except socket.error, err:
252 if err[0] in (errno.ECONNRESET, errno.EPIPE):
252 if err.args[0] in (errno.ECONNRESET, errno.EPIPE):
253 raise util.Abort(_('push failed: %s') % err[1])
253 raise util.Abort(_('push failed: %s') % err.args[1])
254 raise util.Abort(err[1])
254 raise util.Abort(err.args[1])
255 finally:
255 finally:
256 fp.close()
256 fp.close()
257 os.unlink(tempname)
257 os.unlink(tempname)
258
258
259 def stream_out(self):
259 def stream_out(self):
260 return self.do_cmd('stream_out')
260 return self.do_cmd('stream_out')
261
261
262 def pushkey(self, namespace, key, old, new):
262 def pushkey(self, namespace, key, old, new):
263 if not self.capable('pushkey'):
263 if not self.capable('pushkey'):
264 return False
264 return False
265 d = self.do_cmd("pushkey", data="", # force a POST
265 d = self.do_cmd("pushkey", data="", # force a POST
266 namespace=namespace, key=key, old=old, new=new).read()
266 namespace=namespace, key=key, old=old, new=new).read()
267 code, output = d.split('\n', 1)
267 code, output = d.split('\n', 1)
268 try:
268 try:
269 ret = bool(int(code))
269 ret = bool(int(code))
270 except ValueError, err:
270 except ValueError, err:
271 raise error.ResponseError(
271 raise error.ResponseError(
272 _('push failed (unexpected response):'), d)
272 _('push failed (unexpected response):'), d)
273 for l in output.splitlines(True):
273 for l in output.splitlines(True):
274 self.ui.status(_('remote: '), l)
274 self.ui.status(_('remote: '), l)
275 return ret
275 return ret
276
276
277 def listkeys(self, namespace):
277 def listkeys(self, namespace):
278 if not self.capable('pushkey'):
278 if not self.capable('pushkey'):
279 return {}
279 return {}
280 d = self.do_cmd("listkeys", namespace=namespace).read()
280 d = self.do_cmd("listkeys", namespace=namespace).read()
281 r = {}
281 r = {}
282 for l in d.splitlines():
282 for l in d.splitlines():
283 k, v = l.split('\t')
283 k, v = l.split('\t')
284 r[k.decode('string-escape')] = v.decode('string-escape')
284 r[k.decode('string-escape')] = v.decode('string-escape')
285 return r
285 return r
286
286
287 class httpsrepository(httprepository):
287 class httpsrepository(httprepository):
288 def __init__(self, ui, path):
288 def __init__(self, ui, path):
289 if not url.has_https:
289 if not url.has_https:
290 raise util.Abort(_('Python support for SSL and HTTPS '
290 raise util.Abort(_('Python support for SSL and HTTPS '
291 'is not installed'))
291 'is not installed'))
292 httprepository.__init__(self, ui, path)
292 httprepository.__init__(self, ui, path)
293
293
294 def instance(ui, path, create):
294 def instance(ui, path, create):
295 if create:
295 if create:
296 raise util.Abort(_('cannot create new http repository'))
296 raise util.Abort(_('cannot create new http repository'))
297 try:
297 try:
298 if path.startswith('https:'):
298 if path.startswith('https:'):
299 inst = httpsrepository(ui, path)
299 inst = httpsrepository(ui, path)
300 else:
300 else:
301 inst = httprepository(ui, path)
301 inst = httprepository(ui, path)
302 inst.between([(nullid, nullid)])
302 inst.between([(nullid, nullid)])
303 return inst
303 return inst
304 except error.RepoError:
304 except error.RepoError:
305 ui.note('(falling back to static-http)\n')
305 ui.note('(falling back to static-http)\n')
306 return statichttprepo.instance(ui, "static-" + path, create)
306 return statichttprepo.instance(ui, "static-" + path, create)
General Comments 0
You need to be logged in to leave comments. Login now