##// END OF EJS Templates
bundle2: adds a capabilities attribute on bundler20...
Pierre-Yves David -
r21134:2f8c4fa2 default
parent child Browse files
Show More
@@ -1,702 +1,703
1 1 # bundle2.py - generic container format to transmit arbitrary data.
2 2 #
3 3 # Copyright 2013 Facebook, Inc.
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 """Handling of the new bundle2 format
8 8
9 9 The goal of bundle2 is to act as an atomically packet to transmit a set of
10 10 payloads in an application agnostic way. It consist in a sequence of "parts"
11 11 that will be handed to and processed by the application layer.
12 12
13 13
14 14 General format architecture
15 15 ===========================
16 16
17 17 The format is architectured as follow
18 18
19 19 - magic string
20 20 - stream level parameters
21 21 - payload parts (any number)
22 22 - end of stream marker.
23 23
24 24 the Binary format
25 25 ============================
26 26
27 27 All numbers are unsigned and big-endian.
28 28
29 29 stream level parameters
30 30 ------------------------
31 31
32 32 Binary format is as follow
33 33
34 34 :params size: (16 bits integer)
35 35
36 36 The total number of Bytes used by the parameters
37 37
38 38 :params value: arbitrary number of Bytes
39 39
40 40 A blob of `params size` containing the serialized version of all stream level
41 41 parameters.
42 42
43 43 The blob contains a space separated list of parameters. Parameters with value
44 44 are stored in the form `<name>=<value>`. Both name and value are urlquoted.
45 45
46 46 Empty name are obviously forbidden.
47 47
48 48 Name MUST start with a letter. If this first letter is lower case, the
49 49 parameter is advisory and can be safely ignored. However when the first
50 50 letter is capital, the parameter is mandatory and the bundling process MUST
51 51 stop if he is not able to proceed it.
52 52
53 53 Stream parameters use a simple textual format for two main reasons:
54 54
55 55 - Stream level parameters should remain simple and we want to discourage any
56 56 crazy usage.
57 57 - Textual data allow easy human inspection of a bundle2 header in case of
58 58 troubles.
59 59
60 60 Any Applicative level options MUST go into a bundle2 part instead.
61 61
62 62 Payload part
63 63 ------------------------
64 64
65 65 Binary format is as follow
66 66
67 67 :header size: (16 bits inter)
68 68
69 69 The total number of Bytes used by the part headers. When the header is empty
70 70 (size = 0) this is interpreted as the end of stream marker.
71 71
72 72 :header:
73 73
74 74 The header defines how to interpret the part. It contains two piece of
75 75 data: the part type, and the part parameters.
76 76
77 77 The part type is used to route an application level handler, that can
78 78 interpret payload.
79 79
80 80 Part parameters are passed to the application level handler. They are
81 81 meant to convey information that will help the application level object to
82 82 interpret the part payload.
83 83
84 84 The binary format of the header is has follow
85 85
86 86 :typesize: (one byte)
87 87
88 88 :parttype: alphanumerical part name
89 89
90 90 :partid: A 32bits integer (unique in the bundle) that can be used to refer
91 91 to this part.
92 92
93 93 :parameters:
94 94
95 95 Part's parameter may have arbitrary content, the binary structure is::
96 96
97 97 <mandatory-count><advisory-count><param-sizes><param-data>
98 98
99 99 :mandatory-count: 1 byte, number of mandatory parameters
100 100
101 101 :advisory-count: 1 byte, number of advisory parameters
102 102
103 103 :param-sizes:
104 104
105 105 N couple of bytes, where N is the total number of parameters. Each
106 106 couple contains (<size-of-key>, <size-of-value) for one parameter.
107 107
108 108 :param-data:
109 109
110 110 A blob of bytes from which each parameter key and value can be
111 111 retrieved using the list of size couples stored in the previous
112 112 field.
113 113
114 114 Mandatory parameters comes first, then the advisory ones.
115 115
116 116 :payload:
117 117
118 118 payload is a series of `<chunksize><chunkdata>`.
119 119
120 120 `chunksize` is a 32 bits integer, `chunkdata` are plain bytes (as much as
121 121 `chunksize` says)` The payload part is concluded by a zero size chunk.
122 122
123 123 The current implementation always produces either zero or one chunk.
124 124 This is an implementation limitation that will ultimately be lifted.
125 125
126 126 Bundle processing
127 127 ============================
128 128
129 129 Each part is processed in order using a "part handler". Handler are registered
130 130 for a certain part type.
131 131
132 132 The matching of a part to its handler is case insensitive. The case of the
133 133 part type is used to know if a part is mandatory or advisory. If the Part type
134 134 contains any uppercase char it is considered mandatory. When no handler is
135 135 known for a Mandatory part, the process is aborted and an exception is raised.
136 136 If the part is advisory and no handler is known, the part is ignored. When the
137 137 process is aborted, the full bundle is still read from the stream to keep the
138 138 channel usable. But none of the part read from an abort are processed. In the
139 139 future, dropping the stream may become an option for channel we do not care to
140 140 preserve.
141 141 """
142 142
143 143 import util
144 144 import struct
145 145 import urllib
146 146 import string
147 147
148 148 import changegroup
149 149 from i18n import _
150 150
151 151 _pack = struct.pack
152 152 _unpack = struct.unpack
153 153
154 154 _magicstring = 'HG20'
155 155
156 156 _fstreamparamsize = '>H'
157 157 _fpartheadersize = '>H'
158 158 _fparttypesize = '>B'
159 159 _fpartid = '>I'
160 160 _fpayloadsize = '>I'
161 161 _fpartparamcount = '>BB'
162 162
163 163 preferedchunksize = 4096
164 164
165 165 def _makefpartparamsizes(nbparams):
166 166 """return a struct format to read part parameter sizes
167 167
168 168 The number parameters is variable so we need to build that format
169 169 dynamically.
170 170 """
171 171 return '>'+('BB'*nbparams)
172 172
173 173 parthandlermapping = {}
174 174
175 175 def parthandler(parttype):
176 176 """decorator that register a function as a bundle2 part handler
177 177
178 178 eg::
179 179
180 180 @parthandler('myparttype')
181 181 def myparttypehandler(...):
182 182 '''process a part of type "my part".'''
183 183 ...
184 184 """
185 185 def _decorator(func):
186 186 lparttype = parttype.lower() # enforce lower case matching.
187 187 assert lparttype not in parthandlermapping
188 188 parthandlermapping[lparttype] = func
189 189 return func
190 190 return _decorator
191 191
192 192 class unbundlerecords(object):
193 193 """keep record of what happens during and unbundle
194 194
195 195 New records are added using `records.add('cat', obj)`. Where 'cat' is a
196 196 category of record and obj is an arbitrary object.
197 197
198 198 `records['cat']` will return all entries of this category 'cat'.
199 199
200 200 Iterating on the object itself will yield `('category', obj)` tuples
201 201 for all entries.
202 202
203 203 All iterations happens in chronological order.
204 204 """
205 205
206 206 def __init__(self):
207 207 self._categories = {}
208 208 self._sequences = []
209 209 self._replies = {}
210 210
211 211 def add(self, category, entry, inreplyto=None):
212 212 """add a new record of a given category.
213 213
214 214 The entry can then be retrieved in the list returned by
215 215 self['category']."""
216 216 self._categories.setdefault(category, []).append(entry)
217 217 self._sequences.append((category, entry))
218 218 if inreplyto is not None:
219 219 self.getreplies(inreplyto).add(category, entry)
220 220
221 221 def getreplies(self, partid):
222 222 """get the subrecords that replies to a specific part"""
223 223 return self._replies.setdefault(partid, unbundlerecords())
224 224
225 225 def __getitem__(self, cat):
226 226 return tuple(self._categories.get(cat, ()))
227 227
228 228 def __iter__(self):
229 229 return iter(self._sequences)
230 230
231 231 def __len__(self):
232 232 return len(self._sequences)
233 233
234 234 def __nonzero__(self):
235 235 return bool(self._sequences)
236 236
237 237 class bundleoperation(object):
238 238 """an object that represents a single bundling process
239 239
240 240 Its purpose is to carry unbundle-related objects and states.
241 241
242 242 A new object should be created at the beginning of each bundle processing.
243 243 The object is to be returned by the processing function.
244 244
245 245 The object has very little content now it will ultimately contain:
246 246 * an access to the repo the bundle is applied to,
247 247 * a ui object,
248 248 * a way to retrieve a transaction to add changes to the repo,
249 249 * a way to record the result of processing each part,
250 250 * a way to construct a bundle response when applicable.
251 251 """
252 252
253 253 def __init__(self, repo, transactiongetter):
254 254 self.repo = repo
255 255 self.ui = repo.ui
256 256 self.records = unbundlerecords()
257 257 self.gettransaction = transactiongetter
258 258 self.reply = None
259 259
260 260 class TransactionUnavailable(RuntimeError):
261 261 pass
262 262
263 263 def _notransaction():
264 264 """default method to get a transaction while processing a bundle
265 265
266 266 Raise an exception to highlight the fact that no transaction was expected
267 267 to be created"""
268 268 raise TransactionUnavailable()
269 269
270 270 def processbundle(repo, unbundler, transactiongetter=_notransaction):
271 271 """This function process a bundle, apply effect to/from a repo
272 272
273 273 It iterates over each part then searches for and uses the proper handling
274 274 code to process the part. Parts are processed in order.
275 275
276 276 This is very early version of this function that will be strongly reworked
277 277 before final usage.
278 278
279 279 Unknown Mandatory part will abort the process.
280 280 """
281 281 op = bundleoperation(repo, transactiongetter)
282 282 # todo:
283 283 # - replace this is a init function soon.
284 284 # - exception catching
285 285 unbundler.params
286 286 iterparts = unbundler.iterparts()
287 287 part = None
288 288 try:
289 289 for part in iterparts:
290 290 parttype = part.type
291 291 # part key are matched lower case
292 292 key = parttype.lower()
293 293 try:
294 294 handler = parthandlermapping[key]
295 295 op.ui.debug('found a handler for part %r\n' % parttype)
296 296 except KeyError:
297 297 if key != parttype: # mandatory parts
298 298 # todo:
299 299 # - use a more precise exception
300 300 raise
301 301 op.ui.debug('ignoring unknown advisory part %r\n' % key)
302 302 # consuming the part
303 303 part.read()
304 304 continue
305 305
306 306 # handler is called outside the above try block so that we don't
307 307 # risk catching KeyErrors from anything other than the
308 308 # parthandlermapping lookup (any KeyError raised by handler()
309 309 # itself represents a defect of a different variety).
310 310 output = None
311 311 if op.reply is not None:
312 312 op.ui.pushbuffer(error=True)
313 313 output = ''
314 314 try:
315 315 handler(op, part)
316 316 finally:
317 317 if output is not None:
318 318 output = op.ui.popbuffer()
319 319 if output:
320 320 outpart = bundlepart('output',
321 321 advisoryparams=[('in-reply-to',
322 322 str(part.id))],
323 323 data=output)
324 324 op.reply.addpart(outpart)
325 325 part.read()
326 326 except Exception:
327 327 if part is not None:
328 328 # consume the bundle content
329 329 part.read()
330 330 for part in iterparts:
331 331 # consume the bundle content
332 332 part.read()
333 333 raise
334 334 return op
335 335
336 336 class bundle20(object):
337 337 """represent an outgoing bundle2 container
338 338
339 339 Use the `addparam` method to add stream level parameter. and `addpart` to
340 340 populate it. Then call `getchunks` to retrieve all the binary chunks of
341 341 data that compose the bundle2 container."""
342 342
343 def __init__(self, ui):
343 def __init__(self, ui, capabilities=()):
344 344 self.ui = ui
345 345 self._params = []
346 346 self._parts = []
347 self.capabilities = set(capabilities)
347 348
348 349 def addparam(self, name, value=None):
349 350 """add a stream level parameter"""
350 351 if not name:
351 352 raise ValueError('empty parameter name')
352 353 if name[0] not in string.letters:
353 354 raise ValueError('non letter first character: %r' % name)
354 355 self._params.append((name, value))
355 356
356 357 def addpart(self, part):
357 358 """add a new part to the bundle2 container
358 359
359 360 Parts contains the actual applicative payload."""
360 361 assert part.id is None
361 362 part.id = len(self._parts) # very cheap counter
362 363 self._parts.append(part)
363 364
364 365 def getchunks(self):
365 366 self.ui.debug('start emission of %s stream\n' % _magicstring)
366 367 yield _magicstring
367 368 param = self._paramchunk()
368 369 self.ui.debug('bundle parameter: %s\n' % param)
369 370 yield _pack(_fstreamparamsize, len(param))
370 371 if param:
371 372 yield param
372 373
373 374 self.ui.debug('start of parts\n')
374 375 for part in self._parts:
375 376 self.ui.debug('bundle part: "%s"\n' % part.type)
376 377 for chunk in part.getchunks():
377 378 yield chunk
378 379 self.ui.debug('end of bundle\n')
379 380 yield '\0\0'
380 381
381 382 def _paramchunk(self):
382 383 """return a encoded version of all stream parameters"""
383 384 blocks = []
384 385 for par, value in self._params:
385 386 par = urllib.quote(par)
386 387 if value is not None:
387 388 value = urllib.quote(value)
388 389 par = '%s=%s' % (par, value)
389 390 blocks.append(par)
390 391 return ' '.join(blocks)
391 392
392 393 class unpackermixin(object):
393 394 """A mixin to extract bytes and struct data from a stream"""
394 395
395 396 def __init__(self, fp):
396 397 self._fp = fp
397 398
398 399 def _unpack(self, format):
399 400 """unpack this struct format from the stream"""
400 401 data = self._readexact(struct.calcsize(format))
401 402 return _unpack(format, data)
402 403
403 404 def _readexact(self, size):
404 405 """read exactly <size> bytes from the stream"""
405 406 return changegroup.readexactly(self._fp, size)
406 407
407 408
408 409 class unbundle20(unpackermixin):
409 410 """interpret a bundle2 stream
410 411
411 412 This class is fed with a binary stream and yields parts through its
412 413 `iterparts` methods."""
413 414
414 415 def __init__(self, ui, fp, header=None):
415 416 """If header is specified, we do not read it out of the stream."""
416 417 self.ui = ui
417 418 super(unbundle20, self).__init__(fp)
418 419 if header is None:
419 420 header = self._readexact(4)
420 421 magic, version = header[0:2], header[2:4]
421 422 if magic != 'HG':
422 423 raise util.Abort(_('not a Mercurial bundle'))
423 424 if version != '20':
424 425 raise util.Abort(_('unknown bundle version %s') % version)
425 426 self.ui.debug('start processing of %s stream\n' % header)
426 427
427 428 @util.propertycache
428 429 def params(self):
429 430 """dictionary of stream level parameters"""
430 431 self.ui.debug('reading bundle2 stream parameters\n')
431 432 params = {}
432 433 paramssize = self._unpack(_fstreamparamsize)[0]
433 434 if paramssize:
434 435 for p in self._readexact(paramssize).split(' '):
435 436 p = p.split('=', 1)
436 437 p = [urllib.unquote(i) for i in p]
437 438 if len(p) < 2:
438 439 p.append(None)
439 440 self._processparam(*p)
440 441 params[p[0]] = p[1]
441 442 return params
442 443
443 444 def _processparam(self, name, value):
444 445 """process a parameter, applying its effect if needed
445 446
446 447 Parameter starting with a lower case letter are advisory and will be
447 448 ignored when unknown. Those starting with an upper case letter are
448 449 mandatory and will this function will raise a KeyError when unknown.
449 450
450 451 Note: no option are currently supported. Any input will be either
451 452 ignored or failing.
452 453 """
453 454 if not name:
454 455 raise ValueError('empty parameter name')
455 456 if name[0] not in string.letters:
456 457 raise ValueError('non letter first character: %r' % name)
457 458 # Some logic will be later added here to try to process the option for
458 459 # a dict of known parameter.
459 460 if name[0].islower():
460 461 self.ui.debug("ignoring unknown parameter %r\n" % name)
461 462 else:
462 463 raise KeyError(name)
463 464
464 465
465 466 def iterparts(self):
466 467 """yield all parts contained in the stream"""
467 468 # make sure param have been loaded
468 469 self.params
469 470 self.ui.debug('start extraction of bundle2 parts\n')
470 471 headerblock = self._readpartheader()
471 472 while headerblock is not None:
472 473 part = unbundlepart(self.ui, headerblock, self._fp)
473 474 yield part
474 475 headerblock = self._readpartheader()
475 476 self.ui.debug('end of bundle2 stream\n')
476 477
477 478 def _readpartheader(self):
478 479 """reads a part header size and return the bytes blob
479 480
480 481 returns None if empty"""
481 482 headersize = self._unpack(_fpartheadersize)[0]
482 483 self.ui.debug('part header size: %i\n' % headersize)
483 484 if headersize:
484 485 return self._readexact(headersize)
485 486 return None
486 487
487 488
488 489 class bundlepart(object):
489 490 """A bundle2 part contains application level payload
490 491
491 492 The part `type` is used to route the part to the application level
492 493 handler.
493 494 """
494 495
495 496 def __init__(self, parttype, mandatoryparams=(), advisoryparams=(),
496 497 data=''):
497 498 self.id = None
498 499 self.type = parttype
499 500 self.data = data
500 501 self.mandatoryparams = mandatoryparams
501 502 self.advisoryparams = advisoryparams
502 503
503 504 def getchunks(self):
504 505 #### header
505 506 ## parttype
506 507 header = [_pack(_fparttypesize, len(self.type)),
507 508 self.type, _pack(_fpartid, self.id),
508 509 ]
509 510 ## parameters
510 511 # count
511 512 manpar = self.mandatoryparams
512 513 advpar = self.advisoryparams
513 514 header.append(_pack(_fpartparamcount, len(manpar), len(advpar)))
514 515 # size
515 516 parsizes = []
516 517 for key, value in manpar:
517 518 parsizes.append(len(key))
518 519 parsizes.append(len(value))
519 520 for key, value in advpar:
520 521 parsizes.append(len(key))
521 522 parsizes.append(len(value))
522 523 paramsizes = _pack(_makefpartparamsizes(len(parsizes) / 2), *parsizes)
523 524 header.append(paramsizes)
524 525 # key, value
525 526 for key, value in manpar:
526 527 header.append(key)
527 528 header.append(value)
528 529 for key, value in advpar:
529 530 header.append(key)
530 531 header.append(value)
531 532 ## finalize header
532 533 headerchunk = ''.join(header)
533 534 yield _pack(_fpartheadersize, len(headerchunk))
534 535 yield headerchunk
535 536 ## payload
536 537 for chunk in self._payloadchunks():
537 538 yield _pack(_fpayloadsize, len(chunk))
538 539 yield chunk
539 540 # end of payload
540 541 yield _pack(_fpayloadsize, 0)
541 542
542 543 def _payloadchunks(self):
543 544 """yield chunks of a the part payload
544 545
545 546 Exists to handle the different methods to provide data to a part."""
546 547 # we only support fixed size data now.
547 548 # This will be improved in the future.
548 549 if util.safehasattr(self.data, 'next'):
549 550 buff = util.chunkbuffer(self.data)
550 551 chunk = buff.read(preferedchunksize)
551 552 while chunk:
552 553 yield chunk
553 554 chunk = buff.read(preferedchunksize)
554 555 elif len(self.data):
555 556 yield self.data
556 557
557 558 class unbundlepart(unpackermixin):
558 559 """a bundle part read from a bundle"""
559 560
560 561 def __init__(self, ui, header, fp):
561 562 super(unbundlepart, self).__init__(fp)
562 563 self.ui = ui
563 564 # unbundle state attr
564 565 self._headerdata = header
565 566 self._headeroffset = 0
566 567 self._initialized = False
567 568 self.consumed = False
568 569 # part data
569 570 self.id = None
570 571 self.type = None
571 572 self.mandatoryparams = None
572 573 self.advisoryparams = None
573 574 self._payloadstream = None
574 575 self._readheader()
575 576
576 577 def _fromheader(self, size):
577 578 """return the next <size> byte from the header"""
578 579 offset = self._headeroffset
579 580 data = self._headerdata[offset:(offset + size)]
580 581 self._headeroffset = offset + size
581 582 return data
582 583
583 584 def _unpackheader(self, format):
584 585 """read given format from header
585 586
586 587 This automatically compute the size of the format to read."""
587 588 data = self._fromheader(struct.calcsize(format))
588 589 return _unpack(format, data)
589 590
590 591 def _readheader(self):
591 592 """read the header and setup the object"""
592 593 typesize = self._unpackheader(_fparttypesize)[0]
593 594 self.type = self._fromheader(typesize)
594 595 self.ui.debug('part type: "%s"\n' % self.type)
595 596 self.id = self._unpackheader(_fpartid)[0]
596 597 self.ui.debug('part id: "%s"\n' % self.id)
597 598 ## reading parameters
598 599 # param count
599 600 mancount, advcount = self._unpackheader(_fpartparamcount)
600 601 self.ui.debug('part parameters: %i\n' % (mancount + advcount))
601 602 # param size
602 603 fparamsizes = _makefpartparamsizes(mancount + advcount)
603 604 paramsizes = self._unpackheader(fparamsizes)
604 605 # make it a list of couple again
605 606 paramsizes = zip(paramsizes[::2], paramsizes[1::2])
606 607 # split mandatory from advisory
607 608 mansizes = paramsizes[:mancount]
608 609 advsizes = paramsizes[mancount:]
609 610 # retrive param value
610 611 manparams = []
611 612 for key, value in mansizes:
612 613 manparams.append((self._fromheader(key), self._fromheader(value)))
613 614 advparams = []
614 615 for key, value in advsizes:
615 616 advparams.append((self._fromheader(key), self._fromheader(value)))
616 617 self.mandatoryparams = manparams
617 618 self.advisoryparams = advparams
618 619 ## part payload
619 620 def payloadchunks():
620 621 payloadsize = self._unpack(_fpayloadsize)[0]
621 622 self.ui.debug('payload chunk size: %i\n' % payloadsize)
622 623 while payloadsize:
623 624 yield self._readexact(payloadsize)
624 625 payloadsize = self._unpack(_fpayloadsize)[0]
625 626 self.ui.debug('payload chunk size: %i\n' % payloadsize)
626 627 self._payloadstream = util.chunkbuffer(payloadchunks())
627 628 # we read the data, tell it
628 629 self._initialized = True
629 630
630 631 def read(self, size=None):
631 632 """read payload data"""
632 633 if not self._initialized:
633 634 self._readheader()
634 635 if size is None:
635 636 data = self._payloadstream.read()
636 637 else:
637 638 data = self._payloadstream.read(size)
638 639 if size is None or len(data) < size:
639 640 self.consumed = True
640 641 return data
641 642
642 643
643 644 @parthandler('changegroup')
644 645 def handlechangegroup(op, inpart):
645 646 """apply a changegroup part on the repo
646 647
647 648 This is a very early implementation that will massive rework before being
648 649 inflicted to any end-user.
649 650 """
650 651 # Make sure we trigger a transaction creation
651 652 #
652 653 # The addchangegroup function will get a transaction object by itself, but
653 654 # we need to make sure we trigger the creation of a transaction object used
654 655 # for the whole processing scope.
655 656 op.gettransaction()
656 657 cg = changegroup.unbundle10(inpart, 'UN')
657 658 ret = changegroup.addchangegroup(op.repo, cg, 'bundle2', 'bundle2')
658 659 op.records.add('changegroup', {'return': ret})
659 660 if op.reply is not None:
660 661 # This is definitly not the final form of this
661 662 # return. But one need to start somewhere.
662 663 part = bundlepart('reply:changegroup', (),
663 664 [('in-reply-to', str(inpart.id)),
664 665 ('return', '%i' % ret)])
665 666 op.reply.addpart(part)
666 667 assert not inpart.read()
667 668
668 669 @parthandler('reply:changegroup')
669 670 def handlechangegroup(op, inpart):
670 671 p = dict(inpart.advisoryparams)
671 672 ret = int(p['return'])
672 673 op.records.add('changegroup', {'return': ret}, int(p['in-reply-to']))
673 674
674 675 @parthandler('check:heads')
675 676 def handlechangegroup(op, inpart):
676 677 """check that head of the repo did not change
677 678
678 679 This is used to detect a push race when using unbundle.
679 680 This replaces the "heads" argument of unbundle."""
680 681 h = inpart.read(20)
681 682 heads = []
682 683 while len(h) == 20:
683 684 heads.append(h)
684 685 h = inpart.read(20)
685 686 assert not h
686 687 if heads != op.repo.heads():
687 688 raise exchange.PushRaced()
688 689
689 690 @parthandler('output')
690 691 def handleoutput(op, inpart):
691 692 """forward output captured on the server to the client"""
692 693 for line in inpart.read().splitlines():
693 694 op.ui.write(('remote: %s\n' % line))
694 695
695 696 @parthandler('replycaps')
696 697 def handlereplycaps(op, inpart):
697 698 """Notify that a reply bundle should be created
698 699
699 700 Will convey bundle capability at some point too."""
700 701 if op.reply is None:
701 702 op.reply = bundle20(op.ui)
702 703
General Comments 0
You need to be logged in to leave comments. Login now