##// END OF EJS Templates
chgserver: store csystem separately...
Jun Wu -
r30643:dfb19aed default
parent child Browse files
Show More
@@ -1,640 +1,641 b''
1 1 # chgserver.py - command server extension for cHg
2 2 #
3 3 # Copyright 2011 Yuya Nishihara <yuya@tcha.org>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 """command server extension for cHg
9 9
10 10 'S' channel (read/write)
11 11 propagate ui.system() request to client
12 12
13 13 'attachio' command
14 14 attach client's stdio passed by sendmsg()
15 15
16 16 'chdir' command
17 17 change current directory
18 18
19 19 'getpager' command
20 20 checks if pager is enabled and which pager should be executed
21 21
22 22 'setenv' command
23 23 replace os.environ completely
24 24
25 25 'setumask' command
26 26 set umask
27 27
28 28 'validate' command
29 29 reload the config and check if the server is up to date
30 30
31 31 Config
32 32 ------
33 33
34 34 ::
35 35
36 36 [chgserver]
37 37 idletimeout = 3600 # seconds, after which an idle server will exit
38 38 skiphash = False # whether to skip config or env change checks
39 39 """
40 40
41 41 from __future__ import absolute_import
42 42
43 43 import errno
44 44 import hashlib
45 45 import inspect
46 46 import os
47 47 import re
48 48 import signal
49 49 import struct
50 50 import sys
51 51 import time
52 52
53 53 from .i18n import _
54 54
55 55 from . import (
56 56 cmdutil,
57 57 commandserver,
58 58 encoding,
59 59 error,
60 60 extensions,
61 61 osutil,
62 62 util,
63 63 )
64 64
65 65 _log = commandserver.log
66 66
67 67 def _hashlist(items):
68 68 """return sha1 hexdigest for a list"""
69 69 return hashlib.sha1(str(items)).hexdigest()
70 70
71 71 # sensitive config sections affecting confighash
72 72 _configsections = [
73 73 'alias', # affects global state commands.table
74 74 'extdiff', # uisetup will register new commands
75 75 'extensions',
76 76 ]
77 77
78 78 # sensitive environment variables affecting confighash
79 79 _envre = re.compile(r'''\A(?:
80 80 CHGHG
81 81 |HG(?:[A-Z].*)?
82 82 |LANG(?:UAGE)?
83 83 |LC_.*
84 84 |LD_.*
85 85 |PATH
86 86 |PYTHON.*
87 87 |TERM(?:INFO)?
88 88 |TZ
89 89 )\Z''', re.X)
90 90
91 91 def _confighash(ui):
92 92 """return a quick hash for detecting config/env changes
93 93
94 94 confighash is the hash of sensitive config items and environment variables.
95 95
96 96 for chgserver, it is designed that once confighash changes, the server is
97 97 not qualified to serve its client and should redirect the client to a new
98 98 server. different from mtimehash, confighash change will not mark the
99 99 server outdated and exit since the user can have different configs at the
100 100 same time.
101 101 """
102 102 sectionitems = []
103 103 for section in _configsections:
104 104 sectionitems.append(ui.configitems(section))
105 105 sectionhash = _hashlist(sectionitems)
106 106 envitems = [(k, v) for k, v in encoding.environ.iteritems()
107 107 if _envre.match(k)]
108 108 envhash = _hashlist(sorted(envitems))
109 109 return sectionhash[:6] + envhash[:6]
110 110
111 111 def _getmtimepaths(ui):
112 112 """get a list of paths that should be checked to detect change
113 113
114 114 The list will include:
115 115 - extensions (will not cover all files for complex extensions)
116 116 - mercurial/__version__.py
117 117 - python binary
118 118 """
119 119 modules = [m for n, m in extensions.extensions(ui)]
120 120 try:
121 121 from . import __version__
122 122 modules.append(__version__)
123 123 except ImportError:
124 124 pass
125 125 files = [sys.executable]
126 126 for m in modules:
127 127 try:
128 128 files.append(inspect.getabsfile(m))
129 129 except TypeError:
130 130 pass
131 131 return sorted(set(files))
132 132
133 133 def _mtimehash(paths):
134 134 """return a quick hash for detecting file changes
135 135
136 136 mtimehash calls stat on given paths and calculate a hash based on size and
137 137 mtime of each file. mtimehash does not read file content because reading is
138 138 expensive. therefore it's not 100% reliable for detecting content changes.
139 139 it's possible to return different hashes for same file contents.
140 140 it's also possible to return a same hash for different file contents for
141 141 some carefully crafted situation.
142 142
143 143 for chgserver, it is designed that once mtimehash changes, the server is
144 144 considered outdated immediately and should no longer provide service.
145 145
146 146 mtimehash is not included in confighash because we only know the paths of
147 147 extensions after importing them (there is imp.find_module but that faces
148 148 race conditions). We need to calculate confighash without importing.
149 149 """
150 150 def trystat(path):
151 151 try:
152 152 st = os.stat(path)
153 153 return (st.st_mtime, st.st_size)
154 154 except OSError:
155 155 # could be ENOENT, EPERM etc. not fatal in any case
156 156 pass
157 157 return _hashlist(map(trystat, paths))[:12]
158 158
159 159 class hashstate(object):
160 160 """a structure storing confighash, mtimehash, paths used for mtimehash"""
161 161 def __init__(self, confighash, mtimehash, mtimepaths):
162 162 self.confighash = confighash
163 163 self.mtimehash = mtimehash
164 164 self.mtimepaths = mtimepaths
165 165
166 166 @staticmethod
167 167 def fromui(ui, mtimepaths=None):
168 168 if mtimepaths is None:
169 169 mtimepaths = _getmtimepaths(ui)
170 170 confighash = _confighash(ui)
171 171 mtimehash = _mtimehash(mtimepaths)
172 172 _log('confighash = %s mtimehash = %s\n' % (confighash, mtimehash))
173 173 return hashstate(confighash, mtimehash, mtimepaths)
174 174
175 175 # copied from hgext/pager.py:uisetup()
176 176 def _setuppagercmd(ui, options, cmd):
177 177 from . import commands # avoid cycle
178 178
179 179 if not ui.formatted():
180 180 return
181 181
182 182 p = ui.config("pager", "pager", encoding.environ.get("PAGER"))
183 183 usepager = False
184 184 always = util.parsebool(options['pager'])
185 185 auto = options['pager'] == 'auto'
186 186
187 187 if not p:
188 188 pass
189 189 elif always:
190 190 usepager = True
191 191 elif not auto:
192 192 usepager = False
193 193 else:
194 194 attended = ['annotate', 'cat', 'diff', 'export', 'glog', 'log', 'qdiff']
195 195 attend = ui.configlist('pager', 'attend', attended)
196 196 ignore = ui.configlist('pager', 'ignore')
197 197 cmds, _ = cmdutil.findcmd(cmd, commands.table)
198 198
199 199 for cmd in cmds:
200 200 var = 'attend-%s' % cmd
201 201 if ui.config('pager', var):
202 202 usepager = ui.configbool('pager', var)
203 203 break
204 204 if (cmd in attend or
205 205 (cmd not in ignore and not attend)):
206 206 usepager = True
207 207 break
208 208
209 209 if usepager:
210 210 ui.setconfig('ui', 'formatted', ui.formatted(), 'pager')
211 211 ui.setconfig('ui', 'interactive', False, 'pager')
212 212 return p
213 213
214 214 def _newchgui(srcui, csystem):
215 215 class chgui(srcui.__class__):
216 216 def __init__(self, src=None):
217 217 super(chgui, self).__init__(src)
218 218 if src:
219 219 self._csystem = getattr(src, '_csystem', csystem)
220 220 else:
221 221 self._csystem = csystem
222 222
223 223 def system(self, cmd, environ=None, cwd=None, onerr=None,
224 224 errprefix=None):
225 225 # fallback to the original system method if the output needs to be
226 226 # captured (to self._buffers), or the output stream is not stdout
227 227 # (e.g. stderr, cStringIO), because the chg client is not aware of
228 228 # these situations and will behave differently (write to stdout).
229 229 if (any(s[1] for s in self._bufferstates)
230 230 or not util.safehasattr(self.fout, 'fileno')
231 231 or self.fout.fileno() != util.stdout.fileno()):
232 232 return super(chgui, self).system(cmd, environ, cwd, onerr,
233 233 errprefix)
234 234 # copied from mercurial/util.py:system()
235 235 self.flush()
236 236 def py2shell(val):
237 237 if val is None or val is False:
238 238 return '0'
239 239 if val is True:
240 240 return '1'
241 241 return str(val)
242 242 env = encoding.environ.copy()
243 243 if environ:
244 244 env.update((k, py2shell(v)) for k, v in environ.iteritems())
245 245 env['HG'] = util.hgexecutable()
246 246 rc = self._csystem(cmd, env, cwd)
247 247 if rc and onerr:
248 248 errmsg = '%s %s' % (os.path.basename(cmd.split(None, 1)[0]),
249 249 util.explainexit(rc)[0])
250 250 if errprefix:
251 251 errmsg = '%s: %s' % (errprefix, errmsg)
252 252 raise onerr(errmsg)
253 253 return rc
254 254
255 255 return chgui(srcui)
256 256
257 257 def _loadnewui(srcui, args):
258 258 from . import dispatch # avoid cycle
259 259
260 260 newui = srcui.__class__.load()
261 261 for a in ['fin', 'fout', 'ferr', 'environ']:
262 262 setattr(newui, a, getattr(srcui, a))
263 263 if util.safehasattr(srcui, '_csystem'):
264 264 newui._csystem = srcui._csystem
265 265
266 266 # command line args
267 267 args = args[:]
268 268 dispatch._parseconfig(newui, dispatch._earlygetopt(['--config'], args))
269 269
270 270 # stolen from tortoisehg.util.copydynamicconfig()
271 271 for section, name, value in srcui.walkconfig():
272 272 source = srcui.configsource(section, name)
273 273 if ':' in source or source == '--config':
274 274 # path:line or command line
275 275 continue
276 276 newui.setconfig(section, name, value, source)
277 277
278 278 # load wd and repo config, copied from dispatch.py
279 279 cwds = dispatch._earlygetopt(['--cwd'], args)
280 280 cwd = cwds and os.path.realpath(cwds[-1]) or None
281 281 rpath = dispatch._earlygetopt(["-R", "--repository", "--repo"], args)
282 282 path, newlui = dispatch._getlocal(newui, rpath, wd=cwd)
283 283
284 284 return (newui, newlui)
285 285
286 286 class channeledsystem(object):
287 287 """Propagate ui.system() request in the following format:
288 288
289 289 payload length (unsigned int),
290 290 cmd, '\0',
291 291 cwd, '\0',
292 292 envkey, '=', val, '\0',
293 293 ...
294 294 envkey, '=', val
295 295
296 296 and waits:
297 297
298 298 exitcode length (unsigned int),
299 299 exitcode (int)
300 300 """
301 301 def __init__(self, in_, out, channel):
302 302 self.in_ = in_
303 303 self.out = out
304 304 self.channel = channel
305 305
306 306 def __call__(self, cmd, environ, cwd):
307 307 args = [util.quotecommand(cmd), os.path.abspath(cwd or '.')]
308 308 args.extend('%s=%s' % (k, v) for k, v in environ.iteritems())
309 309 data = '\0'.join(args)
310 310 self.out.write(struct.pack('>cI', self.channel, len(data)))
311 311 self.out.write(data)
312 312 self.out.flush()
313 313
314 314 length = self.in_.read(4)
315 315 length, = struct.unpack('>I', length)
316 316 if length != 4:
317 317 raise error.Abort(_('invalid response'))
318 318 rc, = struct.unpack('>i', self.in_.read(4))
319 319 return rc
320 320
321 321 _iochannels = [
322 322 # server.ch, ui.fp, mode
323 323 ('cin', 'fin', 'rb'),
324 324 ('cout', 'fout', 'wb'),
325 325 ('cerr', 'ferr', 'wb'),
326 326 ]
327 327
328 328 class chgcmdserver(commandserver.server):
329 329 def __init__(self, ui, repo, fin, fout, sock, hashstate, baseaddress):
330 self._csystem = channeledsystem(fin, fout, 'S')
330 331 super(chgcmdserver, self).__init__(
331 _newchgui(ui, channeledsystem(fin, fout, 'S')), repo, fin, fout)
332 _newchgui(ui, self._csystem), repo, fin, fout)
332 333 self.clientsock = sock
333 334 self._oldios = [] # original (self.ch, ui.fp, fd) before "attachio"
334 335 self.hashstate = hashstate
335 336 self.baseaddress = baseaddress
336 337 if hashstate is not None:
337 338 self.capabilities = self.capabilities.copy()
338 339 self.capabilities['validate'] = chgcmdserver.validate
339 340
340 341 def cleanup(self):
341 342 super(chgcmdserver, self).cleanup()
342 343 # dispatch._runcatch() does not flush outputs if exception is not
343 344 # handled by dispatch._dispatch()
344 345 self.ui.flush()
345 346 self._restoreio()
346 347
347 348 def attachio(self):
348 349 """Attach to client's stdio passed via unix domain socket; all
349 350 channels except cresult will no longer be used
350 351 """
351 352 # tell client to sendmsg() with 1-byte payload, which makes it
352 353 # distinctive from "attachio\n" command consumed by client.read()
353 354 self.clientsock.sendall(struct.pack('>cI', 'I', 1))
354 355 clientfds = osutil.recvfds(self.clientsock.fileno())
355 356 _log('received fds: %r\n' % clientfds)
356 357
357 358 ui = self.ui
358 359 ui.flush()
359 360 first = self._saveio()
360 361 for fd, (cn, fn, mode) in zip(clientfds, _iochannels):
361 362 assert fd > 0
362 363 fp = getattr(ui, fn)
363 364 os.dup2(fd, fp.fileno())
364 365 os.close(fd)
365 366 if not first:
366 367 continue
367 368 # reset buffering mode when client is first attached. as we want
368 369 # to see output immediately on pager, the mode stays unchanged
369 370 # when client re-attached. ferr is unchanged because it should
370 371 # be unbuffered no matter if it is a tty or not.
371 372 if fn == 'ferr':
372 373 newfp = fp
373 374 else:
374 375 # make it line buffered explicitly because the default is
375 376 # decided on first write(), where fout could be a pager.
376 377 if fp.isatty():
377 378 bufsize = 1 # line buffered
378 379 else:
379 380 bufsize = -1 # system default
380 381 newfp = os.fdopen(fp.fileno(), mode, bufsize)
381 382 setattr(ui, fn, newfp)
382 383 setattr(self, cn, newfp)
383 384
384 385 self.cresult.write(struct.pack('>i', len(clientfds)))
385 386
386 387 def _saveio(self):
387 388 if self._oldios:
388 389 return False
389 390 ui = self.ui
390 391 for cn, fn, _mode in _iochannels:
391 392 ch = getattr(self, cn)
392 393 fp = getattr(ui, fn)
393 394 fd = os.dup(fp.fileno())
394 395 self._oldios.append((ch, fp, fd))
395 396 return True
396 397
397 398 def _restoreio(self):
398 399 ui = self.ui
399 400 for (ch, fp, fd), (cn, fn, _mode) in zip(self._oldios, _iochannels):
400 401 newfp = getattr(ui, fn)
401 402 # close newfp while it's associated with client; otherwise it
402 403 # would be closed when newfp is deleted
403 404 if newfp is not fp:
404 405 newfp.close()
405 406 # restore original fd: fp is open again
406 407 os.dup2(fd, fp.fileno())
407 408 os.close(fd)
408 409 setattr(self, cn, ch)
409 410 setattr(ui, fn, fp)
410 411 del self._oldios[:]
411 412
412 413 def validate(self):
413 414 """Reload the config and check if the server is up to date
414 415
415 416 Read a list of '\0' separated arguments.
416 417 Write a non-empty list of '\0' separated instruction strings or '\0'
417 418 if the list is empty.
418 419 An instruction string could be either:
419 420 - "unlink $path", the client should unlink the path to stop the
420 421 outdated server.
421 422 - "redirect $path", the client should attempt to connect to $path
422 423 first. If it does not work, start a new server. It implies
423 424 "reconnect".
424 425 - "exit $n", the client should exit directly with code n.
425 426 This may happen if we cannot parse the config.
426 427 - "reconnect", the client should close the connection and
427 428 reconnect.
428 429 If neither "reconnect" nor "redirect" is included in the instruction
429 430 list, the client can continue with this server after completing all
430 431 the instructions.
431 432 """
432 433 from . import dispatch # avoid cycle
433 434
434 435 args = self._readlist()
435 436 try:
436 437 self.ui, lui = _loadnewui(self.ui, args)
437 438 except error.ParseError as inst:
438 439 dispatch._formatparse(self.ui.warn, inst)
439 440 self.ui.flush()
440 441 self.cresult.write('exit 255')
441 442 return
442 443 newhash = hashstate.fromui(lui, self.hashstate.mtimepaths)
443 444 insts = []
444 445 if newhash.mtimehash != self.hashstate.mtimehash:
445 446 addr = _hashaddress(self.baseaddress, self.hashstate.confighash)
446 447 insts.append('unlink %s' % addr)
447 448 # mtimehash is empty if one or more extensions fail to load.
448 449 # to be compatible with hg, still serve the client this time.
449 450 if self.hashstate.mtimehash:
450 451 insts.append('reconnect')
451 452 if newhash.confighash != self.hashstate.confighash:
452 453 addr = _hashaddress(self.baseaddress, newhash.confighash)
453 454 insts.append('redirect %s' % addr)
454 455 _log('validate: %s\n' % insts)
455 456 self.cresult.write('\0'.join(insts) or '\0')
456 457
457 458 def chdir(self):
458 459 """Change current directory
459 460
460 461 Note that the behavior of --cwd option is bit different from this.
461 462 It does not affect --config parameter.
462 463 """
463 464 path = self._readstr()
464 465 if not path:
465 466 return
466 467 _log('chdir to %r\n' % path)
467 468 os.chdir(path)
468 469
469 470 def setumask(self):
470 471 """Change umask"""
471 472 mask = struct.unpack('>I', self._read(4))[0]
472 473 _log('setumask %r\n' % mask)
473 474 os.umask(mask)
474 475
475 476 def getpager(self):
476 477 """Read cmdargs and write pager command to r-channel if enabled
477 478
478 479 If pager isn't enabled, this writes '\0' because channeledoutput
479 480 does not allow to write empty data.
480 481 """
481 482 from . import dispatch # avoid cycle
482 483
483 484 args = self._readlist()
484 485 try:
485 486 cmd, _func, args, options, _cmdoptions = dispatch._parse(self.ui,
486 487 args)
487 488 except (error.Abort, error.AmbiguousCommand, error.CommandError,
488 489 error.UnknownCommand):
489 490 cmd = None
490 491 options = {}
491 492 if not cmd or 'pager' not in options:
492 493 self.cresult.write('\0')
493 494 return
494 495
495 496 pagercmd = _setuppagercmd(self.ui, options, cmd)
496 497 if pagercmd:
497 498 # Python's SIGPIPE is SIG_IGN by default. change to SIG_DFL so
498 499 # we can exit if the pipe to the pager is closed
499 500 if util.safehasattr(signal, 'SIGPIPE') and \
500 501 signal.getsignal(signal.SIGPIPE) == signal.SIG_IGN:
501 502 signal.signal(signal.SIGPIPE, signal.SIG_DFL)
502 503 self.cresult.write(pagercmd)
503 504 else:
504 505 self.cresult.write('\0')
505 506
506 507 def setenv(self):
507 508 """Clear and update os.environ
508 509
509 510 Note that not all variables can make an effect on the running process.
510 511 """
511 512 l = self._readlist()
512 513 try:
513 514 newenv = dict(s.split('=', 1) for s in l)
514 515 except ValueError:
515 516 raise ValueError('unexpected value in setenv request')
516 517 _log('setenv: %r\n' % sorted(newenv.keys()))
517 518 encoding.environ.clear()
518 519 encoding.environ.update(newenv)
519 520
520 521 capabilities = commandserver.server.capabilities.copy()
521 522 capabilities.update({'attachio': attachio,
522 523 'chdir': chdir,
523 524 'getpager': getpager,
524 525 'setenv': setenv,
525 526 'setumask': setumask})
526 527
527 528 def _tempaddress(address):
528 529 return '%s.%d.tmp' % (address, os.getpid())
529 530
530 531 def _hashaddress(address, hashstr):
531 532 # if the basename of address contains '.', use only the left part. this
532 533 # makes it possible for the client to pass 'server.tmp$PID' and follow by
533 534 # an atomic rename to avoid locking when spawning new servers.
534 535 dirname, basename = os.path.split(address)
535 536 basename = basename.split('.', 1)[0]
536 537 return '%s-%s' % (os.path.join(dirname, basename), hashstr)
537 538
538 539 class chgunixservicehandler(object):
539 540 """Set of operations for chg services"""
540 541
541 542 pollinterval = 1 # [sec]
542 543
543 544 def __init__(self, ui):
544 545 self.ui = ui
545 546 self._idletimeout = ui.configint('chgserver', 'idletimeout', 3600)
546 547 self._lastactive = time.time()
547 548
548 549 def bindsocket(self, sock, address):
549 550 self._inithashstate(address)
550 551 self._checkextensions()
551 552 self._bind(sock)
552 553 self._createsymlink()
553 554
554 555 def _inithashstate(self, address):
555 556 self._baseaddress = address
556 557 if self.ui.configbool('chgserver', 'skiphash', False):
557 558 self._hashstate = None
558 559 self._realaddress = address
559 560 return
560 561 self._hashstate = hashstate.fromui(self.ui)
561 562 self._realaddress = _hashaddress(address, self._hashstate.confighash)
562 563
563 564 def _checkextensions(self):
564 565 if not self._hashstate:
565 566 return
566 567 if extensions.notloaded():
567 568 # one or more extensions failed to load. mtimehash becomes
568 569 # meaningless because we do not know the paths of those extensions.
569 570 # set mtimehash to an illegal hash value to invalidate the server.
570 571 self._hashstate.mtimehash = ''
571 572
572 573 def _bind(self, sock):
573 574 # use a unique temp address so we can stat the file and do ownership
574 575 # check later
575 576 tempaddress = _tempaddress(self._realaddress)
576 577 util.bindunixsocket(sock, tempaddress)
577 578 self._socketstat = os.stat(tempaddress)
578 579 # rename will replace the old socket file if exists atomically. the
579 580 # old server will detect ownership change and exit.
580 581 util.rename(tempaddress, self._realaddress)
581 582
582 583 def _createsymlink(self):
583 584 if self._baseaddress == self._realaddress:
584 585 return
585 586 tempaddress = _tempaddress(self._baseaddress)
586 587 os.symlink(os.path.basename(self._realaddress), tempaddress)
587 588 util.rename(tempaddress, self._baseaddress)
588 589
589 590 def _issocketowner(self):
590 591 try:
591 592 stat = os.stat(self._realaddress)
592 593 return (stat.st_ino == self._socketstat.st_ino and
593 594 stat.st_mtime == self._socketstat.st_mtime)
594 595 except OSError:
595 596 return False
596 597
597 598 def unlinksocket(self, address):
598 599 if not self._issocketowner():
599 600 return
600 601 # it is possible to have a race condition here that we may
601 602 # remove another server's socket file. but that's okay
602 603 # since that server will detect and exit automatically and
603 604 # the client will start a new server on demand.
604 605 try:
605 606 os.unlink(self._realaddress)
606 607 except OSError as exc:
607 608 if exc.errno != errno.ENOENT:
608 609 raise
609 610
610 611 def printbanner(self, address):
611 612 # no "listening at" message should be printed to simulate hg behavior
612 613 pass
613 614
614 615 def shouldexit(self):
615 616 if not self._issocketowner():
616 617 self.ui.debug('%s is not owned, exiting.\n' % self._realaddress)
617 618 return True
618 619 if time.time() - self._lastactive > self._idletimeout:
619 620 self.ui.debug('being idle too long. exiting.\n')
620 621 return True
621 622 return False
622 623
623 624 def newconnection(self):
624 625 self._lastactive = time.time()
625 626
626 627 def createcmdserver(self, repo, conn, fin, fout):
627 628 return chgcmdserver(self.ui, repo, fin, fout, conn,
628 629 self._hashstate, self._baseaddress)
629 630
630 631 def chgunixservice(ui, repo, opts):
631 632 # CHGINTERNALMARK is temporarily set by chg client to detect if chg will
632 633 # start another chg. drop it to avoid possible side effects.
633 634 if 'CHGINTERNALMARK' in encoding.environ:
634 635 del encoding.environ['CHGINTERNALMARK']
635 636
636 637 if repo:
637 638 # one chgserver can serve multiple repos. drop repo information
638 639 ui.setconfig('bundle', 'mainreporoot', '', 'repo')
639 640 h = chgunixservicehandler(ui)
640 641 return commandserver.unixforkingservice(ui, repo=None, opts=opts, handler=h)
General Comments 0
You need to be logged in to leave comments. Login now