##// END OF EJS Templates
py3: delete b'' prefix from safehasattr arguments...
Martin von Zweigbergk -
r43385:4aa72cdf default
parent child Browse files
Show More
@@ -1,1131 +1,1131 b''
1 1 # absorb.py
2 2 #
3 3 # Copyright 2016 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
8 8 """apply working directory changes to changesets (EXPERIMENTAL)
9 9
10 10 The absorb extension provides a command to use annotate information to
11 11 amend modified chunks into the corresponding non-public changesets.
12 12
13 13 ::
14 14
15 15 [absorb]
16 16 # only check 50 recent non-public changesets at most
17 17 max-stack-size = 50
18 18 # whether to add noise to new commits to avoid obsolescence cycle
19 19 add-noise = 1
20 20 # make `amend --correlated` a shortcut to the main command
21 21 amend-flag = correlated
22 22
23 23 [color]
24 24 absorb.description = yellow
25 25 absorb.node = blue bold
26 26 absorb.path = bold
27 27 """
28 28
29 29 # TODO:
30 30 # * Rename config items to [commands] namespace
31 31 # * Converge getdraftstack() with other code in core
32 32 # * move many attributes on fixupstate to be private
33 33
34 34 from __future__ import absolute_import
35 35
36 36 import collections
37 37
38 38 from mercurial.i18n import _
39 39 from mercurial import (
40 40 cmdutil,
41 41 commands,
42 42 context,
43 43 crecord,
44 44 error,
45 45 linelog,
46 46 mdiff,
47 47 node,
48 48 obsolete,
49 49 patch,
50 50 phases,
51 51 pycompat,
52 52 registrar,
53 53 scmutil,
54 54 util,
55 55 )
56 56 from mercurial.utils import stringutil
57 57
58 58 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
59 59 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
60 60 # be specifying the version(s) of Mercurial they are tested with, or
61 61 # leave the attribute unspecified.
62 62 testedwith = b'ships-with-hg-core'
63 63
64 64 cmdtable = {}
65 65 command = registrar.command(cmdtable)
66 66
67 67 configtable = {}
68 68 configitem = registrar.configitem(configtable)
69 69
70 70 configitem(b'absorb', b'add-noise', default=True)
71 71 configitem(b'absorb', b'amend-flag', default=None)
72 72 configitem(b'absorb', b'max-stack-size', default=50)
73 73
74 74 colortable = {
75 75 b'absorb.description': b'yellow',
76 76 b'absorb.node': b'blue bold',
77 77 b'absorb.path': b'bold',
78 78 }
79 79
80 80 defaultdict = collections.defaultdict
81 81
82 82
83 83 class nullui(object):
84 84 """blank ui object doing nothing"""
85 85
86 86 debugflag = False
87 87 verbose = False
88 88 quiet = True
89 89
90 90 def __getitem__(name):
91 91 def nullfunc(*args, **kwds):
92 92 return
93 93
94 94 return nullfunc
95 95
96 96
97 97 class emptyfilecontext(object):
98 98 """minimal filecontext representing an empty file"""
99 99
100 100 def data(self):
101 101 return b''
102 102
103 103 def node(self):
104 104 return node.nullid
105 105
106 106
107 107 def uniq(lst):
108 108 """list -> list. remove duplicated items without changing the order"""
109 109 seen = set()
110 110 result = []
111 111 for x in lst:
112 112 if x not in seen:
113 113 seen.add(x)
114 114 result.append(x)
115 115 return result
116 116
117 117
118 118 def getdraftstack(headctx, limit=None):
119 119 """(ctx, int?) -> [ctx]. get a linear stack of non-public changesets.
120 120
121 121 changesets are sorted in topo order, oldest first.
122 122 return at most limit items, if limit is a positive number.
123 123
124 124 merges are considered as non-draft as well. i.e. every commit
125 125 returned has and only has 1 parent.
126 126 """
127 127 ctx = headctx
128 128 result = []
129 129 while ctx.phase() != phases.public:
130 130 if limit and len(result) >= limit:
131 131 break
132 132 parents = ctx.parents()
133 133 if len(parents) != 1:
134 134 break
135 135 result.append(ctx)
136 136 ctx = parents[0]
137 137 result.reverse()
138 138 return result
139 139
140 140
141 141 def getfilestack(stack, path, seenfctxs=None):
142 142 """([ctx], str, set) -> [fctx], {ctx: fctx}
143 143
144 144 stack is a list of contexts, from old to new. usually they are what
145 145 "getdraftstack" returns.
146 146
147 147 follows renames, but not copies.
148 148
149 149 seenfctxs is a set of filecontexts that will be considered "immutable".
150 150 they are usually what this function returned in earlier calls, useful
151 151 to avoid issues that a file was "moved" to multiple places and was then
152 152 modified differently, like: "a" was copied to "b", "a" was also copied to
153 153 "c" and then "a" was deleted, then both "b" and "c" were "moved" from "a"
154 154 and we enforce only one of them to be able to affect "a"'s content.
155 155
156 156 return an empty list and an empty dict, if the specified path does not
157 157 exist in stack[-1] (the top of the stack).
158 158
159 159 otherwise, return a list of de-duplicated filecontexts, and the map to
160 160 convert ctx in the stack to fctx, for possible mutable fctxs. the first item
161 161 of the list would be outside the stack and should be considered immutable.
162 162 the remaining items are within the stack.
163 163
164 164 for example, given the following changelog and corresponding filelog
165 165 revisions:
166 166
167 167 changelog: 3----4----5----6----7
168 168 filelog: x 0----1----1----2 (x: no such file yet)
169 169
170 170 - if stack = [5, 6, 7], returns ([0, 1, 2], {5: 1, 6: 1, 7: 2})
171 171 - if stack = [3, 4, 5], returns ([e, 0, 1], {4: 0, 5: 1}), where "e" is a
172 172 dummy empty filecontext.
173 173 - if stack = [2], returns ([], {})
174 174 - if stack = [7], returns ([1, 2], {7: 2})
175 175 - if stack = [6, 7], returns ([1, 2], {6: 1, 7: 2}), although {6: 1} can be
176 176 removed, since 1 is immutable.
177 177 """
178 178 if seenfctxs is None:
179 179 seenfctxs = set()
180 180 assert stack
181 181
182 182 if path not in stack[-1]:
183 183 return [], {}
184 184
185 185 fctxs = []
186 186 fctxmap = {}
187 187
188 188 pctx = stack[0].p1() # the public (immutable) ctx we stop at
189 189 for ctx in reversed(stack):
190 190 if path not in ctx: # the file is added in the next commit
191 191 pctx = ctx
192 192 break
193 193 fctx = ctx[path]
194 194 fctxs.append(fctx)
195 195 if fctx in seenfctxs: # treat fctx as the immutable one
196 196 pctx = None # do not add another immutable fctx
197 197 break
198 198 fctxmap[ctx] = fctx # only for mutable fctxs
199 199 copy = fctx.copysource()
200 200 if copy:
201 201 path = copy # follow rename
202 202 if path in ctx: # but do not follow copy
203 203 pctx = ctx.p1()
204 204 break
205 205
206 206 if pctx is not None: # need an extra immutable fctx
207 207 if path in pctx:
208 208 fctxs.append(pctx[path])
209 209 else:
210 210 fctxs.append(emptyfilecontext())
211 211
212 212 fctxs.reverse()
213 213 # note: we rely on a property of hg: filerev is not reused for linear
214 214 # history. i.e. it's impossible to have:
215 215 # changelog: 4----5----6 (linear, no merges)
216 216 # filelog: 1----2----1
217 217 # ^ reuse filerev (impossible)
218 218 # because parents are part of the hash. if that's not true, we need to
219 219 # remove uniq and find a different way to identify fctxs.
220 220 return uniq(fctxs), fctxmap
221 221
222 222
223 223 class overlaystore(patch.filestore):
224 224 """read-only, hybrid store based on a dict and ctx.
225 225 memworkingcopy: {path: content}, overrides file contents.
226 226 """
227 227
228 228 def __init__(self, basectx, memworkingcopy):
229 229 self.basectx = basectx
230 230 self.memworkingcopy = memworkingcopy
231 231
232 232 def getfile(self, path):
233 233 """comply with mercurial.patch.filestore.getfile"""
234 234 if path not in self.basectx:
235 235 return None, None, None
236 236 fctx = self.basectx[path]
237 237 if path in self.memworkingcopy:
238 238 content = self.memworkingcopy[path]
239 239 else:
240 240 content = fctx.data()
241 241 mode = (fctx.islink(), fctx.isexec())
242 242 copy = fctx.copysource()
243 243 return content, mode, copy
244 244
245 245
246 246 def overlaycontext(memworkingcopy, ctx, parents=None, extra=None):
247 247 """({path: content}, ctx, (p1node, p2node)?, {}?) -> memctx
248 248 memworkingcopy overrides file contents.
249 249 """
250 250 # parents must contain 2 items: (node1, node2)
251 251 if parents is None:
252 252 parents = ctx.repo().changelog.parents(ctx.node())
253 253 if extra is None:
254 254 extra = ctx.extra()
255 255 date = ctx.date()
256 256 desc = ctx.description()
257 257 user = ctx.user()
258 258 files = set(ctx.files()).union(memworkingcopy)
259 259 store = overlaystore(ctx, memworkingcopy)
260 260 return context.memctx(
261 261 repo=ctx.repo(),
262 262 parents=parents,
263 263 text=desc,
264 264 files=files,
265 265 filectxfn=store,
266 266 user=user,
267 267 date=date,
268 268 branch=None,
269 269 extra=extra,
270 270 )
271 271
272 272
273 273 class filefixupstate(object):
274 274 """state needed to apply fixups to a single file
275 275
276 276 internally, it keeps file contents of several revisions and a linelog.
277 277
278 278 the linelog uses odd revision numbers for original contents (fctxs passed
279 279 to __init__), and even revision numbers for fixups, like:
280 280
281 281 linelog rev 1: self.fctxs[0] (from an immutable "public" changeset)
282 282 linelog rev 2: fixups made to self.fctxs[0]
283 283 linelog rev 3: self.fctxs[1] (a child of fctxs[0])
284 284 linelog rev 4: fixups made to self.fctxs[1]
285 285 ...
286 286
287 287 a typical use is like:
288 288
289 289 1. call diffwith, to calculate self.fixups
290 290 2. (optionally), present self.fixups to the user, or change it
291 291 3. call apply, to apply changes
292 292 4. read results from "finalcontents", or call getfinalcontent
293 293 """
294 294
295 295 def __init__(self, fctxs, path, ui=None, opts=None):
296 296 """([fctx], ui or None) -> None
297 297
298 298 fctxs should be linear, and sorted by topo order - oldest first.
299 299 fctxs[0] will be considered as "immutable" and will not be changed.
300 300 """
301 301 self.fctxs = fctxs
302 302 self.path = path
303 303 self.ui = ui or nullui()
304 304 self.opts = opts or {}
305 305
306 306 # following fields are built from fctxs. they exist for perf reason
307 307 self.contents = [f.data() for f in fctxs]
308 308 self.contentlines = pycompat.maplist(mdiff.splitnewlines, self.contents)
309 309 self.linelog = self._buildlinelog()
310 310 if self.ui.debugflag:
311 311 assert self._checkoutlinelog() == self.contents
312 312
313 313 # following fields will be filled later
314 314 self.chunkstats = [0, 0] # [adopted, total : int]
315 315 self.targetlines = [] # [str]
316 316 self.fixups = [] # [(linelog rev, a1, a2, b1, b2)]
317 317 self.finalcontents = [] # [str]
318 318 self.ctxaffected = set()
319 319
320 320 def diffwith(self, targetfctx, fm=None):
321 321 """calculate fixups needed by examining the differences between
322 322 self.fctxs[-1] and targetfctx, chunk by chunk.
323 323
324 324 targetfctx is the target state we move towards. we may or may not be
325 325 able to get there because not all modified chunks can be amended into
326 326 a non-public fctx unambiguously.
327 327
328 328 call this only once, before apply().
329 329
330 330 update self.fixups, self.chunkstats, and self.targetlines.
331 331 """
332 332 a = self.contents[-1]
333 333 alines = self.contentlines[-1]
334 334 b = targetfctx.data()
335 335 blines = mdiff.splitnewlines(b)
336 336 self.targetlines = blines
337 337
338 338 self.linelog.annotate(self.linelog.maxrev)
339 339 annotated = self.linelog.annotateresult # [(linelog rev, linenum)]
340 340 assert len(annotated) == len(alines)
341 341 # add a dummy end line to make insertion at the end easier
342 342 if annotated:
343 343 dummyendline = (annotated[-1][0], annotated[-1][1] + 1)
344 344 annotated.append(dummyendline)
345 345
346 346 # analyse diff blocks
347 347 for chunk in self._alldiffchunks(a, b, alines, blines):
348 348 newfixups = self._analysediffchunk(chunk, annotated)
349 349 self.chunkstats[0] += bool(newfixups) # 1 or 0
350 350 self.chunkstats[1] += 1
351 351 self.fixups += newfixups
352 352 if fm is not None:
353 353 self._showchanges(fm, alines, blines, chunk, newfixups)
354 354
355 355 def apply(self):
356 356 """apply self.fixups. update self.linelog, self.finalcontents.
357 357
358 358 call this only once, before getfinalcontent(), after diffwith().
359 359 """
360 360 # the following is unnecessary, as it's done by "diffwith":
361 361 # self.linelog.annotate(self.linelog.maxrev)
362 362 for rev, a1, a2, b1, b2 in reversed(self.fixups):
363 363 blines = self.targetlines[b1:b2]
364 364 if self.ui.debugflag:
365 365 idx = (max(rev - 1, 0)) // 2
366 366 self.ui.write(
367 367 _(b'%s: chunk %d:%d -> %d lines\n')
368 368 % (node.short(self.fctxs[idx].node()), a1, a2, len(blines))
369 369 )
370 370 self.linelog.replacelines(rev, a1, a2, b1, b2)
371 371 if self.opts.get(b'edit_lines', False):
372 372 self.finalcontents = self._checkoutlinelogwithedits()
373 373 else:
374 374 self.finalcontents = self._checkoutlinelog()
375 375
376 376 def getfinalcontent(self, fctx):
377 377 """(fctx) -> str. get modified file content for a given filecontext"""
378 378 idx = self.fctxs.index(fctx)
379 379 return self.finalcontents[idx]
380 380
381 381 def _analysediffchunk(self, chunk, annotated):
382 382 """analyse a different chunk and return new fixups found
383 383
384 384 return [] if no lines from the chunk can be safely applied.
385 385
386 386 the chunk (or lines) cannot be safely applied, if, for example:
387 387 - the modified (deleted) lines belong to a public changeset
388 388 (self.fctxs[0])
389 389 - the chunk is a pure insertion and the adjacent lines (at most 2
390 390 lines) belong to different non-public changesets, or do not belong
391 391 to any non-public changesets.
392 392 - the chunk is modifying lines from different changesets.
393 393 in this case, if the number of lines deleted equals to the number
394 394 of lines added, assume it's a simple 1:1 map (could be wrong).
395 395 otherwise, give up.
396 396 - the chunk is modifying lines from a single non-public changeset,
397 397 but other revisions touch the area as well. i.e. the lines are
398 398 not continuous as seen from the linelog.
399 399 """
400 400 a1, a2, b1, b2 = chunk
401 401 # find involved indexes from annotate result
402 402 involved = annotated[a1:a2]
403 403 if not involved and annotated: # a1 == a2 and a is not empty
404 404 # pure insertion, check nearby lines. ignore lines belong
405 405 # to the public (first) changeset (i.e. annotated[i][0] == 1)
406 406 nearbylinenums = {a2, max(0, a1 - 1)}
407 407 involved = [
408 408 annotated[i] for i in nearbylinenums if annotated[i][0] != 1
409 409 ]
410 410 involvedrevs = list(set(r for r, l in involved))
411 411 newfixups = []
412 412 if len(involvedrevs) == 1 and self._iscontinuous(a1, a2 - 1, True):
413 413 # chunk belongs to a single revision
414 414 rev = involvedrevs[0]
415 415 if rev > 1:
416 416 fixuprev = rev + 1
417 417 newfixups.append((fixuprev, a1, a2, b1, b2))
418 418 elif a2 - a1 == b2 - b1 or b1 == b2:
419 419 # 1:1 line mapping, or chunk was deleted
420 420 for i in pycompat.xrange(a1, a2):
421 421 rev, linenum = annotated[i]
422 422 if rev > 1:
423 423 if b1 == b2: # deletion, simply remove that single line
424 424 nb1 = nb2 = 0
425 425 else: # 1:1 line mapping, change the corresponding rev
426 426 nb1 = b1 + i - a1
427 427 nb2 = nb1 + 1
428 428 fixuprev = rev + 1
429 429 newfixups.append((fixuprev, i, i + 1, nb1, nb2))
430 430 return self._optimizefixups(newfixups)
431 431
432 432 @staticmethod
433 433 def _alldiffchunks(a, b, alines, blines):
434 434 """like mdiff.allblocks, but only care about differences"""
435 435 blocks = mdiff.allblocks(a, b, lines1=alines, lines2=blines)
436 436 for chunk, btype in blocks:
437 437 if btype != b'!':
438 438 continue
439 439 yield chunk
440 440
441 441 def _buildlinelog(self):
442 442 """calculate the initial linelog based on self.content{,line}s.
443 443 this is similar to running a partial "annotate".
444 444 """
445 445 llog = linelog.linelog()
446 446 a, alines = b'', []
447 447 for i in pycompat.xrange(len(self.contents)):
448 448 b, blines = self.contents[i], self.contentlines[i]
449 449 llrev = i * 2 + 1
450 450 chunks = self._alldiffchunks(a, b, alines, blines)
451 451 for a1, a2, b1, b2 in reversed(list(chunks)):
452 452 llog.replacelines(llrev, a1, a2, b1, b2)
453 453 a, alines = b, blines
454 454 return llog
455 455
456 456 def _checkoutlinelog(self):
457 457 """() -> [str]. check out file contents from linelog"""
458 458 contents = []
459 459 for i in pycompat.xrange(len(self.contents)):
460 460 rev = (i + 1) * 2
461 461 self.linelog.annotate(rev)
462 462 content = b''.join(map(self._getline, self.linelog.annotateresult))
463 463 contents.append(content)
464 464 return contents
465 465
466 466 def _checkoutlinelogwithedits(self):
467 467 """() -> [str]. prompt all lines for edit"""
468 468 alllines = self.linelog.getalllines()
469 469 # header
470 470 editortext = (
471 471 _(
472 472 b'HG: editing %s\nHG: "y" means the line to the right '
473 473 b'exists in the changeset to the top\nHG:\n'
474 474 )
475 475 % self.fctxs[-1].path()
476 476 )
477 477 # [(idx, fctx)]. hide the dummy emptyfilecontext
478 478 visiblefctxs = [
479 479 (i, f)
480 480 for i, f in enumerate(self.fctxs)
481 481 if not isinstance(f, emptyfilecontext)
482 482 ]
483 483 for i, (j, f) in enumerate(visiblefctxs):
484 484 editortext += _(b'HG: %s/%s %s %s\n') % (
485 485 b'|' * i,
486 486 b'-' * (len(visiblefctxs) - i + 1),
487 487 node.short(f.node()),
488 488 f.description().split(b'\n', 1)[0],
489 489 )
490 490 editortext += _(b'HG: %s\n') % (b'|' * len(visiblefctxs))
491 491 # figure out the lifetime of a line, this is relatively inefficient,
492 492 # but probably fine
493 493 lineset = defaultdict(lambda: set()) # {(llrev, linenum): {llrev}}
494 494 for i, f in visiblefctxs:
495 495 self.linelog.annotate((i + 1) * 2)
496 496 for l in self.linelog.annotateresult:
497 497 lineset[l].add(i)
498 498 # append lines
499 499 for l in alllines:
500 500 editortext += b' %s : %s' % (
501 501 b''.join(
502 502 [
503 503 (b'y' if i in lineset[l] else b' ')
504 504 for i, _f in visiblefctxs
505 505 ]
506 506 ),
507 507 self._getline(l),
508 508 )
509 509 # run editor
510 510 editedtext = self.ui.edit(editortext, b'', action=b'absorb')
511 511 if not editedtext:
512 512 raise error.Abort(_(b'empty editor text'))
513 513 # parse edited result
514 514 contents = [b'' for i in self.fctxs]
515 515 leftpadpos = 4
516 516 colonpos = leftpadpos + len(visiblefctxs) + 1
517 517 for l in mdiff.splitnewlines(editedtext):
518 518 if l.startswith(b'HG:'):
519 519 continue
520 520 if l[colonpos - 1 : colonpos + 2] != b' : ':
521 521 raise error.Abort(_(b'malformed line: %s') % l)
522 522 linecontent = l[colonpos + 2 :]
523 523 for i, ch in enumerate(
524 524 pycompat.bytestr(l[leftpadpos : colonpos - 1])
525 525 ):
526 526 if ch == b'y':
527 527 contents[visiblefctxs[i][0]] += linecontent
528 528 # chunkstats is hard to calculate if anything changes, therefore
529 529 # set them to just a simple value (1, 1).
530 530 if editedtext != editortext:
531 531 self.chunkstats = [1, 1]
532 532 return contents
533 533
534 534 def _getline(self, lineinfo):
535 535 """((rev, linenum)) -> str. convert rev+line number to line content"""
536 536 rev, linenum = lineinfo
537 537 if rev & 1: # odd: original line taken from fctxs
538 538 return self.contentlines[rev // 2][linenum]
539 539 else: # even: fixup line from targetfctx
540 540 return self.targetlines[linenum]
541 541
542 542 def _iscontinuous(self, a1, a2, closedinterval=False):
543 543 """(a1, a2 : int) -> bool
544 544
545 545 check if these lines are continuous. i.e. no other insertions or
546 546 deletions (from other revisions) among these lines.
547 547
548 548 closedinterval decides whether a2 should be included or not. i.e. is
549 549 it [a1, a2), or [a1, a2] ?
550 550 """
551 551 if a1 >= a2:
552 552 return True
553 553 llog = self.linelog
554 554 offset1 = llog.getoffset(a1)
555 555 offset2 = llog.getoffset(a2) + int(closedinterval)
556 556 linesinbetween = llog.getalllines(offset1, offset2)
557 557 return len(linesinbetween) == a2 - a1 + int(closedinterval)
558 558
559 559 def _optimizefixups(self, fixups):
560 560 """[(rev, a1, a2, b1, b2)] -> [(rev, a1, a2, b1, b2)].
561 561 merge adjacent fixups to make them less fragmented.
562 562 """
563 563 result = []
564 564 pcurrentchunk = [[-1, -1, -1, -1, -1]]
565 565
566 566 def pushchunk():
567 567 if pcurrentchunk[0][0] != -1:
568 568 result.append(tuple(pcurrentchunk[0]))
569 569
570 570 for i, chunk in enumerate(fixups):
571 571 rev, a1, a2, b1, b2 = chunk
572 572 lastrev = pcurrentchunk[0][0]
573 573 lasta2 = pcurrentchunk[0][2]
574 574 lastb2 = pcurrentchunk[0][4]
575 575 if (
576 576 a1 == lasta2
577 577 and b1 == lastb2
578 578 and rev == lastrev
579 579 and self._iscontinuous(max(a1 - 1, 0), a1)
580 580 ):
581 581 # merge into currentchunk
582 582 pcurrentchunk[0][2] = a2
583 583 pcurrentchunk[0][4] = b2
584 584 else:
585 585 pushchunk()
586 586 pcurrentchunk[0] = list(chunk)
587 587 pushchunk()
588 588 return result
589 589
590 590 def _showchanges(self, fm, alines, blines, chunk, fixups):
591 591 def trim(line):
592 592 if line.endswith(b'\n'):
593 593 line = line[:-1]
594 594 return line
595 595
596 596 # this is not optimized for perf but _showchanges only gets executed
597 597 # with an extra command-line flag.
598 598 a1, a2, b1, b2 = chunk
599 599 aidxs, bidxs = [0] * (a2 - a1), [0] * (b2 - b1)
600 600 for idx, fa1, fa2, fb1, fb2 in fixups:
601 601 for i in pycompat.xrange(fa1, fa2):
602 602 aidxs[i - a1] = (max(idx, 1) - 1) // 2
603 603 for i in pycompat.xrange(fb1, fb2):
604 604 bidxs[i - b1] = (max(idx, 1) - 1) // 2
605 605
606 606 fm.startitem()
607 607 fm.write(
608 608 b'hunk',
609 609 b' %s\n',
610 610 b'@@ -%d,%d +%d,%d @@' % (a1, a2 - a1, b1, b2 - b1),
611 611 label=b'diff.hunk',
612 612 )
613 613 fm.data(path=self.path, linetype=b'hunk')
614 614
615 615 def writeline(idx, diffchar, line, linetype, linelabel):
616 616 fm.startitem()
617 617 node = b''
618 618 if idx:
619 619 ctx = self.fctxs[idx]
620 620 fm.context(fctx=ctx)
621 621 node = ctx.hex()
622 622 self.ctxaffected.add(ctx.changectx())
623 623 fm.write(b'node', b'%-7.7s ', node, label=b'absorb.node')
624 624 fm.write(
625 625 b'diffchar ' + linetype,
626 626 b'%s%s\n',
627 627 diffchar,
628 628 line,
629 629 label=linelabel,
630 630 )
631 631 fm.data(path=self.path, linetype=linetype)
632 632
633 633 for i in pycompat.xrange(a1, a2):
634 634 writeline(
635 635 aidxs[i - a1],
636 636 b'-',
637 637 trim(alines[i]),
638 638 b'deleted',
639 639 b'diff.deleted',
640 640 )
641 641 for i in pycompat.xrange(b1, b2):
642 642 writeline(
643 643 bidxs[i - b1],
644 644 b'+',
645 645 trim(blines[i]),
646 646 b'inserted',
647 647 b'diff.inserted',
648 648 )
649 649
650 650
651 651 class fixupstate(object):
652 652 """state needed to run absorb
653 653
654 654 internally, it keeps paths and filefixupstates.
655 655
656 656 a typical use is like filefixupstates:
657 657
658 658 1. call diffwith, to calculate fixups
659 659 2. (optionally), present fixups to the user, or edit fixups
660 660 3. call apply, to apply changes to memory
661 661 4. call commit, to commit changes to hg database
662 662 """
663 663
664 664 def __init__(self, stack, ui=None, opts=None):
665 665 """([ctx], ui or None) -> None
666 666
667 667 stack: should be linear, and sorted by topo order - oldest first.
668 668 all commits in stack are considered mutable.
669 669 """
670 670 assert stack
671 671 self.ui = ui or nullui()
672 672 self.opts = opts or {}
673 673 self.stack = stack
674 674 self.repo = stack[-1].repo().unfiltered()
675 675
676 676 # following fields will be filled later
677 677 self.paths = [] # [str]
678 678 self.status = None # ctx.status output
679 679 self.fctxmap = {} # {path: {ctx: fctx}}
680 680 self.fixupmap = {} # {path: filefixupstate}
681 681 self.replacemap = {} # {oldnode: newnode or None}
682 682 self.finalnode = None # head after all fixups
683 683 self.ctxaffected = set() # ctx that will be absorbed into
684 684
685 685 def diffwith(self, targetctx, match=None, fm=None):
686 686 """diff and prepare fixups. update self.fixupmap, self.paths"""
687 687 # only care about modified files
688 688 self.status = self.stack[-1].status(targetctx, match)
689 689 self.paths = []
690 690 # but if --edit-lines is used, the user may want to edit files
691 691 # even if they are not modified
692 692 editopt = self.opts.get(b'edit_lines')
693 693 if not self.status.modified and editopt and match:
694 694 interestingpaths = match.files()
695 695 else:
696 696 interestingpaths = self.status.modified
697 697 # prepare the filefixupstate
698 698 seenfctxs = set()
699 699 # sorting is necessary to eliminate ambiguity for the "double move"
700 700 # case: "hg cp A B; hg cp A C; hg rm A", then only "B" can affect "A".
701 701 for path in sorted(interestingpaths):
702 702 self.ui.debug(b'calculating fixups for %s\n' % path)
703 703 targetfctx = targetctx[path]
704 704 fctxs, ctx2fctx = getfilestack(self.stack, path, seenfctxs)
705 705 # ignore symbolic links or binary, or unchanged files
706 706 if any(
707 707 f.islink() or stringutil.binary(f.data())
708 708 for f in [targetfctx] + fctxs
709 709 if not isinstance(f, emptyfilecontext)
710 710 ):
711 711 continue
712 712 if targetfctx.data() == fctxs[-1].data() and not editopt:
713 713 continue
714 714 seenfctxs.update(fctxs[1:])
715 715 self.fctxmap[path] = ctx2fctx
716 716 fstate = filefixupstate(fctxs, path, ui=self.ui, opts=self.opts)
717 717 if fm is not None:
718 718 fm.startitem()
719 719 fm.plain(b'showing changes for ')
720 720 fm.write(b'path', b'%s\n', path, label=b'absorb.path')
721 721 fm.data(linetype=b'path')
722 722 fstate.diffwith(targetfctx, fm)
723 723 self.fixupmap[path] = fstate
724 724 self.paths.append(path)
725 725 self.ctxaffected.update(fstate.ctxaffected)
726 726
727 727 def apply(self):
728 728 """apply fixups to individual filefixupstates"""
729 729 for path, state in pycompat.iteritems(self.fixupmap):
730 730 if self.ui.debugflag:
731 731 self.ui.write(_(b'applying fixups to %s\n') % path)
732 732 state.apply()
733 733
734 734 @property
735 735 def chunkstats(self):
736 736 """-> {path: chunkstats}. collect chunkstats from filefixupstates"""
737 737 return dict(
738 738 (path, state.chunkstats)
739 739 for path, state in pycompat.iteritems(self.fixupmap)
740 740 )
741 741
742 742 def commit(self):
743 743 """commit changes. update self.finalnode, self.replacemap"""
744 744 with self.repo.transaction(b'absorb') as tr:
745 745 self._commitstack()
746 746 self._movebookmarks(tr)
747 747 if self.repo[b'.'].node() in self.replacemap:
748 748 self._moveworkingdirectoryparent()
749 749 self._cleanupoldcommits()
750 750 return self.finalnode
751 751
752 752 def printchunkstats(self):
753 753 """print things like '1 of 2 chunk(s) applied'"""
754 754 ui = self.ui
755 755 chunkstats = self.chunkstats
756 756 if ui.verbose:
757 757 # chunkstats for each file
758 758 for path, stat in pycompat.iteritems(chunkstats):
759 759 if stat[0]:
760 760 ui.write(
761 761 _(b'%s: %d of %d chunk(s) applied\n')
762 762 % (path, stat[0], stat[1])
763 763 )
764 764 elif not ui.quiet:
765 765 # a summary for all files
766 766 stats = chunkstats.values()
767 767 applied, total = (sum(s[i] for s in stats) for i in (0, 1))
768 768 ui.write(_(b'%d of %d chunk(s) applied\n') % (applied, total))
769 769
770 770 def _commitstack(self):
771 771 """make new commits. update self.finalnode, self.replacemap.
772 772 it is splitted from "commit" to avoid too much indentation.
773 773 """
774 774 # last node (20-char) committed by us
775 775 lastcommitted = None
776 776 # p1 which overrides the parent of the next commit, "None" means use
777 777 # the original parent unchanged
778 778 nextp1 = None
779 779 for ctx in self.stack:
780 780 memworkingcopy = self._getnewfilecontents(ctx)
781 781 if not memworkingcopy and not lastcommitted:
782 782 # nothing changed, nothing commited
783 783 nextp1 = ctx
784 784 continue
785 785 if self._willbecomenoop(memworkingcopy, ctx, nextp1):
786 786 # changeset is no longer necessary
787 787 self.replacemap[ctx.node()] = None
788 788 msg = _(b'became empty and was dropped')
789 789 else:
790 790 # changeset needs re-commit
791 791 nodestr = self._commitsingle(memworkingcopy, ctx, p1=nextp1)
792 792 lastcommitted = self.repo[nodestr]
793 793 nextp1 = lastcommitted
794 794 self.replacemap[ctx.node()] = lastcommitted.node()
795 795 if memworkingcopy:
796 796 msg = _(b'%d file(s) changed, became %s') % (
797 797 len(memworkingcopy),
798 798 self._ctx2str(lastcommitted),
799 799 )
800 800 else:
801 801 msg = _(b'became %s') % self._ctx2str(lastcommitted)
802 802 if self.ui.verbose and msg:
803 803 self.ui.write(_(b'%s: %s\n') % (self._ctx2str(ctx), msg))
804 804 self.finalnode = lastcommitted and lastcommitted.node()
805 805
806 806 def _ctx2str(self, ctx):
807 807 if self.ui.debugflag:
808 808 return b'%d:%s' % (ctx.rev(), ctx.hex())
809 809 else:
810 810 return b'%d:%s' % (ctx.rev(), node.short(ctx.node()))
811 811
812 812 def _getnewfilecontents(self, ctx):
813 813 """(ctx) -> {path: str}
814 814
815 815 fetch file contents from filefixupstates.
816 816 return the working copy overrides - files different from ctx.
817 817 """
818 818 result = {}
819 819 for path in self.paths:
820 820 ctx2fctx = self.fctxmap[path] # {ctx: fctx}
821 821 if ctx not in ctx2fctx:
822 822 continue
823 823 fctx = ctx2fctx[ctx]
824 824 content = fctx.data()
825 825 newcontent = self.fixupmap[path].getfinalcontent(fctx)
826 826 if content != newcontent:
827 827 result[fctx.path()] = newcontent
828 828 return result
829 829
830 830 def _movebookmarks(self, tr):
831 831 repo = self.repo
832 832 needupdate = [
833 833 (name, self.replacemap[hsh])
834 834 for name, hsh in pycompat.iteritems(repo._bookmarks)
835 835 if hsh in self.replacemap
836 836 ]
837 837 changes = []
838 838 for name, hsh in needupdate:
839 839 if hsh:
840 840 changes.append((name, hsh))
841 841 if self.ui.verbose:
842 842 self.ui.write(
843 843 _(b'moving bookmark %s to %s\n') % (name, node.hex(hsh))
844 844 )
845 845 else:
846 846 changes.append((name, None))
847 847 if self.ui.verbose:
848 848 self.ui.write(_(b'deleting bookmark %s\n') % name)
849 849 repo._bookmarks.applychanges(repo, tr, changes)
850 850
851 851 def _moveworkingdirectoryparent(self):
852 852 if not self.finalnode:
853 853 # Find the latest not-{obsoleted,stripped} parent.
854 854 revs = self.repo.revs(b'max(::. - %ln)', self.replacemap.keys())
855 855 ctx = self.repo[revs.first()]
856 856 self.finalnode = ctx.node()
857 857 else:
858 858 ctx = self.repo[self.finalnode]
859 859
860 860 dirstate = self.repo.dirstate
861 861 # dirstate.rebuild invalidates fsmonitorstate, causing "hg status" to
862 862 # be slow. in absorb's case, no need to invalidate fsmonitorstate.
863 863 noop = lambda: 0
864 864 restore = noop
865 if util.safehasattr(dirstate, b'_fsmonitorstate'):
865 if util.safehasattr(dirstate, '_fsmonitorstate'):
866 866 bak = dirstate._fsmonitorstate.invalidate
867 867
868 868 def restore():
869 869 dirstate._fsmonitorstate.invalidate = bak
870 870
871 871 dirstate._fsmonitorstate.invalidate = noop
872 872 try:
873 873 with dirstate.parentchange():
874 874 dirstate.rebuild(ctx.node(), ctx.manifest(), self.paths)
875 875 finally:
876 876 restore()
877 877
878 878 @staticmethod
879 879 def _willbecomenoop(memworkingcopy, ctx, pctx=None):
880 880 """({path: content}, ctx, ctx) -> bool. test if a commit will be noop
881 881
882 882 if it will become an empty commit (does not change anything, after the
883 883 memworkingcopy overrides), return True. otherwise return False.
884 884 """
885 885 if not pctx:
886 886 parents = ctx.parents()
887 887 if len(parents) != 1:
888 888 return False
889 889 pctx = parents[0]
890 890 # ctx changes more files (not a subset of memworkingcopy)
891 891 if not set(ctx.files()).issubset(set(memworkingcopy)):
892 892 return False
893 893 for path, content in pycompat.iteritems(memworkingcopy):
894 894 if path not in pctx or path not in ctx:
895 895 return False
896 896 fctx = ctx[path]
897 897 pfctx = pctx[path]
898 898 if pfctx.flags() != fctx.flags():
899 899 return False
900 900 if pfctx.data() != content:
901 901 return False
902 902 return True
903 903
904 904 def _commitsingle(self, memworkingcopy, ctx, p1=None):
905 905 """(ctx, {path: content}, node) -> node. make a single commit
906 906
907 907 the commit is a clone from ctx, with a (optionally) different p1, and
908 908 different file contents replaced by memworkingcopy.
909 909 """
910 910 parents = p1 and (p1, node.nullid)
911 911 extra = ctx.extra()
912 912 if self._useobsolete and self.ui.configbool(b'absorb', b'add-noise'):
913 913 extra[b'absorb_source'] = ctx.hex()
914 914 mctx = overlaycontext(memworkingcopy, ctx, parents, extra=extra)
915 915 return mctx.commit()
916 916
917 917 @util.propertycache
918 918 def _useobsolete(self):
919 919 """() -> bool"""
920 920 return obsolete.isenabled(self.repo, obsolete.createmarkersopt)
921 921
922 922 def _cleanupoldcommits(self):
923 923 replacements = {
924 924 k: ([v] if v is not None else [])
925 925 for k, v in pycompat.iteritems(self.replacemap)
926 926 }
927 927 if replacements:
928 928 scmutil.cleanupnodes(
929 929 self.repo, replacements, operation=b'absorb', fixphase=True
930 930 )
931 931
932 932
933 933 def _parsechunk(hunk):
934 934 """(crecord.uihunk or patch.recordhunk) -> (path, (a1, a2, [bline]))"""
935 935 if type(hunk) not in (crecord.uihunk, patch.recordhunk):
936 936 return None, None
937 937 path = hunk.header.filename()
938 938 a1 = hunk.fromline + len(hunk.before) - 1
939 939 # remove before and after context
940 940 hunk.before = hunk.after = []
941 941 buf = util.stringio()
942 942 hunk.write(buf)
943 943 patchlines = mdiff.splitnewlines(buf.getvalue())
944 944 # hunk.prettystr() will update hunk.removed
945 945 a2 = a1 + hunk.removed
946 946 blines = [l[1:] for l in patchlines[1:] if not l.startswith(b'-')]
947 947 return path, (a1, a2, blines)
948 948
949 949
950 950 def overlaydiffcontext(ctx, chunks):
951 951 """(ctx, [crecord.uihunk]) -> memctx
952 952
953 953 return a memctx with some [1] patches (chunks) applied to ctx.
954 954 [1]: modifications are handled. renames, mode changes, etc. are ignored.
955 955 """
956 956 # sadly the applying-patch logic is hardly reusable, and messy:
957 957 # 1. the core logic "_applydiff" is too heavy - it writes .rej files, it
958 958 # needs a file stream of a patch and will re-parse it, while we have
959 959 # structured hunk objects at hand.
960 960 # 2. a lot of different implementations about "chunk" (patch.hunk,
961 961 # patch.recordhunk, crecord.uihunk)
962 962 # as we only care about applying changes to modified files, no mode
963 963 # change, no binary diff, and no renames, it's probably okay to
964 964 # re-invent the logic using much simpler code here.
965 965 memworkingcopy = {} # {path: content}
966 966 patchmap = defaultdict(lambda: []) # {path: [(a1, a2, [bline])]}
967 967 for path, info in map(_parsechunk, chunks):
968 968 if not path or not info:
969 969 continue
970 970 patchmap[path].append(info)
971 971 for path, patches in pycompat.iteritems(patchmap):
972 972 if path not in ctx or not patches:
973 973 continue
974 974 patches.sort(reverse=True)
975 975 lines = mdiff.splitnewlines(ctx[path].data())
976 976 for a1, a2, blines in patches:
977 977 lines[a1:a2] = blines
978 978 memworkingcopy[path] = b''.join(lines)
979 979 return overlaycontext(memworkingcopy, ctx)
980 980
981 981
982 982 def absorb(ui, repo, stack=None, targetctx=None, pats=None, opts=None):
983 983 """pick fixup chunks from targetctx, apply them to stack.
984 984
985 985 if targetctx is None, the working copy context will be used.
986 986 if stack is None, the current draft stack will be used.
987 987 return fixupstate.
988 988 """
989 989 if stack is None:
990 990 limit = ui.configint(b'absorb', b'max-stack-size')
991 991 headctx = repo[b'.']
992 992 if len(headctx.parents()) > 1:
993 993 raise error.Abort(_(b'cannot absorb into a merge'))
994 994 stack = getdraftstack(headctx, limit)
995 995 if limit and len(stack) >= limit:
996 996 ui.warn(
997 997 _(
998 998 b'absorb: only the recent %d changesets will '
999 999 b'be analysed\n'
1000 1000 )
1001 1001 % limit
1002 1002 )
1003 1003 if not stack:
1004 1004 raise error.Abort(_(b'no mutable changeset to change'))
1005 1005 if targetctx is None: # default to working copy
1006 1006 targetctx = repo[None]
1007 1007 if pats is None:
1008 1008 pats = ()
1009 1009 if opts is None:
1010 1010 opts = {}
1011 1011 state = fixupstate(stack, ui=ui, opts=opts)
1012 1012 matcher = scmutil.match(targetctx, pats, opts)
1013 1013 if opts.get(b'interactive'):
1014 1014 diff = patch.diff(repo, stack[-1].node(), targetctx.node(), matcher)
1015 1015 origchunks = patch.parsepatch(diff)
1016 1016 chunks = cmdutil.recordfilter(ui, origchunks, matcher)[0]
1017 1017 targetctx = overlaydiffcontext(stack[-1], chunks)
1018 1018 fm = None
1019 1019 if opts.get(b'print_changes') or not opts.get(b'apply_changes'):
1020 1020 fm = ui.formatter(b'absorb', opts)
1021 1021 state.diffwith(targetctx, matcher, fm)
1022 1022 if fm is not None:
1023 1023 fm.startitem()
1024 1024 fm.write(
1025 1025 b"count", b"\n%d changesets affected\n", len(state.ctxaffected)
1026 1026 )
1027 1027 fm.data(linetype=b'summary')
1028 1028 for ctx in reversed(stack):
1029 1029 if ctx not in state.ctxaffected:
1030 1030 continue
1031 1031 fm.startitem()
1032 1032 fm.context(ctx=ctx)
1033 1033 fm.data(linetype=b'changeset')
1034 1034 fm.write(b'node', b'%-7.7s ', ctx.hex(), label=b'absorb.node')
1035 1035 descfirstline = ctx.description().splitlines()[0]
1036 1036 fm.write(
1037 1037 b'descfirstline',
1038 1038 b'%s\n',
1039 1039 descfirstline,
1040 1040 label=b'absorb.description',
1041 1041 )
1042 1042 fm.end()
1043 1043 if not opts.get(b'dry_run'):
1044 1044 if (
1045 1045 not opts.get(b'apply_changes')
1046 1046 and state.ctxaffected
1047 1047 and ui.promptchoice(
1048 1048 b"apply changes (yn)? $$ &Yes $$ &No", default=1
1049 1049 )
1050 1050 ):
1051 1051 raise error.Abort(_(b'absorb cancelled\n'))
1052 1052
1053 1053 state.apply()
1054 1054 if state.commit():
1055 1055 state.printchunkstats()
1056 1056 elif not ui.quiet:
1057 1057 ui.write(_(b'nothing applied\n'))
1058 1058 return state
1059 1059
1060 1060
1061 1061 @command(
1062 1062 b'absorb',
1063 1063 [
1064 1064 (
1065 1065 b'a',
1066 1066 b'apply-changes',
1067 1067 None,
1068 1068 _(b'apply changes without prompting for confirmation'),
1069 1069 ),
1070 1070 (
1071 1071 b'p',
1072 1072 b'print-changes',
1073 1073 None,
1074 1074 _(b'always print which changesets are modified by which changes'),
1075 1075 ),
1076 1076 (
1077 1077 b'i',
1078 1078 b'interactive',
1079 1079 None,
1080 1080 _(b'interactively select which chunks to apply (EXPERIMENTAL)'),
1081 1081 ),
1082 1082 (
1083 1083 b'e',
1084 1084 b'edit-lines',
1085 1085 None,
1086 1086 _(
1087 1087 b'edit what lines belong to which changesets before commit '
1088 1088 b'(EXPERIMENTAL)'
1089 1089 ),
1090 1090 ),
1091 1091 ]
1092 1092 + commands.dryrunopts
1093 1093 + commands.templateopts
1094 1094 + commands.walkopts,
1095 1095 _(b'hg absorb [OPTION] [FILE]...'),
1096 1096 helpcategory=command.CATEGORY_COMMITTING,
1097 1097 helpbasic=True,
1098 1098 )
1099 1099 def absorbcmd(ui, repo, *pats, **opts):
1100 1100 """incorporate corrections into the stack of draft changesets
1101 1101
1102 1102 absorb analyzes each change in your working directory and attempts to
1103 1103 amend the changed lines into the changesets in your stack that first
1104 1104 introduced those lines.
1105 1105
1106 1106 If absorb cannot find an unambiguous changeset to amend for a change,
1107 1107 that change will be left in the working directory, untouched. They can be
1108 1108 observed by :hg:`status` or :hg:`diff` afterwards. In other words,
1109 1109 absorb does not write to the working directory.
1110 1110
1111 1111 Changesets outside the revset `::. and not public() and not merge()` will
1112 1112 not be changed.
1113 1113
1114 1114 Changesets that become empty after applying the changes will be deleted.
1115 1115
1116 1116 By default, absorb will show what it plans to do and prompt for
1117 1117 confirmation. If you are confident that the changes will be absorbed
1118 1118 to the correct place, run :hg:`absorb -a` to apply the changes
1119 1119 immediately.
1120 1120
1121 1121 Returns 0 on success, 1 if all chunks were ignored and nothing amended.
1122 1122 """
1123 1123 opts = pycompat.byteskwargs(opts)
1124 1124
1125 1125 with repo.wlock(), repo.lock():
1126 1126 if not opts[b'dry_run']:
1127 1127 cmdutil.checkunfinished(repo)
1128 1128
1129 1129 state = absorb(ui, repo, pats=pats, opts=opts)
1130 1130 if sum(s[0] for s in state.chunkstats.values()) == 0:
1131 1131 return 1
@@ -1,1215 +1,1215 b''
1 1 # bugzilla.py - bugzilla integration for mercurial
2 2 #
3 3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 4 # Copyright 2011-4 Jim Hague <jim.hague@acm.org>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9 '''hooks for integrating with the Bugzilla bug tracker
10 10
11 11 This hook extension adds comments on bugs in Bugzilla when changesets
12 12 that refer to bugs by Bugzilla ID are seen. The comment is formatted using
13 13 the Mercurial template mechanism.
14 14
15 15 The bug references can optionally include an update for Bugzilla of the
16 16 hours spent working on the bug. Bugs can also be marked fixed.
17 17
18 18 Four basic modes of access to Bugzilla are provided:
19 19
20 20 1. Access via the Bugzilla REST-API. Requires bugzilla 5.0 or later.
21 21
22 22 2. Access via the Bugzilla XMLRPC interface. Requires Bugzilla 3.4 or later.
23 23
24 24 3. Check data via the Bugzilla XMLRPC interface and submit bug change
25 25 via email to Bugzilla email interface. Requires Bugzilla 3.4 or later.
26 26
27 27 4. Writing directly to the Bugzilla database. Only Bugzilla installations
28 28 using MySQL are supported. Requires Python MySQLdb.
29 29
30 30 Writing directly to the database is susceptible to schema changes, and
31 31 relies on a Bugzilla contrib script to send out bug change
32 32 notification emails. This script runs as the user running Mercurial,
33 33 must be run on the host with the Bugzilla install, and requires
34 34 permission to read Bugzilla configuration details and the necessary
35 35 MySQL user and password to have full access rights to the Bugzilla
36 36 database. For these reasons this access mode is now considered
37 37 deprecated, and will not be updated for new Bugzilla versions going
38 38 forward. Only adding comments is supported in this access mode.
39 39
40 40 Access via XMLRPC needs a Bugzilla username and password to be specified
41 41 in the configuration. Comments are added under that username. Since the
42 42 configuration must be readable by all Mercurial users, it is recommended
43 43 that the rights of that user are restricted in Bugzilla to the minimum
44 44 necessary to add comments. Marking bugs fixed requires Bugzilla 4.0 and later.
45 45
46 46 Access via XMLRPC/email uses XMLRPC to query Bugzilla, but sends
47 47 email to the Bugzilla email interface to submit comments to bugs.
48 48 The From: address in the email is set to the email address of the Mercurial
49 49 user, so the comment appears to come from the Mercurial user. In the event
50 50 that the Mercurial user email is not recognized by Bugzilla as a Bugzilla
51 51 user, the email associated with the Bugzilla username used to log into
52 52 Bugzilla is used instead as the source of the comment. Marking bugs fixed
53 53 works on all supported Bugzilla versions.
54 54
55 55 Access via the REST-API needs either a Bugzilla username and password
56 56 or an apikey specified in the configuration. Comments are made under
57 57 the given username or the user associated with the apikey in Bugzilla.
58 58
59 59 Configuration items common to all access modes:
60 60
61 61 bugzilla.version
62 62 The access type to use. Values recognized are:
63 63
64 64 :``restapi``: Bugzilla REST-API, Bugzilla 5.0 and later.
65 65 :``xmlrpc``: Bugzilla XMLRPC interface.
66 66 :``xmlrpc+email``: Bugzilla XMLRPC and email interfaces.
67 67 :``3.0``: MySQL access, Bugzilla 3.0 and later.
68 68 :``2.18``: MySQL access, Bugzilla 2.18 and up to but not
69 69 including 3.0.
70 70 :``2.16``: MySQL access, Bugzilla 2.16 and up to but not
71 71 including 2.18.
72 72
73 73 bugzilla.regexp
74 74 Regular expression to match bug IDs for update in changeset commit message.
75 75 It must contain one "()" named group ``<ids>`` containing the bug
76 76 IDs separated by non-digit characters. It may also contain
77 77 a named group ``<hours>`` with a floating-point number giving the
78 78 hours worked on the bug. If no named groups are present, the first
79 79 "()" group is assumed to contain the bug IDs, and work time is not
80 80 updated. The default expression matches ``Bug 1234``, ``Bug no. 1234``,
81 81 ``Bug number 1234``, ``Bugs 1234,5678``, ``Bug 1234 and 5678`` and
82 82 variations thereof, followed by an hours number prefixed by ``h`` or
83 83 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
84 84
85 85 bugzilla.fixregexp
86 86 Regular expression to match bug IDs for marking fixed in changeset
87 87 commit message. This must contain a "()" named group ``<ids>` containing
88 88 the bug IDs separated by non-digit characters. It may also contain
89 89 a named group ``<hours>`` with a floating-point number giving the
90 90 hours worked on the bug. If no named groups are present, the first
91 91 "()" group is assumed to contain the bug IDs, and work time is not
92 92 updated. The default expression matches ``Fixes 1234``, ``Fixes bug 1234``,
93 93 ``Fixes bugs 1234,5678``, ``Fixes 1234 and 5678`` and
94 94 variations thereof, followed by an hours number prefixed by ``h`` or
95 95 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
96 96
97 97 bugzilla.fixstatus
98 98 The status to set a bug to when marking fixed. Default ``RESOLVED``.
99 99
100 100 bugzilla.fixresolution
101 101 The resolution to set a bug to when marking fixed. Default ``FIXED``.
102 102
103 103 bugzilla.style
104 104 The style file to use when formatting comments.
105 105
106 106 bugzilla.template
107 107 Template to use when formatting comments. Overrides style if
108 108 specified. In addition to the usual Mercurial keywords, the
109 109 extension specifies:
110 110
111 111 :``{bug}``: The Bugzilla bug ID.
112 112 :``{root}``: The full pathname of the Mercurial repository.
113 113 :``{webroot}``: Stripped pathname of the Mercurial repository.
114 114 :``{hgweb}``: Base URL for browsing Mercurial repositories.
115 115
116 116 Default ``changeset {node|short} in repo {root} refers to bug
117 117 {bug}.\\ndetails:\\n\\t{desc|tabindent}``
118 118
119 119 bugzilla.strip
120 120 The number of path separator characters to strip from the front of
121 121 the Mercurial repository path (``{root}`` in templates) to produce
122 122 ``{webroot}``. For example, a repository with ``{root}``
123 123 ``/var/local/my-project`` with a strip of 2 gives a value for
124 124 ``{webroot}`` of ``my-project``. Default 0.
125 125
126 126 web.baseurl
127 127 Base URL for browsing Mercurial repositories. Referenced from
128 128 templates as ``{hgweb}``.
129 129
130 130 Configuration items common to XMLRPC+email and MySQL access modes:
131 131
132 132 bugzilla.usermap
133 133 Path of file containing Mercurial committer email to Bugzilla user email
134 134 mappings. If specified, the file should contain one mapping per
135 135 line::
136 136
137 137 committer = Bugzilla user
138 138
139 139 See also the ``[usermap]`` section.
140 140
141 141 The ``[usermap]`` section is used to specify mappings of Mercurial
142 142 committer email to Bugzilla user email. See also ``bugzilla.usermap``.
143 143 Contains entries of the form ``committer = Bugzilla user``.
144 144
145 145 XMLRPC and REST-API access mode configuration:
146 146
147 147 bugzilla.bzurl
148 148 The base URL for the Bugzilla installation.
149 149 Default ``http://localhost/bugzilla``.
150 150
151 151 bugzilla.user
152 152 The username to use to log into Bugzilla via XMLRPC. Default
153 153 ``bugs``.
154 154
155 155 bugzilla.password
156 156 The password for Bugzilla login.
157 157
158 158 REST-API access mode uses the options listed above as well as:
159 159
160 160 bugzilla.apikey
161 161 An apikey generated on the Bugzilla instance for api access.
162 162 Using an apikey removes the need to store the user and password
163 163 options.
164 164
165 165 XMLRPC+email access mode uses the XMLRPC access mode configuration items,
166 166 and also:
167 167
168 168 bugzilla.bzemail
169 169 The Bugzilla email address.
170 170
171 171 In addition, the Mercurial email settings must be configured. See the
172 172 documentation in hgrc(5), sections ``[email]`` and ``[smtp]``.
173 173
174 174 MySQL access mode configuration:
175 175
176 176 bugzilla.host
177 177 Hostname of the MySQL server holding the Bugzilla database.
178 178 Default ``localhost``.
179 179
180 180 bugzilla.db
181 181 Name of the Bugzilla database in MySQL. Default ``bugs``.
182 182
183 183 bugzilla.user
184 184 Username to use to access MySQL server. Default ``bugs``.
185 185
186 186 bugzilla.password
187 187 Password to use to access MySQL server.
188 188
189 189 bugzilla.timeout
190 190 Database connection timeout (seconds). Default 5.
191 191
192 192 bugzilla.bzuser
193 193 Fallback Bugzilla user name to record comments with, if changeset
194 194 committer cannot be found as a Bugzilla user.
195 195
196 196 bugzilla.bzdir
197 197 Bugzilla install directory. Used by default notify. Default
198 198 ``/var/www/html/bugzilla``.
199 199
200 200 bugzilla.notify
201 201 The command to run to get Bugzilla to send bug change notification
202 202 emails. Substitutes from a map with 3 keys, ``bzdir``, ``id`` (bug
203 203 id) and ``user`` (committer bugzilla email). Default depends on
204 204 version; from 2.18 it is "cd %(bzdir)s && perl -T
205 205 contrib/sendbugmail.pl %(id)s %(user)s".
206 206
207 207 Activating the extension::
208 208
209 209 [extensions]
210 210 bugzilla =
211 211
212 212 [hooks]
213 213 # run bugzilla hook on every change pulled or pushed in here
214 214 incoming.bugzilla = python:hgext.bugzilla.hook
215 215
216 216 Example configurations:
217 217
218 218 XMLRPC example configuration. This uses the Bugzilla at
219 219 ``http://my-project.org/bugzilla``, logging in as user
220 220 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
221 221 collection of Mercurial repositories in ``/var/local/hg/repos/``,
222 222 with a web interface at ``http://my-project.org/hg``. ::
223 223
224 224 [bugzilla]
225 225 bzurl=http://my-project.org/bugzilla
226 226 user=bugmail@my-project.org
227 227 password=plugh
228 228 version=xmlrpc
229 229 template=Changeset {node|short} in {root|basename}.
230 230 {hgweb}/{webroot}/rev/{node|short}\\n
231 231 {desc}\\n
232 232 strip=5
233 233
234 234 [web]
235 235 baseurl=http://my-project.org/hg
236 236
237 237 XMLRPC+email example configuration. This uses the Bugzilla at
238 238 ``http://my-project.org/bugzilla``, logging in as user
239 239 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
240 240 collection of Mercurial repositories in ``/var/local/hg/repos/``,
241 241 with a web interface at ``http://my-project.org/hg``. Bug comments
242 242 are sent to the Bugzilla email address
243 243 ``bugzilla@my-project.org``. ::
244 244
245 245 [bugzilla]
246 246 bzurl=http://my-project.org/bugzilla
247 247 user=bugmail@my-project.org
248 248 password=plugh
249 249 version=xmlrpc+email
250 250 bzemail=bugzilla@my-project.org
251 251 template=Changeset {node|short} in {root|basename}.
252 252 {hgweb}/{webroot}/rev/{node|short}\\n
253 253 {desc}\\n
254 254 strip=5
255 255
256 256 [web]
257 257 baseurl=http://my-project.org/hg
258 258
259 259 [usermap]
260 260 user@emaildomain.com=user.name@bugzilladomain.com
261 261
262 262 MySQL example configuration. This has a local Bugzilla 3.2 installation
263 263 in ``/opt/bugzilla-3.2``. The MySQL database is on ``localhost``,
264 264 the Bugzilla database name is ``bugs`` and MySQL is
265 265 accessed with MySQL username ``bugs`` password ``XYZZY``. It is used
266 266 with a collection of Mercurial repositories in ``/var/local/hg/repos/``,
267 267 with a web interface at ``http://my-project.org/hg``. ::
268 268
269 269 [bugzilla]
270 270 host=localhost
271 271 password=XYZZY
272 272 version=3.0
273 273 bzuser=unknown@domain.com
274 274 bzdir=/opt/bugzilla-3.2
275 275 template=Changeset {node|short} in {root|basename}.
276 276 {hgweb}/{webroot}/rev/{node|short}\\n
277 277 {desc}\\n
278 278 strip=5
279 279
280 280 [web]
281 281 baseurl=http://my-project.org/hg
282 282
283 283 [usermap]
284 284 user@emaildomain.com=user.name@bugzilladomain.com
285 285
286 286 All the above add a comment to the Bugzilla bug record of the form::
287 287
288 288 Changeset 3b16791d6642 in repository-name.
289 289 http://my-project.org/hg/repository-name/rev/3b16791d6642
290 290
291 291 Changeset commit comment. Bug 1234.
292 292 '''
293 293
294 294 from __future__ import absolute_import
295 295
296 296 import json
297 297 import re
298 298 import time
299 299
300 300 from mercurial.i18n import _
301 301 from mercurial.node import short
302 302 from mercurial import (
303 303 error,
304 304 logcmdutil,
305 305 mail,
306 306 pycompat,
307 307 registrar,
308 308 url,
309 309 util,
310 310 )
311 311 from mercurial.utils import (
312 312 procutil,
313 313 stringutil,
314 314 )
315 315
316 316 xmlrpclib = util.xmlrpclib
317 317
318 318 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
319 319 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
320 320 # be specifying the version(s) of Mercurial they are tested with, or
321 321 # leave the attribute unspecified.
322 322 testedwith = b'ships-with-hg-core'
323 323
324 324 configtable = {}
325 325 configitem = registrar.configitem(configtable)
326 326
327 327 configitem(
328 328 b'bugzilla', b'apikey', default=b'',
329 329 )
330 330 configitem(
331 331 b'bugzilla', b'bzdir', default=b'/var/www/html/bugzilla',
332 332 )
333 333 configitem(
334 334 b'bugzilla', b'bzemail', default=None,
335 335 )
336 336 configitem(
337 337 b'bugzilla', b'bzurl', default=b'http://localhost/bugzilla/',
338 338 )
339 339 configitem(
340 340 b'bugzilla', b'bzuser', default=None,
341 341 )
342 342 configitem(
343 343 b'bugzilla', b'db', default=b'bugs',
344 344 )
345 345 configitem(
346 346 b'bugzilla',
347 347 b'fixregexp',
348 348 default=(
349 349 br'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
350 350 br'(?:nos?\.?|num(?:ber)?s?)?\s*'
351 351 br'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
352 352 br'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?'
353 353 ),
354 354 )
355 355 configitem(
356 356 b'bugzilla', b'fixresolution', default=b'FIXED',
357 357 )
358 358 configitem(
359 359 b'bugzilla', b'fixstatus', default=b'RESOLVED',
360 360 )
361 361 configitem(
362 362 b'bugzilla', b'host', default=b'localhost',
363 363 )
364 364 configitem(
365 365 b'bugzilla', b'notify', default=configitem.dynamicdefault,
366 366 )
367 367 configitem(
368 368 b'bugzilla', b'password', default=None,
369 369 )
370 370 configitem(
371 371 b'bugzilla',
372 372 b'regexp',
373 373 default=(
374 374 br'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
375 375 br'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
376 376 br'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?'
377 377 ),
378 378 )
379 379 configitem(
380 380 b'bugzilla', b'strip', default=0,
381 381 )
382 382 configitem(
383 383 b'bugzilla', b'style', default=None,
384 384 )
385 385 configitem(
386 386 b'bugzilla', b'template', default=None,
387 387 )
388 388 configitem(
389 389 b'bugzilla', b'timeout', default=5,
390 390 )
391 391 configitem(
392 392 b'bugzilla', b'user', default=b'bugs',
393 393 )
394 394 configitem(
395 395 b'bugzilla', b'usermap', default=None,
396 396 )
397 397 configitem(
398 398 b'bugzilla', b'version', default=None,
399 399 )
400 400
401 401
402 402 class bzaccess(object):
403 403 '''Base class for access to Bugzilla.'''
404 404
405 405 def __init__(self, ui):
406 406 self.ui = ui
407 407 usermap = self.ui.config(b'bugzilla', b'usermap')
408 408 if usermap:
409 409 self.ui.readconfig(usermap, sections=[b'usermap'])
410 410
411 411 def map_committer(self, user):
412 412 '''map name of committer to Bugzilla user name.'''
413 413 for committer, bzuser in self.ui.configitems(b'usermap'):
414 414 if committer.lower() == user.lower():
415 415 return bzuser
416 416 return user
417 417
418 418 # Methods to be implemented by access classes.
419 419 #
420 420 # 'bugs' is a dict keyed on bug id, where values are a dict holding
421 421 # updates to bug state. Recognized dict keys are:
422 422 #
423 423 # 'hours': Value, float containing work hours to be updated.
424 424 # 'fix': If key present, bug is to be marked fixed. Value ignored.
425 425
426 426 def filter_real_bug_ids(self, bugs):
427 427 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
428 428
429 429 def filter_cset_known_bug_ids(self, node, bugs):
430 430 '''remove bug IDs where node occurs in comment text from bugs.'''
431 431
432 432 def updatebug(self, bugid, newstate, text, committer):
433 433 '''update the specified bug. Add comment text and set new states.
434 434
435 435 If possible add the comment as being from the committer of
436 436 the changeset. Otherwise use the default Bugzilla user.
437 437 '''
438 438
439 439 def notify(self, bugs, committer):
440 440 '''Force sending of Bugzilla notification emails.
441 441
442 442 Only required if the access method does not trigger notification
443 443 emails automatically.
444 444 '''
445 445
446 446
447 447 # Bugzilla via direct access to MySQL database.
448 448 class bzmysql(bzaccess):
449 449 '''Support for direct MySQL access to Bugzilla.
450 450
451 451 The earliest Bugzilla version this is tested with is version 2.16.
452 452
453 453 If your Bugzilla is version 3.4 or above, you are strongly
454 454 recommended to use the XMLRPC access method instead.
455 455 '''
456 456
457 457 @staticmethod
458 458 def sql_buglist(ids):
459 459 '''return SQL-friendly list of bug ids'''
460 460 return b'(' + b','.join(map(str, ids)) + b')'
461 461
462 462 _MySQLdb = None
463 463
464 464 def __init__(self, ui):
465 465 try:
466 466 import MySQLdb as mysql
467 467
468 468 bzmysql._MySQLdb = mysql
469 469 except ImportError as err:
470 470 raise error.Abort(
471 471 _(b'python mysql support not available: %s') % err
472 472 )
473 473
474 474 bzaccess.__init__(self, ui)
475 475
476 476 host = self.ui.config(b'bugzilla', b'host')
477 477 user = self.ui.config(b'bugzilla', b'user')
478 478 passwd = self.ui.config(b'bugzilla', b'password')
479 479 db = self.ui.config(b'bugzilla', b'db')
480 480 timeout = int(self.ui.config(b'bugzilla', b'timeout'))
481 481 self.ui.note(
482 482 _(b'connecting to %s:%s as %s, password %s\n')
483 483 % (host, db, user, b'*' * len(passwd))
484 484 )
485 485 self.conn = bzmysql._MySQLdb.connect(
486 486 host=host, user=user, passwd=passwd, db=db, connect_timeout=timeout
487 487 )
488 488 self.cursor = self.conn.cursor()
489 489 self.longdesc_id = self.get_longdesc_id()
490 490 self.user_ids = {}
491 491 self.default_notify = b"cd %(bzdir)s && ./processmail %(id)s %(user)s"
492 492
493 493 def run(self, *args, **kwargs):
494 494 '''run a query.'''
495 495 self.ui.note(_(b'query: %s %s\n') % (args, kwargs))
496 496 try:
497 497 self.cursor.execute(*args, **kwargs)
498 498 except bzmysql._MySQLdb.MySQLError:
499 499 self.ui.note(_(b'failed query: %s %s\n') % (args, kwargs))
500 500 raise
501 501
502 502 def get_longdesc_id(self):
503 503 '''get identity of longdesc field'''
504 504 self.run(b'select fieldid from fielddefs where name = "longdesc"')
505 505 ids = self.cursor.fetchall()
506 506 if len(ids) != 1:
507 507 raise error.Abort(_(b'unknown database schema'))
508 508 return ids[0][0]
509 509
510 510 def filter_real_bug_ids(self, bugs):
511 511 '''filter not-existing bugs from set.'''
512 512 self.run(
513 513 b'select bug_id from bugs where bug_id in %s'
514 514 % bzmysql.sql_buglist(bugs.keys())
515 515 )
516 516 existing = [id for (id,) in self.cursor.fetchall()]
517 517 for id in bugs.keys():
518 518 if id not in existing:
519 519 self.ui.status(_(b'bug %d does not exist\n') % id)
520 520 del bugs[id]
521 521
522 522 def filter_cset_known_bug_ids(self, node, bugs):
523 523 '''filter bug ids that already refer to this changeset from set.'''
524 524 self.run(
525 525 '''select bug_id from longdescs where
526 526 bug_id in %s and thetext like "%%%s%%"'''
527 527 % (bzmysql.sql_buglist(bugs.keys()), short(node))
528 528 )
529 529 for (id,) in self.cursor.fetchall():
530 530 self.ui.status(
531 531 _(b'bug %d already knows about changeset %s\n')
532 532 % (id, short(node))
533 533 )
534 534 del bugs[id]
535 535
536 536 def notify(self, bugs, committer):
537 537 '''tell bugzilla to send mail.'''
538 538 self.ui.status(_(b'telling bugzilla to send mail:\n'))
539 539 (user, userid) = self.get_bugzilla_user(committer)
540 540 for id in bugs.keys():
541 541 self.ui.status(_(b' bug %s\n') % id)
542 542 cmdfmt = self.ui.config(b'bugzilla', b'notify', self.default_notify)
543 543 bzdir = self.ui.config(b'bugzilla', b'bzdir')
544 544 try:
545 545 # Backwards-compatible with old notify string, which
546 546 # took one string. This will throw with a new format
547 547 # string.
548 548 cmd = cmdfmt % id
549 549 except TypeError:
550 550 cmd = cmdfmt % {b'bzdir': bzdir, b'id': id, b'user': user}
551 551 self.ui.note(_(b'running notify command %s\n') % cmd)
552 552 fp = procutil.popen(b'(%s) 2>&1' % cmd, b'rb')
553 553 out = util.fromnativeeol(fp.read())
554 554 ret = fp.close()
555 555 if ret:
556 556 self.ui.warn(out)
557 557 raise error.Abort(
558 558 _(b'bugzilla notify command %s') % procutil.explainexit(ret)
559 559 )
560 560 self.ui.status(_(b'done\n'))
561 561
562 562 def get_user_id(self, user):
563 563 '''look up numeric bugzilla user id.'''
564 564 try:
565 565 return self.user_ids[user]
566 566 except KeyError:
567 567 try:
568 568 userid = int(user)
569 569 except ValueError:
570 570 self.ui.note(_(b'looking up user %s\n') % user)
571 571 self.run(
572 572 '''select userid from profiles
573 573 where login_name like %s''',
574 574 user,
575 575 )
576 576 all = self.cursor.fetchall()
577 577 if len(all) != 1:
578 578 raise KeyError(user)
579 579 userid = int(all[0][0])
580 580 self.user_ids[user] = userid
581 581 return userid
582 582
583 583 def get_bugzilla_user(self, committer):
584 584 '''See if committer is a registered bugzilla user. Return
585 585 bugzilla username and userid if so. If not, return default
586 586 bugzilla username and userid.'''
587 587 user = self.map_committer(committer)
588 588 try:
589 589 userid = self.get_user_id(user)
590 590 except KeyError:
591 591 try:
592 592 defaultuser = self.ui.config(b'bugzilla', b'bzuser')
593 593 if not defaultuser:
594 594 raise error.Abort(
595 595 _(b'cannot find bugzilla user id for %s') % user
596 596 )
597 597 userid = self.get_user_id(defaultuser)
598 598 user = defaultuser
599 599 except KeyError:
600 600 raise error.Abort(
601 601 _(b'cannot find bugzilla user id for %s or %s')
602 602 % (user, defaultuser)
603 603 )
604 604 return (user, userid)
605 605
606 606 def updatebug(self, bugid, newstate, text, committer):
607 607 '''update bug state with comment text.
608 608
609 609 Try adding comment as committer of changeset, otherwise as
610 610 default bugzilla user.'''
611 611 if len(newstate) > 0:
612 612 self.ui.warn(_(b"Bugzilla/MySQL cannot update bug state\n"))
613 613
614 614 (user, userid) = self.get_bugzilla_user(committer)
615 615 now = time.strftime(r'%Y-%m-%d %H:%M:%S')
616 616 self.run(
617 617 '''insert into longdescs
618 618 (bug_id, who, bug_when, thetext)
619 619 values (%s, %s, %s, %s)''',
620 620 (bugid, userid, now, text),
621 621 )
622 622 self.run(
623 623 '''insert into bugs_activity (bug_id, who, bug_when, fieldid)
624 624 values (%s, %s, %s, %s)''',
625 625 (bugid, userid, now, self.longdesc_id),
626 626 )
627 627 self.conn.commit()
628 628
629 629
630 630 class bzmysql_2_18(bzmysql):
631 631 '''support for bugzilla 2.18 series.'''
632 632
633 633 def __init__(self, ui):
634 634 bzmysql.__init__(self, ui)
635 635 self.default_notify = (
636 636 b"cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
637 637 )
638 638
639 639
640 640 class bzmysql_3_0(bzmysql_2_18):
641 641 '''support for bugzilla 3.0 series.'''
642 642
643 643 def __init__(self, ui):
644 644 bzmysql_2_18.__init__(self, ui)
645 645
646 646 def get_longdesc_id(self):
647 647 '''get identity of longdesc field'''
648 648 self.run(b'select id from fielddefs where name = "longdesc"')
649 649 ids = self.cursor.fetchall()
650 650 if len(ids) != 1:
651 651 raise error.Abort(_(b'unknown database schema'))
652 652 return ids[0][0]
653 653
654 654
655 655 # Bugzilla via XMLRPC interface.
656 656
657 657
658 658 class cookietransportrequest(object):
659 659 """A Transport request method that retains cookies over its lifetime.
660 660
661 661 The regular xmlrpclib transports ignore cookies. Which causes
662 662 a bit of a problem when you need a cookie-based login, as with
663 663 the Bugzilla XMLRPC interface prior to 4.4.3.
664 664
665 665 So this is a helper for defining a Transport which looks for
666 666 cookies being set in responses and saves them to add to all future
667 667 requests.
668 668 """
669 669
670 670 # Inspiration drawn from
671 671 # http://blog.godson.in/2010/09/how-to-make-python-xmlrpclib-client.html
672 672 # http://www.itkovian.net/base/transport-class-for-pythons-xml-rpc-lib/
673 673
674 674 cookies = []
675 675
676 676 def send_cookies(self, connection):
677 677 if self.cookies:
678 678 for cookie in self.cookies:
679 679 connection.putheader(b"Cookie", cookie)
680 680
681 681 def request(self, host, handler, request_body, verbose=0):
682 682 self.verbose = verbose
683 683 self.accept_gzip_encoding = False
684 684
685 685 # issue XML-RPC request
686 686 h = self.make_connection(host)
687 687 if verbose:
688 688 h.set_debuglevel(1)
689 689
690 690 self.send_request(h, handler, request_body)
691 691 self.send_host(h, host)
692 692 self.send_cookies(h)
693 693 self.send_user_agent(h)
694 694 self.send_content(h, request_body)
695 695
696 696 # Deal with differences between Python 2.6 and 2.7.
697 697 # In the former h is a HTTP(S). In the latter it's a
698 698 # HTTP(S)Connection. Luckily, the 2.6 implementation of
699 699 # HTTP(S) has an underlying HTTP(S)Connection, so extract
700 700 # that and use it.
701 701 try:
702 702 response = h.getresponse()
703 703 except AttributeError:
704 704 response = h._conn.getresponse()
705 705
706 706 # Add any cookie definitions to our list.
707 707 for header in response.msg.getallmatchingheaders(b"Set-Cookie"):
708 708 val = header.split(b": ", 1)[1]
709 709 cookie = val.split(b";", 1)[0]
710 710 self.cookies.append(cookie)
711 711
712 712 if response.status != 200:
713 713 raise xmlrpclib.ProtocolError(
714 714 host + handler,
715 715 response.status,
716 716 response.reason,
717 717 response.msg.headers,
718 718 )
719 719
720 720 payload = response.read()
721 721 parser, unmarshaller = self.getparser()
722 722 parser.feed(payload)
723 723 parser.close()
724 724
725 725 return unmarshaller.close()
726 726
727 727
728 728 # The explicit calls to the underlying xmlrpclib __init__() methods are
729 729 # necessary. The xmlrpclib.Transport classes are old-style classes, and
730 730 # it turns out their __init__() doesn't get called when doing multiple
731 731 # inheritance with a new-style class.
732 732 class cookietransport(cookietransportrequest, xmlrpclib.Transport):
733 733 def __init__(self, use_datetime=0):
734 if util.safehasattr(xmlrpclib.Transport, b"__init__"):
734 if util.safehasattr(xmlrpclib.Transport, "__init__"):
735 735 xmlrpclib.Transport.__init__(self, use_datetime)
736 736
737 737
738 738 class cookiesafetransport(cookietransportrequest, xmlrpclib.SafeTransport):
739 739 def __init__(self, use_datetime=0):
740 if util.safehasattr(xmlrpclib.Transport, b"__init__"):
740 if util.safehasattr(xmlrpclib.Transport, "__init__"):
741 741 xmlrpclib.SafeTransport.__init__(self, use_datetime)
742 742
743 743
744 744 class bzxmlrpc(bzaccess):
745 745 """Support for access to Bugzilla via the Bugzilla XMLRPC API.
746 746
747 747 Requires a minimum Bugzilla version 3.4.
748 748 """
749 749
750 750 def __init__(self, ui):
751 751 bzaccess.__init__(self, ui)
752 752
753 753 bzweb = self.ui.config(b'bugzilla', b'bzurl')
754 754 bzweb = bzweb.rstrip(b"/") + b"/xmlrpc.cgi"
755 755
756 756 user = self.ui.config(b'bugzilla', b'user')
757 757 passwd = self.ui.config(b'bugzilla', b'password')
758 758
759 759 self.fixstatus = self.ui.config(b'bugzilla', b'fixstatus')
760 760 self.fixresolution = self.ui.config(b'bugzilla', b'fixresolution')
761 761
762 762 self.bzproxy = xmlrpclib.ServerProxy(bzweb, self.transport(bzweb))
763 763 ver = self.bzproxy.Bugzilla.version()[b'version'].split(b'.')
764 764 self.bzvermajor = int(ver[0])
765 765 self.bzverminor = int(ver[1])
766 766 login = self.bzproxy.User.login(
767 767 {b'login': user, b'password': passwd, b'restrict_login': True}
768 768 )
769 769 self.bztoken = login.get(b'token', b'')
770 770
771 771 def transport(self, uri):
772 772 if util.urlreq.urlparse(uri, b"http")[0] == b"https":
773 773 return cookiesafetransport()
774 774 else:
775 775 return cookietransport()
776 776
777 777 def get_bug_comments(self, id):
778 778 """Return a string with all comment text for a bug."""
779 779 c = self.bzproxy.Bug.comments(
780 780 {b'ids': [id], b'include_fields': [b'text'], b'token': self.bztoken}
781 781 )
782 782 return b''.join(
783 783 [t[b'text'] for t in c[b'bugs'][b'%d' % id][b'comments']]
784 784 )
785 785
786 786 def filter_real_bug_ids(self, bugs):
787 787 probe = self.bzproxy.Bug.get(
788 788 {
789 789 b'ids': sorted(bugs.keys()),
790 790 b'include_fields': [],
791 791 b'permissive': True,
792 792 b'token': self.bztoken,
793 793 }
794 794 )
795 795 for badbug in probe[b'faults']:
796 796 id = badbug[b'id']
797 797 self.ui.status(_(b'bug %d does not exist\n') % id)
798 798 del bugs[id]
799 799
800 800 def filter_cset_known_bug_ids(self, node, bugs):
801 801 for id in sorted(bugs.keys()):
802 802 if self.get_bug_comments(id).find(short(node)) != -1:
803 803 self.ui.status(
804 804 _(b'bug %d already knows about changeset %s\n')
805 805 % (id, short(node))
806 806 )
807 807 del bugs[id]
808 808
809 809 def updatebug(self, bugid, newstate, text, committer):
810 810 args = {}
811 811 if b'hours' in newstate:
812 812 args[b'work_time'] = newstate[b'hours']
813 813
814 814 if self.bzvermajor >= 4:
815 815 args[b'ids'] = [bugid]
816 816 args[b'comment'] = {b'body': text}
817 817 if b'fix' in newstate:
818 818 args[b'status'] = self.fixstatus
819 819 args[b'resolution'] = self.fixresolution
820 820 args[b'token'] = self.bztoken
821 821 self.bzproxy.Bug.update(args)
822 822 else:
823 823 if b'fix' in newstate:
824 824 self.ui.warn(
825 825 _(
826 826 b"Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
827 827 b"to mark bugs fixed\n"
828 828 )
829 829 )
830 830 args[b'id'] = bugid
831 831 args[b'comment'] = text
832 832 self.bzproxy.Bug.add_comment(args)
833 833
834 834
835 835 class bzxmlrpcemail(bzxmlrpc):
836 836 """Read data from Bugzilla via XMLRPC, send updates via email.
837 837
838 838 Advantages of sending updates via email:
839 839 1. Comments can be added as any user, not just logged in user.
840 840 2. Bug statuses or other fields not accessible via XMLRPC can
841 841 potentially be updated.
842 842
843 843 There is no XMLRPC function to change bug status before Bugzilla
844 844 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
845 845 But bugs can be marked fixed via email from 3.4 onwards.
846 846 """
847 847
848 848 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
849 849 # in-email fields are specified as '@<fieldname> = <value>'. In
850 850 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
851 851 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
852 852 # compatibility, but rather than rely on this use the new format for
853 853 # 4.0 onwards.
854 854
855 855 def __init__(self, ui):
856 856 bzxmlrpc.__init__(self, ui)
857 857
858 858 self.bzemail = self.ui.config(b'bugzilla', b'bzemail')
859 859 if not self.bzemail:
860 860 raise error.Abort(_(b"configuration 'bzemail' missing"))
861 861 mail.validateconfig(self.ui)
862 862
863 863 def makecommandline(self, fieldname, value):
864 864 if self.bzvermajor >= 4:
865 865 return b"@%s %s" % (fieldname, pycompat.bytestr(value))
866 866 else:
867 867 if fieldname == b"id":
868 868 fieldname = b"bug_id"
869 869 return b"@%s = %s" % (fieldname, pycompat.bytestr(value))
870 870
871 871 def send_bug_modify_email(self, bugid, commands, comment, committer):
872 872 '''send modification message to Bugzilla bug via email.
873 873
874 874 The message format is documented in the Bugzilla email_in.pl
875 875 specification. commands is a list of command lines, comment is the
876 876 comment text.
877 877
878 878 To stop users from crafting commit comments with
879 879 Bugzilla commands, specify the bug ID via the message body, rather
880 880 than the subject line, and leave a blank line after it.
881 881 '''
882 882 user = self.map_committer(committer)
883 883 matches = self.bzproxy.User.get(
884 884 {b'match': [user], b'token': self.bztoken}
885 885 )
886 886 if not matches[b'users']:
887 887 user = self.ui.config(b'bugzilla', b'user')
888 888 matches = self.bzproxy.User.get(
889 889 {b'match': [user], b'token': self.bztoken}
890 890 )
891 891 if not matches[b'users']:
892 892 raise error.Abort(
893 893 _(b"default bugzilla user %s email not found") % user
894 894 )
895 895 user = matches[b'users'][0][b'email']
896 896 commands.append(self.makecommandline(b"id", bugid))
897 897
898 898 text = b"\n".join(commands) + b"\n\n" + comment
899 899
900 900 _charsets = mail._charsets(self.ui)
901 901 user = mail.addressencode(self.ui, user, _charsets)
902 902 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
903 903 msg = mail.mimeencode(self.ui, text, _charsets)
904 904 msg[b'From'] = user
905 905 msg[b'To'] = bzemail
906 906 msg[b'Subject'] = mail.headencode(
907 907 self.ui, b"Bug modification", _charsets
908 908 )
909 909 sendmail = mail.connect(self.ui)
910 910 sendmail(user, bzemail, msg.as_string())
911 911
912 912 def updatebug(self, bugid, newstate, text, committer):
913 913 cmds = []
914 914 if b'hours' in newstate:
915 915 cmds.append(self.makecommandline(b"work_time", newstate[b'hours']))
916 916 if b'fix' in newstate:
917 917 cmds.append(self.makecommandline(b"bug_status", self.fixstatus))
918 918 cmds.append(self.makecommandline(b"resolution", self.fixresolution))
919 919 self.send_bug_modify_email(bugid, cmds, text, committer)
920 920
921 921
922 922 class NotFound(LookupError):
923 923 pass
924 924
925 925
926 926 class bzrestapi(bzaccess):
927 927 """Read and write bugzilla data using the REST API available since
928 928 Bugzilla 5.0.
929 929 """
930 930
931 931 def __init__(self, ui):
932 932 bzaccess.__init__(self, ui)
933 933 bz = self.ui.config(b'bugzilla', b'bzurl')
934 934 self.bzroot = b'/'.join([bz, b'rest'])
935 935 self.apikey = self.ui.config(b'bugzilla', b'apikey')
936 936 self.user = self.ui.config(b'bugzilla', b'user')
937 937 self.passwd = self.ui.config(b'bugzilla', b'password')
938 938 self.fixstatus = self.ui.config(b'bugzilla', b'fixstatus')
939 939 self.fixresolution = self.ui.config(b'bugzilla', b'fixresolution')
940 940
941 941 def apiurl(self, targets, include_fields=None):
942 942 url = b'/'.join([self.bzroot] + [pycompat.bytestr(t) for t in targets])
943 943 qv = {}
944 944 if self.apikey:
945 945 qv[b'api_key'] = self.apikey
946 946 elif self.user and self.passwd:
947 947 qv[b'login'] = self.user
948 948 qv[b'password'] = self.passwd
949 949 if include_fields:
950 950 qv[b'include_fields'] = include_fields
951 951 if qv:
952 952 url = b'%s?%s' % (url, util.urlreq.urlencode(qv))
953 953 return url
954 954
955 955 def _fetch(self, burl):
956 956 try:
957 957 resp = url.open(self.ui, burl)
958 958 return json.loads(resp.read())
959 959 except util.urlerr.httperror as inst:
960 960 if inst.code == 401:
961 961 raise error.Abort(_(b'authorization failed'))
962 962 if inst.code == 404:
963 963 raise NotFound()
964 964 else:
965 965 raise
966 966
967 967 def _submit(self, burl, data, method=b'POST'):
968 968 data = json.dumps(data)
969 969 if method == b'PUT':
970 970
971 971 class putrequest(util.urlreq.request):
972 972 def get_method(self):
973 973 return b'PUT'
974 974
975 975 request_type = putrequest
976 976 else:
977 977 request_type = util.urlreq.request
978 978 req = request_type(burl, data, {b'Content-Type': b'application/json'})
979 979 try:
980 980 resp = url.opener(self.ui).open(req)
981 981 return json.loads(resp.read())
982 982 except util.urlerr.httperror as inst:
983 983 if inst.code == 401:
984 984 raise error.Abort(_(b'authorization failed'))
985 985 if inst.code == 404:
986 986 raise NotFound()
987 987 else:
988 988 raise
989 989
990 990 def filter_real_bug_ids(self, bugs):
991 991 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
992 992 badbugs = set()
993 993 for bugid in bugs:
994 994 burl = self.apiurl((b'bug', bugid), include_fields=b'status')
995 995 try:
996 996 self._fetch(burl)
997 997 except NotFound:
998 998 badbugs.add(bugid)
999 999 for bugid in badbugs:
1000 1000 del bugs[bugid]
1001 1001
1002 1002 def filter_cset_known_bug_ids(self, node, bugs):
1003 1003 '''remove bug IDs where node occurs in comment text from bugs.'''
1004 1004 sn = short(node)
1005 1005 for bugid in bugs.keys():
1006 1006 burl = self.apiurl(
1007 1007 (b'bug', bugid, b'comment'), include_fields=b'text'
1008 1008 )
1009 1009 result = self._fetch(burl)
1010 1010 comments = result[b'bugs'][pycompat.bytestr(bugid)][b'comments']
1011 1011 if any(sn in c[b'text'] for c in comments):
1012 1012 self.ui.status(
1013 1013 _(b'bug %d already knows about changeset %s\n')
1014 1014 % (bugid, sn)
1015 1015 )
1016 1016 del bugs[bugid]
1017 1017
1018 1018 def updatebug(self, bugid, newstate, text, committer):
1019 1019 '''update the specified bug. Add comment text and set new states.
1020 1020
1021 1021 If possible add the comment as being from the committer of
1022 1022 the changeset. Otherwise use the default Bugzilla user.
1023 1023 '''
1024 1024 bugmod = {}
1025 1025 if b'hours' in newstate:
1026 1026 bugmod[b'work_time'] = newstate[b'hours']
1027 1027 if b'fix' in newstate:
1028 1028 bugmod[b'status'] = self.fixstatus
1029 1029 bugmod[b'resolution'] = self.fixresolution
1030 1030 if bugmod:
1031 1031 # if we have to change the bugs state do it here
1032 1032 bugmod[b'comment'] = {
1033 1033 b'comment': text,
1034 1034 b'is_private': False,
1035 1035 b'is_markdown': False,
1036 1036 }
1037 1037 burl = self.apiurl((b'bug', bugid))
1038 1038 self._submit(burl, bugmod, method=b'PUT')
1039 1039 self.ui.debug(b'updated bug %s\n' % bugid)
1040 1040 else:
1041 1041 burl = self.apiurl((b'bug', bugid, b'comment'))
1042 1042 self._submit(
1043 1043 burl,
1044 1044 {
1045 1045 b'comment': text,
1046 1046 b'is_private': False,
1047 1047 b'is_markdown': False,
1048 1048 },
1049 1049 )
1050 1050 self.ui.debug(b'added comment to bug %s\n' % bugid)
1051 1051
1052 1052 def notify(self, bugs, committer):
1053 1053 '''Force sending of Bugzilla notification emails.
1054 1054
1055 1055 Only required if the access method does not trigger notification
1056 1056 emails automatically.
1057 1057 '''
1058 1058 pass
1059 1059
1060 1060
1061 1061 class bugzilla(object):
1062 1062 # supported versions of bugzilla. different versions have
1063 1063 # different schemas.
1064 1064 _versions = {
1065 1065 b'2.16': bzmysql,
1066 1066 b'2.18': bzmysql_2_18,
1067 1067 b'3.0': bzmysql_3_0,
1068 1068 b'xmlrpc': bzxmlrpc,
1069 1069 b'xmlrpc+email': bzxmlrpcemail,
1070 1070 b'restapi': bzrestapi,
1071 1071 }
1072 1072
1073 1073 def __init__(self, ui, repo):
1074 1074 self.ui = ui
1075 1075 self.repo = repo
1076 1076
1077 1077 bzversion = self.ui.config(b'bugzilla', b'version')
1078 1078 try:
1079 1079 bzclass = bugzilla._versions[bzversion]
1080 1080 except KeyError:
1081 1081 raise error.Abort(
1082 1082 _(b'bugzilla version %s not supported') % bzversion
1083 1083 )
1084 1084 self.bzdriver = bzclass(self.ui)
1085 1085
1086 1086 self.bug_re = re.compile(
1087 1087 self.ui.config(b'bugzilla', b'regexp'), re.IGNORECASE
1088 1088 )
1089 1089 self.fix_re = re.compile(
1090 1090 self.ui.config(b'bugzilla', b'fixregexp'), re.IGNORECASE
1091 1091 )
1092 1092 self.split_re = re.compile(br'\D+')
1093 1093
1094 1094 def find_bugs(self, ctx):
1095 1095 '''return bugs dictionary created from commit comment.
1096 1096
1097 1097 Extract bug info from changeset comments. Filter out any that are
1098 1098 not known to Bugzilla, and any that already have a reference to
1099 1099 the given changeset in their comments.
1100 1100 '''
1101 1101 start = 0
1102 1102 hours = 0.0
1103 1103 bugs = {}
1104 1104 bugmatch = self.bug_re.search(ctx.description(), start)
1105 1105 fixmatch = self.fix_re.search(ctx.description(), start)
1106 1106 while True:
1107 1107 bugattribs = {}
1108 1108 if not bugmatch and not fixmatch:
1109 1109 break
1110 1110 if not bugmatch:
1111 1111 m = fixmatch
1112 1112 elif not fixmatch:
1113 1113 m = bugmatch
1114 1114 else:
1115 1115 if bugmatch.start() < fixmatch.start():
1116 1116 m = bugmatch
1117 1117 else:
1118 1118 m = fixmatch
1119 1119 start = m.end()
1120 1120 if m is bugmatch:
1121 1121 bugmatch = self.bug_re.search(ctx.description(), start)
1122 1122 if b'fix' in bugattribs:
1123 1123 del bugattribs[b'fix']
1124 1124 else:
1125 1125 fixmatch = self.fix_re.search(ctx.description(), start)
1126 1126 bugattribs[b'fix'] = None
1127 1127
1128 1128 try:
1129 1129 ids = m.group(b'ids')
1130 1130 except IndexError:
1131 1131 ids = m.group(1)
1132 1132 try:
1133 1133 hours = float(m.group(b'hours'))
1134 1134 bugattribs[b'hours'] = hours
1135 1135 except IndexError:
1136 1136 pass
1137 1137 except TypeError:
1138 1138 pass
1139 1139 except ValueError:
1140 1140 self.ui.status(_(b"%s: invalid hours\n") % m.group(b'hours'))
1141 1141
1142 1142 for id in self.split_re.split(ids):
1143 1143 if not id:
1144 1144 continue
1145 1145 bugs[int(id)] = bugattribs
1146 1146 if bugs:
1147 1147 self.bzdriver.filter_real_bug_ids(bugs)
1148 1148 if bugs:
1149 1149 self.bzdriver.filter_cset_known_bug_ids(ctx.node(), bugs)
1150 1150 return bugs
1151 1151
1152 1152 def update(self, bugid, newstate, ctx):
1153 1153 '''update bugzilla bug with reference to changeset.'''
1154 1154
1155 1155 def webroot(root):
1156 1156 '''strip leading prefix of repo root and turn into
1157 1157 url-safe path.'''
1158 1158 count = int(self.ui.config(b'bugzilla', b'strip'))
1159 1159 root = util.pconvert(root)
1160 1160 while count > 0:
1161 1161 c = root.find(b'/')
1162 1162 if c == -1:
1163 1163 break
1164 1164 root = root[c + 1 :]
1165 1165 count -= 1
1166 1166 return root
1167 1167
1168 1168 mapfile = None
1169 1169 tmpl = self.ui.config(b'bugzilla', b'template')
1170 1170 if not tmpl:
1171 1171 mapfile = self.ui.config(b'bugzilla', b'style')
1172 1172 if not mapfile and not tmpl:
1173 1173 tmpl = _(
1174 1174 b'changeset {node|short} in repo {root} refers '
1175 1175 b'to bug {bug}.\ndetails:\n\t{desc|tabindent}'
1176 1176 )
1177 1177 spec = logcmdutil.templatespec(tmpl, mapfile)
1178 1178 t = logcmdutil.changesettemplater(self.ui, self.repo, spec)
1179 1179 self.ui.pushbuffer()
1180 1180 t.show(
1181 1181 ctx,
1182 1182 changes=ctx.changeset(),
1183 1183 bug=pycompat.bytestr(bugid),
1184 1184 hgweb=self.ui.config(b'web', b'baseurl'),
1185 1185 root=self.repo.root,
1186 1186 webroot=webroot(self.repo.root),
1187 1187 )
1188 1188 data = self.ui.popbuffer()
1189 1189 self.bzdriver.updatebug(
1190 1190 bugid, newstate, data, stringutil.email(ctx.user())
1191 1191 )
1192 1192
1193 1193 def notify(self, bugs, committer):
1194 1194 '''ensure Bugzilla users are notified of bug change.'''
1195 1195 self.bzdriver.notify(bugs, committer)
1196 1196
1197 1197
1198 1198 def hook(ui, repo, hooktype, node=None, **kwargs):
1199 1199 '''add comment to bugzilla for each changeset that refers to a
1200 1200 bugzilla bug id. only add a comment once per bug, so same change
1201 1201 seen multiple times does not fill bug with duplicate data.'''
1202 1202 if node is None:
1203 1203 raise error.Abort(
1204 1204 _(b'hook type %s does not pass a changeset id') % hooktype
1205 1205 )
1206 1206 try:
1207 1207 bz = bugzilla(ui, repo)
1208 1208 ctx = repo[node]
1209 1209 bugs = bz.find_bugs(ctx)
1210 1210 if bugs:
1211 1211 for bug in bugs:
1212 1212 bz.update(bug, bugs[bug], ctx)
1213 1213 bz.notify(bugs, stringutil.email(ctx.user()))
1214 1214 except Exception as e:
1215 1215 raise error.Abort(_(b'Bugzilla error: %s') % e)
@@ -1,89 +1,89 b''
1 1 # commitextras.py
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
8 8 '''adds a new flag extras to commit (ADVANCED)'''
9 9
10 10 from __future__ import absolute_import
11 11
12 12 import re
13 13
14 14 from mercurial.i18n import _
15 15 from mercurial import (
16 16 commands,
17 17 error,
18 18 extensions,
19 19 registrar,
20 20 util,
21 21 )
22 22
23 23 cmdtable = {}
24 24 command = registrar.command(cmdtable)
25 25 testedwith = b'ships-with-hg-core'
26 26
27 27 usedinternally = {
28 28 b'amend_source',
29 29 b'branch',
30 30 b'close',
31 31 b'histedit_source',
32 32 b'topic',
33 33 b'rebase_source',
34 34 b'intermediate-source',
35 35 b'__touch-noise__',
36 36 b'source',
37 37 b'transplant_source',
38 38 }
39 39
40 40
41 41 def extsetup(ui):
42 42 entry = extensions.wrapcommand(commands.table, b'commit', _commit)
43 43 options = entry[1]
44 44 options.append(
45 45 (
46 46 b'',
47 47 b'extra',
48 48 [],
49 49 _(b'set a changeset\'s extra values'),
50 50 _(b"KEY=VALUE"),
51 51 )
52 52 )
53 53
54 54
55 55 def _commit(orig, ui, repo, *pats, **opts):
56 if util.safehasattr(repo, b'unfiltered'):
56 if util.safehasattr(repo, 'unfiltered'):
57 57 repo = repo.unfiltered()
58 58
59 59 class repoextra(repo.__class__):
60 60 def commit(self, *innerpats, **inneropts):
61 61 extras = opts.get(r'extra')
62 62 for raw in extras:
63 63 if b'=' not in raw:
64 64 msg = _(
65 65 b"unable to parse '%s', should follow "
66 66 b"KEY=VALUE format"
67 67 )
68 68 raise error.Abort(msg % raw)
69 69 k, v = raw.split(b'=', 1)
70 70 if not k:
71 71 msg = _(b"unable to parse '%s', keys can't be empty")
72 72 raise error.Abort(msg % raw)
73 73 if re.search(br'[^\w-]', k):
74 74 msg = _(
75 75 b"keys can only contain ascii letters, digits,"
76 76 b" '_' and '-'"
77 77 )
78 78 raise error.Abort(msg)
79 79 if k in usedinternally:
80 80 msg = _(
81 81 b"key '%s' is used internally, can't be set "
82 82 b"manually"
83 83 )
84 84 raise error.Abort(msg % k)
85 85 inneropts[r'extra'][k] = v
86 86 return super(repoextra, self).commit(*innerpats, **inneropts)
87 87
88 88 repo.__class__ = repoextra
89 89 return orig(ui, repo, *pats, **opts)
@@ -1,357 +1,357 b''
1 1 # Copyright 2016-present Facebook. All Rights Reserved.
2 2 #
3 3 # commands: fastannotate commands
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 from __future__ import absolute_import
9 9
10 10 import os
11 11
12 12 from mercurial.i18n import _
13 13 from mercurial import (
14 14 commands,
15 15 encoding,
16 16 error,
17 17 extensions,
18 18 patch,
19 19 pycompat,
20 20 registrar,
21 21 scmutil,
22 22 util,
23 23 )
24 24
25 25 from . import (
26 26 context as facontext,
27 27 error as faerror,
28 28 formatter as faformatter,
29 29 )
30 30
31 31 cmdtable = {}
32 32 command = registrar.command(cmdtable)
33 33
34 34
35 35 def _matchpaths(repo, rev, pats, opts, aopts=facontext.defaultopts):
36 36 """generate paths matching given patterns"""
37 37 perfhack = repo.ui.configbool(b'fastannotate', b'perfhack')
38 38
39 39 # disable perfhack if:
40 40 # a) any walkopt is used
41 41 # b) if we treat pats as plain file names, some of them do not have
42 42 # corresponding linelog files
43 43 if perfhack:
44 44 # cwd related to reporoot
45 45 reporoot = os.path.dirname(repo.path)
46 46 reldir = os.path.relpath(encoding.getcwd(), reporoot)
47 47 if reldir == b'.':
48 48 reldir = b''
49 49 if any(opts.get(o[1]) for o in commands.walkopts): # a)
50 50 perfhack = False
51 51 else: # b)
52 52 relpats = [
53 53 os.path.relpath(p, reporoot) if os.path.isabs(p) else p
54 54 for p in pats
55 55 ]
56 56 # disable perfhack on '..' since it allows escaping from the repo
57 57 if any(
58 58 (
59 59 b'..' in f
60 60 or not os.path.isfile(
61 61 facontext.pathhelper(repo, f, aopts).linelogpath
62 62 )
63 63 )
64 64 for f in relpats
65 65 ):
66 66 perfhack = False
67 67
68 68 # perfhack: emit paths directory without checking with manifest
69 69 # this can be incorrect if the rev dos not have file.
70 70 if perfhack:
71 71 for p in relpats:
72 72 yield os.path.join(reldir, p)
73 73 else:
74 74
75 75 def bad(x, y):
76 76 raise error.Abort(b"%s: %s" % (x, y))
77 77
78 78 ctx = scmutil.revsingle(repo, rev)
79 79 m = scmutil.match(ctx, pats, opts, badfn=bad)
80 80 for p in ctx.walk(m):
81 81 yield p
82 82
83 83
84 84 fastannotatecommandargs = {
85 85 r'options': [
86 86 (b'r', b'rev', b'.', _(b'annotate the specified revision'), _(b'REV')),
87 87 (b'u', b'user', None, _(b'list the author (long with -v)')),
88 88 (b'f', b'file', None, _(b'list the filename')),
89 89 (b'd', b'date', None, _(b'list the date (short with -q)')),
90 90 (b'n', b'number', None, _(b'list the revision number (default)')),
91 91 (b'c', b'changeset', None, _(b'list the changeset')),
92 92 (
93 93 b'l',
94 94 b'line-number',
95 95 None,
96 96 _(b'show line number at the first ' b'appearance'),
97 97 ),
98 98 (
99 99 b'e',
100 100 b'deleted',
101 101 None,
102 102 _(b'show deleted lines (slow) (EXPERIMENTAL)'),
103 103 ),
104 104 (
105 105 b'',
106 106 b'no-content',
107 107 None,
108 108 _(b'do not show file content (EXPERIMENTAL)'),
109 109 ),
110 110 (b'', b'no-follow', None, _(b"don't follow copies and renames")),
111 111 (
112 112 b'',
113 113 b'linear',
114 114 None,
115 115 _(
116 116 b'enforce linear history, ignore second parent '
117 117 b'of merges (EXPERIMENTAL)'
118 118 ),
119 119 ),
120 120 (
121 121 b'',
122 122 b'long-hash',
123 123 None,
124 124 _(b'show long changeset hash (EXPERIMENTAL)'),
125 125 ),
126 126 (
127 127 b'',
128 128 b'rebuild',
129 129 None,
130 130 _(b'rebuild cache even if it exists ' b'(EXPERIMENTAL)'),
131 131 ),
132 132 ]
133 133 + commands.diffwsopts
134 134 + commands.walkopts
135 135 + commands.formatteropts,
136 136 r'synopsis': _(b'[-r REV] [-f] [-a] [-u] [-d] [-n] [-c] [-l] FILE...'),
137 137 r'inferrepo': True,
138 138 }
139 139
140 140
141 141 def fastannotate(ui, repo, *pats, **opts):
142 142 """show changeset information by line for each file
143 143
144 144 List changes in files, showing the revision id responsible for each line.
145 145
146 146 This command is useful for discovering when a change was made and by whom.
147 147
148 148 By default this command prints revision numbers. If you include --file,
149 149 --user, or --date, the revision number is suppressed unless you also
150 150 include --number. The default format can also be customized by setting
151 151 fastannotate.defaultformat.
152 152
153 153 Returns 0 on success.
154 154
155 155 .. container:: verbose
156 156
157 157 This command uses an implementation different from the vanilla annotate
158 158 command, which may produce slightly different (while still reasonable)
159 159 outputs for some cases.
160 160
161 161 Unlike the vanilla anootate, fastannotate follows rename regardless of
162 162 the existence of --file.
163 163
164 164 For the best performance when running on a full repo, use -c, -l,
165 165 avoid -u, -d, -n. Use --linear and --no-content to make it even faster.
166 166
167 167 For the best performance when running on a shallow (remotefilelog)
168 168 repo, avoid --linear, --no-follow, or any diff options. As the server
169 169 won't be able to populate annotate cache when non-default options
170 170 affecting results are used.
171 171 """
172 172 if not pats:
173 173 raise error.Abort(_(b'at least one filename or pattern is required'))
174 174
175 175 # performance hack: filtered repo can be slow. unfilter by default.
176 176 if ui.configbool(b'fastannotate', b'unfilteredrepo'):
177 177 repo = repo.unfiltered()
178 178
179 179 opts = pycompat.byteskwargs(opts)
180 180
181 181 rev = opts.get(b'rev', b'.')
182 182 rebuild = opts.get(b'rebuild', False)
183 183
184 184 diffopts = patch.difffeatureopts(
185 185 ui, opts, section=b'annotate', whitespace=True
186 186 )
187 187 aopts = facontext.annotateopts(
188 188 diffopts=diffopts,
189 189 followmerge=not opts.get(b'linear', False),
190 190 followrename=not opts.get(b'no_follow', False),
191 191 )
192 192
193 193 if not any(
194 194 opts.get(s)
195 195 for s in [b'user', b'date', b'file', b'number', b'changeset']
196 196 ):
197 197 # default 'number' for compatibility. but fastannotate is more
198 198 # efficient with "changeset", "line-number" and "no-content".
199 199 for name in ui.configlist(
200 200 b'fastannotate', b'defaultformat', [b'number']
201 201 ):
202 202 opts[name] = True
203 203
204 204 ui.pager(b'fastannotate')
205 205 template = opts.get(b'template')
206 206 if template == b'json':
207 207 formatter = faformatter.jsonformatter(ui, repo, opts)
208 208 else:
209 209 formatter = faformatter.defaultformatter(ui, repo, opts)
210 210 showdeleted = opts.get(b'deleted', False)
211 211 showlines = not bool(opts.get(b'no_content'))
212 212 showpath = opts.get(b'file', False)
213 213
214 214 # find the head of the main (master) branch
215 215 master = ui.config(b'fastannotate', b'mainbranch') or rev
216 216
217 217 # paths will be used for prefetching and the real annotating
218 218 paths = list(_matchpaths(repo, rev, pats, opts, aopts))
219 219
220 220 # for client, prefetch from the server
221 if util.safehasattr(repo, b'prefetchfastannotate'):
221 if util.safehasattr(repo, 'prefetchfastannotate'):
222 222 repo.prefetchfastannotate(paths)
223 223
224 224 for path in paths:
225 225 result = lines = existinglines = None
226 226 while True:
227 227 try:
228 228 with facontext.annotatecontext(repo, path, aopts, rebuild) as a:
229 229 result = a.annotate(
230 230 rev,
231 231 master=master,
232 232 showpath=showpath,
233 233 showlines=(showlines and not showdeleted),
234 234 )
235 235 if showdeleted:
236 236 existinglines = set((l[0], l[1]) for l in result)
237 237 result = a.annotatealllines(
238 238 rev, showpath=showpath, showlines=showlines
239 239 )
240 240 break
241 241 except (faerror.CannotReuseError, faerror.CorruptedFileError):
242 242 # happens if master moves backwards, or the file was deleted
243 243 # and readded, or renamed to an existing name, or corrupted.
244 244 if rebuild: # give up since we have tried rebuild already
245 245 raise
246 246 else: # try a second time rebuilding the cache (slow)
247 247 rebuild = True
248 248 continue
249 249
250 250 if showlines:
251 251 result, lines = result
252 252
253 253 formatter.write(result, lines, existinglines=existinglines)
254 254 formatter.end()
255 255
256 256
257 257 _newopts = set()
258 258 _knownopts = {
259 259 opt[1].replace(b'-', b'_')
260 260 for opt in (fastannotatecommandargs[r'options'] + commands.globalopts)
261 261 }
262 262
263 263
264 264 def _annotatewrapper(orig, ui, repo, *pats, **opts):
265 265 """used by wrapdefault"""
266 266 # we need this hack until the obsstore has 0.0 seconds perf impact
267 267 if ui.configbool(b'fastannotate', b'unfilteredrepo'):
268 268 repo = repo.unfiltered()
269 269
270 270 # treat the file as text (skip the isbinary check)
271 271 if ui.configbool(b'fastannotate', b'forcetext'):
272 272 opts[r'text'] = True
273 273
274 274 # check if we need to do prefetch (client-side)
275 275 rev = opts.get(r'rev')
276 if util.safehasattr(repo, b'prefetchfastannotate') and rev is not None:
276 if util.safehasattr(repo, 'prefetchfastannotate') and rev is not None:
277 277 paths = list(_matchpaths(repo, rev, pats, pycompat.byteskwargs(opts)))
278 278 repo.prefetchfastannotate(paths)
279 279
280 280 return orig(ui, repo, *pats, **opts)
281 281
282 282
283 283 def registercommand():
284 284 """register the fastannotate command"""
285 285 name = b'fastannotate|fastblame|fa'
286 286 command(name, helpbasic=True, **fastannotatecommandargs)(fastannotate)
287 287
288 288
289 289 def wrapdefault():
290 290 """wrap the default annotate command, to be aware of the protocol"""
291 291 extensions.wrapcommand(commands.table, b'annotate', _annotatewrapper)
292 292
293 293
294 294 @command(
295 295 b'debugbuildannotatecache',
296 296 [(b'r', b'rev', b'', _(b'build up to the specific revision'), _(b'REV'))]
297 297 + commands.walkopts,
298 298 _(b'[-r REV] FILE...'),
299 299 )
300 300 def debugbuildannotatecache(ui, repo, *pats, **opts):
301 301 """incrementally build fastannotate cache up to REV for specified files
302 302
303 303 If REV is not specified, use the config 'fastannotate.mainbranch'.
304 304
305 305 If fastannotate.client is True, download the annotate cache from the
306 306 server. Otherwise, build the annotate cache locally.
307 307
308 308 The annotate cache will be built using the default diff and follow
309 309 options and lives in '.hg/fastannotate/default'.
310 310 """
311 311 opts = pycompat.byteskwargs(opts)
312 312 rev = opts.get(b'REV') or ui.config(b'fastannotate', b'mainbranch')
313 313 if not rev:
314 314 raise error.Abort(
315 315 _(b'you need to provide a revision'),
316 316 hint=_(b'set fastannotate.mainbranch or use --rev'),
317 317 )
318 318 if ui.configbool(b'fastannotate', b'unfilteredrepo'):
319 319 repo = repo.unfiltered()
320 320 ctx = scmutil.revsingle(repo, rev)
321 321 m = scmutil.match(ctx, pats, opts)
322 322 paths = list(ctx.walk(m))
323 if util.safehasattr(repo, b'prefetchfastannotate'):
323 if util.safehasattr(repo, 'prefetchfastannotate'):
324 324 # client
325 325 if opts.get(b'REV'):
326 326 raise error.Abort(_(b'--rev cannot be used for client'))
327 327 repo.prefetchfastannotate(paths)
328 328 else:
329 329 # server, or full repo
330 330 progress = ui.makeprogress(_(b'building'), total=len(paths))
331 331 for i, path in enumerate(paths):
332 332 progress.update(i)
333 333 with facontext.annotatecontext(repo, path) as actx:
334 334 try:
335 335 if actx.isuptodate(rev):
336 336 continue
337 337 actx.annotate(rev, rev)
338 338 except (faerror.CannotReuseError, faerror.CorruptedFileError):
339 339 # the cache is broken (could happen with renaming so the
340 340 # file history gets invalidated). rebuild and try again.
341 341 ui.debug(
342 342 b'fastannotate: %s: rebuilding broken cache\n' % path
343 343 )
344 344 actx.rebuild()
345 345 try:
346 346 actx.annotate(rev, rev)
347 347 except Exception as ex:
348 348 # possibly a bug, but should not stop us from building
349 349 # cache for other files.
350 350 ui.warn(
351 351 _(
352 352 b'fastannotate: %s: failed to '
353 353 b'build cache: %r\n'
354 354 )
355 355 % (path, ex)
356 356 )
357 357 progress.complete()
@@ -1,118 +1,118 b''
1 1 # watchmanclient.py - Watchman client for the fsmonitor extension
2 2 #
3 3 # Copyright 2013-2016 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
8 8 from __future__ import absolute_import
9 9
10 10 import getpass
11 11
12 12 from mercurial import util
13 13
14 14 from . import pywatchman
15 15
16 16
17 17 class Unavailable(Exception):
18 18 def __init__(self, msg, warn=True, invalidate=False):
19 19 self.msg = msg
20 20 self.warn = warn
21 21 if self.msg == b'timed out waiting for response':
22 22 self.warn = False
23 23 self.invalidate = invalidate
24 24
25 25 def __str__(self):
26 26 if self.warn:
27 27 return b'warning: Watchman unavailable: %s' % self.msg
28 28 else:
29 29 return b'Watchman unavailable: %s' % self.msg
30 30
31 31
32 32 class WatchmanNoRoot(Unavailable):
33 33 def __init__(self, root, msg):
34 34 self.root = root
35 35 super(WatchmanNoRoot, self).__init__(msg)
36 36
37 37
38 38 class client(object):
39 39 def __init__(self, ui, root, timeout=1.0):
40 40 err = None
41 41 if not self._user:
42 42 err = b"couldn't get user"
43 43 warn = True
44 44 if self._user in ui.configlist(b'fsmonitor', b'blacklistusers'):
45 45 err = b'user %s in blacklist' % self._user
46 46 warn = False
47 47
48 48 if err:
49 49 raise Unavailable(err, warn)
50 50
51 51 self._timeout = timeout
52 52 self._watchmanclient = None
53 53 self._root = root
54 54 self._ui = ui
55 55 self._firsttime = True
56 56
57 57 def settimeout(self, timeout):
58 58 self._timeout = timeout
59 59 if self._watchmanclient is not None:
60 60 self._watchmanclient.setTimeout(timeout)
61 61
62 62 def getcurrentclock(self):
63 63 result = self.command(b'clock')
64 if not util.safehasattr(result, b'clock'):
64 if not util.safehasattr(result, 'clock'):
65 65 raise Unavailable(
66 66 b'clock result is missing clock value', invalidate=True
67 67 )
68 68 return result.clock
69 69
70 70 def clearconnection(self):
71 71 self._watchmanclient = None
72 72
73 73 def available(self):
74 74 return self._watchmanclient is not None or self._firsttime
75 75
76 76 @util.propertycache
77 77 def _user(self):
78 78 try:
79 79 return getpass.getuser()
80 80 except KeyError:
81 81 # couldn't figure out our user
82 82 return None
83 83
84 84 def _command(self, *args):
85 85 watchmanargs = (args[0], self._root) + args[1:]
86 86 try:
87 87 if self._watchmanclient is None:
88 88 self._firsttime = False
89 89 watchman_exe = self._ui.configpath(
90 90 b'fsmonitor', b'watchman_exe'
91 91 )
92 92 self._watchmanclient = pywatchman.client(
93 93 timeout=self._timeout,
94 94 useImmutableBser=True,
95 95 watchman_exe=watchman_exe,
96 96 )
97 97 return self._watchmanclient.query(*watchmanargs)
98 98 except pywatchman.CommandError as ex:
99 99 if b'unable to resolve root' in ex.msg:
100 100 raise WatchmanNoRoot(self._root, ex.msg)
101 101 raise Unavailable(ex.msg)
102 102 except pywatchman.WatchmanError as ex:
103 103 raise Unavailable(str(ex))
104 104
105 105 def command(self, *args):
106 106 try:
107 107 try:
108 108 return self._command(*args)
109 109 except WatchmanNoRoot:
110 110 # this 'watch' command can also raise a WatchmanNoRoot if
111 111 # watchman refuses to accept this root
112 112 self._command(b'watch')
113 113 return self._command(*args)
114 114 except Unavailable:
115 115 # this is in an outer scope to catch Unavailable form any of the
116 116 # above _command calls
117 117 self._watchmanclient = None
118 118 raise
@@ -1,604 +1,604 b''
1 1 # journal.py
2 2 #
3 3 # Copyright 2014-2016 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 """track previous positions of bookmarks (EXPERIMENTAL)
8 8
9 9 This extension adds a new command: `hg journal`, which shows you where
10 10 bookmarks were previously located.
11 11
12 12 """
13 13
14 14 from __future__ import absolute_import
15 15
16 16 import collections
17 17 import errno
18 18 import os
19 19 import weakref
20 20
21 21 from mercurial.i18n import _
22 22
23 23 from mercurial import (
24 24 bookmarks,
25 25 cmdutil,
26 26 dispatch,
27 27 encoding,
28 28 error,
29 29 extensions,
30 30 hg,
31 31 localrepo,
32 32 lock,
33 33 logcmdutil,
34 34 node,
35 35 pycompat,
36 36 registrar,
37 37 util,
38 38 )
39 39 from mercurial.utils import (
40 40 dateutil,
41 41 procutil,
42 42 stringutil,
43 43 )
44 44
45 45 cmdtable = {}
46 46 command = registrar.command(cmdtable)
47 47
48 48 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
49 49 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
50 50 # be specifying the version(s) of Mercurial they are tested with, or
51 51 # leave the attribute unspecified.
52 52 testedwith = b'ships-with-hg-core'
53 53
54 54 # storage format version; increment when the format changes
55 55 storageversion = 0
56 56
57 57 # namespaces
58 58 bookmarktype = b'bookmark'
59 59 wdirparenttype = b'wdirparent'
60 60 # In a shared repository, what shared feature name is used
61 61 # to indicate this namespace is shared with the source?
62 62 sharednamespaces = {
63 63 bookmarktype: hg.sharedbookmarks,
64 64 }
65 65
66 66 # Journal recording, register hooks and storage object
67 67 def extsetup(ui):
68 68 extensions.wrapfunction(dispatch, b'runcommand', runcommand)
69 69 extensions.wrapfunction(bookmarks.bmstore, b'_write', recordbookmarks)
70 70 extensions.wrapfilecache(
71 71 localrepo.localrepository, b'dirstate', wrapdirstate
72 72 )
73 73 extensions.wrapfunction(hg, b'postshare', wrappostshare)
74 74 extensions.wrapfunction(hg, b'copystore', unsharejournal)
75 75
76 76
77 77 def reposetup(ui, repo):
78 78 if repo.local():
79 79 repo.journal = journalstorage(repo)
80 80 repo._wlockfreeprefix.add(b'namejournal')
81 81
82 82 dirstate, cached = localrepo.isfilecached(repo, b'dirstate')
83 83 if cached:
84 84 # already instantiated dirstate isn't yet marked as
85 85 # "journal"-ing, even though repo.dirstate() was already
86 86 # wrapped by own wrapdirstate()
87 87 _setupdirstate(repo, dirstate)
88 88
89 89
90 90 def runcommand(orig, lui, repo, cmd, fullargs, *args):
91 91 """Track the command line options for recording in the journal"""
92 92 journalstorage.recordcommand(*fullargs)
93 93 return orig(lui, repo, cmd, fullargs, *args)
94 94
95 95
96 96 def _setupdirstate(repo, dirstate):
97 97 dirstate.journalstorage = repo.journal
98 98 dirstate.addparentchangecallback(b'journal', recorddirstateparents)
99 99
100 100
101 101 # hooks to record dirstate changes
102 102 def wrapdirstate(orig, repo):
103 103 """Make journal storage available to the dirstate object"""
104 104 dirstate = orig(repo)
105 if util.safehasattr(repo, b'journal'):
105 if util.safehasattr(repo, 'journal'):
106 106 _setupdirstate(repo, dirstate)
107 107 return dirstate
108 108
109 109
110 110 def recorddirstateparents(dirstate, old, new):
111 111 """Records all dirstate parent changes in the journal."""
112 112 old = list(old)
113 113 new = list(new)
114 if util.safehasattr(dirstate, b'journalstorage'):
114 if util.safehasattr(dirstate, 'journalstorage'):
115 115 # only record two hashes if there was a merge
116 116 oldhashes = old[:1] if old[1] == node.nullid else old
117 117 newhashes = new[:1] if new[1] == node.nullid else new
118 118 dirstate.journalstorage.record(
119 119 wdirparenttype, b'.', oldhashes, newhashes
120 120 )
121 121
122 122
123 123 # hooks to record bookmark changes (both local and remote)
124 124 def recordbookmarks(orig, store, fp):
125 125 """Records all bookmark changes in the journal."""
126 126 repo = store._repo
127 if util.safehasattr(repo, b'journal'):
127 if util.safehasattr(repo, 'journal'):
128 128 oldmarks = bookmarks.bmstore(repo)
129 129 for mark, value in pycompat.iteritems(store):
130 130 oldvalue = oldmarks.get(mark, node.nullid)
131 131 if value != oldvalue:
132 132 repo.journal.record(bookmarktype, mark, oldvalue, value)
133 133 return orig(store, fp)
134 134
135 135
136 136 # shared repository support
137 137 def _readsharedfeatures(repo):
138 138 """A set of shared features for this repository"""
139 139 try:
140 140 return set(repo.vfs.read(b'shared').splitlines())
141 141 except IOError as inst:
142 142 if inst.errno != errno.ENOENT:
143 143 raise
144 144 return set()
145 145
146 146
147 147 def _mergeentriesiter(*iterables, **kwargs):
148 148 """Given a set of sorted iterables, yield the next entry in merged order
149 149
150 150 Note that by default entries go from most recent to oldest.
151 151 """
152 152 order = kwargs.pop(r'order', max)
153 153 iterables = [iter(it) for it in iterables]
154 154 # this tracks still active iterables; iterables are deleted as they are
155 155 # exhausted, which is why this is a dictionary and why each entry also
156 156 # stores the key. Entries are mutable so we can store the next value each
157 157 # time.
158 158 iterable_map = {}
159 159 for key, it in enumerate(iterables):
160 160 try:
161 161 iterable_map[key] = [next(it), key, it]
162 162 except StopIteration:
163 163 # empty entry, can be ignored
164 164 pass
165 165
166 166 while iterable_map:
167 167 value, key, it = order(pycompat.itervalues(iterable_map))
168 168 yield value
169 169 try:
170 170 iterable_map[key][0] = next(it)
171 171 except StopIteration:
172 172 # this iterable is empty, remove it from consideration
173 173 del iterable_map[key]
174 174
175 175
176 176 def wrappostshare(orig, sourcerepo, destrepo, **kwargs):
177 177 """Mark this shared working copy as sharing journal information"""
178 178 with destrepo.wlock():
179 179 orig(sourcerepo, destrepo, **kwargs)
180 180 with destrepo.vfs(b'shared', b'a') as fp:
181 181 fp.write(b'journal\n')
182 182
183 183
184 184 def unsharejournal(orig, ui, repo, repopath):
185 185 """Copy shared journal entries into this repo when unsharing"""
186 186 if (
187 187 repo.path == repopath
188 188 and repo.shared()
189 and util.safehasattr(repo, b'journal')
189 and util.safehasattr(repo, 'journal')
190 190 ):
191 191 sharedrepo = hg.sharedreposource(repo)
192 192 sharedfeatures = _readsharedfeatures(repo)
193 193 if sharedrepo and sharedfeatures > {b'journal'}:
194 194 # there is a shared repository and there are shared journal entries
195 195 # to copy. move shared date over from source to destination but
196 196 # move the local file first
197 197 if repo.vfs.exists(b'namejournal'):
198 198 journalpath = repo.vfs.join(b'namejournal')
199 199 util.rename(journalpath, journalpath + b'.bak')
200 200 storage = repo.journal
201 201 local = storage._open(
202 202 repo.vfs, filename=b'namejournal.bak', _newestfirst=False
203 203 )
204 204 shared = (
205 205 e
206 206 for e in storage._open(sharedrepo.vfs, _newestfirst=False)
207 207 if sharednamespaces.get(e.namespace) in sharedfeatures
208 208 )
209 209 for entry in _mergeentriesiter(local, shared, order=min):
210 210 storage._write(repo.vfs, entry)
211 211
212 212 return orig(ui, repo, repopath)
213 213
214 214
215 215 class journalentry(
216 216 collections.namedtuple(
217 217 r'journalentry',
218 218 r'timestamp user command namespace name oldhashes newhashes',
219 219 )
220 220 ):
221 221 """Individual journal entry
222 222
223 223 * timestamp: a mercurial (time, timezone) tuple
224 224 * user: the username that ran the command
225 225 * namespace: the entry namespace, an opaque string
226 226 * name: the name of the changed item, opaque string with meaning in the
227 227 namespace
228 228 * command: the hg command that triggered this record
229 229 * oldhashes: a tuple of one or more binary hashes for the old location
230 230 * newhashes: a tuple of one or more binary hashes for the new location
231 231
232 232 Handles serialisation from and to the storage format. Fields are
233 233 separated by newlines, hashes are written out in hex separated by commas,
234 234 timestamp and timezone are separated by a space.
235 235
236 236 """
237 237
238 238 @classmethod
239 239 def fromstorage(cls, line):
240 240 (
241 241 time,
242 242 user,
243 243 command,
244 244 namespace,
245 245 name,
246 246 oldhashes,
247 247 newhashes,
248 248 ) = line.split(b'\n')
249 249 timestamp, tz = time.split()
250 250 timestamp, tz = float(timestamp), int(tz)
251 251 oldhashes = tuple(node.bin(hash) for hash in oldhashes.split(b','))
252 252 newhashes = tuple(node.bin(hash) for hash in newhashes.split(b','))
253 253 return cls(
254 254 (timestamp, tz),
255 255 user,
256 256 command,
257 257 namespace,
258 258 name,
259 259 oldhashes,
260 260 newhashes,
261 261 )
262 262
263 263 def __bytes__(self):
264 264 """bytes representation for storage"""
265 265 time = b' '.join(map(pycompat.bytestr, self.timestamp))
266 266 oldhashes = b','.join([node.hex(hash) for hash in self.oldhashes])
267 267 newhashes = b','.join([node.hex(hash) for hash in self.newhashes])
268 268 return b'\n'.join(
269 269 (
270 270 time,
271 271 self.user,
272 272 self.command,
273 273 self.namespace,
274 274 self.name,
275 275 oldhashes,
276 276 newhashes,
277 277 )
278 278 )
279 279
280 280 __str__ = encoding.strmethod(__bytes__)
281 281
282 282
283 283 class journalstorage(object):
284 284 """Storage for journal entries
285 285
286 286 Entries are divided over two files; one with entries that pertain to the
287 287 local working copy *only*, and one with entries that are shared across
288 288 multiple working copies when shared using the share extension.
289 289
290 290 Entries are stored with NUL bytes as separators. See the journalentry
291 291 class for the per-entry structure.
292 292
293 293 The file format starts with an integer version, delimited by a NUL.
294 294
295 295 This storage uses a dedicated lock; this makes it easier to avoid issues
296 296 with adding entries that added when the regular wlock is unlocked (e.g.
297 297 the dirstate).
298 298
299 299 """
300 300
301 301 _currentcommand = ()
302 302 _lockref = None
303 303
304 304 def __init__(self, repo):
305 305 self.user = procutil.getuser()
306 306 self.ui = repo.ui
307 307 self.vfs = repo.vfs
308 308
309 309 # is this working copy using a shared storage?
310 310 self.sharedfeatures = self.sharedvfs = None
311 311 if repo.shared():
312 312 features = _readsharedfeatures(repo)
313 313 sharedrepo = hg.sharedreposource(repo)
314 314 if sharedrepo is not None and b'journal' in features:
315 315 self.sharedvfs = sharedrepo.vfs
316 316 self.sharedfeatures = features
317 317
318 318 # track the current command for recording in journal entries
319 319 @property
320 320 def command(self):
321 321 commandstr = b' '.join(
322 322 map(procutil.shellquote, journalstorage._currentcommand)
323 323 )
324 324 if b'\n' in commandstr:
325 325 # truncate multi-line commands
326 326 commandstr = commandstr.partition(b'\n')[0] + b' ...'
327 327 return commandstr
328 328
329 329 @classmethod
330 330 def recordcommand(cls, *fullargs):
331 331 """Set the current hg arguments, stored with recorded entries"""
332 332 # Set the current command on the class because we may have started
333 333 # with a non-local repo (cloning for example).
334 334 cls._currentcommand = fullargs
335 335
336 336 def _currentlock(self, lockref):
337 337 """Returns the lock if it's held, or None if it's not.
338 338
339 339 (This is copied from the localrepo class)
340 340 """
341 341 if lockref is None:
342 342 return None
343 343 l = lockref()
344 344 if l is None or not l.held:
345 345 return None
346 346 return l
347 347
348 348 def jlock(self, vfs):
349 349 """Create a lock for the journal file"""
350 350 if self._currentlock(self._lockref) is not None:
351 351 raise error.Abort(_(b'journal lock does not support nesting'))
352 352 desc = _(b'journal of %s') % vfs.base
353 353 try:
354 354 l = lock.lock(vfs, b'namejournal.lock', 0, desc=desc)
355 355 except error.LockHeld as inst:
356 356 self.ui.warn(
357 357 _(b"waiting for lock on %s held by %r\n") % (desc, inst.locker)
358 358 )
359 359 # default to 600 seconds timeout
360 360 l = lock.lock(
361 361 vfs,
362 362 b'namejournal.lock',
363 363 self.ui.configint(b"ui", b"timeout"),
364 364 desc=desc,
365 365 )
366 366 self.ui.warn(_(b"got lock after %s seconds\n") % l.delay)
367 367 self._lockref = weakref.ref(l)
368 368 return l
369 369
370 370 def record(self, namespace, name, oldhashes, newhashes):
371 371 """Record a new journal entry
372 372
373 373 * namespace: an opaque string; this can be used to filter on the type
374 374 of recorded entries.
375 375 * name: the name defining this entry; for bookmarks, this is the
376 376 bookmark name. Can be filtered on when retrieving entries.
377 377 * oldhashes and newhashes: each a single binary hash, or a list of
378 378 binary hashes. These represent the old and new position of the named
379 379 item.
380 380
381 381 """
382 382 if not isinstance(oldhashes, list):
383 383 oldhashes = [oldhashes]
384 384 if not isinstance(newhashes, list):
385 385 newhashes = [newhashes]
386 386
387 387 entry = journalentry(
388 388 dateutil.makedate(),
389 389 self.user,
390 390 self.command,
391 391 namespace,
392 392 name,
393 393 oldhashes,
394 394 newhashes,
395 395 )
396 396
397 397 vfs = self.vfs
398 398 if self.sharedvfs is not None:
399 399 # write to the shared repository if this feature is being
400 400 # shared between working copies.
401 401 if sharednamespaces.get(namespace) in self.sharedfeatures:
402 402 vfs = self.sharedvfs
403 403
404 404 self._write(vfs, entry)
405 405
406 406 def _write(self, vfs, entry):
407 407 with self.jlock(vfs):
408 408 # open file in amend mode to ensure it is created if missing
409 409 with vfs(b'namejournal', mode=b'a+b') as f:
410 410 f.seek(0, os.SEEK_SET)
411 411 # Read just enough bytes to get a version number (up to 2
412 412 # digits plus separator)
413 413 version = f.read(3).partition(b'\0')[0]
414 414 if version and version != b"%d" % storageversion:
415 415 # different version of the storage. Exit early (and not
416 416 # write anything) if this is not a version we can handle or
417 417 # the file is corrupt. In future, perhaps rotate the file
418 418 # instead?
419 419 self.ui.warn(
420 420 _(b"unsupported journal file version '%s'\n") % version
421 421 )
422 422 return
423 423 if not version:
424 424 # empty file, write version first
425 425 f.write((b"%d" % storageversion) + b'\0')
426 426 f.seek(0, os.SEEK_END)
427 427 f.write(bytes(entry) + b'\0')
428 428
429 429 def filtered(self, namespace=None, name=None):
430 430 """Yield all journal entries with the given namespace or name
431 431
432 432 Both the namespace and the name are optional; if neither is given all
433 433 entries in the journal are produced.
434 434
435 435 Matching supports regular expressions by using the `re:` prefix
436 436 (use `literal:` to match names or namespaces that start with `re:`)
437 437
438 438 """
439 439 if namespace is not None:
440 440 namespace = stringutil.stringmatcher(namespace)[-1]
441 441 if name is not None:
442 442 name = stringutil.stringmatcher(name)[-1]
443 443 for entry in self:
444 444 if namespace is not None and not namespace(entry.namespace):
445 445 continue
446 446 if name is not None and not name(entry.name):
447 447 continue
448 448 yield entry
449 449
450 450 def __iter__(self):
451 451 """Iterate over the storage
452 452
453 453 Yields journalentry instances for each contained journal record.
454 454
455 455 """
456 456 local = self._open(self.vfs)
457 457
458 458 if self.sharedvfs is None:
459 459 return local
460 460
461 461 # iterate over both local and shared entries, but only those
462 462 # shared entries that are among the currently shared features
463 463 shared = (
464 464 e
465 465 for e in self._open(self.sharedvfs)
466 466 if sharednamespaces.get(e.namespace) in self.sharedfeatures
467 467 )
468 468 return _mergeentriesiter(local, shared)
469 469
470 470 def _open(self, vfs, filename=b'namejournal', _newestfirst=True):
471 471 if not vfs.exists(filename):
472 472 return
473 473
474 474 with vfs(filename) as f:
475 475 raw = f.read()
476 476
477 477 lines = raw.split(b'\0')
478 478 version = lines and lines[0]
479 479 if version != b"%d" % storageversion:
480 480 version = version or _(b'not available')
481 481 raise error.Abort(_(b"unknown journal file version '%s'") % version)
482 482
483 483 # Skip the first line, it's a version number. Normally we iterate over
484 484 # these in reverse order to list newest first; only when copying across
485 485 # a shared storage do we forgo reversing.
486 486 lines = lines[1:]
487 487 if _newestfirst:
488 488 lines = reversed(lines)
489 489 for line in lines:
490 490 if not line:
491 491 continue
492 492 yield journalentry.fromstorage(line)
493 493
494 494
495 495 # journal reading
496 496 # log options that don't make sense for journal
497 497 _ignoreopts = (b'no-merges', b'graph')
498 498
499 499
500 500 @command(
501 501 b'journal',
502 502 [
503 503 (b'', b'all', None, b'show history for all names'),
504 504 (b'c', b'commits', None, b'show commit metadata'),
505 505 ]
506 506 + [opt for opt in cmdutil.logopts if opt[1] not in _ignoreopts],
507 507 b'[OPTION]... [BOOKMARKNAME]',
508 508 helpcategory=command.CATEGORY_CHANGE_ORGANIZATION,
509 509 )
510 510 def journal(ui, repo, *args, **opts):
511 511 """show the previous position of bookmarks and the working copy
512 512
513 513 The journal is used to see the previous commits that bookmarks and the
514 514 working copy pointed to. By default the previous locations for the working
515 515 copy. Passing a bookmark name will show all the previous positions of
516 516 that bookmark. Use the --all switch to show previous locations for all
517 517 bookmarks and the working copy; each line will then include the bookmark
518 518 name, or '.' for the working copy, as well.
519 519
520 520 If `name` starts with `re:`, the remainder of the name is treated as
521 521 a regular expression. To match a name that actually starts with `re:`,
522 522 use the prefix `literal:`.
523 523
524 524 By default hg journal only shows the commit hash and the command that was
525 525 running at that time. -v/--verbose will show the prior hash, the user, and
526 526 the time at which it happened.
527 527
528 528 Use -c/--commits to output log information on each commit hash; at this
529 529 point you can use the usual `--patch`, `--git`, `--stat` and `--template`
530 530 switches to alter the log output for these.
531 531
532 532 `hg journal -T json` can be used to produce machine readable output.
533 533
534 534 """
535 535 opts = pycompat.byteskwargs(opts)
536 536 name = b'.'
537 537 if opts.get(b'all'):
538 538 if args:
539 539 raise error.Abort(
540 540 _(b"You can't combine --all and filtering on a name")
541 541 )
542 542 name = None
543 543 if args:
544 544 name = args[0]
545 545
546 546 fm = ui.formatter(b'journal', opts)
547 547
548 548 def formatnodes(nodes):
549 549 return fm.formatlist(map(fm.hexfunc, nodes), name=b'node', sep=b',')
550 550
551 551 if opts.get(b"template") != b"json":
552 552 if name is None:
553 553 displayname = _(b'the working copy and bookmarks')
554 554 else:
555 555 displayname = b"'%s'" % name
556 556 ui.status(_(b"previous locations of %s:\n") % displayname)
557 557
558 558 limit = logcmdutil.getlimit(opts)
559 559 entry = None
560 560 ui.pager(b'journal')
561 561 for count, entry in enumerate(repo.journal.filtered(name=name)):
562 562 if count == limit:
563 563 break
564 564
565 565 fm.startitem()
566 566 fm.condwrite(
567 567 ui.verbose, b'oldnodes', b'%s -> ', formatnodes(entry.oldhashes)
568 568 )
569 569 fm.write(b'newnodes', b'%s', formatnodes(entry.newhashes))
570 570 fm.condwrite(ui.verbose, b'user', b' %-8s', entry.user)
571 571 fm.condwrite(
572 572 opts.get(b'all') or name.startswith(b're:'),
573 573 b'name',
574 574 b' %-8s',
575 575 entry.name,
576 576 )
577 577
578 578 fm.condwrite(
579 579 ui.verbose,
580 580 b'date',
581 581 b' %s',
582 582 fm.formatdate(entry.timestamp, b'%Y-%m-%d %H:%M %1%2'),
583 583 )
584 584 fm.write(b'command', b' %s\n', entry.command)
585 585
586 586 if opts.get(b"commits"):
587 587 if fm.isplain():
588 588 displayer = logcmdutil.changesetdisplayer(ui, repo, opts)
589 589 else:
590 590 displayer = logcmdutil.changesetformatter(
591 591 ui, repo, fm.nested(b'changesets'), diffopts=opts
592 592 )
593 593 for hash in entry.newhashes:
594 594 try:
595 595 ctx = repo[hash]
596 596 displayer.show(ctx)
597 597 except error.RepoLookupError as e:
598 598 fm.plain(b"%s\n\n" % pycompat.bytestr(e))
599 599 displayer.close()
600 600
601 601 fm.end()
602 602
603 603 if entry is None:
604 604 ui.status(_(b"no recorded locations\n"))
@@ -1,370 +1,370 b''
1 1 # wireprotolfsserver.py - lfs protocol server side implementation
2 2 #
3 3 # Copyright 2018 Matt Harbison <matt_harbison@yahoo.com>
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 from __future__ import absolute_import
9 9
10 10 import datetime
11 11 import errno
12 12 import json
13 13 import traceback
14 14
15 15 from mercurial.hgweb import common as hgwebcommon
16 16
17 17 from mercurial import (
18 18 exthelper,
19 19 pycompat,
20 20 util,
21 21 wireprotoserver,
22 22 )
23 23
24 24 from . import blobstore
25 25
26 26 HTTP_OK = hgwebcommon.HTTP_OK
27 27 HTTP_CREATED = hgwebcommon.HTTP_CREATED
28 28 HTTP_BAD_REQUEST = hgwebcommon.HTTP_BAD_REQUEST
29 29 HTTP_NOT_FOUND = hgwebcommon.HTTP_NOT_FOUND
30 30 HTTP_METHOD_NOT_ALLOWED = hgwebcommon.HTTP_METHOD_NOT_ALLOWED
31 31 HTTP_NOT_ACCEPTABLE = hgwebcommon.HTTP_NOT_ACCEPTABLE
32 32 HTTP_UNSUPPORTED_MEDIA_TYPE = hgwebcommon.HTTP_UNSUPPORTED_MEDIA_TYPE
33 33
34 34 eh = exthelper.exthelper()
35 35
36 36
37 37 @eh.wrapfunction(wireprotoserver, b'handlewsgirequest')
38 38 def handlewsgirequest(orig, rctx, req, res, checkperm):
39 39 """Wrap wireprotoserver.handlewsgirequest() to possibly process an LFS
40 40 request if it is left unprocessed by the wrapped method.
41 41 """
42 42 if orig(rctx, req, res, checkperm):
43 43 return True
44 44
45 45 if not rctx.repo.ui.configbool(b'experimental', b'lfs.serve'):
46 46 return False
47 47
48 if not util.safehasattr(rctx.repo.svfs, b'lfslocalblobstore'):
48 if not util.safehasattr(rctx.repo.svfs, 'lfslocalblobstore'):
49 49 return False
50 50
51 51 if not req.dispatchpath:
52 52 return False
53 53
54 54 try:
55 55 if req.dispatchpath == b'.git/info/lfs/objects/batch':
56 56 checkperm(rctx, req, b'pull')
57 57 return _processbatchrequest(rctx.repo, req, res)
58 58 # TODO: reserve and use a path in the proposed http wireprotocol /api/
59 59 # namespace?
60 60 elif req.dispatchpath.startswith(b'.hg/lfs/objects'):
61 61 return _processbasictransfer(
62 62 rctx.repo, req, res, lambda perm: checkperm(rctx, req, perm)
63 63 )
64 64 return False
65 65 except hgwebcommon.ErrorResponse as e:
66 66 # XXX: copied from the handler surrounding wireprotoserver._callhttp()
67 67 # in the wrapped function. Should this be moved back to hgweb to
68 68 # be a common handler?
69 69 for k, v in e.headers:
70 70 res.headers[k] = v
71 71 res.status = hgwebcommon.statusmessage(e.code, pycompat.bytestr(e))
72 72 res.setbodybytes(b'0\n%s\n' % pycompat.bytestr(e))
73 73 return True
74 74
75 75
76 76 def _sethttperror(res, code, message=None):
77 77 res.status = hgwebcommon.statusmessage(code, message=message)
78 78 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
79 79 res.setbodybytes(b'')
80 80
81 81
82 82 def _logexception(req):
83 83 """Write information about the current exception to wsgi.errors."""
84 84 tb = pycompat.sysbytes(traceback.format_exc())
85 85 errorlog = req.rawenv[b'wsgi.errors']
86 86
87 87 uri = b''
88 88 if req.apppath:
89 89 uri += req.apppath
90 90 uri += b'/' + req.dispatchpath
91 91
92 92 errorlog.write(
93 93 b"Exception happened while processing request '%s':\n%s" % (uri, tb)
94 94 )
95 95
96 96
97 97 def _processbatchrequest(repo, req, res):
98 98 """Handle a request for the Batch API, which is the gateway to granting file
99 99 access.
100 100
101 101 https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
102 102 """
103 103
104 104 # Mercurial client request:
105 105 #
106 106 # HOST: localhost:$HGPORT
107 107 # ACCEPT: application/vnd.git-lfs+json
108 108 # ACCEPT-ENCODING: identity
109 109 # USER-AGENT: git-lfs/2.3.4 (Mercurial 4.5.2+1114-f48b9754f04c+20180316)
110 110 # Content-Length: 125
111 111 # Content-Type: application/vnd.git-lfs+json
112 112 #
113 113 # {
114 114 # "objects": [
115 115 # {
116 116 # "oid": "31cf...8e5b"
117 117 # "size": 12
118 118 # }
119 119 # ]
120 120 # "operation": "upload"
121 121 # }
122 122
123 123 if req.method != b'POST':
124 124 _sethttperror(res, HTTP_METHOD_NOT_ALLOWED)
125 125 return True
126 126
127 127 if req.headers[b'Content-Type'] != b'application/vnd.git-lfs+json':
128 128 _sethttperror(res, HTTP_UNSUPPORTED_MEDIA_TYPE)
129 129 return True
130 130
131 131 if req.headers[b'Accept'] != b'application/vnd.git-lfs+json':
132 132 _sethttperror(res, HTTP_NOT_ACCEPTABLE)
133 133 return True
134 134
135 135 # XXX: specify an encoding?
136 136 lfsreq = json.loads(req.bodyfh.read())
137 137
138 138 # If no transfer handlers are explicitly requested, 'basic' is assumed.
139 139 if r'basic' not in lfsreq.get(r'transfers', [r'basic']):
140 140 _sethttperror(
141 141 res,
142 142 HTTP_BAD_REQUEST,
143 143 b'Only the basic LFS transfer handler is supported',
144 144 )
145 145 return True
146 146
147 147 operation = lfsreq.get(r'operation')
148 148 operation = pycompat.bytestr(operation)
149 149
150 150 if operation not in (b'upload', b'download'):
151 151 _sethttperror(
152 152 res,
153 153 HTTP_BAD_REQUEST,
154 154 b'Unsupported LFS transfer operation: %s' % operation,
155 155 )
156 156 return True
157 157
158 158 localstore = repo.svfs.lfslocalblobstore
159 159
160 160 objects = [
161 161 p
162 162 for p in _batchresponseobjects(
163 163 req, lfsreq.get(r'objects', []), operation, localstore
164 164 )
165 165 ]
166 166
167 167 rsp = {
168 168 r'transfer': r'basic',
169 169 r'objects': objects,
170 170 }
171 171
172 172 res.status = hgwebcommon.statusmessage(HTTP_OK)
173 173 res.headers[b'Content-Type'] = b'application/vnd.git-lfs+json'
174 174 res.setbodybytes(pycompat.bytestr(json.dumps(rsp)))
175 175
176 176 return True
177 177
178 178
179 179 def _batchresponseobjects(req, objects, action, store):
180 180 """Yield one dictionary of attributes for the Batch API response for each
181 181 object in the list.
182 182
183 183 req: The parsedrequest for the Batch API request
184 184 objects: The list of objects in the Batch API object request list
185 185 action: 'upload' or 'download'
186 186 store: The local blob store for servicing requests"""
187 187
188 188 # Successful lfs-test-server response to solict an upload:
189 189 # {
190 190 # u'objects': [{
191 191 # u'size': 12,
192 192 # u'oid': u'31cf...8e5b',
193 193 # u'actions': {
194 194 # u'upload': {
195 195 # u'href': u'http://localhost:$HGPORT/objects/31cf...8e5b',
196 196 # u'expires_at': u'0001-01-01T00:00:00Z',
197 197 # u'header': {
198 198 # u'Accept': u'application/vnd.git-lfs'
199 199 # }
200 200 # }
201 201 # }
202 202 # }]
203 203 # }
204 204
205 205 # TODO: Sort out the expires_at/expires_in/authenticated keys.
206 206
207 207 for obj in objects:
208 208 # Convert unicode to ASCII to create a filesystem path
209 209 soid = obj.get(r'oid')
210 210 oid = soid.encode(r'ascii')
211 211 rsp = {
212 212 r'oid': soid,
213 213 r'size': obj.get(r'size'), # XXX: should this check the local size?
214 214 # r'authenticated': True,
215 215 }
216 216
217 217 exists = True
218 218 verifies = False
219 219
220 220 # Verify an existing file on the upload request, so that the client is
221 221 # solicited to re-upload if it corrupt locally. Download requests are
222 222 # also verified, so the error can be flagged in the Batch API response.
223 223 # (Maybe we can use this to short circuit the download for `hg verify`,
224 224 # IFF the client can assert that the remote end is an hg server.)
225 225 # Otherwise, it's potentially overkill on download, since it is also
226 226 # verified as the file is streamed to the caller.
227 227 try:
228 228 verifies = store.verify(oid)
229 229 if verifies and action == b'upload':
230 230 # The client will skip this upload, but make sure it remains
231 231 # available locally.
232 232 store.linkfromusercache(oid)
233 233 except IOError as inst:
234 234 if inst.errno != errno.ENOENT:
235 235 _logexception(req)
236 236
237 237 rsp[r'error'] = {
238 238 r'code': 500,
239 239 r'message': inst.strerror or r'Internal Server Server',
240 240 }
241 241 yield rsp
242 242 continue
243 243
244 244 exists = False
245 245
246 246 # Items are always listed for downloads. They are dropped for uploads
247 247 # IFF they already exist locally.
248 248 if action == b'download':
249 249 if not exists:
250 250 rsp[r'error'] = {
251 251 r'code': 404,
252 252 r'message': r"The object does not exist",
253 253 }
254 254 yield rsp
255 255 continue
256 256
257 257 elif not verifies:
258 258 rsp[r'error'] = {
259 259 r'code': 422, # XXX: is this the right code?
260 260 r'message': r"The object is corrupt",
261 261 }
262 262 yield rsp
263 263 continue
264 264
265 265 elif verifies:
266 266 yield rsp # Skip 'actions': already uploaded
267 267 continue
268 268
269 269 expiresat = datetime.datetime.now() + datetime.timedelta(minutes=10)
270 270
271 271 def _buildheader():
272 272 # The spec doesn't mention the Accept header here, but avoid
273 273 # a gratuitous deviation from lfs-test-server in the test
274 274 # output.
275 275 hdr = {r'Accept': r'application/vnd.git-lfs'}
276 276
277 277 auth = req.headers.get(b'Authorization', b'')
278 278 if auth.startswith(b'Basic '):
279 279 hdr[r'Authorization'] = pycompat.strurl(auth)
280 280
281 281 return hdr
282 282
283 283 rsp[r'actions'] = {
284 284 r'%s'
285 285 % pycompat.strurl(action): {
286 286 r'href': pycompat.strurl(
287 287 b'%s%s/.hg/lfs/objects/%s' % (req.baseurl, req.apppath, oid)
288 288 ),
289 289 # datetime.isoformat() doesn't include the 'Z' suffix
290 290 r"expires_at": expiresat.strftime(r'%Y-%m-%dT%H:%M:%SZ'),
291 291 r'header': _buildheader(),
292 292 }
293 293 }
294 294
295 295 yield rsp
296 296
297 297
298 298 def _processbasictransfer(repo, req, res, checkperm):
299 299 """Handle a single file upload (PUT) or download (GET) action for the Basic
300 300 Transfer Adapter.
301 301
302 302 After determining if the request is for an upload or download, the access
303 303 must be checked by calling ``checkperm()`` with either 'pull' or 'upload'
304 304 before accessing the files.
305 305
306 306 https://github.com/git-lfs/git-lfs/blob/master/docs/api/basic-transfers.md
307 307 """
308 308
309 309 method = req.method
310 310 oid = req.dispatchparts[-1]
311 311 localstore = repo.svfs.lfslocalblobstore
312 312
313 313 if len(req.dispatchparts) != 4:
314 314 _sethttperror(res, HTTP_NOT_FOUND)
315 315 return True
316 316
317 317 if method == b'PUT':
318 318 checkperm(b'upload')
319 319
320 320 # TODO: verify Content-Type?
321 321
322 322 existed = localstore.has(oid)
323 323
324 324 # TODO: how to handle timeouts? The body proxy handles limiting to
325 325 # Content-Length, but what happens if a client sends less than it
326 326 # says it will?
327 327
328 328 statusmessage = hgwebcommon.statusmessage
329 329 try:
330 330 localstore.download(oid, req.bodyfh)
331 331 res.status = statusmessage(HTTP_OK if existed else HTTP_CREATED)
332 332 except blobstore.LfsCorruptionError:
333 333 _logexception(req)
334 334
335 335 # XXX: Is this the right code?
336 336 res.status = statusmessage(422, b'corrupt blob')
337 337
338 338 # There's no payload here, but this is the header that lfs-test-server
339 339 # sends back. This eliminates some gratuitous test output conditionals.
340 340 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
341 341 res.setbodybytes(b'')
342 342
343 343 return True
344 344 elif method == b'GET':
345 345 checkperm(b'pull')
346 346
347 347 res.status = hgwebcommon.statusmessage(HTTP_OK)
348 348 res.headers[b'Content-Type'] = b'application/octet-stream'
349 349
350 350 try:
351 351 # TODO: figure out how to send back the file in chunks, instead of
352 352 # reading the whole thing. (Also figure out how to send back
353 353 # an error status if an IOError occurs after a partial write
354 354 # in that case. Here, everything is read before starting.)
355 355 res.setbodybytes(localstore.read(oid))
356 356 except blobstore.LfsCorruptionError:
357 357 _logexception(req)
358 358
359 359 # XXX: Is this the right code?
360 360 res.status = hgwebcommon.statusmessage(422, b'corrupt blob')
361 361 res.setbodybytes(b'')
362 362
363 363 return True
364 364 else:
365 365 _sethttperror(
366 366 res,
367 367 HTTP_METHOD_NOT_ALLOWED,
368 368 message=b'Unsupported LFS transfer method: %s' % method,
369 369 )
370 370 return True
@@ -1,360 +1,360 b''
1 1 # narrowbundle2.py - bundle2 extensions for narrow repository support
2 2 #
3 3 # Copyright 2017 Google, 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
8 8 from __future__ import absolute_import
9 9
10 10 import errno
11 11 import struct
12 12
13 13 from mercurial.i18n import _
14 14 from mercurial.node import (
15 15 bin,
16 16 nullid,
17 17 )
18 18 from mercurial import (
19 19 bundle2,
20 20 changegroup,
21 21 error,
22 22 exchange,
23 23 localrepo,
24 24 narrowspec,
25 25 repair,
26 26 util,
27 27 wireprototypes,
28 28 )
29 29 from mercurial.interfaces import repository
30 30 from mercurial.utils import stringutil
31 31
32 32 _NARROWACL_SECTION = b'narrowacl'
33 33 _CHANGESPECPART = b'narrow:changespec'
34 34 _RESSPECS = b'narrow:responsespec'
35 35 _SPECPART = b'narrow:spec'
36 36 _SPECPART_INCLUDE = b'include'
37 37 _SPECPART_EXCLUDE = b'exclude'
38 38 _KILLNODESIGNAL = b'KILL'
39 39 _DONESIGNAL = b'DONE'
40 40 _ELIDEDCSHEADER = b'>20s20s20sl' # cset id, p1, p2, len(text)
41 41 _ELIDEDMFHEADER = b'>20s20s20s20sl' # manifest id, p1, p2, link id, len(text)
42 42 _CSHEADERSIZE = struct.calcsize(_ELIDEDCSHEADER)
43 43 _MFHEADERSIZE = struct.calcsize(_ELIDEDMFHEADER)
44 44
45 45 # Serve a changegroup for a client with a narrow clone.
46 46 def getbundlechangegrouppart_narrow(
47 47 bundler,
48 48 repo,
49 49 source,
50 50 bundlecaps=None,
51 51 b2caps=None,
52 52 heads=None,
53 53 common=None,
54 54 **kwargs
55 55 ):
56 56 assert repo.ui.configbool(b'experimental', b'narrowservebrokenellipses')
57 57
58 58 cgversions = b2caps.get(b'changegroup')
59 59 cgversions = [
60 60 v
61 61 for v in cgversions
62 62 if v in changegroup.supportedoutgoingversions(repo)
63 63 ]
64 64 if not cgversions:
65 65 raise ValueError(_(b'no common changegroup version'))
66 66 version = max(cgversions)
67 67
68 68 oldinclude = sorted(filter(bool, kwargs.get(r'oldincludepats', [])))
69 69 oldexclude = sorted(filter(bool, kwargs.get(r'oldexcludepats', [])))
70 70 newinclude = sorted(filter(bool, kwargs.get(r'includepats', [])))
71 71 newexclude = sorted(filter(bool, kwargs.get(r'excludepats', [])))
72 72 known = {bin(n) for n in kwargs.get(r'known', [])}
73 73 generateellipsesbundle2(
74 74 bundler,
75 75 repo,
76 76 oldinclude,
77 77 oldexclude,
78 78 newinclude,
79 79 newexclude,
80 80 version,
81 81 common,
82 82 heads,
83 83 known,
84 84 kwargs.get(r'depth', None),
85 85 )
86 86
87 87
88 88 def generateellipsesbundle2(
89 89 bundler,
90 90 repo,
91 91 oldinclude,
92 92 oldexclude,
93 93 newinclude,
94 94 newexclude,
95 95 version,
96 96 common,
97 97 heads,
98 98 known,
99 99 depth,
100 100 ):
101 101 newmatch = narrowspec.match(
102 102 repo.root, include=newinclude, exclude=newexclude
103 103 )
104 104 if depth is not None:
105 105 depth = int(depth)
106 106 if depth < 1:
107 107 raise error.Abort(_(b'depth must be positive, got %d') % depth)
108 108
109 109 heads = set(heads or repo.heads())
110 110 common = set(common or [nullid])
111 111 if known and (oldinclude != newinclude or oldexclude != newexclude):
112 112 # Steps:
113 113 # 1. Send kill for "$known & ::common"
114 114 #
115 115 # 2. Send changegroup for ::common
116 116 #
117 117 # 3. Proceed.
118 118 #
119 119 # In the future, we can send kills for only the specific
120 120 # nodes we know should go away or change shape, and then
121 121 # send a data stream that tells the client something like this:
122 122 #
123 123 # a) apply this changegroup
124 124 # b) apply nodes XXX, YYY, ZZZ that you already have
125 125 # c) goto a
126 126 #
127 127 # until they've built up the full new state.
128 128 # Convert to revnums and intersect with "common". The client should
129 129 # have made it a subset of "common" already, but let's be safe.
130 130 known = set(repo.revs(b"%ln & ::%ln", known, common))
131 131 # TODO: we could send only roots() of this set, and the
132 132 # list of nodes in common, and the client could work out
133 133 # what to strip, instead of us explicitly sending every
134 134 # single node.
135 135 deadrevs = known
136 136
137 137 def genkills():
138 138 for r in deadrevs:
139 139 yield _KILLNODESIGNAL
140 140 yield repo.changelog.node(r)
141 141 yield _DONESIGNAL
142 142
143 143 bundler.newpart(_CHANGESPECPART, data=genkills())
144 144 newvisit, newfull, newellipsis = exchange._computeellipsis(
145 145 repo, set(), common, known, newmatch
146 146 )
147 147 if newvisit:
148 148 packer = changegroup.getbundler(
149 149 version,
150 150 repo,
151 151 matcher=newmatch,
152 152 ellipses=True,
153 153 shallow=depth is not None,
154 154 ellipsisroots=newellipsis,
155 155 fullnodes=newfull,
156 156 )
157 157 cgdata = packer.generate(common, newvisit, False, b'narrow_widen')
158 158
159 159 part = bundler.newpart(b'changegroup', data=cgdata)
160 160 part.addparam(b'version', version)
161 161 if b'treemanifest' in repo.requirements:
162 162 part.addparam(b'treemanifest', b'1')
163 163
164 164 visitnodes, relevant_nodes, ellipsisroots = exchange._computeellipsis(
165 165 repo, common, heads, set(), newmatch, depth=depth
166 166 )
167 167
168 168 repo.ui.debug(b'Found %d relevant revs\n' % len(relevant_nodes))
169 169 if visitnodes:
170 170 packer = changegroup.getbundler(
171 171 version,
172 172 repo,
173 173 matcher=newmatch,
174 174 ellipses=True,
175 175 shallow=depth is not None,
176 176 ellipsisroots=ellipsisroots,
177 177 fullnodes=relevant_nodes,
178 178 )
179 179 cgdata = packer.generate(common, visitnodes, False, b'narrow_widen')
180 180
181 181 part = bundler.newpart(b'changegroup', data=cgdata)
182 182 part.addparam(b'version', version)
183 183 if b'treemanifest' in repo.requirements:
184 184 part.addparam(b'treemanifest', b'1')
185 185
186 186
187 187 @bundle2.parthandler(_SPECPART, (_SPECPART_INCLUDE, _SPECPART_EXCLUDE))
188 188 def _handlechangespec_2(op, inpart):
189 189 # XXX: This bundle2 handling is buggy and should be removed after hg5.2 is
190 190 # released. New servers will send a mandatory bundle2 part named
191 191 # 'Narrowspec' and will send specs as data instead of params.
192 192 # Refer to issue5952 and 6019
193 193 includepats = set(inpart.params.get(_SPECPART_INCLUDE, b'').splitlines())
194 194 excludepats = set(inpart.params.get(_SPECPART_EXCLUDE, b'').splitlines())
195 195 narrowspec.validatepatterns(includepats)
196 196 narrowspec.validatepatterns(excludepats)
197 197
198 198 if not repository.NARROW_REQUIREMENT in op.repo.requirements:
199 199 op.repo.requirements.add(repository.NARROW_REQUIREMENT)
200 200 op.repo._writerequirements()
201 201 op.repo.setnarrowpats(includepats, excludepats)
202 202 narrowspec.copytoworkingcopy(op.repo)
203 203
204 204
205 205 @bundle2.parthandler(_RESSPECS)
206 206 def _handlenarrowspecs(op, inpart):
207 207 data = inpart.read()
208 208 inc, exc = data.split(b'\0')
209 209 includepats = set(inc.splitlines())
210 210 excludepats = set(exc.splitlines())
211 211 narrowspec.validatepatterns(includepats)
212 212 narrowspec.validatepatterns(excludepats)
213 213
214 214 if repository.NARROW_REQUIREMENT not in op.repo.requirements:
215 215 op.repo.requirements.add(repository.NARROW_REQUIREMENT)
216 216 op.repo._writerequirements()
217 217 op.repo.setnarrowpats(includepats, excludepats)
218 218 narrowspec.copytoworkingcopy(op.repo)
219 219
220 220
221 221 @bundle2.parthandler(_CHANGESPECPART)
222 222 def _handlechangespec(op, inpart):
223 223 repo = op.repo
224 224 cl = repo.changelog
225 225
226 226 # changesets which need to be stripped entirely. either they're no longer
227 227 # needed in the new narrow spec, or the server is sending a replacement
228 228 # in the changegroup part.
229 229 clkills = set()
230 230
231 231 # A changespec part contains all the updates to ellipsis nodes
232 232 # that will happen as a result of widening or narrowing a
233 233 # repo. All the changes that this block encounters are ellipsis
234 234 # nodes or flags to kill an existing ellipsis.
235 235 chunksignal = changegroup.readexactly(inpart, 4)
236 236 while chunksignal != _DONESIGNAL:
237 237 if chunksignal == _KILLNODESIGNAL:
238 238 # a node used to be an ellipsis but isn't anymore
239 239 ck = changegroup.readexactly(inpart, 20)
240 240 if cl.hasnode(ck):
241 241 clkills.add(ck)
242 242 else:
243 243 raise error.Abort(
244 244 _(b'unexpected changespec node chunk type: %s') % chunksignal
245 245 )
246 246 chunksignal = changegroup.readexactly(inpart, 4)
247 247
248 248 if clkills:
249 249 # preserve bookmarks that repair.strip() would otherwise strip
250 250 op._bookmarksbackup = repo._bookmarks
251 251
252 252 class dummybmstore(dict):
253 253 def applychanges(self, repo, tr, changes):
254 254 pass
255 255
256 256 localrepo.localrepository._bookmarks.set(repo, dummybmstore())
257 257 chgrpfile = repair.strip(
258 258 op.ui, repo, list(clkills), backup=True, topic=b'widen'
259 259 )
260 260 if chgrpfile:
261 261 op._widen_uninterr = repo.ui.uninterruptible()
262 262 op._widen_uninterr.__enter__()
263 263 # presence of _widen_bundle attribute activates widen handler later
264 264 op._widen_bundle = chgrpfile
265 265 # Set the new narrowspec if we're widening. The setnewnarrowpats() method
266 266 # will currently always be there when using the core+narrowhg server, but
267 267 # other servers may include a changespec part even when not widening (e.g.
268 268 # because we're deepening a shallow repo).
269 if util.safehasattr(repo, b'setnewnarrowpats'):
269 if util.safehasattr(repo, 'setnewnarrowpats'):
270 270 repo.setnewnarrowpats()
271 271
272 272
273 273 def handlechangegroup_widen(op, inpart):
274 274 """Changegroup exchange handler which restores temporarily-stripped nodes"""
275 275 # We saved a bundle with stripped node data we must now restore.
276 276 # This approach is based on mercurial/repair.py@6ee26a53c111.
277 277 repo = op.repo
278 278 ui = op.ui
279 279
280 280 chgrpfile = op._widen_bundle
281 281 del op._widen_bundle
282 282 vfs = repo.vfs
283 283
284 284 ui.note(_(b"adding branch\n"))
285 285 f = vfs.open(chgrpfile, b"rb")
286 286 try:
287 287 gen = exchange.readbundle(ui, f, chgrpfile, vfs)
288 288 # silence internal shuffling chatter
289 289 override = {(b'ui', b'quiet'): True}
290 290 if ui.verbose:
291 291 override = {}
292 292 with ui.configoverride(override):
293 293 if isinstance(gen, bundle2.unbundle20):
294 294 with repo.transaction(b'strip') as tr:
295 295 bundle2.processbundle(repo, gen, lambda: tr)
296 296 else:
297 297 gen.apply(
298 298 repo, b'strip', b'bundle:' + vfs.join(chgrpfile), True
299 299 )
300 300 finally:
301 301 f.close()
302 302
303 303 # remove undo files
304 304 for undovfs, undofile in repo.undofiles():
305 305 try:
306 306 undovfs.unlink(undofile)
307 307 except OSError as e:
308 308 if e.errno != errno.ENOENT:
309 309 ui.warn(
310 310 _(b'error removing %s: %s\n')
311 311 % (undovfs.join(undofile), stringutil.forcebytestr(e))
312 312 )
313 313
314 314 # Remove partial backup only if there were no exceptions
315 315 op._widen_uninterr.__exit__(None, None, None)
316 316 vfs.unlink(chgrpfile)
317 317
318 318
319 319 def setup():
320 320 """Enable narrow repo support in bundle2-related extension points."""
321 321 getbundleargs = wireprototypes.GETBUNDLE_ARGUMENTS
322 322
323 323 getbundleargs[b'narrow'] = b'boolean'
324 324 getbundleargs[b'depth'] = b'plain'
325 325 getbundleargs[b'oldincludepats'] = b'csv'
326 326 getbundleargs[b'oldexcludepats'] = b'csv'
327 327 getbundleargs[b'known'] = b'csv'
328 328
329 329 # Extend changegroup serving to handle requests from narrow clients.
330 330 origcgfn = exchange.getbundle2partsmapping[b'changegroup']
331 331
332 332 def wrappedcgfn(*args, **kwargs):
333 333 repo = args[1]
334 334 if repo.ui.has_section(_NARROWACL_SECTION):
335 335 kwargs = exchange.applynarrowacl(repo, kwargs)
336 336
337 337 if kwargs.get(r'narrow', False) and repo.ui.configbool(
338 338 b'experimental', b'narrowservebrokenellipses'
339 339 ):
340 340 getbundlechangegrouppart_narrow(*args, **kwargs)
341 341 else:
342 342 origcgfn(*args, **kwargs)
343 343
344 344 exchange.getbundle2partsmapping[b'changegroup'] = wrappedcgfn
345 345
346 346 # Extend changegroup receiver so client can fixup after widen requests.
347 347 origcghandler = bundle2.parthandlermapping[b'changegroup']
348 348
349 349 def wrappedcghandler(op, inpart):
350 350 origcghandler(op, inpart)
351 if util.safehasattr(op, b'_widen_bundle'):
351 if util.safehasattr(op, '_widen_bundle'):
352 352 handlechangegroup_widen(op, inpart)
353 if util.safehasattr(op, b'_bookmarksbackup'):
353 if util.safehasattr(op, '_bookmarksbackup'):
354 354 localrepo.localrepository._bookmarks.set(
355 355 op.repo, op._bookmarksbackup
356 356 )
357 357 del op._bookmarksbackup
358 358
359 359 wrappedcghandler.params = origcghandler.params
360 360 bundle2.parthandlermapping[b'changegroup'] = wrappedcghandler
@@ -1,88 +1,88 b''
1 1 # connectionpool.py - class for pooling peer connections for reuse
2 2 #
3 3 # Copyright 2017 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
8 8 from __future__ import absolute_import
9 9
10 10 from mercurial import (
11 11 extensions,
12 12 hg,
13 13 pycompat,
14 14 sshpeer,
15 15 util,
16 16 )
17 17
18 18 _sshv1peer = sshpeer.sshv1peer
19 19
20 20
21 21 class connectionpool(object):
22 22 def __init__(self, repo):
23 23 self._repo = repo
24 24 self._pool = dict()
25 25
26 26 def get(self, path):
27 27 pathpool = self._pool.get(path)
28 28 if pathpool is None:
29 29 pathpool = list()
30 30 self._pool[path] = pathpool
31 31
32 32 conn = None
33 33 if len(pathpool) > 0:
34 34 try:
35 35 conn = pathpool.pop()
36 36 peer = conn.peer
37 37 # If the connection has died, drop it
38 38 if isinstance(peer, _sshv1peer):
39 39 if peer._subprocess.poll() is not None:
40 40 conn = None
41 41 except IndexError:
42 42 pass
43 43
44 44 if conn is None:
45 45
46 46 def _cleanup(orig):
47 47 # close pipee first so peer.cleanup reading it won't deadlock,
48 48 # if there are other processes with pipeo open (i.e. us).
49 49 peer = orig.im_self
50 if util.safehasattr(peer, b'pipee'):
50 if util.safehasattr(peer, 'pipee'):
51 51 peer.pipee.close()
52 52 return orig()
53 53
54 54 peer = hg.peer(self._repo.ui, {}, path)
55 if util.safehasattr(peer, b'cleanup'):
55 if util.safehasattr(peer, 'cleanup'):
56 56 extensions.wrapfunction(peer, b'cleanup', _cleanup)
57 57
58 58 conn = connection(pathpool, peer)
59 59
60 60 return conn
61 61
62 62 def close(self):
63 63 for pathpool in pycompat.itervalues(self._pool):
64 64 for conn in pathpool:
65 65 conn.close()
66 66 del pathpool[:]
67 67
68 68
69 69 class connection(object):
70 70 def __init__(self, pool, peer):
71 71 self._pool = pool
72 72 self.peer = peer
73 73
74 74 def __enter__(self):
75 75 return self
76 76
77 77 def __exit__(self, type, value, traceback):
78 78 # Only add the connection back to the pool if there was no exception,
79 79 # since an exception could mean the connection is not in a reusable
80 80 # state.
81 81 if type is None:
82 82 self._pool.append(self)
83 83 else:
84 84 self.close()
85 85
86 86 def close(self):
87 if util.safehasattr(self.peer, b'cleanup'):
87 if util.safehasattr(self.peer, 'cleanup'):
88 88 self.peer.cleanup()
@@ -1,667 +1,667 b''
1 1 # fileserverclient.py - client for communicating with the cache process
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
8 8 from __future__ import absolute_import
9 9
10 10 import hashlib
11 11 import io
12 12 import os
13 13 import threading
14 14 import time
15 15 import zlib
16 16
17 17 from mercurial.i18n import _
18 18 from mercurial.node import bin, hex, nullid
19 19 from mercurial import (
20 20 error,
21 21 node,
22 22 pycompat,
23 23 revlog,
24 24 sshpeer,
25 25 util,
26 26 wireprotov1peer,
27 27 )
28 28 from mercurial.utils import procutil
29 29
30 30 from . import (
31 31 constants,
32 32 contentstore,
33 33 metadatastore,
34 34 )
35 35
36 36 _sshv1peer = sshpeer.sshv1peer
37 37
38 38 # Statistics for debugging
39 39 fetchcost = 0
40 40 fetches = 0
41 41 fetched = 0
42 42 fetchmisses = 0
43 43
44 44 _lfsmod = None
45 45
46 46
47 47 def getcachekey(reponame, file, id):
48 48 pathhash = node.hex(hashlib.sha1(file).digest())
49 49 return os.path.join(reponame, pathhash[:2], pathhash[2:], id)
50 50
51 51
52 52 def getlocalkey(file, id):
53 53 pathhash = node.hex(hashlib.sha1(file).digest())
54 54 return os.path.join(pathhash, id)
55 55
56 56
57 57 def peersetup(ui, peer):
58 58 class remotefilepeer(peer.__class__):
59 59 @wireprotov1peer.batchable
60 60 def x_rfl_getfile(self, file, node):
61 61 if not self.capable(b'x_rfl_getfile'):
62 62 raise error.Abort(
63 63 b'configured remotefile server does not support getfile'
64 64 )
65 65 f = wireprotov1peer.future()
66 66 yield {b'file': file, b'node': node}, f
67 67 code, data = f.value.split(b'\0', 1)
68 68 if int(code):
69 69 raise error.LookupError(file, node, data)
70 70 yield data
71 71
72 72 @wireprotov1peer.batchable
73 73 def x_rfl_getflogheads(self, path):
74 74 if not self.capable(b'x_rfl_getflogheads'):
75 75 raise error.Abort(
76 76 b'configured remotefile server does not '
77 77 b'support getflogheads'
78 78 )
79 79 f = wireprotov1peer.future()
80 80 yield {b'path': path}, f
81 81 heads = f.value.split(b'\n') if f.value else []
82 82 yield heads
83 83
84 84 def _updatecallstreamopts(self, command, opts):
85 85 if command != b'getbundle':
86 86 return
87 87 if (
88 88 constants.NETWORK_CAP_LEGACY_SSH_GETFILES
89 89 not in self.capabilities()
90 90 ):
91 91 return
92 if not util.safehasattr(self, b'_localrepo'):
92 if not util.safehasattr(self, '_localrepo'):
93 93 return
94 94 if (
95 95 constants.SHALLOWREPO_REQUIREMENT
96 96 not in self._localrepo.requirements
97 97 ):
98 98 return
99 99
100 100 bundlecaps = opts.get(b'bundlecaps')
101 101 if bundlecaps:
102 102 bundlecaps = [bundlecaps]
103 103 else:
104 104 bundlecaps = []
105 105
106 106 # shallow, includepattern, and excludepattern are a hacky way of
107 107 # carrying over data from the local repo to this getbundle
108 108 # command. We need to do it this way because bundle1 getbundle
109 109 # doesn't provide any other place we can hook in to manipulate
110 110 # getbundle args before it goes across the wire. Once we get rid
111 111 # of bundle1, we can use bundle2's _pullbundle2extraprepare to
112 112 # do this more cleanly.
113 113 bundlecaps.append(constants.BUNDLE2_CAPABLITY)
114 114 if self._localrepo.includepattern:
115 115 patterns = b'\0'.join(self._localrepo.includepattern)
116 116 includecap = b"includepattern=" + patterns
117 117 bundlecaps.append(includecap)
118 118 if self._localrepo.excludepattern:
119 119 patterns = b'\0'.join(self._localrepo.excludepattern)
120 120 excludecap = b"excludepattern=" + patterns
121 121 bundlecaps.append(excludecap)
122 122 opts[b'bundlecaps'] = b','.join(bundlecaps)
123 123
124 124 def _sendrequest(self, command, args, **opts):
125 125 self._updatecallstreamopts(command, args)
126 126 return super(remotefilepeer, self)._sendrequest(
127 127 command, args, **opts
128 128 )
129 129
130 130 def _callstream(self, command, **opts):
131 131 supertype = super(remotefilepeer, self)
132 if not util.safehasattr(supertype, b'_sendrequest'):
132 if not util.safehasattr(supertype, '_sendrequest'):
133 133 self._updatecallstreamopts(command, pycompat.byteskwargs(opts))
134 134 return super(remotefilepeer, self)._callstream(command, **opts)
135 135
136 136 peer.__class__ = remotefilepeer
137 137
138 138
139 139 class cacheconnection(object):
140 140 """The connection for communicating with the remote cache. Performs
141 141 gets and sets by communicating with an external process that has the
142 142 cache-specific implementation.
143 143 """
144 144
145 145 def __init__(self):
146 146 self.pipeo = self.pipei = self.pipee = None
147 147 self.subprocess = None
148 148 self.connected = False
149 149
150 150 def connect(self, cachecommand):
151 151 if self.pipeo:
152 152 raise error.Abort(_(b"cache connection already open"))
153 153 self.pipei, self.pipeo, self.pipee, self.subprocess = procutil.popen4(
154 154 cachecommand
155 155 )
156 156 self.connected = True
157 157
158 158 def close(self):
159 159 def tryclose(pipe):
160 160 try:
161 161 pipe.close()
162 162 except Exception:
163 163 pass
164 164
165 165 if self.connected:
166 166 try:
167 167 self.pipei.write(b"exit\n")
168 168 except Exception:
169 169 pass
170 170 tryclose(self.pipei)
171 171 self.pipei = None
172 172 tryclose(self.pipeo)
173 173 self.pipeo = None
174 174 tryclose(self.pipee)
175 175 self.pipee = None
176 176 try:
177 177 # Wait for process to terminate, making sure to avoid deadlock.
178 178 # See https://docs.python.org/2/library/subprocess.html for
179 179 # warnings about wait() and deadlocking.
180 180 self.subprocess.communicate()
181 181 except Exception:
182 182 pass
183 183 self.subprocess = None
184 184 self.connected = False
185 185
186 186 def request(self, request, flush=True):
187 187 if self.connected:
188 188 try:
189 189 self.pipei.write(request)
190 190 if flush:
191 191 self.pipei.flush()
192 192 except IOError:
193 193 self.close()
194 194
195 195 def receiveline(self):
196 196 if not self.connected:
197 197 return None
198 198 try:
199 199 result = self.pipeo.readline()[:-1]
200 200 if not result:
201 201 self.close()
202 202 except IOError:
203 203 self.close()
204 204
205 205 return result
206 206
207 207
208 208 def _getfilesbatch(
209 209 remote, receivemissing, progresstick, missed, idmap, batchsize
210 210 ):
211 211 # Over http(s), iterbatch is a streamy method and we can start
212 212 # looking at results early. This means we send one (potentially
213 213 # large) request, but then we show nice progress as we process
214 214 # file results, rather than showing chunks of $batchsize in
215 215 # progress.
216 216 #
217 217 # Over ssh, iterbatch isn't streamy because batch() wasn't
218 218 # explicitly designed as a streaming method. In the future we
219 219 # should probably introduce a streambatch() method upstream and
220 220 # use that for this.
221 221 with remote.commandexecutor() as e:
222 222 futures = []
223 223 for m in missed:
224 224 futures.append(
225 225 e.callcommand(
226 226 b'x_rfl_getfile', {b'file': idmap[m], b'node': m[-40:]}
227 227 )
228 228 )
229 229
230 230 for i, m in enumerate(missed):
231 231 r = futures[i].result()
232 232 futures[i] = None # release memory
233 233 file_ = idmap[m]
234 234 node = m[-40:]
235 235 receivemissing(io.BytesIO(b'%d\n%s' % (len(r), r)), file_, node)
236 236 progresstick()
237 237
238 238
239 239 def _getfiles_optimistic(
240 240 remote, receivemissing, progresstick, missed, idmap, step
241 241 ):
242 242 remote._callstream(b"x_rfl_getfiles")
243 243 i = 0
244 244 pipeo = remote._pipeo
245 245 pipei = remote._pipei
246 246 while i < len(missed):
247 247 # issue a batch of requests
248 248 start = i
249 249 end = min(len(missed), start + step)
250 250 i = end
251 251 for missingid in missed[start:end]:
252 252 # issue new request
253 253 versionid = missingid[-40:]
254 254 file = idmap[missingid]
255 255 sshrequest = b"%s%s\n" % (versionid, file)
256 256 pipeo.write(sshrequest)
257 257 pipeo.flush()
258 258
259 259 # receive batch results
260 260 for missingid in missed[start:end]:
261 261 versionid = missingid[-40:]
262 262 file = idmap[missingid]
263 263 receivemissing(pipei, file, versionid)
264 264 progresstick()
265 265
266 266 # End the command
267 267 pipeo.write(b'\n')
268 268 pipeo.flush()
269 269
270 270
271 271 def _getfiles_threaded(
272 272 remote, receivemissing, progresstick, missed, idmap, step
273 273 ):
274 274 remote._callstream(b"getfiles")
275 275 pipeo = remote._pipeo
276 276 pipei = remote._pipei
277 277
278 278 def writer():
279 279 for missingid in missed:
280 280 versionid = missingid[-40:]
281 281 file = idmap[missingid]
282 282 sshrequest = b"%s%s\n" % (versionid, file)
283 283 pipeo.write(sshrequest)
284 284 pipeo.flush()
285 285
286 286 writerthread = threading.Thread(target=writer)
287 287 writerthread.daemon = True
288 288 writerthread.start()
289 289
290 290 for missingid in missed:
291 291 versionid = missingid[-40:]
292 292 file = idmap[missingid]
293 293 receivemissing(pipei, file, versionid)
294 294 progresstick()
295 295
296 296 writerthread.join()
297 297 # End the command
298 298 pipeo.write(b'\n')
299 299 pipeo.flush()
300 300
301 301
302 302 class fileserverclient(object):
303 303 """A client for requesting files from the remote file server.
304 304 """
305 305
306 306 def __init__(self, repo):
307 307 ui = repo.ui
308 308 self.repo = repo
309 309 self.ui = ui
310 310 self.cacheprocess = ui.config(b"remotefilelog", b"cacheprocess")
311 311 if self.cacheprocess:
312 312 self.cacheprocess = util.expandpath(self.cacheprocess)
313 313
314 314 # This option causes remotefilelog to pass the full file path to the
315 315 # cacheprocess instead of a hashed key.
316 316 self.cacheprocesspasspath = ui.configbool(
317 317 b"remotefilelog", b"cacheprocess.includepath"
318 318 )
319 319
320 320 self.debugoutput = ui.configbool(b"remotefilelog", b"debug")
321 321
322 322 self.remotecache = cacheconnection()
323 323
324 324 def setstore(self, datastore, historystore, writedata, writehistory):
325 325 self.datastore = datastore
326 326 self.historystore = historystore
327 327 self.writedata = writedata
328 328 self.writehistory = writehistory
329 329
330 330 def _connect(self):
331 331 return self.repo.connectionpool.get(self.repo.fallbackpath)
332 332
333 333 def request(self, fileids):
334 334 """Takes a list of filename/node pairs and fetches them from the
335 335 server. Files are stored in the local cache.
336 336 A list of nodes that the server couldn't find is returned.
337 337 If the connection fails, an exception is raised.
338 338 """
339 339 if not self.remotecache.connected:
340 340 self.connect()
341 341 cache = self.remotecache
342 342 writedata = self.writedata
343 343
344 344 repo = self.repo
345 345 total = len(fileids)
346 346 request = b"get\n%d\n" % total
347 347 idmap = {}
348 348 reponame = repo.name
349 349 for file, id in fileids:
350 350 fullid = getcachekey(reponame, file, id)
351 351 if self.cacheprocesspasspath:
352 352 request += file + b'\0'
353 353 request += fullid + b"\n"
354 354 idmap[fullid] = file
355 355
356 356 cache.request(request)
357 357
358 358 progress = self.ui.makeprogress(_(b'downloading'), total=total)
359 359 progress.update(0)
360 360
361 361 missed = []
362 362 while True:
363 363 missingid = cache.receiveline()
364 364 if not missingid:
365 365 missedset = set(missed)
366 366 for missingid in idmap:
367 367 if not missingid in missedset:
368 368 missed.append(missingid)
369 369 self.ui.warn(
370 370 _(
371 371 b"warning: cache connection closed early - "
372 372 + b"falling back to server\n"
373 373 )
374 374 )
375 375 break
376 376 if missingid == b"0":
377 377 break
378 378 if missingid.startswith(b"_hits_"):
379 379 # receive progress reports
380 380 parts = missingid.split(b"_")
381 381 progress.increment(int(parts[2]))
382 382 continue
383 383
384 384 missed.append(missingid)
385 385
386 386 global fetchmisses
387 387 fetchmisses += len(missed)
388 388
389 389 fromcache = total - len(missed)
390 390 progress.update(fromcache, total=total)
391 391 self.ui.log(
392 392 b"remotefilelog",
393 393 b"remote cache hit rate is %r of %r\n",
394 394 fromcache,
395 395 total,
396 396 hit=fromcache,
397 397 total=total,
398 398 )
399 399
400 400 oldumask = os.umask(0o002)
401 401 try:
402 402 # receive cache misses from master
403 403 if missed:
404 404 # When verbose is true, sshpeer prints 'running ssh...'
405 405 # to stdout, which can interfere with some command
406 406 # outputs
407 407 verbose = self.ui.verbose
408 408 self.ui.verbose = False
409 409 try:
410 410 with self._connect() as conn:
411 411 remote = conn.peer
412 412 if remote.capable(
413 413 constants.NETWORK_CAP_LEGACY_SSH_GETFILES
414 414 ):
415 415 if not isinstance(remote, _sshv1peer):
416 416 raise error.Abort(
417 417 b'remotefilelog requires ssh ' b'servers'
418 418 )
419 419 step = self.ui.configint(
420 420 b'remotefilelog', b'getfilesstep'
421 421 )
422 422 getfilestype = self.ui.config(
423 423 b'remotefilelog', b'getfilestype'
424 424 )
425 425 if getfilestype == b'threaded':
426 426 _getfiles = _getfiles_threaded
427 427 else:
428 428 _getfiles = _getfiles_optimistic
429 429 _getfiles(
430 430 remote,
431 431 self.receivemissing,
432 432 progress.increment,
433 433 missed,
434 434 idmap,
435 435 step,
436 436 )
437 437 elif remote.capable(b"x_rfl_getfile"):
438 438 if remote.capable(b'batch'):
439 439 batchdefault = 100
440 440 else:
441 441 batchdefault = 10
442 442 batchsize = self.ui.configint(
443 443 b'remotefilelog', b'batchsize', batchdefault
444 444 )
445 445 self.ui.debug(
446 446 b'requesting %d files from '
447 447 b'remotefilelog server...\n' % len(missed)
448 448 )
449 449 _getfilesbatch(
450 450 remote,
451 451 self.receivemissing,
452 452 progress.increment,
453 453 missed,
454 454 idmap,
455 455 batchsize,
456 456 )
457 457 else:
458 458 raise error.Abort(
459 459 b"configured remotefilelog server"
460 460 b" does not support remotefilelog"
461 461 )
462 462
463 463 self.ui.log(
464 464 b"remotefilefetchlog",
465 465 b"Success\n",
466 466 fetched_files=progress.pos - fromcache,
467 467 total_to_fetch=total - fromcache,
468 468 )
469 469 except Exception:
470 470 self.ui.log(
471 471 b"remotefilefetchlog",
472 472 b"Fail\n",
473 473 fetched_files=progress.pos - fromcache,
474 474 total_to_fetch=total - fromcache,
475 475 )
476 476 raise
477 477 finally:
478 478 self.ui.verbose = verbose
479 479 # send to memcache
480 480 request = b"set\n%d\n%s\n" % (len(missed), b"\n".join(missed))
481 481 cache.request(request)
482 482
483 483 progress.complete()
484 484
485 485 # mark ourselves as a user of this cache
486 486 writedata.markrepo(self.repo.path)
487 487 finally:
488 488 os.umask(oldumask)
489 489
490 490 def receivemissing(self, pipe, filename, node):
491 491 line = pipe.readline()[:-1]
492 492 if not line:
493 493 raise error.ResponseError(
494 494 _(b"error downloading file contents:"),
495 495 _(b"connection closed early"),
496 496 )
497 497 size = int(line)
498 498 data = pipe.read(size)
499 499 if len(data) != size:
500 500 raise error.ResponseError(
501 501 _(b"error downloading file contents:"),
502 502 _(b"only received %s of %s bytes") % (len(data), size),
503 503 )
504 504
505 505 self.writedata.addremotefilelognode(
506 506 filename, bin(node), zlib.decompress(data)
507 507 )
508 508
509 509 def connect(self):
510 510 if self.cacheprocess:
511 511 cmd = b"%s %s" % (self.cacheprocess, self.writedata._path)
512 512 self.remotecache.connect(cmd)
513 513 else:
514 514 # If no cache process is specified, we fake one that always
515 515 # returns cache misses. This enables tests to run easily
516 516 # and may eventually allow us to be a drop in replacement
517 517 # for the largefiles extension.
518 518 class simplecache(object):
519 519 def __init__(self):
520 520 self.missingids = []
521 521 self.connected = True
522 522
523 523 def close(self):
524 524 pass
525 525
526 526 def request(self, value, flush=True):
527 527 lines = value.split(b"\n")
528 528 if lines[0] != b"get":
529 529 return
530 530 self.missingids = lines[2:-1]
531 531 self.missingids.append(b'0')
532 532
533 533 def receiveline(self):
534 534 if len(self.missingids) > 0:
535 535 return self.missingids.pop(0)
536 536 return None
537 537
538 538 self.remotecache = simplecache()
539 539
540 540 def close(self):
541 541 if fetches:
542 542 msg = (
543 543 b"%d files fetched over %d fetches - "
544 544 + b"(%d misses, %0.2f%% hit ratio) over %0.2fs\n"
545 545 ) % (
546 546 fetched,
547 547 fetches,
548 548 fetchmisses,
549 549 float(fetched - fetchmisses) / float(fetched) * 100.0,
550 550 fetchcost,
551 551 )
552 552 if self.debugoutput:
553 553 self.ui.warn(msg)
554 554 self.ui.log(
555 555 b"remotefilelog.prefetch",
556 556 msg.replace(b"%", b"%%"),
557 557 remotefilelogfetched=fetched,
558 558 remotefilelogfetches=fetches,
559 559 remotefilelogfetchmisses=fetchmisses,
560 560 remotefilelogfetchtime=fetchcost * 1000,
561 561 )
562 562
563 563 if self.remotecache.connected:
564 564 self.remotecache.close()
565 565
566 566 def prefetch(
567 567 self, fileids, force=False, fetchdata=True, fetchhistory=False
568 568 ):
569 569 """downloads the given file versions to the cache
570 570 """
571 571 repo = self.repo
572 572 idstocheck = []
573 573 for file, id in fileids:
574 574 # hack
575 575 # - we don't use .hgtags
576 576 # - workingctx produces ids with length 42,
577 577 # which we skip since they aren't in any cache
578 578 if (
579 579 file == b'.hgtags'
580 580 or len(id) == 42
581 581 or not repo.shallowmatch(file)
582 582 ):
583 583 continue
584 584
585 585 idstocheck.append((file, bin(id)))
586 586
587 587 datastore = self.datastore
588 588 historystore = self.historystore
589 589 if force:
590 590 datastore = contentstore.unioncontentstore(*repo.shareddatastores)
591 591 historystore = metadatastore.unionmetadatastore(
592 592 *repo.sharedhistorystores
593 593 )
594 594
595 595 missingids = set()
596 596 if fetchdata:
597 597 missingids.update(datastore.getmissing(idstocheck))
598 598 if fetchhistory:
599 599 missingids.update(historystore.getmissing(idstocheck))
600 600
601 601 # partition missing nodes into nullid and not-nullid so we can
602 602 # warn about this filtering potentially shadowing bugs.
603 603 nullids = len([None for unused, id in missingids if id == nullid])
604 604 if nullids:
605 605 missingids = [(f, id) for f, id in missingids if id != nullid]
606 606 repo.ui.develwarn(
607 607 (
608 608 b'remotefilelog not fetching %d null revs'
609 609 b' - this is likely hiding bugs' % nullids
610 610 ),
611 611 config=b'remotefilelog-ext',
612 612 )
613 613 if missingids:
614 614 global fetches, fetched, fetchcost
615 615 fetches += 1
616 616
617 617 # We want to be able to detect excess individual file downloads, so
618 618 # let's log that information for debugging.
619 619 if fetches >= 15 and fetches < 18:
620 620 if fetches == 15:
621 621 fetchwarning = self.ui.config(
622 622 b'remotefilelog', b'fetchwarning'
623 623 )
624 624 if fetchwarning:
625 625 self.ui.warn(fetchwarning + b'\n')
626 626 self.logstacktrace()
627 627 missingids = [(file, hex(id)) for file, id in sorted(missingids)]
628 628 fetched += len(missingids)
629 629 start = time.time()
630 630 missingids = self.request(missingids)
631 631 if missingids:
632 632 raise error.Abort(
633 633 _(b"unable to download %d files") % len(missingids)
634 634 )
635 635 fetchcost += time.time() - start
636 636 self._lfsprefetch(fileids)
637 637
638 638 def _lfsprefetch(self, fileids):
639 639 if not _lfsmod or not util.safehasattr(
640 640 self.repo.svfs, b'lfslocalblobstore'
641 641 ):
642 642 return
643 643 if not _lfsmod.wrapper.candownload(self.repo):
644 644 return
645 645 pointers = []
646 646 store = self.repo.svfs.lfslocalblobstore
647 647 for file, id in fileids:
648 648 node = bin(id)
649 649 rlog = self.repo.file(file)
650 650 if rlog.flags(node) & revlog.REVIDX_EXTSTORED:
651 651 text = rlog.rawdata(node)
652 652 p = _lfsmod.pointer.deserialize(text)
653 653 oid = p.oid()
654 654 if not store.has(oid):
655 655 pointers.append(p)
656 656 if len(pointers) > 0:
657 657 self.repo.svfs.lfsremoteblobstore.readbatch(pointers, store)
658 658 assert all(store.has(p.oid()) for p in pointers)
659 659
660 660 def logstacktrace(self):
661 661 import traceback
662 662
663 663 self.ui.log(
664 664 b'remotefilelog',
665 665 b'excess remotefilelog fetching:\n%s\n',
666 666 b''.join(traceback.format_stack()),
667 667 )
@@ -1,912 +1,912 b''
1 1 from __future__ import absolute_import
2 2
3 3 import os
4 4 import time
5 5
6 6 from mercurial.i18n import _
7 7 from mercurial.node import (
8 8 nullid,
9 9 short,
10 10 )
11 11 from mercurial import (
12 12 encoding,
13 13 error,
14 14 lock as lockmod,
15 15 mdiff,
16 16 policy,
17 17 pycompat,
18 18 scmutil,
19 19 util,
20 20 vfs,
21 21 )
22 22 from mercurial.utils import procutil
23 23 from . import (
24 24 constants,
25 25 contentstore,
26 26 datapack,
27 27 historypack,
28 28 metadatastore,
29 29 shallowutil,
30 30 )
31 31
32 32 osutil = policy.importmod(r'osutil')
33 33
34 34
35 35 class RepackAlreadyRunning(error.Abort):
36 36 pass
37 37
38 38
39 39 def backgroundrepack(
40 40 repo, incremental=True, packsonly=False, ensurestart=False
41 41 ):
42 42 cmd = [procutil.hgexecutable(), b'-R', repo.origroot, b'repack']
43 43 msg = _(b"(running background repack)\n")
44 44 if incremental:
45 45 cmd.append(b'--incremental')
46 46 msg = _(b"(running background incremental repack)\n")
47 47 if packsonly:
48 48 cmd.append(b'--packsonly')
49 49 repo.ui.warn(msg)
50 50 # We know this command will find a binary, so don't block on it starting.
51 51 procutil.runbgcommand(cmd, encoding.environ, ensurestart=ensurestart)
52 52
53 53
54 54 def fullrepack(repo, options=None):
55 55 """If ``packsonly`` is True, stores creating only loose objects are skipped.
56 56 """
57 if util.safehasattr(repo, b'shareddatastores'):
57 if util.safehasattr(repo, 'shareddatastores'):
58 58 datasource = contentstore.unioncontentstore(*repo.shareddatastores)
59 59 historysource = metadatastore.unionmetadatastore(
60 60 *repo.sharedhistorystores, allowincomplete=True
61 61 )
62 62
63 63 packpath = shallowutil.getcachepackpath(
64 64 repo, constants.FILEPACK_CATEGORY
65 65 )
66 66 _runrepack(
67 67 repo,
68 68 datasource,
69 69 historysource,
70 70 packpath,
71 71 constants.FILEPACK_CATEGORY,
72 72 options=options,
73 73 )
74 74
75 if util.safehasattr(repo.manifestlog, b'datastore'):
75 if util.safehasattr(repo.manifestlog, 'datastore'):
76 76 localdata, shareddata = _getmanifeststores(repo)
77 77 lpackpath, ldstores, lhstores = localdata
78 78 spackpath, sdstores, shstores = shareddata
79 79
80 80 # Repack the shared manifest store
81 81 datasource = contentstore.unioncontentstore(*sdstores)
82 82 historysource = metadatastore.unionmetadatastore(
83 83 *shstores, allowincomplete=True
84 84 )
85 85 _runrepack(
86 86 repo,
87 87 datasource,
88 88 historysource,
89 89 spackpath,
90 90 constants.TREEPACK_CATEGORY,
91 91 options=options,
92 92 )
93 93
94 94 # Repack the local manifest store
95 95 datasource = contentstore.unioncontentstore(
96 96 *ldstores, allowincomplete=True
97 97 )
98 98 historysource = metadatastore.unionmetadatastore(
99 99 *lhstores, allowincomplete=True
100 100 )
101 101 _runrepack(
102 102 repo,
103 103 datasource,
104 104 historysource,
105 105 lpackpath,
106 106 constants.TREEPACK_CATEGORY,
107 107 options=options,
108 108 )
109 109
110 110
111 111 def incrementalrepack(repo, options=None):
112 112 """This repacks the repo by looking at the distribution of pack files in the
113 113 repo and performing the most minimal repack to keep the repo in good shape.
114 114 """
115 if util.safehasattr(repo, b'shareddatastores'):
115 if util.safehasattr(repo, 'shareddatastores'):
116 116 packpath = shallowutil.getcachepackpath(
117 117 repo, constants.FILEPACK_CATEGORY
118 118 )
119 119 _incrementalrepack(
120 120 repo,
121 121 repo.shareddatastores,
122 122 repo.sharedhistorystores,
123 123 packpath,
124 124 constants.FILEPACK_CATEGORY,
125 125 options=options,
126 126 )
127 127
128 if util.safehasattr(repo.manifestlog, b'datastore'):
128 if util.safehasattr(repo.manifestlog, 'datastore'):
129 129 localdata, shareddata = _getmanifeststores(repo)
130 130 lpackpath, ldstores, lhstores = localdata
131 131 spackpath, sdstores, shstores = shareddata
132 132
133 133 # Repack the shared manifest store
134 134 _incrementalrepack(
135 135 repo,
136 136 sdstores,
137 137 shstores,
138 138 spackpath,
139 139 constants.TREEPACK_CATEGORY,
140 140 options=options,
141 141 )
142 142
143 143 # Repack the local manifest store
144 144 _incrementalrepack(
145 145 repo,
146 146 ldstores,
147 147 lhstores,
148 148 lpackpath,
149 149 constants.TREEPACK_CATEGORY,
150 150 allowincompletedata=True,
151 151 options=options,
152 152 )
153 153
154 154
155 155 def _getmanifeststores(repo):
156 156 shareddatastores = repo.manifestlog.shareddatastores
157 157 localdatastores = repo.manifestlog.localdatastores
158 158 sharedhistorystores = repo.manifestlog.sharedhistorystores
159 159 localhistorystores = repo.manifestlog.localhistorystores
160 160
161 161 sharedpackpath = shallowutil.getcachepackpath(
162 162 repo, constants.TREEPACK_CATEGORY
163 163 )
164 164 localpackpath = shallowutil.getlocalpackpath(
165 165 repo.svfs.vfs.base, constants.TREEPACK_CATEGORY
166 166 )
167 167
168 168 return (
169 169 (localpackpath, localdatastores, localhistorystores),
170 170 (sharedpackpath, shareddatastores, sharedhistorystores),
171 171 )
172 172
173 173
174 174 def _topacks(packpath, files, constructor):
175 175 paths = list(os.path.join(packpath, p) for p in files)
176 176 packs = list(constructor(p) for p in paths)
177 177 return packs
178 178
179 179
180 180 def _deletebigpacks(repo, folder, files):
181 181 """Deletes packfiles that are bigger than ``packs.maxpacksize``.
182 182
183 183 Returns ``files` with the removed files omitted."""
184 184 maxsize = repo.ui.configbytes(b"packs", b"maxpacksize")
185 185 if maxsize <= 0:
186 186 return files
187 187
188 188 # This only considers datapacks today, but we could broaden it to include
189 189 # historypacks.
190 190 VALIDEXTS = [b".datapack", b".dataidx"]
191 191
192 192 # Either an oversize index or datapack will trigger cleanup of the whole
193 193 # pack:
194 194 oversized = {
195 195 os.path.splitext(path)[0]
196 196 for path, ftype, stat in files
197 197 if (stat.st_size > maxsize and (os.path.splitext(path)[1] in VALIDEXTS))
198 198 }
199 199
200 200 for rootfname in oversized:
201 201 rootpath = os.path.join(folder, rootfname)
202 202 for ext in VALIDEXTS:
203 203 path = rootpath + ext
204 204 repo.ui.debug(
205 205 b'removing oversize packfile %s (%s)\n'
206 206 % (path, util.bytecount(os.stat(path).st_size))
207 207 )
208 208 os.unlink(path)
209 209 return [row for row in files if os.path.basename(row[0]) not in oversized]
210 210
211 211
212 212 def _incrementalrepack(
213 213 repo,
214 214 datastore,
215 215 historystore,
216 216 packpath,
217 217 category,
218 218 allowincompletedata=False,
219 219 options=None,
220 220 ):
221 221 shallowutil.mkstickygroupdir(repo.ui, packpath)
222 222
223 223 files = osutil.listdir(packpath, stat=True)
224 224 files = _deletebigpacks(repo, packpath, files)
225 225 datapacks = _topacks(
226 226 packpath, _computeincrementaldatapack(repo.ui, files), datapack.datapack
227 227 )
228 228 datapacks.extend(
229 229 s for s in datastore if not isinstance(s, datapack.datapackstore)
230 230 )
231 231
232 232 historypacks = _topacks(
233 233 packpath,
234 234 _computeincrementalhistorypack(repo.ui, files),
235 235 historypack.historypack,
236 236 )
237 237 historypacks.extend(
238 238 s
239 239 for s in historystore
240 240 if not isinstance(s, historypack.historypackstore)
241 241 )
242 242
243 243 # ``allhistory{files,packs}`` contains all known history packs, even ones we
244 244 # don't plan to repack. They are used during the datapack repack to ensure
245 245 # good ordering of nodes.
246 246 allhistoryfiles = _allpackfileswithsuffix(
247 247 files, historypack.PACKSUFFIX, historypack.INDEXSUFFIX
248 248 )
249 249 allhistorypacks = _topacks(
250 250 packpath,
251 251 (f for f, mode, stat in allhistoryfiles),
252 252 historypack.historypack,
253 253 )
254 254 allhistorypacks.extend(
255 255 s
256 256 for s in historystore
257 257 if not isinstance(s, historypack.historypackstore)
258 258 )
259 259 _runrepack(
260 260 repo,
261 261 contentstore.unioncontentstore(
262 262 *datapacks, allowincomplete=allowincompletedata
263 263 ),
264 264 metadatastore.unionmetadatastore(*historypacks, allowincomplete=True),
265 265 packpath,
266 266 category,
267 267 fullhistory=metadatastore.unionmetadatastore(
268 268 *allhistorypacks, allowincomplete=True
269 269 ),
270 270 options=options,
271 271 )
272 272
273 273
274 274 def _computeincrementaldatapack(ui, files):
275 275 opts = {
276 276 b'gencountlimit': ui.configint(b'remotefilelog', b'data.gencountlimit'),
277 277 b'generations': ui.configlist(b'remotefilelog', b'data.generations'),
278 278 b'maxrepackpacks': ui.configint(
279 279 b'remotefilelog', b'data.maxrepackpacks'
280 280 ),
281 281 b'repackmaxpacksize': ui.configbytes(
282 282 b'remotefilelog', b'data.repackmaxpacksize'
283 283 ),
284 284 b'repacksizelimit': ui.configbytes(
285 285 b'remotefilelog', b'data.repacksizelimit'
286 286 ),
287 287 }
288 288
289 289 packfiles = _allpackfileswithsuffix(
290 290 files, datapack.PACKSUFFIX, datapack.INDEXSUFFIX
291 291 )
292 292 return _computeincrementalpack(packfiles, opts)
293 293
294 294
295 295 def _computeincrementalhistorypack(ui, files):
296 296 opts = {
297 297 b'gencountlimit': ui.configint(
298 298 b'remotefilelog', b'history.gencountlimit'
299 299 ),
300 300 b'generations': ui.configlist(
301 301 b'remotefilelog', b'history.generations', [b'100MB']
302 302 ),
303 303 b'maxrepackpacks': ui.configint(
304 304 b'remotefilelog', b'history.maxrepackpacks'
305 305 ),
306 306 b'repackmaxpacksize': ui.configbytes(
307 307 b'remotefilelog', b'history.repackmaxpacksize', b'400MB'
308 308 ),
309 309 b'repacksizelimit': ui.configbytes(
310 310 b'remotefilelog', b'history.repacksizelimit'
311 311 ),
312 312 }
313 313
314 314 packfiles = _allpackfileswithsuffix(
315 315 files, historypack.PACKSUFFIX, historypack.INDEXSUFFIX
316 316 )
317 317 return _computeincrementalpack(packfiles, opts)
318 318
319 319
320 320 def _allpackfileswithsuffix(files, packsuffix, indexsuffix):
321 321 result = []
322 322 fileset = set(fn for fn, mode, stat in files)
323 323 for filename, mode, stat in files:
324 324 if not filename.endswith(packsuffix):
325 325 continue
326 326
327 327 prefix = filename[: -len(packsuffix)]
328 328
329 329 # Don't process a pack if it doesn't have an index.
330 330 if (prefix + indexsuffix) not in fileset:
331 331 continue
332 332 result.append((prefix, mode, stat))
333 333
334 334 return result
335 335
336 336
337 337 def _computeincrementalpack(files, opts):
338 338 """Given a set of pack files along with the configuration options, this
339 339 function computes the list of files that should be packed as part of an
340 340 incremental repack.
341 341
342 342 It tries to strike a balance between keeping incremental repacks cheap (i.e.
343 343 packing small things when possible, and rolling the packs up to the big ones
344 344 over time).
345 345 """
346 346
347 347 limits = list(
348 348 sorted((util.sizetoint(s) for s in opts[b'generations']), reverse=True)
349 349 )
350 350 limits.append(0)
351 351
352 352 # Group the packs by generation (i.e. by size)
353 353 generations = []
354 354 for i in pycompat.xrange(len(limits)):
355 355 generations.append([])
356 356
357 357 sizes = {}
358 358 for prefix, mode, stat in files:
359 359 size = stat.st_size
360 360 if size > opts[b'repackmaxpacksize']:
361 361 continue
362 362
363 363 sizes[prefix] = size
364 364 for i, limit in enumerate(limits):
365 365 if size > limit:
366 366 generations[i].append(prefix)
367 367 break
368 368
369 369 # Steps for picking what packs to repack:
370 370 # 1. Pick the largest generation with > gencountlimit pack files.
371 371 # 2. Take the smallest three packs.
372 372 # 3. While total-size-of-packs < repacksizelimit: add another pack
373 373
374 374 # Find the largest generation with more than gencountlimit packs
375 375 genpacks = []
376 376 for i, limit in enumerate(limits):
377 377 if len(generations[i]) > opts[b'gencountlimit']:
378 378 # Sort to be smallest last, for easy popping later
379 379 genpacks.extend(
380 380 sorted(generations[i], reverse=True, key=lambda x: sizes[x])
381 381 )
382 382 break
383 383
384 384 # Take as many packs from the generation as we can
385 385 chosenpacks = genpacks[-3:]
386 386 genpacks = genpacks[:-3]
387 387 repacksize = sum(sizes[n] for n in chosenpacks)
388 388 while (
389 389 repacksize < opts[b'repacksizelimit']
390 390 and genpacks
391 391 and len(chosenpacks) < opts[b'maxrepackpacks']
392 392 ):
393 393 chosenpacks.append(genpacks.pop())
394 394 repacksize += sizes[chosenpacks[-1]]
395 395
396 396 return chosenpacks
397 397
398 398
399 399 def _runrepack(
400 400 repo, data, history, packpath, category, fullhistory=None, options=None
401 401 ):
402 402 shallowutil.mkstickygroupdir(repo.ui, packpath)
403 403
404 404 def isold(repo, filename, node):
405 405 """Check if the file node is older than a limit.
406 406 Unless a limit is specified in the config the default limit is taken.
407 407 """
408 408 filectx = repo.filectx(filename, fileid=node)
409 409 filetime = repo[filectx.linkrev()].date()
410 410
411 411 ttl = repo.ui.configint(b'remotefilelog', b'nodettl')
412 412
413 413 limit = time.time() - ttl
414 414 return filetime[0] < limit
415 415
416 416 garbagecollect = repo.ui.configbool(b'remotefilelog', b'gcrepack')
417 417 if not fullhistory:
418 418 fullhistory = history
419 419 packer = repacker(
420 420 repo,
421 421 data,
422 422 history,
423 423 fullhistory,
424 424 category,
425 425 gc=garbagecollect,
426 426 isold=isold,
427 427 options=options,
428 428 )
429 429
430 430 with datapack.mutabledatapack(repo.ui, packpath) as dpack:
431 431 with historypack.mutablehistorypack(repo.ui, packpath) as hpack:
432 432 try:
433 433 packer.run(dpack, hpack)
434 434 except error.LockHeld:
435 435 raise RepackAlreadyRunning(
436 436 _(
437 437 b"skipping repack - another repack "
438 438 b"is already running"
439 439 )
440 440 )
441 441
442 442
443 443 def keepset(repo, keyfn, lastkeepkeys=None):
444 444 """Computes a keepset which is not garbage collected.
445 445 'keyfn' is a function that maps filename, node to a unique key.
446 446 'lastkeepkeys' is an optional argument and if provided the keepset
447 447 function updates lastkeepkeys with more keys and returns the result.
448 448 """
449 449 if not lastkeepkeys:
450 450 keepkeys = set()
451 451 else:
452 452 keepkeys = lastkeepkeys
453 453
454 454 # We want to keep:
455 455 # 1. Working copy parent
456 456 # 2. Draft commits
457 457 # 3. Parents of draft commits
458 458 # 4. Pullprefetch and bgprefetchrevs revsets if specified
459 459 revs = [b'.', b'draft()', b'parents(draft())']
460 460 prefetchrevs = repo.ui.config(b'remotefilelog', b'pullprefetch', None)
461 461 if prefetchrevs:
462 462 revs.append(b'(%s)' % prefetchrevs)
463 463 prefetchrevs = repo.ui.config(b'remotefilelog', b'bgprefetchrevs', None)
464 464 if prefetchrevs:
465 465 revs.append(b'(%s)' % prefetchrevs)
466 466 revs = b'+'.join(revs)
467 467
468 468 revs = [b'sort((%s), "topo")' % revs]
469 469 keep = scmutil.revrange(repo, revs)
470 470
471 471 processed = set()
472 472 lastmanifest = None
473 473
474 474 # process the commits in toposorted order starting from the oldest
475 475 for r in reversed(keep._list):
476 476 if repo[r].p1().rev() in processed:
477 477 # if the direct parent has already been processed
478 478 # then we only need to process the delta
479 479 m = repo[r].manifestctx().readdelta()
480 480 else:
481 481 # otherwise take the manifest and diff it
482 482 # with the previous manifest if one exists
483 483 if lastmanifest:
484 484 m = repo[r].manifest().diff(lastmanifest)
485 485 else:
486 486 m = repo[r].manifest()
487 487 lastmanifest = repo[r].manifest()
488 488 processed.add(r)
489 489
490 490 # populate keepkeys with keys from the current manifest
491 491 if type(m) is dict:
492 492 # m is a result of diff of two manifests and is a dictionary that
493 493 # maps filename to ((newnode, newflag), (oldnode, oldflag)) tuple
494 494 for filename, diff in pycompat.iteritems(m):
495 495 if diff[0][0] is not None:
496 496 keepkeys.add(keyfn(filename, diff[0][0]))
497 497 else:
498 498 # m is a manifest object
499 499 for filename, filenode in pycompat.iteritems(m):
500 500 keepkeys.add(keyfn(filename, filenode))
501 501
502 502 return keepkeys
503 503
504 504
505 505 class repacker(object):
506 506 """Class for orchestrating the repack of data and history information into a
507 507 new format.
508 508 """
509 509
510 510 def __init__(
511 511 self,
512 512 repo,
513 513 data,
514 514 history,
515 515 fullhistory,
516 516 category,
517 517 gc=False,
518 518 isold=None,
519 519 options=None,
520 520 ):
521 521 self.repo = repo
522 522 self.data = data
523 523 self.history = history
524 524 self.fullhistory = fullhistory
525 525 self.unit = constants.getunits(category)
526 526 self.garbagecollect = gc
527 527 self.options = options
528 528 if self.garbagecollect:
529 529 if not isold:
530 530 raise ValueError(b"Function 'isold' is not properly specified")
531 531 # use (filename, node) tuple as a keepset key
532 532 self.keepkeys = keepset(repo, lambda f, n: (f, n))
533 533 self.isold = isold
534 534
535 535 def run(self, targetdata, targethistory):
536 536 ledger = repackledger()
537 537
538 538 with lockmod.lock(
539 539 repacklockvfs(self.repo), b"repacklock", desc=None, timeout=0
540 540 ):
541 541 self.repo.hook(b'prerepack')
542 542
543 543 # Populate ledger from source
544 544 self.data.markledger(ledger, options=self.options)
545 545 self.history.markledger(ledger, options=self.options)
546 546
547 547 # Run repack
548 548 self.repackdata(ledger, targetdata)
549 549 self.repackhistory(ledger, targethistory)
550 550
551 551 # Call cleanup on each source
552 552 for source in ledger.sources:
553 553 source.cleanup(ledger)
554 554
555 555 def _chainorphans(self, ui, filename, nodes, orphans, deltabases):
556 556 """Reorderes ``orphans`` into a single chain inside ``nodes`` and
557 557 ``deltabases``.
558 558
559 559 We often have orphan entries (nodes without a base that aren't
560 560 referenced by other nodes -- i.e., part of a chain) due to gaps in
561 561 history. Rather than store them as individual fulltexts, we prefer to
562 562 insert them as one chain sorted by size.
563 563 """
564 564 if not orphans:
565 565 return nodes
566 566
567 567 def getsize(node, default=0):
568 568 meta = self.data.getmeta(filename, node)
569 569 if constants.METAKEYSIZE in meta:
570 570 return meta[constants.METAKEYSIZE]
571 571 else:
572 572 return default
573 573
574 574 # Sort orphans by size; biggest first is preferred, since it's more
575 575 # likely to be the newest version assuming files grow over time.
576 576 # (Sort by node first to ensure the sort is stable.)
577 577 orphans = sorted(orphans)
578 578 orphans = list(sorted(orphans, key=getsize, reverse=True))
579 579 if ui.debugflag:
580 580 ui.debug(
581 581 b"%s: orphan chain: %s\n"
582 582 % (filename, b", ".join([short(s) for s in orphans]))
583 583 )
584 584
585 585 # Create one contiguous chain and reassign deltabases.
586 586 for i, node in enumerate(orphans):
587 587 if i == 0:
588 588 deltabases[node] = (nullid, 0)
589 589 else:
590 590 parent = orphans[i - 1]
591 591 deltabases[node] = (parent, deltabases[parent][1] + 1)
592 592 nodes = [n for n in nodes if n not in orphans]
593 593 nodes += orphans
594 594 return nodes
595 595
596 596 def repackdata(self, ledger, target):
597 597 ui = self.repo.ui
598 598 maxchainlen = ui.configint(b'packs', b'maxchainlen', 1000)
599 599
600 600 byfile = {}
601 601 for entry in pycompat.itervalues(ledger.entries):
602 602 if entry.datasource:
603 603 byfile.setdefault(entry.filename, {})[entry.node] = entry
604 604
605 605 count = 0
606 606 repackprogress = ui.makeprogress(
607 607 _(b"repacking data"), unit=self.unit, total=len(byfile)
608 608 )
609 609 for filename, entries in sorted(pycompat.iteritems(byfile)):
610 610 repackprogress.update(count)
611 611
612 612 ancestors = {}
613 613 nodes = list(node for node in entries)
614 614 nohistory = []
615 615 buildprogress = ui.makeprogress(
616 616 _(b"building history"), unit=b'nodes', total=len(nodes)
617 617 )
618 618 for i, node in enumerate(nodes):
619 619 if node in ancestors:
620 620 continue
621 621 buildprogress.update(i)
622 622 try:
623 623 ancestors.update(
624 624 self.fullhistory.getancestors(
625 625 filename, node, known=ancestors
626 626 )
627 627 )
628 628 except KeyError:
629 629 # Since we're packing data entries, we may not have the
630 630 # corresponding history entries for them. It's not a big
631 631 # deal, but the entries won't be delta'd perfectly.
632 632 nohistory.append(node)
633 633 buildprogress.complete()
634 634
635 635 # Order the nodes children first, so we can produce reverse deltas
636 636 orderednodes = list(reversed(self._toposort(ancestors)))
637 637 if len(nohistory) > 0:
638 638 ui.debug(
639 639 b'repackdata: %d nodes without history\n' % len(nohistory)
640 640 )
641 641 orderednodes.extend(sorted(nohistory))
642 642
643 643 # Filter orderednodes to just the nodes we want to serialize (it
644 644 # currently also has the edge nodes' ancestors).
645 645 orderednodes = list(
646 646 filter(lambda node: node in nodes, orderednodes)
647 647 )
648 648
649 649 # Garbage collect old nodes:
650 650 if self.garbagecollect:
651 651 neworderednodes = []
652 652 for node in orderednodes:
653 653 # If the node is old and is not in the keepset, we skip it,
654 654 # and mark as garbage collected
655 655 if (filename, node) not in self.keepkeys and self.isold(
656 656 self.repo, filename, node
657 657 ):
658 658 entries[node].gced = True
659 659 continue
660 660 neworderednodes.append(node)
661 661 orderednodes = neworderednodes
662 662
663 663 # Compute delta bases for nodes:
664 664 deltabases = {}
665 665 nobase = set()
666 666 referenced = set()
667 667 nodes = set(nodes)
668 668 processprogress = ui.makeprogress(
669 669 _(b"processing nodes"), unit=b'nodes', total=len(orderednodes)
670 670 )
671 671 for i, node in enumerate(orderednodes):
672 672 processprogress.update(i)
673 673 # Find delta base
674 674 # TODO: allow delta'ing against most recent descendant instead
675 675 # of immediate child
676 676 deltatuple = deltabases.get(node, None)
677 677 if deltatuple is None:
678 678 deltabase, chainlen = nullid, 0
679 679 deltabases[node] = (nullid, 0)
680 680 nobase.add(node)
681 681 else:
682 682 deltabase, chainlen = deltatuple
683 683 referenced.add(deltabase)
684 684
685 685 # Use available ancestor information to inform our delta choices
686 686 ancestorinfo = ancestors.get(node)
687 687 if ancestorinfo:
688 688 p1, p2, linknode, copyfrom = ancestorinfo
689 689
690 690 # The presence of copyfrom means we're at a point where the
691 691 # file was copied from elsewhere. So don't attempt to do any
692 692 # deltas with the other file.
693 693 if copyfrom:
694 694 p1 = nullid
695 695
696 696 if chainlen < maxchainlen:
697 697 # Record this child as the delta base for its parents.
698 698 # This may be non optimal, since the parents may have
699 699 # many children, and this will only choose the last one.
700 700 # TODO: record all children and try all deltas to find
701 701 # best
702 702 if p1 != nullid:
703 703 deltabases[p1] = (node, chainlen + 1)
704 704 if p2 != nullid:
705 705 deltabases[p2] = (node, chainlen + 1)
706 706
707 707 # experimental config: repack.chainorphansbysize
708 708 if ui.configbool(b'repack', b'chainorphansbysize'):
709 709 orphans = nobase - referenced
710 710 orderednodes = self._chainorphans(
711 711 ui, filename, orderednodes, orphans, deltabases
712 712 )
713 713
714 714 # Compute deltas and write to the pack
715 715 for i, node in enumerate(orderednodes):
716 716 deltabase, chainlen = deltabases[node]
717 717 # Compute delta
718 718 # TODO: Optimize the deltachain fetching. Since we're
719 719 # iterating over the different version of the file, we may
720 720 # be fetching the same deltachain over and over again.
721 721 if deltabase != nullid:
722 722 deltaentry = self.data.getdelta(filename, node)
723 723 delta, deltabasename, origdeltabase, meta = deltaentry
724 724 size = meta.get(constants.METAKEYSIZE)
725 725 if (
726 726 deltabasename != filename
727 727 or origdeltabase != deltabase
728 728 or size is None
729 729 ):
730 730 deltabasetext = self.data.get(filename, deltabase)
731 731 original = self.data.get(filename, node)
732 732 size = len(original)
733 733 delta = mdiff.textdiff(deltabasetext, original)
734 734 else:
735 735 delta = self.data.get(filename, node)
736 736 size = len(delta)
737 737 meta = self.data.getmeta(filename, node)
738 738
739 739 # TODO: don't use the delta if it's larger than the fulltext
740 740 if constants.METAKEYSIZE not in meta:
741 741 meta[constants.METAKEYSIZE] = size
742 742 target.add(filename, node, deltabase, delta, meta)
743 743
744 744 entries[node].datarepacked = True
745 745
746 746 processprogress.complete()
747 747 count += 1
748 748
749 749 repackprogress.complete()
750 750 target.close(ledger=ledger)
751 751
752 752 def repackhistory(self, ledger, target):
753 753 ui = self.repo.ui
754 754
755 755 byfile = {}
756 756 for entry in pycompat.itervalues(ledger.entries):
757 757 if entry.historysource:
758 758 byfile.setdefault(entry.filename, {})[entry.node] = entry
759 759
760 760 progress = ui.makeprogress(
761 761 _(b"repacking history"), unit=self.unit, total=len(byfile)
762 762 )
763 763 for filename, entries in sorted(pycompat.iteritems(byfile)):
764 764 ancestors = {}
765 765 nodes = list(node for node in entries)
766 766
767 767 for node in nodes:
768 768 if node in ancestors:
769 769 continue
770 770 ancestors.update(
771 771 self.history.getancestors(filename, node, known=ancestors)
772 772 )
773 773
774 774 # Order the nodes children first
775 775 orderednodes = reversed(self._toposort(ancestors))
776 776
777 777 # Write to the pack
778 778 dontprocess = set()
779 779 for node in orderednodes:
780 780 p1, p2, linknode, copyfrom = ancestors[node]
781 781
782 782 # If the node is marked dontprocess, but it's also in the
783 783 # explicit entries set, that means the node exists both in this
784 784 # file and in another file that was copied to this file.
785 785 # Usually this happens if the file was copied to another file,
786 786 # then the copy was deleted, then reintroduced without copy
787 787 # metadata. The original add and the new add have the same hash
788 788 # since the content is identical and the parents are null.
789 789 if node in dontprocess and node not in entries:
790 790 # If copyfrom == filename, it means the copy history
791 791 # went to come other file, then came back to this one, so we
792 792 # should continue processing it.
793 793 if p1 != nullid and copyfrom != filename:
794 794 dontprocess.add(p1)
795 795 if p2 != nullid:
796 796 dontprocess.add(p2)
797 797 continue
798 798
799 799 if copyfrom:
800 800 dontprocess.add(p1)
801 801
802 802 target.add(filename, node, p1, p2, linknode, copyfrom)
803 803
804 804 if node in entries:
805 805 entries[node].historyrepacked = True
806 806
807 807 progress.increment()
808 808
809 809 progress.complete()
810 810 target.close(ledger=ledger)
811 811
812 812 def _toposort(self, ancestors):
813 813 def parentfunc(node):
814 814 p1, p2, linknode, copyfrom = ancestors[node]
815 815 parents = []
816 816 if p1 != nullid:
817 817 parents.append(p1)
818 818 if p2 != nullid:
819 819 parents.append(p2)
820 820 return parents
821 821
822 822 sortednodes = shallowutil.sortnodes(ancestors.keys(), parentfunc)
823 823 return sortednodes
824 824
825 825
826 826 class repackledger(object):
827 827 """Storage for all the bookkeeping that happens during a repack. It contains
828 828 the list of revisions being repacked, what happened to each revision, and
829 829 which source store contained which revision originally (for later cleanup).
830 830 """
831 831
832 832 def __init__(self):
833 833 self.entries = {}
834 834 self.sources = {}
835 835 self.created = set()
836 836
837 837 def markdataentry(self, source, filename, node):
838 838 """Mark the given filename+node revision as having a data rev in the
839 839 given source.
840 840 """
841 841 entry = self._getorcreateentry(filename, node)
842 842 entry.datasource = True
843 843 entries = self.sources.get(source)
844 844 if not entries:
845 845 entries = set()
846 846 self.sources[source] = entries
847 847 entries.add(entry)
848 848
849 849 def markhistoryentry(self, source, filename, node):
850 850 """Mark the given filename+node revision as having a history rev in the
851 851 given source.
852 852 """
853 853 entry = self._getorcreateentry(filename, node)
854 854 entry.historysource = True
855 855 entries = self.sources.get(source)
856 856 if not entries:
857 857 entries = set()
858 858 self.sources[source] = entries
859 859 entries.add(entry)
860 860
861 861 def _getorcreateentry(self, filename, node):
862 862 key = (filename, node)
863 863 value = self.entries.get(key)
864 864 if not value:
865 865 value = repackentry(filename, node)
866 866 self.entries[key] = value
867 867
868 868 return value
869 869
870 870 def addcreated(self, value):
871 871 self.created.add(value)
872 872
873 873
874 874 class repackentry(object):
875 875 """Simple class representing a single revision entry in the repackledger.
876 876 """
877 877
878 878 __slots__ = (
879 879 r'filename',
880 880 r'node',
881 881 r'datasource',
882 882 r'historysource',
883 883 r'datarepacked',
884 884 r'historyrepacked',
885 885 r'gced',
886 886 )
887 887
888 888 def __init__(self, filename, node):
889 889 self.filename = filename
890 890 self.node = node
891 891 # If the revision has a data entry in the source
892 892 self.datasource = False
893 893 # If the revision has a history entry in the source
894 894 self.historysource = False
895 895 # If the revision's data entry was repacked into the repack target
896 896 self.datarepacked = False
897 897 # If the revision's history entry was repacked into the repack target
898 898 self.historyrepacked = False
899 899 # If garbage collected
900 900 self.gced = False
901 901
902 902
903 903 def repacklockvfs(repo):
904 if util.safehasattr(repo, b'name'):
904 if util.safehasattr(repo, 'name'):
905 905 # Lock in the shared cache so repacks across multiple copies of the same
906 906 # repo are coordinated.
907 907 sharedcachepath = shallowutil.getcachepackpath(
908 908 repo, constants.FILEPACK_CATEGORY
909 909 )
910 910 return vfs.vfs(sharedcachepath)
911 911 else:
912 912 return repo.svfs
@@ -1,354 +1,354 b''
1 1 # shallowrepo.py - shallow repository that uses remote filelogs
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 from __future__ import absolute_import
8 8
9 9 import os
10 10
11 11 from mercurial.i18n import _
12 12 from mercurial.node import hex, nullid, nullrev
13 13 from mercurial import (
14 14 encoding,
15 15 error,
16 16 localrepo,
17 17 match,
18 18 pycompat,
19 19 scmutil,
20 20 sparse,
21 21 util,
22 22 )
23 23 from mercurial.utils import procutil
24 24 from . import (
25 25 connectionpool,
26 26 constants,
27 27 contentstore,
28 28 datapack,
29 29 fileserverclient,
30 30 historypack,
31 31 metadatastore,
32 32 remotefilectx,
33 33 remotefilelog,
34 34 shallowutil,
35 35 )
36 36
37 37 # These make*stores functions are global so that other extensions can replace
38 38 # them.
39 39 def makelocalstores(repo):
40 40 """In-repo stores, like .hg/store/data; can not be discarded."""
41 41 localpath = os.path.join(repo.svfs.vfs.base, b'data')
42 42 if not os.path.exists(localpath):
43 43 os.makedirs(localpath)
44 44
45 45 # Instantiate local data stores
46 46 localcontent = contentstore.remotefilelogcontentstore(
47 47 repo, localpath, repo.name, shared=False
48 48 )
49 49 localmetadata = metadatastore.remotefilelogmetadatastore(
50 50 repo, localpath, repo.name, shared=False
51 51 )
52 52 return localcontent, localmetadata
53 53
54 54
55 55 def makecachestores(repo):
56 56 """Typically machine-wide, cache of remote data; can be discarded."""
57 57 # Instantiate shared cache stores
58 58 cachepath = shallowutil.getcachepath(repo.ui)
59 59 cachecontent = contentstore.remotefilelogcontentstore(
60 60 repo, cachepath, repo.name, shared=True
61 61 )
62 62 cachemetadata = metadatastore.remotefilelogmetadatastore(
63 63 repo, cachepath, repo.name, shared=True
64 64 )
65 65
66 66 repo.sharedstore = cachecontent
67 67 repo.shareddatastores.append(cachecontent)
68 68 repo.sharedhistorystores.append(cachemetadata)
69 69
70 70 return cachecontent, cachemetadata
71 71
72 72
73 73 def makeremotestores(repo, cachecontent, cachemetadata):
74 74 """These stores fetch data from a remote server."""
75 75 # Instantiate remote stores
76 76 repo.fileservice = fileserverclient.fileserverclient(repo)
77 77 remotecontent = contentstore.remotecontentstore(
78 78 repo.ui, repo.fileservice, cachecontent
79 79 )
80 80 remotemetadata = metadatastore.remotemetadatastore(
81 81 repo.ui, repo.fileservice, cachemetadata
82 82 )
83 83 return remotecontent, remotemetadata
84 84
85 85
86 86 def makepackstores(repo):
87 87 """Packs are more efficient (to read from) cache stores."""
88 88 # Instantiate pack stores
89 89 packpath = shallowutil.getcachepackpath(repo, constants.FILEPACK_CATEGORY)
90 90 packcontentstore = datapack.datapackstore(repo.ui, packpath)
91 91 packmetadatastore = historypack.historypackstore(repo.ui, packpath)
92 92
93 93 repo.shareddatastores.append(packcontentstore)
94 94 repo.sharedhistorystores.append(packmetadatastore)
95 95 shallowutil.reportpackmetrics(
96 96 repo.ui, b'filestore', packcontentstore, packmetadatastore
97 97 )
98 98 return packcontentstore, packmetadatastore
99 99
100 100
101 101 def makeunionstores(repo):
102 102 """Union stores iterate the other stores and return the first result."""
103 103 repo.shareddatastores = []
104 104 repo.sharedhistorystores = []
105 105
106 106 packcontentstore, packmetadatastore = makepackstores(repo)
107 107 cachecontent, cachemetadata = makecachestores(repo)
108 108 localcontent, localmetadata = makelocalstores(repo)
109 109 remotecontent, remotemetadata = makeremotestores(
110 110 repo, cachecontent, cachemetadata
111 111 )
112 112
113 113 # Instantiate union stores
114 114 repo.contentstore = contentstore.unioncontentstore(
115 115 packcontentstore,
116 116 cachecontent,
117 117 localcontent,
118 118 remotecontent,
119 119 writestore=localcontent,
120 120 )
121 121 repo.metadatastore = metadatastore.unionmetadatastore(
122 122 packmetadatastore,
123 123 cachemetadata,
124 124 localmetadata,
125 125 remotemetadata,
126 126 writestore=localmetadata,
127 127 )
128 128
129 129 fileservicedatawrite = cachecontent
130 130 fileservicehistorywrite = cachemetadata
131 131 repo.fileservice.setstore(
132 132 repo.contentstore,
133 133 repo.metadatastore,
134 134 fileservicedatawrite,
135 135 fileservicehistorywrite,
136 136 )
137 137 shallowutil.reportpackmetrics(
138 138 repo.ui, b'filestore', packcontentstore, packmetadatastore
139 139 )
140 140
141 141
142 142 def wraprepo(repo):
143 143 class shallowrepository(repo.__class__):
144 144 @util.propertycache
145 145 def name(self):
146 146 return self.ui.config(b'remotefilelog', b'reponame')
147 147
148 148 @util.propertycache
149 149 def fallbackpath(self):
150 150 path = repo.ui.config(
151 151 b"remotefilelog",
152 152 b"fallbackpath",
153 153 repo.ui.config(b'paths', b'default'),
154 154 )
155 155 if not path:
156 156 raise error.Abort(
157 157 b"no remotefilelog server "
158 158 b"configured - is your .hg/hgrc trusted?"
159 159 )
160 160
161 161 return path
162 162
163 163 def maybesparsematch(self, *revs, **kwargs):
164 164 '''
165 165 A wrapper that allows the remotefilelog to invoke sparsematch() if
166 166 this is a sparse repository, or returns None if this is not a
167 167 sparse repository.
168 168 '''
169 169 if revs:
170 170 ret = sparse.matcher(repo, revs=revs)
171 171 else:
172 172 ret = sparse.matcher(repo)
173 173
174 174 if ret.always():
175 175 return None
176 176 return ret
177 177
178 178 def file(self, f):
179 179 if f[0] == b'/':
180 180 f = f[1:]
181 181
182 182 if self.shallowmatch(f):
183 183 return remotefilelog.remotefilelog(self.svfs, f, self)
184 184 else:
185 185 return super(shallowrepository, self).file(f)
186 186
187 187 def filectx(self, path, *args, **kwargs):
188 188 if self.shallowmatch(path):
189 189 return remotefilectx.remotefilectx(self, path, *args, **kwargs)
190 190 else:
191 191 return super(shallowrepository, self).filectx(
192 192 path, *args, **kwargs
193 193 )
194 194
195 195 @localrepo.unfilteredmethod
196 196 def commitctx(self, ctx, error=False, origctx=None):
197 197 """Add a new revision to current repository.
198 198 Revision information is passed via the context argument.
199 199 """
200 200
201 201 # some contexts already have manifest nodes, they don't need any
202 202 # prefetching (for example if we're just editing a commit message
203 203 # we can reuse manifest
204 204 if not ctx.manifestnode():
205 205 # prefetch files that will likely be compared
206 206 m1 = ctx.p1().manifest()
207 207 files = []
208 208 for f in ctx.modified() + ctx.added():
209 209 fparent1 = m1.get(f, nullid)
210 210 if fparent1 != nullid:
211 211 files.append((f, hex(fparent1)))
212 212 self.fileservice.prefetch(files)
213 213 return super(shallowrepository, self).commitctx(
214 214 ctx, error=error, origctx=origctx
215 215 )
216 216
217 217 def backgroundprefetch(
218 218 self,
219 219 revs,
220 220 base=None,
221 221 repack=False,
222 222 pats=None,
223 223 opts=None,
224 224 ensurestart=False,
225 225 ):
226 226 """Runs prefetch in background with optional repack
227 227 """
228 228 cmd = [procutil.hgexecutable(), b'-R', repo.origroot, b'prefetch']
229 229 if repack:
230 230 cmd.append(b'--repack')
231 231 if revs:
232 232 cmd += [b'-r', revs]
233 233 # We know this command will find a binary, so don't block
234 234 # on it starting.
235 235 procutil.runbgcommand(
236 236 cmd, encoding.environ, ensurestart=ensurestart
237 237 )
238 238
239 239 def prefetch(self, revs, base=None, pats=None, opts=None):
240 240 """Prefetches all the necessary file revisions for the given revs
241 241 Optionally runs repack in background
242 242 """
243 243 with repo._lock(
244 244 repo.svfs,
245 245 b'prefetchlock',
246 246 True,
247 247 None,
248 248 None,
249 249 _(b'prefetching in %s') % repo.origroot,
250 250 ):
251 251 self._prefetch(revs, base, pats, opts)
252 252
253 253 def _prefetch(self, revs, base=None, pats=None, opts=None):
254 254 fallbackpath = self.fallbackpath
255 255 if fallbackpath:
256 256 # If we know a rev is on the server, we should fetch the server
257 257 # version of those files, since our local file versions might
258 258 # become obsolete if the local commits are stripped.
259 259 localrevs = repo.revs(b'outgoing(%s)', fallbackpath)
260 260 if base is not None and base != nullrev:
261 261 serverbase = list(
262 262 repo.revs(
263 263 b'first(reverse(::%s) - %ld)', base, localrevs
264 264 )
265 265 )
266 266 if serverbase:
267 267 base = serverbase[0]
268 268 else:
269 269 localrevs = repo
270 270
271 271 mfl = repo.manifestlog
272 272 mfrevlog = mfl.getstorage(b'')
273 273 if base is not None:
274 274 mfdict = mfl[repo[base].manifestnode()].read()
275 275 skip = set(pycompat.iteritems(mfdict))
276 276 else:
277 277 skip = set()
278 278
279 279 # Copy the skip set to start large and avoid constant resizing,
280 280 # and since it's likely to be very similar to the prefetch set.
281 281 files = skip.copy()
282 282 serverfiles = skip.copy()
283 283 visited = set()
284 284 visited.add(nullrev)
285 285 revcount = len(revs)
286 286 progress = self.ui.makeprogress(_(b'prefetching'), total=revcount)
287 287 progress.update(0)
288 288 for rev in sorted(revs):
289 289 ctx = repo[rev]
290 290 if pats:
291 291 m = scmutil.match(ctx, pats, opts)
292 292 sparsematch = repo.maybesparsematch(rev)
293 293
294 294 mfnode = ctx.manifestnode()
295 295 mfrev = mfrevlog.rev(mfnode)
296 296
297 297 # Decompressing manifests is expensive.
298 298 # When possible, only read the deltas.
299 299 p1, p2 = mfrevlog.parentrevs(mfrev)
300 300 if p1 in visited and p2 in visited:
301 301 mfdict = mfl[mfnode].readfast()
302 302 else:
303 303 mfdict = mfl[mfnode].read()
304 304
305 305 diff = pycompat.iteritems(mfdict)
306 306 if pats:
307 307 diff = (pf for pf in diff if m(pf[0]))
308 308 if sparsematch:
309 309 diff = (pf for pf in diff if sparsematch(pf[0]))
310 310 if rev not in localrevs:
311 311 serverfiles.update(diff)
312 312 else:
313 313 files.update(diff)
314 314
315 315 visited.add(mfrev)
316 316 progress.increment()
317 317
318 318 files.difference_update(skip)
319 319 serverfiles.difference_update(skip)
320 320 progress.complete()
321 321
322 322 # Fetch files known to be on the server
323 323 if serverfiles:
324 324 results = [(path, hex(fnode)) for (path, fnode) in serverfiles]
325 325 repo.fileservice.prefetch(results, force=True)
326 326
327 327 # Fetch files that may or may not be on the server
328 328 if files:
329 329 results = [(path, hex(fnode)) for (path, fnode) in files]
330 330 repo.fileservice.prefetch(results)
331 331
332 332 def close(self):
333 333 super(shallowrepository, self).close()
334 334 self.connectionpool.close()
335 335
336 336 repo.__class__ = shallowrepository
337 337
338 338 repo.shallowmatch = match.always()
339 339
340 340 makeunionstores(repo)
341 341
342 342 repo.includepattern = repo.ui.configlist(
343 343 b"remotefilelog", b"includepattern", None
344 344 )
345 345 repo.excludepattern = repo.ui.configlist(
346 346 b"remotefilelog", b"excludepattern", None
347 347 )
348 if not util.safehasattr(repo, b'connectionpool'):
348 if not util.safehasattr(repo, 'connectionpool'):
349 349 repo.connectionpool = connectionpool.connectionpool(repo)
350 350
351 351 if repo.includepattern or repo.excludepattern:
352 352 repo.shallowmatch = match.match(
353 353 repo.root, b'', None, repo.includepattern, repo.excludepattern
354 354 )
@@ -1,2555 +1,2555 b''
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: int32
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: int32
68 68
69 69 The total number of Bytes used by the part header. 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 (restricted to [a-zA-Z0-9_:-]*)
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 Each parameter's key MUST be unique within the part.
117 117
118 118 :payload:
119 119
120 120 payload is a series of `<chunksize><chunkdata>`.
121 121
122 122 `chunksize` is an int32, `chunkdata` are plain bytes (as much as
123 123 `chunksize` says)` The payload part is concluded by a zero size chunk.
124 124
125 125 The current implementation always produces either zero or one chunk.
126 126 This is an implementation limitation that will ultimately be lifted.
127 127
128 128 `chunksize` can be negative to trigger special case processing. No such
129 129 processing is in place yet.
130 130
131 131 Bundle processing
132 132 ============================
133 133
134 134 Each part is processed in order using a "part handler". Handler are registered
135 135 for a certain part type.
136 136
137 137 The matching of a part to its handler is case insensitive. The case of the
138 138 part type is used to know if a part is mandatory or advisory. If the Part type
139 139 contains any uppercase char it is considered mandatory. When no handler is
140 140 known for a Mandatory part, the process is aborted and an exception is raised.
141 141 If the part is advisory and no handler is known, the part is ignored. When the
142 142 process is aborted, the full bundle is still read from the stream to keep the
143 143 channel usable. But none of the part read from an abort are processed. In the
144 144 future, dropping the stream may become an option for channel we do not care to
145 145 preserve.
146 146 """
147 147
148 148 from __future__ import absolute_import, division
149 149
150 150 import collections
151 151 import errno
152 152 import os
153 153 import re
154 154 import string
155 155 import struct
156 156 import sys
157 157
158 158 from .i18n import _
159 159 from . import (
160 160 bookmarks,
161 161 changegroup,
162 162 encoding,
163 163 error,
164 164 node as nodemod,
165 165 obsolete,
166 166 phases,
167 167 pushkey,
168 168 pycompat,
169 169 streamclone,
170 170 tags,
171 171 url,
172 172 util,
173 173 )
174 174 from .utils import stringutil
175 175
176 176 urlerr = util.urlerr
177 177 urlreq = util.urlreq
178 178
179 179 _pack = struct.pack
180 180 _unpack = struct.unpack
181 181
182 182 _fstreamparamsize = b'>i'
183 183 _fpartheadersize = b'>i'
184 184 _fparttypesize = b'>B'
185 185 _fpartid = b'>I'
186 186 _fpayloadsize = b'>i'
187 187 _fpartparamcount = b'>BB'
188 188
189 189 preferedchunksize = 32768
190 190
191 191 _parttypeforbidden = re.compile(b'[^a-zA-Z0-9_:-]')
192 192
193 193
194 194 def outdebug(ui, message):
195 195 """debug regarding output stream (bundling)"""
196 196 if ui.configbool(b'devel', b'bundle2.debug'):
197 197 ui.debug(b'bundle2-output: %s\n' % message)
198 198
199 199
200 200 def indebug(ui, message):
201 201 """debug on input stream (unbundling)"""
202 202 if ui.configbool(b'devel', b'bundle2.debug'):
203 203 ui.debug(b'bundle2-input: %s\n' % message)
204 204
205 205
206 206 def validateparttype(parttype):
207 207 """raise ValueError if a parttype contains invalid character"""
208 208 if _parttypeforbidden.search(parttype):
209 209 raise ValueError(parttype)
210 210
211 211
212 212 def _makefpartparamsizes(nbparams):
213 213 """return a struct format to read part parameter sizes
214 214
215 215 The number parameters is variable so we need to build that format
216 216 dynamically.
217 217 """
218 218 return b'>' + (b'BB' * nbparams)
219 219
220 220
221 221 parthandlermapping = {}
222 222
223 223
224 224 def parthandler(parttype, params=()):
225 225 """decorator that register a function as a bundle2 part handler
226 226
227 227 eg::
228 228
229 229 @parthandler('myparttype', ('mandatory', 'param', 'handled'))
230 230 def myparttypehandler(...):
231 231 '''process a part of type "my part".'''
232 232 ...
233 233 """
234 234 validateparttype(parttype)
235 235
236 236 def _decorator(func):
237 237 lparttype = parttype.lower() # enforce lower case matching.
238 238 assert lparttype not in parthandlermapping
239 239 parthandlermapping[lparttype] = func
240 240 func.params = frozenset(params)
241 241 return func
242 242
243 243 return _decorator
244 244
245 245
246 246 class unbundlerecords(object):
247 247 """keep record of what happens during and unbundle
248 248
249 249 New records are added using `records.add('cat', obj)`. Where 'cat' is a
250 250 category of record and obj is an arbitrary object.
251 251
252 252 `records['cat']` will return all entries of this category 'cat'.
253 253
254 254 Iterating on the object itself will yield `('category', obj)` tuples
255 255 for all entries.
256 256
257 257 All iterations happens in chronological order.
258 258 """
259 259
260 260 def __init__(self):
261 261 self._categories = {}
262 262 self._sequences = []
263 263 self._replies = {}
264 264
265 265 def add(self, category, entry, inreplyto=None):
266 266 """add a new record of a given category.
267 267
268 268 The entry can then be retrieved in the list returned by
269 269 self['category']."""
270 270 self._categories.setdefault(category, []).append(entry)
271 271 self._sequences.append((category, entry))
272 272 if inreplyto is not None:
273 273 self.getreplies(inreplyto).add(category, entry)
274 274
275 275 def getreplies(self, partid):
276 276 """get the records that are replies to a specific part"""
277 277 return self._replies.setdefault(partid, unbundlerecords())
278 278
279 279 def __getitem__(self, cat):
280 280 return tuple(self._categories.get(cat, ()))
281 281
282 282 def __iter__(self):
283 283 return iter(self._sequences)
284 284
285 285 def __len__(self):
286 286 return len(self._sequences)
287 287
288 288 def __nonzero__(self):
289 289 return bool(self._sequences)
290 290
291 291 __bool__ = __nonzero__
292 292
293 293
294 294 class bundleoperation(object):
295 295 """an object that represents a single bundling process
296 296
297 297 Its purpose is to carry unbundle-related objects and states.
298 298
299 299 A new object should be created at the beginning of each bundle processing.
300 300 The object is to be returned by the processing function.
301 301
302 302 The object has very little content now it will ultimately contain:
303 303 * an access to the repo the bundle is applied to,
304 304 * a ui object,
305 305 * a way to retrieve a transaction to add changes to the repo,
306 306 * a way to record the result of processing each part,
307 307 * a way to construct a bundle response when applicable.
308 308 """
309 309
310 310 def __init__(self, repo, transactiongetter, captureoutput=True, source=b''):
311 311 self.repo = repo
312 312 self.ui = repo.ui
313 313 self.records = unbundlerecords()
314 314 self.reply = None
315 315 self.captureoutput = captureoutput
316 316 self.hookargs = {}
317 317 self._gettransaction = transactiongetter
318 318 # carries value that can modify part behavior
319 319 self.modes = {}
320 320 self.source = source
321 321
322 322 def gettransaction(self):
323 323 transaction = self._gettransaction()
324 324
325 325 if self.hookargs:
326 326 # the ones added to the transaction supercede those added
327 327 # to the operation.
328 328 self.hookargs.update(transaction.hookargs)
329 329 transaction.hookargs = self.hookargs
330 330
331 331 # mark the hookargs as flushed. further attempts to add to
332 332 # hookargs will result in an abort.
333 333 self.hookargs = None
334 334
335 335 return transaction
336 336
337 337 def addhookargs(self, hookargs):
338 338 if self.hookargs is None:
339 339 raise error.ProgrammingError(
340 340 b'attempted to add hookargs to '
341 341 b'operation after transaction started'
342 342 )
343 343 self.hookargs.update(hookargs)
344 344
345 345
346 346 class TransactionUnavailable(RuntimeError):
347 347 pass
348 348
349 349
350 350 def _notransaction():
351 351 """default method to get a transaction while processing a bundle
352 352
353 353 Raise an exception to highlight the fact that no transaction was expected
354 354 to be created"""
355 355 raise TransactionUnavailable()
356 356
357 357
358 358 def applybundle(repo, unbundler, tr, source, url=None, **kwargs):
359 359 # transform me into unbundler.apply() as soon as the freeze is lifted
360 360 if isinstance(unbundler, unbundle20):
361 361 tr.hookargs[b'bundle2'] = b'1'
362 362 if source is not None and b'source' not in tr.hookargs:
363 363 tr.hookargs[b'source'] = source
364 364 if url is not None and b'url' not in tr.hookargs:
365 365 tr.hookargs[b'url'] = url
366 366 return processbundle(repo, unbundler, lambda: tr, source=source)
367 367 else:
368 368 # the transactiongetter won't be used, but we might as well set it
369 369 op = bundleoperation(repo, lambda: tr, source=source)
370 370 _processchangegroup(op, unbundler, tr, source, url, **kwargs)
371 371 return op
372 372
373 373
374 374 class partiterator(object):
375 375 def __init__(self, repo, op, unbundler):
376 376 self.repo = repo
377 377 self.op = op
378 378 self.unbundler = unbundler
379 379 self.iterator = None
380 380 self.count = 0
381 381 self.current = None
382 382
383 383 def __enter__(self):
384 384 def func():
385 385 itr = enumerate(self.unbundler.iterparts(), 1)
386 386 for count, p in itr:
387 387 self.count = count
388 388 self.current = p
389 389 yield p
390 390 p.consume()
391 391 self.current = None
392 392
393 393 self.iterator = func()
394 394 return self.iterator
395 395
396 396 def __exit__(self, type, exc, tb):
397 397 if not self.iterator:
398 398 return
399 399
400 400 # Only gracefully abort in a normal exception situation. User aborts
401 401 # like Ctrl+C throw a KeyboardInterrupt which is not a base Exception,
402 402 # and should not gracefully cleanup.
403 403 if isinstance(exc, Exception):
404 404 # Any exceptions seeking to the end of the bundle at this point are
405 405 # almost certainly related to the underlying stream being bad.
406 406 # And, chances are that the exception we're handling is related to
407 407 # getting in that bad state. So, we swallow the seeking error and
408 408 # re-raise the original error.
409 409 seekerror = False
410 410 try:
411 411 if self.current:
412 412 # consume the part content to not corrupt the stream.
413 413 self.current.consume()
414 414
415 415 for part in self.iterator:
416 416 # consume the bundle content
417 417 part.consume()
418 418 except Exception:
419 419 seekerror = True
420 420
421 421 # Small hack to let caller code distinguish exceptions from bundle2
422 422 # processing from processing the old format. This is mostly needed
423 423 # to handle different return codes to unbundle according to the type
424 424 # of bundle. We should probably clean up or drop this return code
425 425 # craziness in a future version.
426 426 exc.duringunbundle2 = True
427 427 salvaged = []
428 428 replycaps = None
429 429 if self.op.reply is not None:
430 430 salvaged = self.op.reply.salvageoutput()
431 431 replycaps = self.op.reply.capabilities
432 432 exc._replycaps = replycaps
433 433 exc._bundle2salvagedoutput = salvaged
434 434
435 435 # Re-raising from a variable loses the original stack. So only use
436 436 # that form if we need to.
437 437 if seekerror:
438 438 raise exc
439 439
440 440 self.repo.ui.debug(
441 441 b'bundle2-input-bundle: %i parts total\n' % self.count
442 442 )
443 443
444 444
445 445 def processbundle(repo, unbundler, transactiongetter=None, op=None, source=b''):
446 446 """This function process a bundle, apply effect to/from a repo
447 447
448 448 It iterates over each part then searches for and uses the proper handling
449 449 code to process the part. Parts are processed in order.
450 450
451 451 Unknown Mandatory part will abort the process.
452 452
453 453 It is temporarily possible to provide a prebuilt bundleoperation to the
454 454 function. This is used to ensure output is properly propagated in case of
455 455 an error during the unbundling. This output capturing part will likely be
456 456 reworked and this ability will probably go away in the process.
457 457 """
458 458 if op is None:
459 459 if transactiongetter is None:
460 460 transactiongetter = _notransaction
461 461 op = bundleoperation(repo, transactiongetter, source=source)
462 462 # todo:
463 463 # - replace this is a init function soon.
464 464 # - exception catching
465 465 unbundler.params
466 466 if repo.ui.debugflag:
467 467 msg = [b'bundle2-input-bundle:']
468 468 if unbundler.params:
469 469 msg.append(b' %i params' % len(unbundler.params))
470 470 if op._gettransaction is None or op._gettransaction is _notransaction:
471 471 msg.append(b' no-transaction')
472 472 else:
473 473 msg.append(b' with-transaction')
474 474 msg.append(b'\n')
475 475 repo.ui.debug(b''.join(msg))
476 476
477 477 processparts(repo, op, unbundler)
478 478
479 479 return op
480 480
481 481
482 482 def processparts(repo, op, unbundler):
483 483 with partiterator(repo, op, unbundler) as parts:
484 484 for part in parts:
485 485 _processpart(op, part)
486 486
487 487
488 488 def _processchangegroup(op, cg, tr, source, url, **kwargs):
489 489 ret = cg.apply(op.repo, tr, source, url, **kwargs)
490 490 op.records.add(b'changegroup', {b'return': ret,})
491 491 return ret
492 492
493 493
494 494 def _gethandler(op, part):
495 495 status = b'unknown' # used by debug output
496 496 try:
497 497 handler = parthandlermapping.get(part.type)
498 498 if handler is None:
499 499 status = b'unsupported-type'
500 500 raise error.BundleUnknownFeatureError(parttype=part.type)
501 501 indebug(op.ui, b'found a handler for part %s' % part.type)
502 502 unknownparams = part.mandatorykeys - handler.params
503 503 if unknownparams:
504 504 unknownparams = list(unknownparams)
505 505 unknownparams.sort()
506 506 status = b'unsupported-params (%s)' % b', '.join(unknownparams)
507 507 raise error.BundleUnknownFeatureError(
508 508 parttype=part.type, params=unknownparams
509 509 )
510 510 status = b'supported'
511 511 except error.BundleUnknownFeatureError as exc:
512 512 if part.mandatory: # mandatory parts
513 513 raise
514 514 indebug(op.ui, b'ignoring unsupported advisory part %s' % exc)
515 515 return # skip to part processing
516 516 finally:
517 517 if op.ui.debugflag:
518 518 msg = [b'bundle2-input-part: "%s"' % part.type]
519 519 if not part.mandatory:
520 520 msg.append(b' (advisory)')
521 521 nbmp = len(part.mandatorykeys)
522 522 nbap = len(part.params) - nbmp
523 523 if nbmp or nbap:
524 524 msg.append(b' (params:')
525 525 if nbmp:
526 526 msg.append(b' %i mandatory' % nbmp)
527 527 if nbap:
528 528 msg.append(b' %i advisory' % nbmp)
529 529 msg.append(b')')
530 530 msg.append(b' %s\n' % status)
531 531 op.ui.debug(b''.join(msg))
532 532
533 533 return handler
534 534
535 535
536 536 def _processpart(op, part):
537 537 """process a single part from a bundle
538 538
539 539 The part is guaranteed to have been fully consumed when the function exits
540 540 (even if an exception is raised)."""
541 541 handler = _gethandler(op, part)
542 542 if handler is None:
543 543 return
544 544
545 545 # handler is called outside the above try block so that we don't
546 546 # risk catching KeyErrors from anything other than the
547 547 # parthandlermapping lookup (any KeyError raised by handler()
548 548 # itself represents a defect of a different variety).
549 549 output = None
550 550 if op.captureoutput and op.reply is not None:
551 551 op.ui.pushbuffer(error=True, subproc=True)
552 552 output = b''
553 553 try:
554 554 handler(op, part)
555 555 finally:
556 556 if output is not None:
557 557 output = op.ui.popbuffer()
558 558 if output:
559 559 outpart = op.reply.newpart(b'output', data=output, mandatory=False)
560 560 outpart.addparam(
561 561 b'in-reply-to', pycompat.bytestr(part.id), mandatory=False
562 562 )
563 563
564 564
565 565 def decodecaps(blob):
566 566 """decode a bundle2 caps bytes blob into a dictionary
567 567
568 568 The blob is a list of capabilities (one per line)
569 569 Capabilities may have values using a line of the form::
570 570
571 571 capability=value1,value2,value3
572 572
573 573 The values are always a list."""
574 574 caps = {}
575 575 for line in blob.splitlines():
576 576 if not line:
577 577 continue
578 578 if b'=' not in line:
579 579 key, vals = line, ()
580 580 else:
581 581 key, vals = line.split(b'=', 1)
582 582 vals = vals.split(b',')
583 583 key = urlreq.unquote(key)
584 584 vals = [urlreq.unquote(v) for v in vals]
585 585 caps[key] = vals
586 586 return caps
587 587
588 588
589 589 def encodecaps(caps):
590 590 """encode a bundle2 caps dictionary into a bytes blob"""
591 591 chunks = []
592 592 for ca in sorted(caps):
593 593 vals = caps[ca]
594 594 ca = urlreq.quote(ca)
595 595 vals = [urlreq.quote(v) for v in vals]
596 596 if vals:
597 597 ca = b"%s=%s" % (ca, b','.join(vals))
598 598 chunks.append(ca)
599 599 return b'\n'.join(chunks)
600 600
601 601
602 602 bundletypes = {
603 603 b"": (b"", b'UN'), # only when using unbundle on ssh and old http servers
604 604 # since the unification ssh accepts a header but there
605 605 # is no capability signaling it.
606 606 b"HG20": (), # special-cased below
607 607 b"HG10UN": (b"HG10UN", b'UN'),
608 608 b"HG10BZ": (b"HG10", b'BZ'),
609 609 b"HG10GZ": (b"HG10GZ", b'GZ'),
610 610 }
611 611
612 612 # hgweb uses this list to communicate its preferred type
613 613 bundlepriority = [b'HG10GZ', b'HG10BZ', b'HG10UN']
614 614
615 615
616 616 class bundle20(object):
617 617 """represent an outgoing bundle2 container
618 618
619 619 Use the `addparam` method to add stream level parameter. and `newpart` to
620 620 populate it. Then call `getchunks` to retrieve all the binary chunks of
621 621 data that compose the bundle2 container."""
622 622
623 623 _magicstring = b'HG20'
624 624
625 625 def __init__(self, ui, capabilities=()):
626 626 self.ui = ui
627 627 self._params = []
628 628 self._parts = []
629 629 self.capabilities = dict(capabilities)
630 630 self._compengine = util.compengines.forbundletype(b'UN')
631 631 self._compopts = None
632 632 # If compression is being handled by a consumer of the raw
633 633 # data (e.g. the wire protocol), unsetting this flag tells
634 634 # consumers that the bundle is best left uncompressed.
635 635 self.prefercompressed = True
636 636
637 637 def setcompression(self, alg, compopts=None):
638 638 """setup core part compression to <alg>"""
639 639 if alg in (None, b'UN'):
640 640 return
641 641 assert not any(n.lower() == b'compression' for n, v in self._params)
642 642 self.addparam(b'Compression', alg)
643 643 self._compengine = util.compengines.forbundletype(alg)
644 644 self._compopts = compopts
645 645
646 646 @property
647 647 def nbparts(self):
648 648 """total number of parts added to the bundler"""
649 649 return len(self._parts)
650 650
651 651 # methods used to defines the bundle2 content
652 652 def addparam(self, name, value=None):
653 653 """add a stream level parameter"""
654 654 if not name:
655 655 raise error.ProgrammingError(b'empty parameter name')
656 656 if name[0:1] not in pycompat.bytestr(string.ascii_letters):
657 657 raise error.ProgrammingError(
658 658 b'non letter first character: %s' % name
659 659 )
660 660 self._params.append((name, value))
661 661
662 662 def addpart(self, part):
663 663 """add a new part to the bundle2 container
664 664
665 665 Parts contains the actual applicative payload."""
666 666 assert part.id is None
667 667 part.id = len(self._parts) # very cheap counter
668 668 self._parts.append(part)
669 669
670 670 def newpart(self, typeid, *args, **kwargs):
671 671 """create a new part and add it to the containers
672 672
673 673 As the part is directly added to the containers. For now, this means
674 674 that any failure to properly initialize the part after calling
675 675 ``newpart`` should result in a failure of the whole bundling process.
676 676
677 677 You can still fall back to manually create and add if you need better
678 678 control."""
679 679 part = bundlepart(typeid, *args, **kwargs)
680 680 self.addpart(part)
681 681 return part
682 682
683 683 # methods used to generate the bundle2 stream
684 684 def getchunks(self):
685 685 if self.ui.debugflag:
686 686 msg = [b'bundle2-output-bundle: "%s",' % self._magicstring]
687 687 if self._params:
688 688 msg.append(b' (%i params)' % len(self._params))
689 689 msg.append(b' %i parts total\n' % len(self._parts))
690 690 self.ui.debug(b''.join(msg))
691 691 outdebug(self.ui, b'start emission of %s stream' % self._magicstring)
692 692 yield self._magicstring
693 693 param = self._paramchunk()
694 694 outdebug(self.ui, b'bundle parameter: %s' % param)
695 695 yield _pack(_fstreamparamsize, len(param))
696 696 if param:
697 697 yield param
698 698 for chunk in self._compengine.compressstream(
699 699 self._getcorechunk(), self._compopts
700 700 ):
701 701 yield chunk
702 702
703 703 def _paramchunk(self):
704 704 """return a encoded version of all stream parameters"""
705 705 blocks = []
706 706 for par, value in self._params:
707 707 par = urlreq.quote(par)
708 708 if value is not None:
709 709 value = urlreq.quote(value)
710 710 par = b'%s=%s' % (par, value)
711 711 blocks.append(par)
712 712 return b' '.join(blocks)
713 713
714 714 def _getcorechunk(self):
715 715 """yield chunk for the core part of the bundle
716 716
717 717 (all but headers and parameters)"""
718 718 outdebug(self.ui, b'start of parts')
719 719 for part in self._parts:
720 720 outdebug(self.ui, b'bundle part: "%s"' % part.type)
721 721 for chunk in part.getchunks(ui=self.ui):
722 722 yield chunk
723 723 outdebug(self.ui, b'end of bundle')
724 724 yield _pack(_fpartheadersize, 0)
725 725
726 726 def salvageoutput(self):
727 727 """return a list with a copy of all output parts in the bundle
728 728
729 729 This is meant to be used during error handling to make sure we preserve
730 730 server output"""
731 731 salvaged = []
732 732 for part in self._parts:
733 733 if part.type.startswith(b'output'):
734 734 salvaged.append(part.copy())
735 735 return salvaged
736 736
737 737
738 738 class unpackermixin(object):
739 739 """A mixin to extract bytes and struct data from a stream"""
740 740
741 741 def __init__(self, fp):
742 742 self._fp = fp
743 743
744 744 def _unpack(self, format):
745 745 """unpack this struct format from the stream
746 746
747 747 This method is meant for internal usage by the bundle2 protocol only.
748 748 They directly manipulate the low level stream including bundle2 level
749 749 instruction.
750 750
751 751 Do not use it to implement higher-level logic or methods."""
752 752 data = self._readexact(struct.calcsize(format))
753 753 return _unpack(format, data)
754 754
755 755 def _readexact(self, size):
756 756 """read exactly <size> bytes from the stream
757 757
758 758 This method is meant for internal usage by the bundle2 protocol only.
759 759 They directly manipulate the low level stream including bundle2 level
760 760 instruction.
761 761
762 762 Do not use it to implement higher-level logic or methods."""
763 763 return changegroup.readexactly(self._fp, size)
764 764
765 765
766 766 def getunbundler(ui, fp, magicstring=None):
767 767 """return a valid unbundler object for a given magicstring"""
768 768 if magicstring is None:
769 769 magicstring = changegroup.readexactly(fp, 4)
770 770 magic, version = magicstring[0:2], magicstring[2:4]
771 771 if magic != b'HG':
772 772 ui.debug(
773 773 b"error: invalid magic: %r (version %r), should be 'HG'\n"
774 774 % (magic, version)
775 775 )
776 776 raise error.Abort(_(b'not a Mercurial bundle'))
777 777 unbundlerclass = formatmap.get(version)
778 778 if unbundlerclass is None:
779 779 raise error.Abort(_(b'unknown bundle version %s') % version)
780 780 unbundler = unbundlerclass(ui, fp)
781 781 indebug(ui, b'start processing of %s stream' % magicstring)
782 782 return unbundler
783 783
784 784
785 785 class unbundle20(unpackermixin):
786 786 """interpret a bundle2 stream
787 787
788 788 This class is fed with a binary stream and yields parts through its
789 789 `iterparts` methods."""
790 790
791 791 _magicstring = b'HG20'
792 792
793 793 def __init__(self, ui, fp):
794 794 """If header is specified, we do not read it out of the stream."""
795 795 self.ui = ui
796 796 self._compengine = util.compengines.forbundletype(b'UN')
797 797 self._compressed = None
798 798 super(unbundle20, self).__init__(fp)
799 799
800 800 @util.propertycache
801 801 def params(self):
802 802 """dictionary of stream level parameters"""
803 803 indebug(self.ui, b'reading bundle2 stream parameters')
804 804 params = {}
805 805 paramssize = self._unpack(_fstreamparamsize)[0]
806 806 if paramssize < 0:
807 807 raise error.BundleValueError(
808 808 b'negative bundle param size: %i' % paramssize
809 809 )
810 810 if paramssize:
811 811 params = self._readexact(paramssize)
812 812 params = self._processallparams(params)
813 813 return params
814 814
815 815 def _processallparams(self, paramsblock):
816 816 """"""
817 817 params = util.sortdict()
818 818 for p in paramsblock.split(b' '):
819 819 p = p.split(b'=', 1)
820 820 p = [urlreq.unquote(i) for i in p]
821 821 if len(p) < 2:
822 822 p.append(None)
823 823 self._processparam(*p)
824 824 params[p[0]] = p[1]
825 825 return params
826 826
827 827 def _processparam(self, name, value):
828 828 """process a parameter, applying its effect if needed
829 829
830 830 Parameter starting with a lower case letter are advisory and will be
831 831 ignored when unknown. Those starting with an upper case letter are
832 832 mandatory and will this function will raise a KeyError when unknown.
833 833
834 834 Note: no option are currently supported. Any input will be either
835 835 ignored or failing.
836 836 """
837 837 if not name:
838 838 raise ValueError(r'empty parameter name')
839 839 if name[0:1] not in pycompat.bytestr(string.ascii_letters):
840 840 raise ValueError(r'non letter first character: %s' % name)
841 841 try:
842 842 handler = b2streamparamsmap[name.lower()]
843 843 except KeyError:
844 844 if name[0:1].islower():
845 845 indebug(self.ui, b"ignoring unknown parameter %s" % name)
846 846 else:
847 847 raise error.BundleUnknownFeatureError(params=(name,))
848 848 else:
849 849 handler(self, name, value)
850 850
851 851 def _forwardchunks(self):
852 852 """utility to transfer a bundle2 as binary
853 853
854 854 This is made necessary by the fact the 'getbundle' command over 'ssh'
855 855 have no way to know then the reply end, relying on the bundle to be
856 856 interpreted to know its end. This is terrible and we are sorry, but we
857 857 needed to move forward to get general delta enabled.
858 858 """
859 859 yield self._magicstring
860 860 assert b'params' not in vars(self)
861 861 paramssize = self._unpack(_fstreamparamsize)[0]
862 862 if paramssize < 0:
863 863 raise error.BundleValueError(
864 864 b'negative bundle param size: %i' % paramssize
865 865 )
866 866 if paramssize:
867 867 params = self._readexact(paramssize)
868 868 self._processallparams(params)
869 869 # The payload itself is decompressed below, so drop
870 870 # the compression parameter passed down to compensate.
871 871 outparams = []
872 872 for p in params.split(b' '):
873 873 k, v = p.split(b'=', 1)
874 874 if k.lower() != b'compression':
875 875 outparams.append(p)
876 876 outparams = b' '.join(outparams)
877 877 yield _pack(_fstreamparamsize, len(outparams))
878 878 yield outparams
879 879 else:
880 880 yield _pack(_fstreamparamsize, paramssize)
881 881 # From there, payload might need to be decompressed
882 882 self._fp = self._compengine.decompressorreader(self._fp)
883 883 emptycount = 0
884 884 while emptycount < 2:
885 885 # so we can brainlessly loop
886 886 assert _fpartheadersize == _fpayloadsize
887 887 size = self._unpack(_fpartheadersize)[0]
888 888 yield _pack(_fpartheadersize, size)
889 889 if size:
890 890 emptycount = 0
891 891 else:
892 892 emptycount += 1
893 893 continue
894 894 if size == flaginterrupt:
895 895 continue
896 896 elif size < 0:
897 897 raise error.BundleValueError(b'negative chunk size: %i')
898 898 yield self._readexact(size)
899 899
900 900 def iterparts(self, seekable=False):
901 901 """yield all parts contained in the stream"""
902 902 cls = seekableunbundlepart if seekable else unbundlepart
903 903 # make sure param have been loaded
904 904 self.params
905 905 # From there, payload need to be decompressed
906 906 self._fp = self._compengine.decompressorreader(self._fp)
907 907 indebug(self.ui, b'start extraction of bundle2 parts')
908 908 headerblock = self._readpartheader()
909 909 while headerblock is not None:
910 910 part = cls(self.ui, headerblock, self._fp)
911 911 yield part
912 912 # Ensure part is fully consumed so we can start reading the next
913 913 # part.
914 914 part.consume()
915 915
916 916 headerblock = self._readpartheader()
917 917 indebug(self.ui, b'end of bundle2 stream')
918 918
919 919 def _readpartheader(self):
920 920 """reads a part header size and return the bytes blob
921 921
922 922 returns None if empty"""
923 923 headersize = self._unpack(_fpartheadersize)[0]
924 924 if headersize < 0:
925 925 raise error.BundleValueError(
926 926 b'negative part header size: %i' % headersize
927 927 )
928 928 indebug(self.ui, b'part header size: %i' % headersize)
929 929 if headersize:
930 930 return self._readexact(headersize)
931 931 return None
932 932
933 933 def compressed(self):
934 934 self.params # load params
935 935 return self._compressed
936 936
937 937 def close(self):
938 938 """close underlying file"""
939 if util.safehasattr(self._fp, b'close'):
939 if util.safehasattr(self._fp, 'close'):
940 940 return self._fp.close()
941 941
942 942
943 943 formatmap = {b'20': unbundle20}
944 944
945 945 b2streamparamsmap = {}
946 946
947 947
948 948 def b2streamparamhandler(name):
949 949 """register a handler for a stream level parameter"""
950 950
951 951 def decorator(func):
952 952 assert name not in formatmap
953 953 b2streamparamsmap[name] = func
954 954 return func
955 955
956 956 return decorator
957 957
958 958
959 959 @b2streamparamhandler(b'compression')
960 960 def processcompression(unbundler, param, value):
961 961 """read compression parameter and install payload decompression"""
962 962 if value not in util.compengines.supportedbundletypes:
963 963 raise error.BundleUnknownFeatureError(params=(param,), values=(value,))
964 964 unbundler._compengine = util.compengines.forbundletype(value)
965 965 if value is not None:
966 966 unbundler._compressed = True
967 967
968 968
969 969 class bundlepart(object):
970 970 """A bundle2 part contains application level payload
971 971
972 972 The part `type` is used to route the part to the application level
973 973 handler.
974 974
975 975 The part payload is contained in ``part.data``. It could be raw bytes or a
976 976 generator of byte chunks.
977 977
978 978 You can add parameters to the part using the ``addparam`` method.
979 979 Parameters can be either mandatory (default) or advisory. Remote side
980 980 should be able to safely ignore the advisory ones.
981 981
982 982 Both data and parameters cannot be modified after the generation has begun.
983 983 """
984 984
985 985 def __init__(
986 986 self,
987 987 parttype,
988 988 mandatoryparams=(),
989 989 advisoryparams=(),
990 990 data=b'',
991 991 mandatory=True,
992 992 ):
993 993 validateparttype(parttype)
994 994 self.id = None
995 995 self.type = parttype
996 996 self._data = data
997 997 self._mandatoryparams = list(mandatoryparams)
998 998 self._advisoryparams = list(advisoryparams)
999 999 # checking for duplicated entries
1000 1000 self._seenparams = set()
1001 1001 for pname, __ in self._mandatoryparams + self._advisoryparams:
1002 1002 if pname in self._seenparams:
1003 1003 raise error.ProgrammingError(b'duplicated params: %s' % pname)
1004 1004 self._seenparams.add(pname)
1005 1005 # status of the part's generation:
1006 1006 # - None: not started,
1007 1007 # - False: currently generated,
1008 1008 # - True: generation done.
1009 1009 self._generated = None
1010 1010 self.mandatory = mandatory
1011 1011
1012 1012 def __repr__(self):
1013 1013 cls = b"%s.%s" % (self.__class__.__module__, self.__class__.__name__)
1014 1014 return b'<%s object at %x; id: %s; type: %s; mandatory: %s>' % (
1015 1015 cls,
1016 1016 id(self),
1017 1017 self.id,
1018 1018 self.type,
1019 1019 self.mandatory,
1020 1020 )
1021 1021
1022 1022 def copy(self):
1023 1023 """return a copy of the part
1024 1024
1025 1025 The new part have the very same content but no partid assigned yet.
1026 1026 Parts with generated data cannot be copied."""
1027 assert not util.safehasattr(self.data, b'next')
1027 assert not util.safehasattr(self.data, 'next')
1028 1028 return self.__class__(
1029 1029 self.type,
1030 1030 self._mandatoryparams,
1031 1031 self._advisoryparams,
1032 1032 self._data,
1033 1033 self.mandatory,
1034 1034 )
1035 1035
1036 1036 # methods used to defines the part content
1037 1037 @property
1038 1038 def data(self):
1039 1039 return self._data
1040 1040
1041 1041 @data.setter
1042 1042 def data(self, data):
1043 1043 if self._generated is not None:
1044 1044 raise error.ReadOnlyPartError(b'part is being generated')
1045 1045 self._data = data
1046 1046
1047 1047 @property
1048 1048 def mandatoryparams(self):
1049 1049 # make it an immutable tuple to force people through ``addparam``
1050 1050 return tuple(self._mandatoryparams)
1051 1051
1052 1052 @property
1053 1053 def advisoryparams(self):
1054 1054 # make it an immutable tuple to force people through ``addparam``
1055 1055 return tuple(self._advisoryparams)
1056 1056
1057 1057 def addparam(self, name, value=b'', mandatory=True):
1058 1058 """add a parameter to the part
1059 1059
1060 1060 If 'mandatory' is set to True, the remote handler must claim support
1061 1061 for this parameter or the unbundling will be aborted.
1062 1062
1063 1063 The 'name' and 'value' cannot exceed 255 bytes each.
1064 1064 """
1065 1065 if self._generated is not None:
1066 1066 raise error.ReadOnlyPartError(b'part is being generated')
1067 1067 if name in self._seenparams:
1068 1068 raise ValueError(b'duplicated params: %s' % name)
1069 1069 self._seenparams.add(name)
1070 1070 params = self._advisoryparams
1071 1071 if mandatory:
1072 1072 params = self._mandatoryparams
1073 1073 params.append((name, value))
1074 1074
1075 1075 # methods used to generates the bundle2 stream
1076 1076 def getchunks(self, ui):
1077 1077 if self._generated is not None:
1078 1078 raise error.ProgrammingError(b'part can only be consumed once')
1079 1079 self._generated = False
1080 1080
1081 1081 if ui.debugflag:
1082 1082 msg = [b'bundle2-output-part: "%s"' % self.type]
1083 1083 if not self.mandatory:
1084 1084 msg.append(b' (advisory)')
1085 1085 nbmp = len(self.mandatoryparams)
1086 1086 nbap = len(self.advisoryparams)
1087 1087 if nbmp or nbap:
1088 1088 msg.append(b' (params:')
1089 1089 if nbmp:
1090 1090 msg.append(b' %i mandatory' % nbmp)
1091 1091 if nbap:
1092 1092 msg.append(b' %i advisory' % nbmp)
1093 1093 msg.append(b')')
1094 1094 if not self.data:
1095 1095 msg.append(b' empty payload')
1096 elif util.safehasattr(self.data, b'next') or util.safehasattr(
1096 elif util.safehasattr(self.data, 'next') or util.safehasattr(
1097 1097 self.data, b'__next__'
1098 1098 ):
1099 1099 msg.append(b' streamed payload')
1100 1100 else:
1101 1101 msg.append(b' %i bytes payload' % len(self.data))
1102 1102 msg.append(b'\n')
1103 1103 ui.debug(b''.join(msg))
1104 1104
1105 1105 #### header
1106 1106 if self.mandatory:
1107 1107 parttype = self.type.upper()
1108 1108 else:
1109 1109 parttype = self.type.lower()
1110 1110 outdebug(ui, b'part %s: "%s"' % (pycompat.bytestr(self.id), parttype))
1111 1111 ## parttype
1112 1112 header = [
1113 1113 _pack(_fparttypesize, len(parttype)),
1114 1114 parttype,
1115 1115 _pack(_fpartid, self.id),
1116 1116 ]
1117 1117 ## parameters
1118 1118 # count
1119 1119 manpar = self.mandatoryparams
1120 1120 advpar = self.advisoryparams
1121 1121 header.append(_pack(_fpartparamcount, len(manpar), len(advpar)))
1122 1122 # size
1123 1123 parsizes = []
1124 1124 for key, value in manpar:
1125 1125 parsizes.append(len(key))
1126 1126 parsizes.append(len(value))
1127 1127 for key, value in advpar:
1128 1128 parsizes.append(len(key))
1129 1129 parsizes.append(len(value))
1130 1130 paramsizes = _pack(_makefpartparamsizes(len(parsizes) // 2), *parsizes)
1131 1131 header.append(paramsizes)
1132 1132 # key, value
1133 1133 for key, value in manpar:
1134 1134 header.append(key)
1135 1135 header.append(value)
1136 1136 for key, value in advpar:
1137 1137 header.append(key)
1138 1138 header.append(value)
1139 1139 ## finalize header
1140 1140 try:
1141 1141 headerchunk = b''.join(header)
1142 1142 except TypeError:
1143 1143 raise TypeError(
1144 1144 r'Found a non-bytes trying to '
1145 1145 r'build bundle part header: %r' % header
1146 1146 )
1147 1147 outdebug(ui, b'header chunk size: %i' % len(headerchunk))
1148 1148 yield _pack(_fpartheadersize, len(headerchunk))
1149 1149 yield headerchunk
1150 1150 ## payload
1151 1151 try:
1152 1152 for chunk in self._payloadchunks():
1153 1153 outdebug(ui, b'payload chunk size: %i' % len(chunk))
1154 1154 yield _pack(_fpayloadsize, len(chunk))
1155 1155 yield chunk
1156 1156 except GeneratorExit:
1157 1157 # GeneratorExit means that nobody is listening for our
1158 1158 # results anyway, so just bail quickly rather than trying
1159 1159 # to produce an error part.
1160 1160 ui.debug(b'bundle2-generatorexit\n')
1161 1161 raise
1162 1162 except BaseException as exc:
1163 1163 bexc = stringutil.forcebytestr(exc)
1164 1164 # backup exception data for later
1165 1165 ui.debug(
1166 1166 b'bundle2-input-stream-interrupt: encoding exception %s' % bexc
1167 1167 )
1168 1168 tb = sys.exc_info()[2]
1169 1169 msg = b'unexpected error: %s' % bexc
1170 1170 interpart = bundlepart(
1171 1171 b'error:abort', [(b'message', msg)], mandatory=False
1172 1172 )
1173 1173 interpart.id = 0
1174 1174 yield _pack(_fpayloadsize, -1)
1175 1175 for chunk in interpart.getchunks(ui=ui):
1176 1176 yield chunk
1177 1177 outdebug(ui, b'closing payload chunk')
1178 1178 # abort current part payload
1179 1179 yield _pack(_fpayloadsize, 0)
1180 1180 pycompat.raisewithtb(exc, tb)
1181 1181 # end of payload
1182 1182 outdebug(ui, b'closing payload chunk')
1183 1183 yield _pack(_fpayloadsize, 0)
1184 1184 self._generated = True
1185 1185
1186 1186 def _payloadchunks(self):
1187 1187 """yield chunks of a the part payload
1188 1188
1189 1189 Exists to handle the different methods to provide data to a part."""
1190 1190 # we only support fixed size data now.
1191 1191 # This will be improved in the future.
1192 if util.safehasattr(self.data, b'next') or util.safehasattr(
1192 if util.safehasattr(self.data, 'next') or util.safehasattr(
1193 1193 self.data, b'__next__'
1194 1194 ):
1195 1195 buff = util.chunkbuffer(self.data)
1196 1196 chunk = buff.read(preferedchunksize)
1197 1197 while chunk:
1198 1198 yield chunk
1199 1199 chunk = buff.read(preferedchunksize)
1200 1200 elif len(self.data):
1201 1201 yield self.data
1202 1202
1203 1203
1204 1204 flaginterrupt = -1
1205 1205
1206 1206
1207 1207 class interrupthandler(unpackermixin):
1208 1208 """read one part and process it with restricted capability
1209 1209
1210 1210 This allows to transmit exception raised on the producer size during part
1211 1211 iteration while the consumer is reading a part.
1212 1212
1213 1213 Part processed in this manner only have access to a ui object,"""
1214 1214
1215 1215 def __init__(self, ui, fp):
1216 1216 super(interrupthandler, self).__init__(fp)
1217 1217 self.ui = ui
1218 1218
1219 1219 def _readpartheader(self):
1220 1220 """reads a part header size and return the bytes blob
1221 1221
1222 1222 returns None if empty"""
1223 1223 headersize = self._unpack(_fpartheadersize)[0]
1224 1224 if headersize < 0:
1225 1225 raise error.BundleValueError(
1226 1226 b'negative part header size: %i' % headersize
1227 1227 )
1228 1228 indebug(self.ui, b'part header size: %i\n' % headersize)
1229 1229 if headersize:
1230 1230 return self._readexact(headersize)
1231 1231 return None
1232 1232
1233 1233 def __call__(self):
1234 1234
1235 1235 self.ui.debug(
1236 1236 b'bundle2-input-stream-interrupt:' b' opening out of band context\n'
1237 1237 )
1238 1238 indebug(self.ui, b'bundle2 stream interruption, looking for a part.')
1239 1239 headerblock = self._readpartheader()
1240 1240 if headerblock is None:
1241 1241 indebug(self.ui, b'no part found during interruption.')
1242 1242 return
1243 1243 part = unbundlepart(self.ui, headerblock, self._fp)
1244 1244 op = interruptoperation(self.ui)
1245 1245 hardabort = False
1246 1246 try:
1247 1247 _processpart(op, part)
1248 1248 except (SystemExit, KeyboardInterrupt):
1249 1249 hardabort = True
1250 1250 raise
1251 1251 finally:
1252 1252 if not hardabort:
1253 1253 part.consume()
1254 1254 self.ui.debug(
1255 1255 b'bundle2-input-stream-interrupt:' b' closing out of band context\n'
1256 1256 )
1257 1257
1258 1258
1259 1259 class interruptoperation(object):
1260 1260 """A limited operation to be use by part handler during interruption
1261 1261
1262 1262 It only have access to an ui object.
1263 1263 """
1264 1264
1265 1265 def __init__(self, ui):
1266 1266 self.ui = ui
1267 1267 self.reply = None
1268 1268 self.captureoutput = False
1269 1269
1270 1270 @property
1271 1271 def repo(self):
1272 1272 raise error.ProgrammingError(b'no repo access from stream interruption')
1273 1273
1274 1274 def gettransaction(self):
1275 1275 raise TransactionUnavailable(b'no repo access from stream interruption')
1276 1276
1277 1277
1278 1278 def decodepayloadchunks(ui, fh):
1279 1279 """Reads bundle2 part payload data into chunks.
1280 1280
1281 1281 Part payload data consists of framed chunks. This function takes
1282 1282 a file handle and emits those chunks.
1283 1283 """
1284 1284 dolog = ui.configbool(b'devel', b'bundle2.debug')
1285 1285 debug = ui.debug
1286 1286
1287 1287 headerstruct = struct.Struct(_fpayloadsize)
1288 1288 headersize = headerstruct.size
1289 1289 unpack = headerstruct.unpack
1290 1290
1291 1291 readexactly = changegroup.readexactly
1292 1292 read = fh.read
1293 1293
1294 1294 chunksize = unpack(readexactly(fh, headersize))[0]
1295 1295 indebug(ui, b'payload chunk size: %i' % chunksize)
1296 1296
1297 1297 # changegroup.readexactly() is inlined below for performance.
1298 1298 while chunksize:
1299 1299 if chunksize >= 0:
1300 1300 s = read(chunksize)
1301 1301 if len(s) < chunksize:
1302 1302 raise error.Abort(
1303 1303 _(
1304 1304 b'stream ended unexpectedly '
1305 1305 b' (got %d bytes, expected %d)'
1306 1306 )
1307 1307 % (len(s), chunksize)
1308 1308 )
1309 1309
1310 1310 yield s
1311 1311 elif chunksize == flaginterrupt:
1312 1312 # Interrupt "signal" detected. The regular stream is interrupted
1313 1313 # and a bundle2 part follows. Consume it.
1314 1314 interrupthandler(ui, fh)()
1315 1315 else:
1316 1316 raise error.BundleValueError(
1317 1317 b'negative payload chunk size: %s' % chunksize
1318 1318 )
1319 1319
1320 1320 s = read(headersize)
1321 1321 if len(s) < headersize:
1322 1322 raise error.Abort(
1323 1323 _(b'stream ended unexpectedly ' b' (got %d bytes, expected %d)')
1324 1324 % (len(s), chunksize)
1325 1325 )
1326 1326
1327 1327 chunksize = unpack(s)[0]
1328 1328
1329 1329 # indebug() inlined for performance.
1330 1330 if dolog:
1331 1331 debug(b'bundle2-input: payload chunk size: %i\n' % chunksize)
1332 1332
1333 1333
1334 1334 class unbundlepart(unpackermixin):
1335 1335 """a bundle part read from a bundle"""
1336 1336
1337 1337 def __init__(self, ui, header, fp):
1338 1338 super(unbundlepart, self).__init__(fp)
1339 self._seekable = util.safehasattr(fp, b'seek') and util.safehasattr(
1339 self._seekable = util.safehasattr(fp, 'seek') and util.safehasattr(
1340 1340 fp, b'tell'
1341 1341 )
1342 1342 self.ui = ui
1343 1343 # unbundle state attr
1344 1344 self._headerdata = header
1345 1345 self._headeroffset = 0
1346 1346 self._initialized = False
1347 1347 self.consumed = False
1348 1348 # part data
1349 1349 self.id = None
1350 1350 self.type = None
1351 1351 self.mandatoryparams = None
1352 1352 self.advisoryparams = None
1353 1353 self.params = None
1354 1354 self.mandatorykeys = ()
1355 1355 self._readheader()
1356 1356 self._mandatory = None
1357 1357 self._pos = 0
1358 1358
1359 1359 def _fromheader(self, size):
1360 1360 """return the next <size> byte from the header"""
1361 1361 offset = self._headeroffset
1362 1362 data = self._headerdata[offset : (offset + size)]
1363 1363 self._headeroffset = offset + size
1364 1364 return data
1365 1365
1366 1366 def _unpackheader(self, format):
1367 1367 """read given format from header
1368 1368
1369 1369 This automatically compute the size of the format to read."""
1370 1370 data = self._fromheader(struct.calcsize(format))
1371 1371 return _unpack(format, data)
1372 1372
1373 1373 def _initparams(self, mandatoryparams, advisoryparams):
1374 1374 """internal function to setup all logic related parameters"""
1375 1375 # make it read only to prevent people touching it by mistake.
1376 1376 self.mandatoryparams = tuple(mandatoryparams)
1377 1377 self.advisoryparams = tuple(advisoryparams)
1378 1378 # user friendly UI
1379 1379 self.params = util.sortdict(self.mandatoryparams)
1380 1380 self.params.update(self.advisoryparams)
1381 1381 self.mandatorykeys = frozenset(p[0] for p in mandatoryparams)
1382 1382
1383 1383 def _readheader(self):
1384 1384 """read the header and setup the object"""
1385 1385 typesize = self._unpackheader(_fparttypesize)[0]
1386 1386 self.type = self._fromheader(typesize)
1387 1387 indebug(self.ui, b'part type: "%s"' % self.type)
1388 1388 self.id = self._unpackheader(_fpartid)[0]
1389 1389 indebug(self.ui, b'part id: "%s"' % pycompat.bytestr(self.id))
1390 1390 # extract mandatory bit from type
1391 1391 self.mandatory = self.type != self.type.lower()
1392 1392 self.type = self.type.lower()
1393 1393 ## reading parameters
1394 1394 # param count
1395 1395 mancount, advcount = self._unpackheader(_fpartparamcount)
1396 1396 indebug(self.ui, b'part parameters: %i' % (mancount + advcount))
1397 1397 # param size
1398 1398 fparamsizes = _makefpartparamsizes(mancount + advcount)
1399 1399 paramsizes = self._unpackheader(fparamsizes)
1400 1400 # make it a list of couple again
1401 1401 paramsizes = list(zip(paramsizes[::2], paramsizes[1::2]))
1402 1402 # split mandatory from advisory
1403 1403 mansizes = paramsizes[:mancount]
1404 1404 advsizes = paramsizes[mancount:]
1405 1405 # retrieve param value
1406 1406 manparams = []
1407 1407 for key, value in mansizes:
1408 1408 manparams.append((self._fromheader(key), self._fromheader(value)))
1409 1409 advparams = []
1410 1410 for key, value in advsizes:
1411 1411 advparams.append((self._fromheader(key), self._fromheader(value)))
1412 1412 self._initparams(manparams, advparams)
1413 1413 ## part payload
1414 1414 self._payloadstream = util.chunkbuffer(self._payloadchunks())
1415 1415 # we read the data, tell it
1416 1416 self._initialized = True
1417 1417
1418 1418 def _payloadchunks(self):
1419 1419 """Generator of decoded chunks in the payload."""
1420 1420 return decodepayloadchunks(self.ui, self._fp)
1421 1421
1422 1422 def consume(self):
1423 1423 """Read the part payload until completion.
1424 1424
1425 1425 By consuming the part data, the underlying stream read offset will
1426 1426 be advanced to the next part (or end of stream).
1427 1427 """
1428 1428 if self.consumed:
1429 1429 return
1430 1430
1431 1431 chunk = self.read(32768)
1432 1432 while chunk:
1433 1433 self._pos += len(chunk)
1434 1434 chunk = self.read(32768)
1435 1435
1436 1436 def read(self, size=None):
1437 1437 """read payload data"""
1438 1438 if not self._initialized:
1439 1439 self._readheader()
1440 1440 if size is None:
1441 1441 data = self._payloadstream.read()
1442 1442 else:
1443 1443 data = self._payloadstream.read(size)
1444 1444 self._pos += len(data)
1445 1445 if size is None or len(data) < size:
1446 1446 if not self.consumed and self._pos:
1447 1447 self.ui.debug(
1448 1448 b'bundle2-input-part: total payload size %i\n' % self._pos
1449 1449 )
1450 1450 self.consumed = True
1451 1451 return data
1452 1452
1453 1453
1454 1454 class seekableunbundlepart(unbundlepart):
1455 1455 """A bundle2 part in a bundle that is seekable.
1456 1456
1457 1457 Regular ``unbundlepart`` instances can only be read once. This class
1458 1458 extends ``unbundlepart`` to enable bi-directional seeking within the
1459 1459 part.
1460 1460
1461 1461 Bundle2 part data consists of framed chunks. Offsets when seeking
1462 1462 refer to the decoded data, not the offsets in the underlying bundle2
1463 1463 stream.
1464 1464
1465 1465 To facilitate quickly seeking within the decoded data, instances of this
1466 1466 class maintain a mapping between offsets in the underlying stream and
1467 1467 the decoded payload. This mapping will consume memory in proportion
1468 1468 to the number of chunks within the payload (which almost certainly
1469 1469 increases in proportion with the size of the part).
1470 1470 """
1471 1471
1472 1472 def __init__(self, ui, header, fp):
1473 1473 # (payload, file) offsets for chunk starts.
1474 1474 self._chunkindex = []
1475 1475
1476 1476 super(seekableunbundlepart, self).__init__(ui, header, fp)
1477 1477
1478 1478 def _payloadchunks(self, chunknum=0):
1479 1479 '''seek to specified chunk and start yielding data'''
1480 1480 if len(self._chunkindex) == 0:
1481 1481 assert chunknum == 0, b'Must start with chunk 0'
1482 1482 self._chunkindex.append((0, self._tellfp()))
1483 1483 else:
1484 1484 assert chunknum < len(self._chunkindex), (
1485 1485 b'Unknown chunk %d' % chunknum
1486 1486 )
1487 1487 self._seekfp(self._chunkindex[chunknum][1])
1488 1488
1489 1489 pos = self._chunkindex[chunknum][0]
1490 1490
1491 1491 for chunk in decodepayloadchunks(self.ui, self._fp):
1492 1492 chunknum += 1
1493 1493 pos += len(chunk)
1494 1494 if chunknum == len(self._chunkindex):
1495 1495 self._chunkindex.append((pos, self._tellfp()))
1496 1496
1497 1497 yield chunk
1498 1498
1499 1499 def _findchunk(self, pos):
1500 1500 '''for a given payload position, return a chunk number and offset'''
1501 1501 for chunk, (ppos, fpos) in enumerate(self._chunkindex):
1502 1502 if ppos == pos:
1503 1503 return chunk, 0
1504 1504 elif ppos > pos:
1505 1505 return chunk - 1, pos - self._chunkindex[chunk - 1][0]
1506 1506 raise ValueError(b'Unknown chunk')
1507 1507
1508 1508 def tell(self):
1509 1509 return self._pos
1510 1510
1511 1511 def seek(self, offset, whence=os.SEEK_SET):
1512 1512 if whence == os.SEEK_SET:
1513 1513 newpos = offset
1514 1514 elif whence == os.SEEK_CUR:
1515 1515 newpos = self._pos + offset
1516 1516 elif whence == os.SEEK_END:
1517 1517 if not self.consumed:
1518 1518 # Can't use self.consume() here because it advances self._pos.
1519 1519 chunk = self.read(32768)
1520 1520 while chunk:
1521 1521 chunk = self.read(32768)
1522 1522 newpos = self._chunkindex[-1][0] - offset
1523 1523 else:
1524 1524 raise ValueError(b'Unknown whence value: %r' % (whence,))
1525 1525
1526 1526 if newpos > self._chunkindex[-1][0] and not self.consumed:
1527 1527 # Can't use self.consume() here because it advances self._pos.
1528 1528 chunk = self.read(32768)
1529 1529 while chunk:
1530 1530 chunk = self.read(32668)
1531 1531
1532 1532 if not 0 <= newpos <= self._chunkindex[-1][0]:
1533 1533 raise ValueError(b'Offset out of range')
1534 1534
1535 1535 if self._pos != newpos:
1536 1536 chunk, internaloffset = self._findchunk(newpos)
1537 1537 self._payloadstream = util.chunkbuffer(self._payloadchunks(chunk))
1538 1538 adjust = self.read(internaloffset)
1539 1539 if len(adjust) != internaloffset:
1540 1540 raise error.Abort(_(b'Seek failed\n'))
1541 1541 self._pos = newpos
1542 1542
1543 1543 def _seekfp(self, offset, whence=0):
1544 1544 """move the underlying file pointer
1545 1545
1546 1546 This method is meant for internal usage by the bundle2 protocol only.
1547 1547 They directly manipulate the low level stream including bundle2 level
1548 1548 instruction.
1549 1549
1550 1550 Do not use it to implement higher-level logic or methods."""
1551 1551 if self._seekable:
1552 1552 return self._fp.seek(offset, whence)
1553 1553 else:
1554 1554 raise NotImplementedError(_(b'File pointer is not seekable'))
1555 1555
1556 1556 def _tellfp(self):
1557 1557 """return the file offset, or None if file is not seekable
1558 1558
1559 1559 This method is meant for internal usage by the bundle2 protocol only.
1560 1560 They directly manipulate the low level stream including bundle2 level
1561 1561 instruction.
1562 1562
1563 1563 Do not use it to implement higher-level logic or methods."""
1564 1564 if self._seekable:
1565 1565 try:
1566 1566 return self._fp.tell()
1567 1567 except IOError as e:
1568 1568 if e.errno == errno.ESPIPE:
1569 1569 self._seekable = False
1570 1570 else:
1571 1571 raise
1572 1572 return None
1573 1573
1574 1574
1575 1575 # These are only the static capabilities.
1576 1576 # Check the 'getrepocaps' function for the rest.
1577 1577 capabilities = {
1578 1578 b'HG20': (),
1579 1579 b'bookmarks': (),
1580 1580 b'error': (b'abort', b'unsupportedcontent', b'pushraced', b'pushkey'),
1581 1581 b'listkeys': (),
1582 1582 b'pushkey': (),
1583 1583 b'digests': tuple(sorted(util.DIGESTS.keys())),
1584 1584 b'remote-changegroup': (b'http', b'https'),
1585 1585 b'hgtagsfnodes': (),
1586 1586 b'rev-branch-cache': (),
1587 1587 b'phases': (b'heads',),
1588 1588 b'stream': (b'v2',),
1589 1589 }
1590 1590
1591 1591
1592 1592 def getrepocaps(repo, allowpushback=False, role=None):
1593 1593 """return the bundle2 capabilities for a given repo
1594 1594
1595 1595 Exists to allow extensions (like evolution) to mutate the capabilities.
1596 1596
1597 1597 The returned value is used for servers advertising their capabilities as
1598 1598 well as clients advertising their capabilities to servers as part of
1599 1599 bundle2 requests. The ``role`` argument specifies which is which.
1600 1600 """
1601 1601 if role not in (b'client', b'server'):
1602 1602 raise error.ProgrammingError(b'role argument must be client or server')
1603 1603
1604 1604 caps = capabilities.copy()
1605 1605 caps[b'changegroup'] = tuple(
1606 1606 sorted(changegroup.supportedincomingversions(repo))
1607 1607 )
1608 1608 if obsolete.isenabled(repo, obsolete.exchangeopt):
1609 1609 supportedformat = tuple(b'V%i' % v for v in obsolete.formats)
1610 1610 caps[b'obsmarkers'] = supportedformat
1611 1611 if allowpushback:
1612 1612 caps[b'pushback'] = ()
1613 1613 cpmode = repo.ui.config(b'server', b'concurrent-push-mode')
1614 1614 if cpmode == b'check-related':
1615 1615 caps[b'checkheads'] = (b'related',)
1616 1616 if b'phases' in repo.ui.configlist(b'devel', b'legacy.exchange'):
1617 1617 caps.pop(b'phases')
1618 1618
1619 1619 # Don't advertise stream clone support in server mode if not configured.
1620 1620 if role == b'server':
1621 1621 streamsupported = repo.ui.configbool(
1622 1622 b'server', b'uncompressed', untrusted=True
1623 1623 )
1624 1624 featuresupported = repo.ui.configbool(b'server', b'bundle2.stream')
1625 1625
1626 1626 if not streamsupported or not featuresupported:
1627 1627 caps.pop(b'stream')
1628 1628 # Else always advertise support on client, because payload support
1629 1629 # should always be advertised.
1630 1630
1631 1631 return caps
1632 1632
1633 1633
1634 1634 def bundle2caps(remote):
1635 1635 """return the bundle capabilities of a peer as dict"""
1636 1636 raw = remote.capable(b'bundle2')
1637 1637 if not raw and raw != b'':
1638 1638 return {}
1639 1639 capsblob = urlreq.unquote(remote.capable(b'bundle2'))
1640 1640 return decodecaps(capsblob)
1641 1641
1642 1642
1643 1643 def obsmarkersversion(caps):
1644 1644 """extract the list of supported obsmarkers versions from a bundle2caps dict
1645 1645 """
1646 1646 obscaps = caps.get(b'obsmarkers', ())
1647 1647 return [int(c[1:]) for c in obscaps if c.startswith(b'V')]
1648 1648
1649 1649
1650 1650 def writenewbundle(
1651 1651 ui,
1652 1652 repo,
1653 1653 source,
1654 1654 filename,
1655 1655 bundletype,
1656 1656 outgoing,
1657 1657 opts,
1658 1658 vfs=None,
1659 1659 compression=None,
1660 1660 compopts=None,
1661 1661 ):
1662 1662 if bundletype.startswith(b'HG10'):
1663 1663 cg = changegroup.makechangegroup(repo, outgoing, b'01', source)
1664 1664 return writebundle(
1665 1665 ui,
1666 1666 cg,
1667 1667 filename,
1668 1668 bundletype,
1669 1669 vfs=vfs,
1670 1670 compression=compression,
1671 1671 compopts=compopts,
1672 1672 )
1673 1673 elif not bundletype.startswith(b'HG20'):
1674 1674 raise error.ProgrammingError(b'unknown bundle type: %s' % bundletype)
1675 1675
1676 1676 caps = {}
1677 1677 if b'obsolescence' in opts:
1678 1678 caps[b'obsmarkers'] = (b'V1',)
1679 1679 bundle = bundle20(ui, caps)
1680 1680 bundle.setcompression(compression, compopts)
1681 1681 _addpartsfromopts(ui, repo, bundle, source, outgoing, opts)
1682 1682 chunkiter = bundle.getchunks()
1683 1683
1684 1684 return changegroup.writechunks(ui, chunkiter, filename, vfs=vfs)
1685 1685
1686 1686
1687 1687 def _addpartsfromopts(ui, repo, bundler, source, outgoing, opts):
1688 1688 # We should eventually reconcile this logic with the one behind
1689 1689 # 'exchange.getbundle2partsgenerator'.
1690 1690 #
1691 1691 # The type of input from 'getbundle' and 'writenewbundle' are a bit
1692 1692 # different right now. So we keep them separated for now for the sake of
1693 1693 # simplicity.
1694 1694
1695 1695 # we might not always want a changegroup in such bundle, for example in
1696 1696 # stream bundles
1697 1697 if opts.get(b'changegroup', True):
1698 1698 cgversion = opts.get(b'cg.version')
1699 1699 if cgversion is None:
1700 1700 cgversion = changegroup.safeversion(repo)
1701 1701 cg = changegroup.makechangegroup(repo, outgoing, cgversion, source)
1702 1702 part = bundler.newpart(b'changegroup', data=cg.getchunks())
1703 1703 part.addparam(b'version', cg.version)
1704 1704 if b'clcount' in cg.extras:
1705 1705 part.addparam(
1706 1706 b'nbchanges', b'%d' % cg.extras[b'clcount'], mandatory=False
1707 1707 )
1708 1708 if opts.get(b'phases') and repo.revs(
1709 1709 b'%ln and secret()', outgoing.missingheads
1710 1710 ):
1711 1711 part.addparam(
1712 1712 b'targetphase', b'%d' % phases.secret, mandatory=False
1713 1713 )
1714 1714
1715 1715 if opts.get(b'streamv2', False):
1716 1716 addpartbundlestream2(bundler, repo, stream=True)
1717 1717
1718 1718 if opts.get(b'tagsfnodescache', True):
1719 1719 addparttagsfnodescache(repo, bundler, outgoing)
1720 1720
1721 1721 if opts.get(b'revbranchcache', True):
1722 1722 addpartrevbranchcache(repo, bundler, outgoing)
1723 1723
1724 1724 if opts.get(b'obsolescence', False):
1725 1725 obsmarkers = repo.obsstore.relevantmarkers(outgoing.missing)
1726 1726 buildobsmarkerspart(bundler, obsmarkers)
1727 1727
1728 1728 if opts.get(b'phases', False):
1729 1729 headsbyphase = phases.subsetphaseheads(repo, outgoing.missing)
1730 1730 phasedata = phases.binaryencode(headsbyphase)
1731 1731 bundler.newpart(b'phase-heads', data=phasedata)
1732 1732
1733 1733
1734 1734 def addparttagsfnodescache(repo, bundler, outgoing):
1735 1735 # we include the tags fnode cache for the bundle changeset
1736 1736 # (as an optional parts)
1737 1737 cache = tags.hgtagsfnodescache(repo.unfiltered())
1738 1738 chunks = []
1739 1739
1740 1740 # .hgtags fnodes are only relevant for head changesets. While we could
1741 1741 # transfer values for all known nodes, there will likely be little to
1742 1742 # no benefit.
1743 1743 #
1744 1744 # We don't bother using a generator to produce output data because
1745 1745 # a) we only have 40 bytes per head and even esoteric numbers of heads
1746 1746 # consume little memory (1M heads is 40MB) b) we don't want to send the
1747 1747 # part if we don't have entries and knowing if we have entries requires
1748 1748 # cache lookups.
1749 1749 for node in outgoing.missingheads:
1750 1750 # Don't compute missing, as this may slow down serving.
1751 1751 fnode = cache.getfnode(node, computemissing=False)
1752 1752 if fnode is not None:
1753 1753 chunks.extend([node, fnode])
1754 1754
1755 1755 if chunks:
1756 1756 bundler.newpart(b'hgtagsfnodes', data=b''.join(chunks))
1757 1757
1758 1758
1759 1759 def addpartrevbranchcache(repo, bundler, outgoing):
1760 1760 # we include the rev branch cache for the bundle changeset
1761 1761 # (as an optional parts)
1762 1762 cache = repo.revbranchcache()
1763 1763 cl = repo.unfiltered().changelog
1764 1764 branchesdata = collections.defaultdict(lambda: (set(), set()))
1765 1765 for node in outgoing.missing:
1766 1766 branch, close = cache.branchinfo(cl.rev(node))
1767 1767 branchesdata[branch][close].add(node)
1768 1768
1769 1769 def generate():
1770 1770 for branch, (nodes, closed) in sorted(branchesdata.items()):
1771 1771 utf8branch = encoding.fromlocal(branch)
1772 1772 yield rbcstruct.pack(len(utf8branch), len(nodes), len(closed))
1773 1773 yield utf8branch
1774 1774 for n in sorted(nodes):
1775 1775 yield n
1776 1776 for n in sorted(closed):
1777 1777 yield n
1778 1778
1779 1779 bundler.newpart(b'cache:rev-branch-cache', data=generate(), mandatory=False)
1780 1780
1781 1781
1782 1782 def _formatrequirementsspec(requirements):
1783 1783 requirements = [req for req in requirements if req != b"shared"]
1784 1784 return urlreq.quote(b','.join(sorted(requirements)))
1785 1785
1786 1786
1787 1787 def _formatrequirementsparams(requirements):
1788 1788 requirements = _formatrequirementsspec(requirements)
1789 1789 params = b"%s%s" % (urlreq.quote(b"requirements="), requirements)
1790 1790 return params
1791 1791
1792 1792
1793 1793 def addpartbundlestream2(bundler, repo, **kwargs):
1794 1794 if not kwargs.get(r'stream', False):
1795 1795 return
1796 1796
1797 1797 if not streamclone.allowservergeneration(repo):
1798 1798 raise error.Abort(
1799 1799 _(
1800 1800 b'stream data requested but server does not allow '
1801 1801 b'this feature'
1802 1802 ),
1803 1803 hint=_(
1804 1804 b'well-behaved clients should not be '
1805 1805 b'requesting stream data from servers not '
1806 1806 b'advertising it; the client may be buggy'
1807 1807 ),
1808 1808 )
1809 1809
1810 1810 # Stream clones don't compress well. And compression undermines a
1811 1811 # goal of stream clones, which is to be fast. Communicate the desire
1812 1812 # to avoid compression to consumers of the bundle.
1813 1813 bundler.prefercompressed = False
1814 1814
1815 1815 # get the includes and excludes
1816 1816 includepats = kwargs.get(r'includepats')
1817 1817 excludepats = kwargs.get(r'excludepats')
1818 1818
1819 1819 narrowstream = repo.ui.configbool(
1820 1820 b'experimental', b'server.stream-narrow-clones'
1821 1821 )
1822 1822
1823 1823 if (includepats or excludepats) and not narrowstream:
1824 1824 raise error.Abort(_(b'server does not support narrow stream clones'))
1825 1825
1826 1826 includeobsmarkers = False
1827 1827 if repo.obsstore:
1828 1828 remoteversions = obsmarkersversion(bundler.capabilities)
1829 1829 if not remoteversions:
1830 1830 raise error.Abort(
1831 1831 _(
1832 1832 b'server has obsolescence markers, but client '
1833 1833 b'cannot receive them via stream clone'
1834 1834 )
1835 1835 )
1836 1836 elif repo.obsstore._version in remoteversions:
1837 1837 includeobsmarkers = True
1838 1838
1839 1839 filecount, bytecount, it = streamclone.generatev2(
1840 1840 repo, includepats, excludepats, includeobsmarkers
1841 1841 )
1842 1842 requirements = _formatrequirementsspec(repo.requirements)
1843 1843 part = bundler.newpart(b'stream2', data=it)
1844 1844 part.addparam(b'bytecount', b'%d' % bytecount, mandatory=True)
1845 1845 part.addparam(b'filecount', b'%d' % filecount, mandatory=True)
1846 1846 part.addparam(b'requirements', requirements, mandatory=True)
1847 1847
1848 1848
1849 1849 def buildobsmarkerspart(bundler, markers):
1850 1850 """add an obsmarker part to the bundler with <markers>
1851 1851
1852 1852 No part is created if markers is empty.
1853 1853 Raises ValueError if the bundler doesn't support any known obsmarker format.
1854 1854 """
1855 1855 if not markers:
1856 1856 return None
1857 1857
1858 1858 remoteversions = obsmarkersversion(bundler.capabilities)
1859 1859 version = obsolete.commonversion(remoteversions)
1860 1860 if version is None:
1861 1861 raise ValueError(b'bundler does not support common obsmarker format')
1862 1862 stream = obsolete.encodemarkers(markers, True, version=version)
1863 1863 return bundler.newpart(b'obsmarkers', data=stream)
1864 1864
1865 1865
1866 1866 def writebundle(
1867 1867 ui, cg, filename, bundletype, vfs=None, compression=None, compopts=None
1868 1868 ):
1869 1869 """Write a bundle file and return its filename.
1870 1870
1871 1871 Existing files will not be overwritten.
1872 1872 If no filename is specified, a temporary file is created.
1873 1873 bz2 compression can be turned off.
1874 1874 The bundle file will be deleted in case of errors.
1875 1875 """
1876 1876
1877 1877 if bundletype == b"HG20":
1878 1878 bundle = bundle20(ui)
1879 1879 bundle.setcompression(compression, compopts)
1880 1880 part = bundle.newpart(b'changegroup', data=cg.getchunks())
1881 1881 part.addparam(b'version', cg.version)
1882 1882 if b'clcount' in cg.extras:
1883 1883 part.addparam(
1884 1884 b'nbchanges', b'%d' % cg.extras[b'clcount'], mandatory=False
1885 1885 )
1886 1886 chunkiter = bundle.getchunks()
1887 1887 else:
1888 1888 # compression argument is only for the bundle2 case
1889 1889 assert compression is None
1890 1890 if cg.version != b'01':
1891 1891 raise error.Abort(
1892 1892 _(b'old bundle types only supports v1 ' b'changegroups')
1893 1893 )
1894 1894 header, comp = bundletypes[bundletype]
1895 1895 if comp not in util.compengines.supportedbundletypes:
1896 1896 raise error.Abort(_(b'unknown stream compression type: %s') % comp)
1897 1897 compengine = util.compengines.forbundletype(comp)
1898 1898
1899 1899 def chunkiter():
1900 1900 yield header
1901 1901 for chunk in compengine.compressstream(cg.getchunks(), compopts):
1902 1902 yield chunk
1903 1903
1904 1904 chunkiter = chunkiter()
1905 1905
1906 1906 # parse the changegroup data, otherwise we will block
1907 1907 # in case of sshrepo because we don't know the end of the stream
1908 1908 return changegroup.writechunks(ui, chunkiter, filename, vfs=vfs)
1909 1909
1910 1910
1911 1911 def combinechangegroupresults(op):
1912 1912 """logic to combine 0 or more addchangegroup results into one"""
1913 1913 results = [r.get(b'return', 0) for r in op.records[b'changegroup']]
1914 1914 changedheads = 0
1915 1915 result = 1
1916 1916 for ret in results:
1917 1917 # If any changegroup result is 0, return 0
1918 1918 if ret == 0:
1919 1919 result = 0
1920 1920 break
1921 1921 if ret < -1:
1922 1922 changedheads += ret + 1
1923 1923 elif ret > 1:
1924 1924 changedheads += ret - 1
1925 1925 if changedheads > 0:
1926 1926 result = 1 + changedheads
1927 1927 elif changedheads < 0:
1928 1928 result = -1 + changedheads
1929 1929 return result
1930 1930
1931 1931
1932 1932 @parthandler(
1933 1933 b'changegroup', (b'version', b'nbchanges', b'treemanifest', b'targetphase')
1934 1934 )
1935 1935 def handlechangegroup(op, inpart):
1936 1936 """apply a changegroup part on the repo
1937 1937
1938 1938 This is a very early implementation that will massive rework before being
1939 1939 inflicted to any end-user.
1940 1940 """
1941 1941 from . import localrepo
1942 1942
1943 1943 tr = op.gettransaction()
1944 1944 unpackerversion = inpart.params.get(b'version', b'01')
1945 1945 # We should raise an appropriate exception here
1946 1946 cg = changegroup.getunbundler(unpackerversion, inpart, None)
1947 1947 # the source and url passed here are overwritten by the one contained in
1948 1948 # the transaction.hookargs argument. So 'bundle2' is a placeholder
1949 1949 nbchangesets = None
1950 1950 if b'nbchanges' in inpart.params:
1951 1951 nbchangesets = int(inpart.params.get(b'nbchanges'))
1952 1952 if (
1953 1953 b'treemanifest' in inpart.params
1954 1954 and b'treemanifest' not in op.repo.requirements
1955 1955 ):
1956 1956 if len(op.repo.changelog) != 0:
1957 1957 raise error.Abort(
1958 1958 _(
1959 1959 b"bundle contains tree manifests, but local repo is "
1960 1960 b"non-empty and does not use tree manifests"
1961 1961 )
1962 1962 )
1963 1963 op.repo.requirements.add(b'treemanifest')
1964 1964 op.repo.svfs.options = localrepo.resolvestorevfsoptions(
1965 1965 op.repo.ui, op.repo.requirements, op.repo.features
1966 1966 )
1967 1967 op.repo._writerequirements()
1968 1968 extrakwargs = {}
1969 1969 targetphase = inpart.params.get(b'targetphase')
1970 1970 if targetphase is not None:
1971 1971 extrakwargs[r'targetphase'] = int(targetphase)
1972 1972 ret = _processchangegroup(
1973 1973 op,
1974 1974 cg,
1975 1975 tr,
1976 1976 b'bundle2',
1977 1977 b'bundle2',
1978 1978 expectedtotal=nbchangesets,
1979 1979 **extrakwargs
1980 1980 )
1981 1981 if op.reply is not None:
1982 1982 # This is definitely not the final form of this
1983 1983 # return. But one need to start somewhere.
1984 1984 part = op.reply.newpart(b'reply:changegroup', mandatory=False)
1985 1985 part.addparam(
1986 1986 b'in-reply-to', pycompat.bytestr(inpart.id), mandatory=False
1987 1987 )
1988 1988 part.addparam(b'return', b'%i' % ret, mandatory=False)
1989 1989 assert not inpart.read()
1990 1990
1991 1991
1992 1992 _remotechangegroupparams = tuple(
1993 1993 [b'url', b'size', b'digests']
1994 1994 + [b'digest:%s' % k for k in util.DIGESTS.keys()]
1995 1995 )
1996 1996
1997 1997
1998 1998 @parthandler(b'remote-changegroup', _remotechangegroupparams)
1999 1999 def handleremotechangegroup(op, inpart):
2000 2000 """apply a bundle10 on the repo, given an url and validation information
2001 2001
2002 2002 All the information about the remote bundle to import are given as
2003 2003 parameters. The parameters include:
2004 2004 - url: the url to the bundle10.
2005 2005 - size: the bundle10 file size. It is used to validate what was
2006 2006 retrieved by the client matches the server knowledge about the bundle.
2007 2007 - digests: a space separated list of the digest types provided as
2008 2008 parameters.
2009 2009 - digest:<digest-type>: the hexadecimal representation of the digest with
2010 2010 that name. Like the size, it is used to validate what was retrieved by
2011 2011 the client matches what the server knows about the bundle.
2012 2012
2013 2013 When multiple digest types are given, all of them are checked.
2014 2014 """
2015 2015 try:
2016 2016 raw_url = inpart.params[b'url']
2017 2017 except KeyError:
2018 2018 raise error.Abort(_(b'remote-changegroup: missing "%s" param') % b'url')
2019 2019 parsed_url = util.url(raw_url)
2020 2020 if parsed_url.scheme not in capabilities[b'remote-changegroup']:
2021 2021 raise error.Abort(
2022 2022 _(b'remote-changegroup does not support %s urls')
2023 2023 % parsed_url.scheme
2024 2024 )
2025 2025
2026 2026 try:
2027 2027 size = int(inpart.params[b'size'])
2028 2028 except ValueError:
2029 2029 raise error.Abort(
2030 2030 _(b'remote-changegroup: invalid value for param "%s"') % b'size'
2031 2031 )
2032 2032 except KeyError:
2033 2033 raise error.Abort(
2034 2034 _(b'remote-changegroup: missing "%s" param') % b'size'
2035 2035 )
2036 2036
2037 2037 digests = {}
2038 2038 for typ in inpart.params.get(b'digests', b'').split():
2039 2039 param = b'digest:%s' % typ
2040 2040 try:
2041 2041 value = inpart.params[param]
2042 2042 except KeyError:
2043 2043 raise error.Abort(
2044 2044 _(b'remote-changegroup: missing "%s" param') % param
2045 2045 )
2046 2046 digests[typ] = value
2047 2047
2048 2048 real_part = util.digestchecker(url.open(op.ui, raw_url), size, digests)
2049 2049
2050 2050 tr = op.gettransaction()
2051 2051 from . import exchange
2052 2052
2053 2053 cg = exchange.readbundle(op.repo.ui, real_part, raw_url)
2054 2054 if not isinstance(cg, changegroup.cg1unpacker):
2055 2055 raise error.Abort(
2056 2056 _(b'%s: not a bundle version 1.0') % util.hidepassword(raw_url)
2057 2057 )
2058 2058 ret = _processchangegroup(op, cg, tr, b'bundle2', b'bundle2')
2059 2059 if op.reply is not None:
2060 2060 # This is definitely not the final form of this
2061 2061 # return. But one need to start somewhere.
2062 2062 part = op.reply.newpart(b'reply:changegroup')
2063 2063 part.addparam(
2064 2064 b'in-reply-to', pycompat.bytestr(inpart.id), mandatory=False
2065 2065 )
2066 2066 part.addparam(b'return', b'%i' % ret, mandatory=False)
2067 2067 try:
2068 2068 real_part.validate()
2069 2069 except error.Abort as e:
2070 2070 raise error.Abort(
2071 2071 _(b'bundle at %s is corrupted:\n%s')
2072 2072 % (util.hidepassword(raw_url), bytes(e))
2073 2073 )
2074 2074 assert not inpart.read()
2075 2075
2076 2076
2077 2077 @parthandler(b'reply:changegroup', (b'return', b'in-reply-to'))
2078 2078 def handlereplychangegroup(op, inpart):
2079 2079 ret = int(inpart.params[b'return'])
2080 2080 replyto = int(inpart.params[b'in-reply-to'])
2081 2081 op.records.add(b'changegroup', {b'return': ret}, replyto)
2082 2082
2083 2083
2084 2084 @parthandler(b'check:bookmarks')
2085 2085 def handlecheckbookmarks(op, inpart):
2086 2086 """check location of bookmarks
2087 2087
2088 2088 This part is to be used to detect push race regarding bookmark, it
2089 2089 contains binary encoded (bookmark, node) tuple. If the local state does
2090 2090 not marks the one in the part, a PushRaced exception is raised
2091 2091 """
2092 2092 bookdata = bookmarks.binarydecode(inpart)
2093 2093
2094 2094 msgstandard = (
2095 2095 b'remote repository changed while pushing - please try again '
2096 2096 b'(bookmark "%s" move from %s to %s)'
2097 2097 )
2098 2098 msgmissing = (
2099 2099 b'remote repository changed while pushing - please try again '
2100 2100 b'(bookmark "%s" is missing, expected %s)'
2101 2101 )
2102 2102 msgexist = (
2103 2103 b'remote repository changed while pushing - please try again '
2104 2104 b'(bookmark "%s" set on %s, expected missing)'
2105 2105 )
2106 2106 for book, node in bookdata:
2107 2107 currentnode = op.repo._bookmarks.get(book)
2108 2108 if currentnode != node:
2109 2109 if node is None:
2110 2110 finalmsg = msgexist % (book, nodemod.short(currentnode))
2111 2111 elif currentnode is None:
2112 2112 finalmsg = msgmissing % (book, nodemod.short(node))
2113 2113 else:
2114 2114 finalmsg = msgstandard % (
2115 2115 book,
2116 2116 nodemod.short(node),
2117 2117 nodemod.short(currentnode),
2118 2118 )
2119 2119 raise error.PushRaced(finalmsg)
2120 2120
2121 2121
2122 2122 @parthandler(b'check:heads')
2123 2123 def handlecheckheads(op, inpart):
2124 2124 """check that head of the repo did not change
2125 2125
2126 2126 This is used to detect a push race when using unbundle.
2127 2127 This replaces the "heads" argument of unbundle."""
2128 2128 h = inpart.read(20)
2129 2129 heads = []
2130 2130 while len(h) == 20:
2131 2131 heads.append(h)
2132 2132 h = inpart.read(20)
2133 2133 assert not h
2134 2134 # Trigger a transaction so that we are guaranteed to have the lock now.
2135 2135 if op.ui.configbool(b'experimental', b'bundle2lazylocking'):
2136 2136 op.gettransaction()
2137 2137 if sorted(heads) != sorted(op.repo.heads()):
2138 2138 raise error.PushRaced(
2139 2139 b'remote repository changed while pushing - ' b'please try again'
2140 2140 )
2141 2141
2142 2142
2143 2143 @parthandler(b'check:updated-heads')
2144 2144 def handlecheckupdatedheads(op, inpart):
2145 2145 """check for race on the heads touched by a push
2146 2146
2147 2147 This is similar to 'check:heads' but focus on the heads actually updated
2148 2148 during the push. If other activities happen on unrelated heads, it is
2149 2149 ignored.
2150 2150
2151 2151 This allow server with high traffic to avoid push contention as long as
2152 2152 unrelated parts of the graph are involved."""
2153 2153 h = inpart.read(20)
2154 2154 heads = []
2155 2155 while len(h) == 20:
2156 2156 heads.append(h)
2157 2157 h = inpart.read(20)
2158 2158 assert not h
2159 2159 # trigger a transaction so that we are guaranteed to have the lock now.
2160 2160 if op.ui.configbool(b'experimental', b'bundle2lazylocking'):
2161 2161 op.gettransaction()
2162 2162
2163 2163 currentheads = set()
2164 2164 for ls in op.repo.branchmap().iterheads():
2165 2165 currentheads.update(ls)
2166 2166
2167 2167 for h in heads:
2168 2168 if h not in currentheads:
2169 2169 raise error.PushRaced(
2170 2170 b'remote repository changed while pushing - '
2171 2171 b'please try again'
2172 2172 )
2173 2173
2174 2174
2175 2175 @parthandler(b'check:phases')
2176 2176 def handlecheckphases(op, inpart):
2177 2177 """check that phase boundaries of the repository did not change
2178 2178
2179 2179 This is used to detect a push race.
2180 2180 """
2181 2181 phasetonodes = phases.binarydecode(inpart)
2182 2182 unfi = op.repo.unfiltered()
2183 2183 cl = unfi.changelog
2184 2184 phasecache = unfi._phasecache
2185 2185 msg = (
2186 2186 b'remote repository changed while pushing - please try again '
2187 2187 b'(%s is %s expected %s)'
2188 2188 )
2189 2189 for expectedphase, nodes in enumerate(phasetonodes):
2190 2190 for n in nodes:
2191 2191 actualphase = phasecache.phase(unfi, cl.rev(n))
2192 2192 if actualphase != expectedphase:
2193 2193 finalmsg = msg % (
2194 2194 nodemod.short(n),
2195 2195 phases.phasenames[actualphase],
2196 2196 phases.phasenames[expectedphase],
2197 2197 )
2198 2198 raise error.PushRaced(finalmsg)
2199 2199
2200 2200
2201 2201 @parthandler(b'output')
2202 2202 def handleoutput(op, inpart):
2203 2203 """forward output captured on the server to the client"""
2204 2204 for line in inpart.read().splitlines():
2205 2205 op.ui.status(_(b'remote: %s\n') % line)
2206 2206
2207 2207
2208 2208 @parthandler(b'replycaps')
2209 2209 def handlereplycaps(op, inpart):
2210 2210 """Notify that a reply bundle should be created
2211 2211
2212 2212 The payload contains the capabilities information for the reply"""
2213 2213 caps = decodecaps(inpart.read())
2214 2214 if op.reply is None:
2215 2215 op.reply = bundle20(op.ui, caps)
2216 2216
2217 2217
2218 2218 class AbortFromPart(error.Abort):
2219 2219 """Sub-class of Abort that denotes an error from a bundle2 part."""
2220 2220
2221 2221
2222 2222 @parthandler(b'error:abort', (b'message', b'hint'))
2223 2223 def handleerrorabort(op, inpart):
2224 2224 """Used to transmit abort error over the wire"""
2225 2225 raise AbortFromPart(
2226 2226 inpart.params[b'message'], hint=inpart.params.get(b'hint')
2227 2227 )
2228 2228
2229 2229
2230 2230 @parthandler(
2231 2231 b'error:pushkey',
2232 2232 (b'namespace', b'key', b'new', b'old', b'ret', b'in-reply-to'),
2233 2233 )
2234 2234 def handleerrorpushkey(op, inpart):
2235 2235 """Used to transmit failure of a mandatory pushkey over the wire"""
2236 2236 kwargs = {}
2237 2237 for name in (b'namespace', b'key', b'new', b'old', b'ret'):
2238 2238 value = inpart.params.get(name)
2239 2239 if value is not None:
2240 2240 kwargs[name] = value
2241 2241 raise error.PushkeyFailed(
2242 2242 inpart.params[b'in-reply-to'], **pycompat.strkwargs(kwargs)
2243 2243 )
2244 2244
2245 2245
2246 2246 @parthandler(b'error:unsupportedcontent', (b'parttype', b'params'))
2247 2247 def handleerrorunsupportedcontent(op, inpart):
2248 2248 """Used to transmit unknown content error over the wire"""
2249 2249 kwargs = {}
2250 2250 parttype = inpart.params.get(b'parttype')
2251 2251 if parttype is not None:
2252 2252 kwargs[b'parttype'] = parttype
2253 2253 params = inpart.params.get(b'params')
2254 2254 if params is not None:
2255 2255 kwargs[b'params'] = params.split(b'\0')
2256 2256
2257 2257 raise error.BundleUnknownFeatureError(**pycompat.strkwargs(kwargs))
2258 2258
2259 2259
2260 2260 @parthandler(b'error:pushraced', (b'message',))
2261 2261 def handleerrorpushraced(op, inpart):
2262 2262 """Used to transmit push race error over the wire"""
2263 2263 raise error.ResponseError(_(b'push failed:'), inpart.params[b'message'])
2264 2264
2265 2265
2266 2266 @parthandler(b'listkeys', (b'namespace',))
2267 2267 def handlelistkeys(op, inpart):
2268 2268 """retrieve pushkey namespace content stored in a bundle2"""
2269 2269 namespace = inpart.params[b'namespace']
2270 2270 r = pushkey.decodekeys(inpart.read())
2271 2271 op.records.add(b'listkeys', (namespace, r))
2272 2272
2273 2273
2274 2274 @parthandler(b'pushkey', (b'namespace', b'key', b'old', b'new'))
2275 2275 def handlepushkey(op, inpart):
2276 2276 """process a pushkey request"""
2277 2277 dec = pushkey.decode
2278 2278 namespace = dec(inpart.params[b'namespace'])
2279 2279 key = dec(inpart.params[b'key'])
2280 2280 old = dec(inpart.params[b'old'])
2281 2281 new = dec(inpart.params[b'new'])
2282 2282 # Grab the transaction to ensure that we have the lock before performing the
2283 2283 # pushkey.
2284 2284 if op.ui.configbool(b'experimental', b'bundle2lazylocking'):
2285 2285 op.gettransaction()
2286 2286 ret = op.repo.pushkey(namespace, key, old, new)
2287 2287 record = {b'namespace': namespace, b'key': key, b'old': old, b'new': new}
2288 2288 op.records.add(b'pushkey', record)
2289 2289 if op.reply is not None:
2290 2290 rpart = op.reply.newpart(b'reply:pushkey')
2291 2291 rpart.addparam(
2292 2292 b'in-reply-to', pycompat.bytestr(inpart.id), mandatory=False
2293 2293 )
2294 2294 rpart.addparam(b'return', b'%i' % ret, mandatory=False)
2295 2295 if inpart.mandatory and not ret:
2296 2296 kwargs = {}
2297 2297 for key in (b'namespace', b'key', b'new', b'old', b'ret'):
2298 2298 if key in inpart.params:
2299 2299 kwargs[key] = inpart.params[key]
2300 2300 raise error.PushkeyFailed(
2301 2301 partid=b'%d' % inpart.id, **pycompat.strkwargs(kwargs)
2302 2302 )
2303 2303
2304 2304
2305 2305 @parthandler(b'bookmarks')
2306 2306 def handlebookmark(op, inpart):
2307 2307 """transmit bookmark information
2308 2308
2309 2309 The part contains binary encoded bookmark information.
2310 2310
2311 2311 The exact behavior of this part can be controlled by the 'bookmarks' mode
2312 2312 on the bundle operation.
2313 2313
2314 2314 When mode is 'apply' (the default) the bookmark information is applied as
2315 2315 is to the unbundling repository. Make sure a 'check:bookmarks' part is
2316 2316 issued earlier to check for push races in such update. This behavior is
2317 2317 suitable for pushing.
2318 2318
2319 2319 When mode is 'records', the information is recorded into the 'bookmarks'
2320 2320 records of the bundle operation. This behavior is suitable for pulling.
2321 2321 """
2322 2322 changes = bookmarks.binarydecode(inpart)
2323 2323
2324 2324 pushkeycompat = op.repo.ui.configbool(
2325 2325 b'server', b'bookmarks-pushkey-compat'
2326 2326 )
2327 2327 bookmarksmode = op.modes.get(b'bookmarks', b'apply')
2328 2328
2329 2329 if bookmarksmode == b'apply':
2330 2330 tr = op.gettransaction()
2331 2331 bookstore = op.repo._bookmarks
2332 2332 if pushkeycompat:
2333 2333 allhooks = []
2334 2334 for book, node in changes:
2335 2335 hookargs = tr.hookargs.copy()
2336 2336 hookargs[b'pushkeycompat'] = b'1'
2337 2337 hookargs[b'namespace'] = b'bookmarks'
2338 2338 hookargs[b'key'] = book
2339 2339 hookargs[b'old'] = nodemod.hex(bookstore.get(book, b''))
2340 2340 hookargs[b'new'] = nodemod.hex(
2341 2341 node if node is not None else b''
2342 2342 )
2343 2343 allhooks.append(hookargs)
2344 2344
2345 2345 for hookargs in allhooks:
2346 2346 op.repo.hook(
2347 2347 b'prepushkey', throw=True, **pycompat.strkwargs(hookargs)
2348 2348 )
2349 2349
2350 2350 bookstore.applychanges(op.repo, op.gettransaction(), changes)
2351 2351
2352 2352 if pushkeycompat:
2353 2353
2354 2354 def runhook():
2355 2355 for hookargs in allhooks:
2356 2356 op.repo.hook(b'pushkey', **pycompat.strkwargs(hookargs))
2357 2357
2358 2358 op.repo._afterlock(runhook)
2359 2359
2360 2360 elif bookmarksmode == b'records':
2361 2361 for book, node in changes:
2362 2362 record = {b'bookmark': book, b'node': node}
2363 2363 op.records.add(b'bookmarks', record)
2364 2364 else:
2365 2365 raise error.ProgrammingError(
2366 2366 b'unkown bookmark mode: %s' % bookmarksmode
2367 2367 )
2368 2368
2369 2369
2370 2370 @parthandler(b'phase-heads')
2371 2371 def handlephases(op, inpart):
2372 2372 """apply phases from bundle part to repo"""
2373 2373 headsbyphase = phases.binarydecode(inpart)
2374 2374 phases.updatephases(op.repo.unfiltered(), op.gettransaction, headsbyphase)
2375 2375
2376 2376
2377 2377 @parthandler(b'reply:pushkey', (b'return', b'in-reply-to'))
2378 2378 def handlepushkeyreply(op, inpart):
2379 2379 """retrieve the result of a pushkey request"""
2380 2380 ret = int(inpart.params[b'return'])
2381 2381 partid = int(inpart.params[b'in-reply-to'])
2382 2382 op.records.add(b'pushkey', {b'return': ret}, partid)
2383 2383
2384 2384
2385 2385 @parthandler(b'obsmarkers')
2386 2386 def handleobsmarker(op, inpart):
2387 2387 """add a stream of obsmarkers to the repo"""
2388 2388 tr = op.gettransaction()
2389 2389 markerdata = inpart.read()
2390 2390 if op.ui.config(b'experimental', b'obsmarkers-exchange-debug'):
2391 2391 op.ui.writenoi18n(
2392 2392 b'obsmarker-exchange: %i bytes received\n' % len(markerdata)
2393 2393 )
2394 2394 # The mergemarkers call will crash if marker creation is not enabled.
2395 2395 # we want to avoid this if the part is advisory.
2396 2396 if not inpart.mandatory and op.repo.obsstore.readonly:
2397 2397 op.repo.ui.debug(
2398 2398 b'ignoring obsolescence markers, feature not enabled\n'
2399 2399 )
2400 2400 return
2401 2401 new = op.repo.obsstore.mergemarkers(tr, markerdata)
2402 2402 op.repo.invalidatevolatilesets()
2403 2403 op.records.add(b'obsmarkers', {b'new': new})
2404 2404 if op.reply is not None:
2405 2405 rpart = op.reply.newpart(b'reply:obsmarkers')
2406 2406 rpart.addparam(
2407 2407 b'in-reply-to', pycompat.bytestr(inpart.id), mandatory=False
2408 2408 )
2409 2409 rpart.addparam(b'new', b'%i' % new, mandatory=False)
2410 2410
2411 2411
2412 2412 @parthandler(b'reply:obsmarkers', (b'new', b'in-reply-to'))
2413 2413 def handleobsmarkerreply(op, inpart):
2414 2414 """retrieve the result of a pushkey request"""
2415 2415 ret = int(inpart.params[b'new'])
2416 2416 partid = int(inpart.params[b'in-reply-to'])
2417 2417 op.records.add(b'obsmarkers', {b'new': ret}, partid)
2418 2418
2419 2419
2420 2420 @parthandler(b'hgtagsfnodes')
2421 2421 def handlehgtagsfnodes(op, inpart):
2422 2422 """Applies .hgtags fnodes cache entries to the local repo.
2423 2423
2424 2424 Payload is pairs of 20 byte changeset nodes and filenodes.
2425 2425 """
2426 2426 # Grab the transaction so we ensure that we have the lock at this point.
2427 2427 if op.ui.configbool(b'experimental', b'bundle2lazylocking'):
2428 2428 op.gettransaction()
2429 2429 cache = tags.hgtagsfnodescache(op.repo.unfiltered())
2430 2430
2431 2431 count = 0
2432 2432 while True:
2433 2433 node = inpart.read(20)
2434 2434 fnode = inpart.read(20)
2435 2435 if len(node) < 20 or len(fnode) < 20:
2436 2436 op.ui.debug(b'ignoring incomplete received .hgtags fnodes data\n')
2437 2437 break
2438 2438 cache.setfnode(node, fnode)
2439 2439 count += 1
2440 2440
2441 2441 cache.write()
2442 2442 op.ui.debug(b'applied %i hgtags fnodes cache entries\n' % count)
2443 2443
2444 2444
2445 2445 rbcstruct = struct.Struct(b'>III')
2446 2446
2447 2447
2448 2448 @parthandler(b'cache:rev-branch-cache')
2449 2449 def handlerbc(op, inpart):
2450 2450 """receive a rev-branch-cache payload and update the local cache
2451 2451
2452 2452 The payload is a series of data related to each branch
2453 2453
2454 2454 1) branch name length
2455 2455 2) number of open heads
2456 2456 3) number of closed heads
2457 2457 4) open heads nodes
2458 2458 5) closed heads nodes
2459 2459 """
2460 2460 total = 0
2461 2461 rawheader = inpart.read(rbcstruct.size)
2462 2462 cache = op.repo.revbranchcache()
2463 2463 cl = op.repo.unfiltered().changelog
2464 2464 while rawheader:
2465 2465 header = rbcstruct.unpack(rawheader)
2466 2466 total += header[1] + header[2]
2467 2467 utf8branch = inpart.read(header[0])
2468 2468 branch = encoding.tolocal(utf8branch)
2469 2469 for x in pycompat.xrange(header[1]):
2470 2470 node = inpart.read(20)
2471 2471 rev = cl.rev(node)
2472 2472 cache.setdata(branch, rev, node, False)
2473 2473 for x in pycompat.xrange(header[2]):
2474 2474 node = inpart.read(20)
2475 2475 rev = cl.rev(node)
2476 2476 cache.setdata(branch, rev, node, True)
2477 2477 rawheader = inpart.read(rbcstruct.size)
2478 2478 cache.write()
2479 2479
2480 2480
2481 2481 @parthandler(b'pushvars')
2482 2482 def bundle2getvars(op, part):
2483 2483 '''unbundle a bundle2 containing shellvars on the server'''
2484 2484 # An option to disable unbundling on server-side for security reasons
2485 2485 if op.ui.configbool(b'push', b'pushvars.server'):
2486 2486 hookargs = {}
2487 2487 for key, value in part.advisoryparams:
2488 2488 key = key.upper()
2489 2489 # We want pushed variables to have USERVAR_ prepended so we know
2490 2490 # they came from the --pushvar flag.
2491 2491 key = b"USERVAR_" + key
2492 2492 hookargs[key] = value
2493 2493 op.addhookargs(hookargs)
2494 2494
2495 2495
2496 2496 @parthandler(b'stream2', (b'requirements', b'filecount', b'bytecount'))
2497 2497 def handlestreamv2bundle(op, part):
2498 2498
2499 2499 requirements = urlreq.unquote(part.params[b'requirements']).split(b',')
2500 2500 filecount = int(part.params[b'filecount'])
2501 2501 bytecount = int(part.params[b'bytecount'])
2502 2502
2503 2503 repo = op.repo
2504 2504 if len(repo):
2505 2505 msg = _(b'cannot apply stream clone to non empty repository')
2506 2506 raise error.Abort(msg)
2507 2507
2508 2508 repo.ui.debug(b'applying stream bundle\n')
2509 2509 streamclone.applybundlev2(repo, part, filecount, bytecount, requirements)
2510 2510
2511 2511
2512 2512 def widen_bundle(
2513 2513 bundler, repo, oldmatcher, newmatcher, common, known, cgversion, ellipses
2514 2514 ):
2515 2515 """generates bundle2 for widening a narrow clone
2516 2516
2517 2517 bundler is the bundle to which data should be added
2518 2518 repo is the localrepository instance
2519 2519 oldmatcher matches what the client already has
2520 2520 newmatcher matches what the client needs (including what it already has)
2521 2521 common is set of common heads between server and client
2522 2522 known is a set of revs known on the client side (used in ellipses)
2523 2523 cgversion is the changegroup version to send
2524 2524 ellipses is boolean value telling whether to send ellipses data or not
2525 2525
2526 2526 returns bundle2 of the data required for extending
2527 2527 """
2528 2528 commonnodes = set()
2529 2529 cl = repo.changelog
2530 2530 for r in repo.revs(b"::%ln", common):
2531 2531 commonnodes.add(cl.node(r))
2532 2532 if commonnodes:
2533 2533 # XXX: we should only send the filelogs (and treemanifest). user
2534 2534 # already has the changelog and manifest
2535 2535 packer = changegroup.getbundler(
2536 2536 cgversion,
2537 2537 repo,
2538 2538 oldmatcher=oldmatcher,
2539 2539 matcher=newmatcher,
2540 2540 fullnodes=commonnodes,
2541 2541 )
2542 2542 cgdata = packer.generate(
2543 2543 {nodemod.nullid},
2544 2544 list(commonnodes),
2545 2545 False,
2546 2546 b'narrow_widen',
2547 2547 changelog=False,
2548 2548 )
2549 2549
2550 2550 part = bundler.newpart(b'changegroup', data=cgdata)
2551 2551 part.addparam(b'version', cgversion)
2552 2552 if b'treemanifest' in repo.requirements:
2553 2553 part.addparam(b'treemanifest', b'1')
2554 2554
2555 2555 return bundler
@@ -1,670 +1,670 b''
1 1 # bundlerepo.py - repository class for viewing uncompressed bundles
2 2 #
3 3 # Copyright 2006, 2007 Benoit Boissinot <bboissin@gmail.com>
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 """Repository class for viewing uncompressed bundles.
9 9
10 10 This provides a read-only repository interface to bundles as if they
11 11 were part of the actual repository.
12 12 """
13 13
14 14 from __future__ import absolute_import
15 15
16 16 import os
17 17 import shutil
18 18
19 19 from .i18n import _
20 20 from .node import nullid, nullrev
21 21
22 22 from . import (
23 23 bundle2,
24 24 changegroup,
25 25 changelog,
26 26 cmdutil,
27 27 discovery,
28 28 encoding,
29 29 error,
30 30 exchange,
31 31 filelog,
32 32 localrepo,
33 33 manifest,
34 34 mdiff,
35 35 node as nodemod,
36 36 pathutil,
37 37 phases,
38 38 pycompat,
39 39 revlog,
40 40 util,
41 41 vfs as vfsmod,
42 42 )
43 43
44 44
45 45 class bundlerevlog(revlog.revlog):
46 46 def __init__(self, opener, indexfile, cgunpacker, linkmapper):
47 47 # How it works:
48 48 # To retrieve a revision, we need to know the offset of the revision in
49 49 # the bundle (an unbundle object). We store this offset in the index
50 50 # (start). The base of the delta is stored in the base field.
51 51 #
52 52 # To differentiate a rev in the bundle from a rev in the revlog, we
53 53 # check revision against repotiprev.
54 54 opener = vfsmod.readonlyvfs(opener)
55 55 revlog.revlog.__init__(self, opener, indexfile)
56 56 self.bundle = cgunpacker
57 57 n = len(self)
58 58 self.repotiprev = n - 1
59 59 self.bundlerevs = set() # used by 'bundle()' revset expression
60 60 for deltadata in cgunpacker.deltaiter():
61 61 node, p1, p2, cs, deltabase, delta, flags = deltadata
62 62
63 63 size = len(delta)
64 64 start = cgunpacker.tell() - size
65 65
66 66 link = linkmapper(cs)
67 67 if node in self.nodemap:
68 68 # this can happen if two branches make the same change
69 69 self.bundlerevs.add(self.nodemap[node])
70 70 continue
71 71
72 72 for p in (p1, p2):
73 73 if p not in self.nodemap:
74 74 raise error.LookupError(
75 75 p, self.indexfile, _(b"unknown parent")
76 76 )
77 77
78 78 if deltabase not in self.nodemap:
79 79 raise LookupError(
80 80 deltabase, self.indexfile, _(b'unknown delta base')
81 81 )
82 82
83 83 baserev = self.rev(deltabase)
84 84 # start, size, full unc. size, base (unused), link, p1, p2, node
85 85 e = (
86 86 revlog.offset_type(start, flags),
87 87 size,
88 88 -1,
89 89 baserev,
90 90 link,
91 91 self.rev(p1),
92 92 self.rev(p2),
93 93 node,
94 94 )
95 95 self.index.append(e)
96 96 self.nodemap[node] = n
97 97 self.bundlerevs.add(n)
98 98 n += 1
99 99
100 100 def _chunk(self, rev, df=None):
101 101 # Warning: in case of bundle, the diff is against what we stored as
102 102 # delta base, not against rev - 1
103 103 # XXX: could use some caching
104 104 if rev <= self.repotiprev:
105 105 return revlog.revlog._chunk(self, rev)
106 106 self.bundle.seek(self.start(rev))
107 107 return self.bundle.read(self.length(rev))
108 108
109 109 def revdiff(self, rev1, rev2):
110 110 """return or calculate a delta between two revisions"""
111 111 if rev1 > self.repotiprev and rev2 > self.repotiprev:
112 112 # hot path for bundle
113 113 revb = self.index[rev2][3]
114 114 if revb == rev1:
115 115 return self._chunk(rev2)
116 116 elif rev1 <= self.repotiprev and rev2 <= self.repotiprev:
117 117 return revlog.revlog.revdiff(self, rev1, rev2)
118 118
119 119 return mdiff.textdiff(self.rawdata(rev1), self.rawdata(rev2))
120 120
121 121 def _rawtext(self, node, rev, _df=None):
122 122 if rev is None:
123 123 rev = self.rev(node)
124 124 validated = False
125 125 rawtext = None
126 126 chain = []
127 127 iterrev = rev
128 128 # reconstruct the revision if it is from a changegroup
129 129 while iterrev > self.repotiprev:
130 130 if self._revisioncache and self._revisioncache[1] == iterrev:
131 131 rawtext = self._revisioncache[2]
132 132 break
133 133 chain.append(iterrev)
134 134 iterrev = self.index[iterrev][3]
135 135 if iterrev == nullrev:
136 136 rawtext = b''
137 137 elif rawtext is None:
138 138 r = super(bundlerevlog, self)._rawtext(
139 139 self.node(iterrev), iterrev, _df=_df
140 140 )
141 141 __, rawtext, validated = r
142 142 if chain:
143 143 validated = False
144 144 while chain:
145 145 delta = self._chunk(chain.pop())
146 146 rawtext = mdiff.patches(rawtext, [delta])
147 147 return rev, rawtext, validated
148 148
149 149 def addrevision(self, *args, **kwargs):
150 150 raise NotImplementedError
151 151
152 152 def addgroup(self, *args, **kwargs):
153 153 raise NotImplementedError
154 154
155 155 def strip(self, *args, **kwargs):
156 156 raise NotImplementedError
157 157
158 158 def checksize(self):
159 159 raise NotImplementedError
160 160
161 161
162 162 class bundlechangelog(bundlerevlog, changelog.changelog):
163 163 def __init__(self, opener, cgunpacker):
164 164 changelog.changelog.__init__(self, opener)
165 165 linkmapper = lambda x: x
166 166 bundlerevlog.__init__(
167 167 self, opener, self.indexfile, cgunpacker, linkmapper
168 168 )
169 169
170 170
171 171 class bundlemanifest(bundlerevlog, manifest.manifestrevlog):
172 172 def __init__(
173 173 self, opener, cgunpacker, linkmapper, dirlogstarts=None, dir=b''
174 174 ):
175 175 manifest.manifestrevlog.__init__(self, opener, tree=dir)
176 176 bundlerevlog.__init__(
177 177 self, opener, self.indexfile, cgunpacker, linkmapper
178 178 )
179 179 if dirlogstarts is None:
180 180 dirlogstarts = {}
181 181 if self.bundle.version == b"03":
182 182 dirlogstarts = _getfilestarts(self.bundle)
183 183 self._dirlogstarts = dirlogstarts
184 184 self._linkmapper = linkmapper
185 185
186 186 def dirlog(self, d):
187 187 if d in self._dirlogstarts:
188 188 self.bundle.seek(self._dirlogstarts[d])
189 189 return bundlemanifest(
190 190 self.opener,
191 191 self.bundle,
192 192 self._linkmapper,
193 193 self._dirlogstarts,
194 194 dir=d,
195 195 )
196 196 return super(bundlemanifest, self).dirlog(d)
197 197
198 198
199 199 class bundlefilelog(filelog.filelog):
200 200 def __init__(self, opener, path, cgunpacker, linkmapper):
201 201 filelog.filelog.__init__(self, opener, path)
202 202 self._revlog = bundlerevlog(
203 203 opener, self.indexfile, cgunpacker, linkmapper
204 204 )
205 205
206 206
207 207 class bundlepeer(localrepo.localpeer):
208 208 def canpush(self):
209 209 return False
210 210
211 211
212 212 class bundlephasecache(phases.phasecache):
213 213 def __init__(self, *args, **kwargs):
214 214 super(bundlephasecache, self).__init__(*args, **kwargs)
215 if util.safehasattr(self, b'opener'):
215 if util.safehasattr(self, 'opener'):
216 216 self.opener = vfsmod.readonlyvfs(self.opener)
217 217
218 218 def write(self):
219 219 raise NotImplementedError
220 220
221 221 def _write(self, fp):
222 222 raise NotImplementedError
223 223
224 224 def _updateroots(self, phase, newroots, tr):
225 225 self.phaseroots[phase] = newroots
226 226 self.invalidate()
227 227 self.dirty = True
228 228
229 229
230 230 def _getfilestarts(cgunpacker):
231 231 filespos = {}
232 232 for chunkdata in iter(cgunpacker.filelogheader, {}):
233 233 fname = chunkdata[b'filename']
234 234 filespos[fname] = cgunpacker.tell()
235 235 for chunk in iter(lambda: cgunpacker.deltachunk(None), {}):
236 236 pass
237 237 return filespos
238 238
239 239
240 240 class bundlerepository(object):
241 241 """A repository instance that is a union of a local repo and a bundle.
242 242
243 243 Instances represent a read-only repository composed of a local repository
244 244 with the contents of a bundle file applied. The repository instance is
245 245 conceptually similar to the state of a repository after an
246 246 ``hg unbundle`` operation. However, the contents of the bundle are never
247 247 applied to the actual base repository.
248 248
249 249 Instances constructed directly are not usable as repository objects.
250 250 Use instance() or makebundlerepository() to create instances.
251 251 """
252 252
253 253 def __init__(self, bundlepath, url, tempparent):
254 254 self._tempparent = tempparent
255 255 self._url = url
256 256
257 257 self.ui.setconfig(b'phases', b'publish', False, b'bundlerepo')
258 258
259 259 self.tempfile = None
260 260 f = util.posixfile(bundlepath, b"rb")
261 261 bundle = exchange.readbundle(self.ui, f, bundlepath)
262 262
263 263 if isinstance(bundle, bundle2.unbundle20):
264 264 self._bundlefile = bundle
265 265 self._cgunpacker = None
266 266
267 267 cgpart = None
268 268 for part in bundle.iterparts(seekable=True):
269 269 if part.type == b'changegroup':
270 270 if cgpart:
271 271 raise NotImplementedError(
272 272 b"can't process " b"multiple changegroups"
273 273 )
274 274 cgpart = part
275 275
276 276 self._handlebundle2part(bundle, part)
277 277
278 278 if not cgpart:
279 279 raise error.Abort(_(b"No changegroups found"))
280 280
281 281 # This is required to placate a later consumer, which expects
282 282 # the payload offset to be at the beginning of the changegroup.
283 283 # We need to do this after the iterparts() generator advances
284 284 # because iterparts() will seek to end of payload after the
285 285 # generator returns control to iterparts().
286 286 cgpart.seek(0, os.SEEK_SET)
287 287
288 288 elif isinstance(bundle, changegroup.cg1unpacker):
289 289 if bundle.compressed():
290 290 f = self._writetempbundle(
291 291 bundle.read, b'.hg10un', header=b'HG10UN'
292 292 )
293 293 bundle = exchange.readbundle(self.ui, f, bundlepath, self.vfs)
294 294
295 295 self._bundlefile = bundle
296 296 self._cgunpacker = bundle
297 297 else:
298 298 raise error.Abort(
299 299 _(b'bundle type %s cannot be read') % type(bundle)
300 300 )
301 301
302 302 # dict with the mapping 'filename' -> position in the changegroup.
303 303 self._cgfilespos = {}
304 304
305 305 self.firstnewrev = self.changelog.repotiprev + 1
306 306 phases.retractboundary(
307 307 self,
308 308 None,
309 309 phases.draft,
310 310 [ctx.node() for ctx in self[self.firstnewrev :]],
311 311 )
312 312
313 313 def _handlebundle2part(self, bundle, part):
314 314 if part.type != b'changegroup':
315 315 return
316 316
317 317 cgstream = part
318 318 version = part.params.get(b'version', b'01')
319 319 legalcgvers = changegroup.supportedincomingversions(self)
320 320 if version not in legalcgvers:
321 321 msg = _(b'Unsupported changegroup version: %s')
322 322 raise error.Abort(msg % version)
323 323 if bundle.compressed():
324 324 cgstream = self._writetempbundle(part.read, b'.cg%sun' % version)
325 325
326 326 self._cgunpacker = changegroup.getunbundler(version, cgstream, b'UN')
327 327
328 328 def _writetempbundle(self, readfn, suffix, header=b''):
329 329 """Write a temporary file to disk
330 330 """
331 331 fdtemp, temp = self.vfs.mkstemp(prefix=b"hg-bundle-", suffix=suffix)
332 332 self.tempfile = temp
333 333
334 334 with os.fdopen(fdtemp, r'wb') as fptemp:
335 335 fptemp.write(header)
336 336 while True:
337 337 chunk = readfn(2 ** 18)
338 338 if not chunk:
339 339 break
340 340 fptemp.write(chunk)
341 341
342 342 return self.vfs.open(self.tempfile, mode=b"rb")
343 343
344 344 @localrepo.unfilteredpropertycache
345 345 def _phasecache(self):
346 346 return bundlephasecache(self, self._phasedefaults)
347 347
348 348 @localrepo.unfilteredpropertycache
349 349 def changelog(self):
350 350 # consume the header if it exists
351 351 self._cgunpacker.changelogheader()
352 352 c = bundlechangelog(self.svfs, self._cgunpacker)
353 353 self.manstart = self._cgunpacker.tell()
354 354 return c
355 355
356 356 def _refreshchangelog(self):
357 357 # changelog for bundle repo are not filecache, this method is not
358 358 # applicable.
359 359 pass
360 360
361 361 @localrepo.unfilteredpropertycache
362 362 def manifestlog(self):
363 363 self._cgunpacker.seek(self.manstart)
364 364 # consume the header if it exists
365 365 self._cgunpacker.manifestheader()
366 366 linkmapper = self.unfiltered().changelog.rev
367 367 rootstore = bundlemanifest(self.svfs, self._cgunpacker, linkmapper)
368 368 self.filestart = self._cgunpacker.tell()
369 369
370 370 return manifest.manifestlog(
371 371 self.svfs, self, rootstore, self.narrowmatch()
372 372 )
373 373
374 374 def _consumemanifest(self):
375 375 """Consumes the manifest portion of the bundle, setting filestart so the
376 376 file portion can be read."""
377 377 self._cgunpacker.seek(self.manstart)
378 378 self._cgunpacker.manifestheader()
379 379 for delta in self._cgunpacker.deltaiter():
380 380 pass
381 381 self.filestart = self._cgunpacker.tell()
382 382
383 383 @localrepo.unfilteredpropertycache
384 384 def manstart(self):
385 385 self.changelog
386 386 return self.manstart
387 387
388 388 @localrepo.unfilteredpropertycache
389 389 def filestart(self):
390 390 self.manifestlog
391 391
392 392 # If filestart was not set by self.manifestlog, that means the
393 393 # manifestlog implementation did not consume the manifests from the
394 394 # changegroup (ex: it might be consuming trees from a separate bundle2
395 395 # part instead). So we need to manually consume it.
396 396 if r'filestart' not in self.__dict__:
397 397 self._consumemanifest()
398 398
399 399 return self.filestart
400 400
401 401 def url(self):
402 402 return self._url
403 403
404 404 def file(self, f):
405 405 if not self._cgfilespos:
406 406 self._cgunpacker.seek(self.filestart)
407 407 self._cgfilespos = _getfilestarts(self._cgunpacker)
408 408
409 409 if f in self._cgfilespos:
410 410 self._cgunpacker.seek(self._cgfilespos[f])
411 411 linkmapper = self.unfiltered().changelog.rev
412 412 return bundlefilelog(self.svfs, f, self._cgunpacker, linkmapper)
413 413 else:
414 414 return super(bundlerepository, self).file(f)
415 415
416 416 def close(self):
417 417 """Close assigned bundle file immediately."""
418 418 self._bundlefile.close()
419 419 if self.tempfile is not None:
420 420 self.vfs.unlink(self.tempfile)
421 421 if self._tempparent:
422 422 shutil.rmtree(self._tempparent, True)
423 423
424 424 def cancopy(self):
425 425 return False
426 426
427 427 def peer(self):
428 428 return bundlepeer(self)
429 429
430 430 def getcwd(self):
431 431 return encoding.getcwd() # always outside the repo
432 432
433 433 # Check if parents exist in localrepo before setting
434 434 def setparents(self, p1, p2=nullid):
435 435 p1rev = self.changelog.rev(p1)
436 436 p2rev = self.changelog.rev(p2)
437 437 msg = _(b"setting parent to node %s that only exists in the bundle\n")
438 438 if self.changelog.repotiprev < p1rev:
439 439 self.ui.warn(msg % nodemod.hex(p1))
440 440 if self.changelog.repotiprev < p2rev:
441 441 self.ui.warn(msg % nodemod.hex(p2))
442 442 return super(bundlerepository, self).setparents(p1, p2)
443 443
444 444
445 445 def instance(ui, path, create, intents=None, createopts=None):
446 446 if create:
447 447 raise error.Abort(_(b'cannot create new bundle repository'))
448 448 # internal config: bundle.mainreporoot
449 449 parentpath = ui.config(b"bundle", b"mainreporoot")
450 450 if not parentpath:
451 451 # try to find the correct path to the working directory repo
452 452 parentpath = cmdutil.findrepo(encoding.getcwd())
453 453 if parentpath is None:
454 454 parentpath = b''
455 455 if parentpath:
456 456 # Try to make the full path relative so we get a nice, short URL.
457 457 # In particular, we don't want temp dir names in test outputs.
458 458 cwd = encoding.getcwd()
459 459 if parentpath == cwd:
460 460 parentpath = b''
461 461 else:
462 462 cwd = pathutil.normasprefix(cwd)
463 463 if parentpath.startswith(cwd):
464 464 parentpath = parentpath[len(cwd) :]
465 465 u = util.url(path)
466 466 path = u.localpath()
467 467 if u.scheme == b'bundle':
468 468 s = path.split(b"+", 1)
469 469 if len(s) == 1:
470 470 repopath, bundlename = parentpath, s[0]
471 471 else:
472 472 repopath, bundlename = s
473 473 else:
474 474 repopath, bundlename = parentpath, path
475 475
476 476 return makebundlerepository(ui, repopath, bundlename)
477 477
478 478
479 479 def makebundlerepository(ui, repopath, bundlepath):
480 480 """Make a bundle repository object based on repo and bundle paths."""
481 481 if repopath:
482 482 url = b'bundle:%s+%s' % (util.expandpath(repopath), bundlepath)
483 483 else:
484 484 url = b'bundle:%s' % bundlepath
485 485
486 486 # Because we can't make any guarantees about the type of the base
487 487 # repository, we can't have a static class representing the bundle
488 488 # repository. We also can't make any guarantees about how to even
489 489 # call the base repository's constructor!
490 490 #
491 491 # So, our strategy is to go through ``localrepo.instance()`` to construct
492 492 # a repo instance. Then, we dynamically create a new type derived from
493 493 # both it and our ``bundlerepository`` class which overrides some
494 494 # functionality. We then change the type of the constructed repository
495 495 # to this new type and initialize the bundle-specific bits of it.
496 496
497 497 try:
498 498 repo = localrepo.instance(ui, repopath, create=False)
499 499 tempparent = None
500 500 except error.RepoError:
501 501 tempparent = pycompat.mkdtemp()
502 502 try:
503 503 repo = localrepo.instance(ui, tempparent, create=True)
504 504 except Exception:
505 505 shutil.rmtree(tempparent)
506 506 raise
507 507
508 508 class derivedbundlerepository(bundlerepository, repo.__class__):
509 509 pass
510 510
511 511 repo.__class__ = derivedbundlerepository
512 512 bundlerepository.__init__(repo, bundlepath, url, tempparent)
513 513
514 514 return repo
515 515
516 516
517 517 class bundletransactionmanager(object):
518 518 def transaction(self):
519 519 return None
520 520
521 521 def close(self):
522 522 raise NotImplementedError
523 523
524 524 def release(self):
525 525 raise NotImplementedError
526 526
527 527
528 528 def getremotechanges(
529 529 ui, repo, peer, onlyheads=None, bundlename=None, force=False
530 530 ):
531 531 '''obtains a bundle of changes incoming from peer
532 532
533 533 "onlyheads" restricts the returned changes to those reachable from the
534 534 specified heads.
535 535 "bundlename", if given, stores the bundle to this file path permanently;
536 536 otherwise it's stored to a temp file and gets deleted again when you call
537 537 the returned "cleanupfn".
538 538 "force" indicates whether to proceed on unrelated repos.
539 539
540 540 Returns a tuple (local, csets, cleanupfn):
541 541
542 542 "local" is a local repo from which to obtain the actual incoming
543 543 changesets; it is a bundlerepo for the obtained bundle when the
544 544 original "peer" is remote.
545 545 "csets" lists the incoming changeset node ids.
546 546 "cleanupfn" must be called without arguments when you're done processing
547 547 the changes; it closes both the original "peer" and the one returned
548 548 here.
549 549 '''
550 550 tmp = discovery.findcommonincoming(repo, peer, heads=onlyheads, force=force)
551 551 common, incoming, rheads = tmp
552 552 if not incoming:
553 553 try:
554 554 if bundlename:
555 555 os.unlink(bundlename)
556 556 except OSError:
557 557 pass
558 558 return repo, [], peer.close
559 559
560 560 commonset = set(common)
561 561 rheads = [x for x in rheads if x not in commonset]
562 562
563 563 bundle = None
564 564 bundlerepo = None
565 565 localrepo = peer.local()
566 566 if bundlename or not localrepo:
567 567 # create a bundle (uncompressed if peer repo is not local)
568 568
569 569 # developer config: devel.legacy.exchange
570 570 legexc = ui.configlist(b'devel', b'legacy.exchange')
571 571 forcebundle1 = b'bundle2' not in legexc and b'bundle1' in legexc
572 572 canbundle2 = (
573 573 not forcebundle1
574 574 and peer.capable(b'getbundle')
575 575 and peer.capable(b'bundle2')
576 576 )
577 577 if canbundle2:
578 578 with peer.commandexecutor() as e:
579 579 b2 = e.callcommand(
580 580 b'getbundle',
581 581 {
582 582 b'source': b'incoming',
583 583 b'common': common,
584 584 b'heads': rheads,
585 585 b'bundlecaps': exchange.caps20to10(
586 586 repo, role=b'client'
587 587 ),
588 588 b'cg': True,
589 589 },
590 590 ).result()
591 591
592 592 fname = bundle = changegroup.writechunks(
593 593 ui, b2._forwardchunks(), bundlename
594 594 )
595 595 else:
596 596 if peer.capable(b'getbundle'):
597 597 with peer.commandexecutor() as e:
598 598 cg = e.callcommand(
599 599 b'getbundle',
600 600 {
601 601 b'source': b'incoming',
602 602 b'common': common,
603 603 b'heads': rheads,
604 604 },
605 605 ).result()
606 606 elif onlyheads is None and not peer.capable(b'changegroupsubset'):
607 607 # compat with older servers when pulling all remote heads
608 608
609 609 with peer.commandexecutor() as e:
610 610 cg = e.callcommand(
611 611 b'changegroup',
612 612 {b'nodes': incoming, b'source': b'incoming',},
613 613 ).result()
614 614
615 615 rheads = None
616 616 else:
617 617 with peer.commandexecutor() as e:
618 618 cg = e.callcommand(
619 619 b'changegroupsubset',
620 620 {
621 621 b'bases': incoming,
622 622 b'heads': rheads,
623 623 b'source': b'incoming',
624 624 },
625 625 ).result()
626 626
627 627 if localrepo:
628 628 bundletype = b"HG10BZ"
629 629 else:
630 630 bundletype = b"HG10UN"
631 631 fname = bundle = bundle2.writebundle(ui, cg, bundlename, bundletype)
632 632 # keep written bundle?
633 633 if bundlename:
634 634 bundle = None
635 635 if not localrepo:
636 636 # use the created uncompressed bundlerepo
637 637 localrepo = bundlerepo = makebundlerepository(
638 638 repo.baseui, repo.root, fname
639 639 )
640 640
641 641 # this repo contains local and peer now, so filter out local again
642 642 common = repo.heads()
643 643 if localrepo:
644 644 # Part of common may be remotely filtered
645 645 # So use an unfiltered version
646 646 # The discovery process probably need cleanup to avoid that
647 647 localrepo = localrepo.unfiltered()
648 648
649 649 csets = localrepo.changelog.findmissing(common, rheads)
650 650
651 651 if bundlerepo:
652 652 reponodes = [ctx.node() for ctx in bundlerepo[bundlerepo.firstnewrev :]]
653 653
654 654 with peer.commandexecutor() as e:
655 655 remotephases = e.callcommand(
656 656 b'listkeys', {b'namespace': b'phases',}
657 657 ).result()
658 658
659 659 pullop = exchange.pulloperation(bundlerepo, peer, heads=reponodes)
660 660 pullop.trmanager = bundletransactionmanager()
661 661 exchange._pullapplyphases(pullop, remotephases)
662 662
663 663 def cleanup():
664 664 if bundlerepo:
665 665 bundlerepo.close()
666 666 if bundle:
667 667 os.unlink(bundle)
668 668 peer.close()
669 669
670 670 return (localrepo, csets, cleanup)
@@ -1,227 +1,227 b''
1 1 # pvec.py - probabilistic vector clocks for Mercurial
2 2 #
3 3 # Copyright 2012 Matt Mackall <mpm@selenic.com>
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 '''
9 9 A "pvec" is a changeset property based on the theory of vector clocks
10 10 that can be compared to discover relatedness without consulting a
11 11 graph. This can be useful for tasks like determining how a
12 12 disconnected patch relates to a repository.
13 13
14 14 Currently a pvec consist of 448 bits, of which 24 are 'depth' and the
15 15 remainder are a bit vector. It is represented as a 70-character base85
16 16 string.
17 17
18 18 Construction:
19 19
20 20 - a root changeset has a depth of 0 and a bit vector based on its hash
21 21 - a normal commit has a changeset where depth is increased by one and
22 22 one bit vector bit is flipped based on its hash
23 23 - a merge changeset pvec is constructed by copying changes from one pvec into
24 24 the other to balance its depth
25 25
26 26 Properties:
27 27
28 28 - for linear changes, difference in depth is always <= hamming distance
29 29 - otherwise, changes are probably divergent
30 30 - when hamming distance is < 200, we can reliably detect when pvecs are near
31 31
32 32 Issues:
33 33
34 34 - hamming distance ceases to work over distances of ~ 200
35 35 - detecting divergence is less accurate when the common ancestor is very close
36 36 to either revision or total distance is high
37 37 - this could probably be improved by modeling the relation between
38 38 delta and hdist
39 39
40 40 Uses:
41 41
42 42 - a patch pvec can be used to locate the nearest available common ancestor for
43 43 resolving conflicts
44 44 - ordering of patches can be established without a DAG
45 45 - two head pvecs can be compared to determine whether push/pull/merge is needed
46 46 and approximately how many changesets are involved
47 47 - can be used to find a heuristic divergence measure between changesets on
48 48 different branches
49 49 '''
50 50
51 51 from __future__ import absolute_import
52 52
53 53 from .node import nullrev
54 54 from . import (
55 55 pycompat,
56 56 util,
57 57 )
58 58
59 59 _size = 448 # 70 chars b85-encoded
60 60 _bytes = _size / 8
61 61 _depthbits = 24
62 62 _depthbytes = _depthbits / 8
63 63 _vecbytes = _bytes - _depthbytes
64 64 _vecbits = _vecbytes * 8
65 65 _radius = (_vecbits - 30) / 2 # high probability vectors are related
66 66
67 67
68 68 def _bin(bs):
69 69 '''convert a bytestring to a long'''
70 70 v = 0
71 71 for b in bs:
72 72 v = v * 256 + ord(b)
73 73 return v
74 74
75 75
76 76 def _str(v, l):
77 77 bs = b""
78 78 for p in pycompat.xrange(l):
79 79 bs = chr(v & 255) + bs
80 80 v >>= 8
81 81 return bs
82 82
83 83
84 84 def _split(b):
85 85 '''depth and bitvec'''
86 86 return _bin(b[:_depthbytes]), _bin(b[_depthbytes:])
87 87
88 88
89 89 def _join(depth, bitvec):
90 90 return _str(depth, _depthbytes) + _str(bitvec, _vecbytes)
91 91
92 92
93 93 def _hweight(x):
94 94 c = 0
95 95 while x:
96 96 if x & 1:
97 97 c += 1
98 98 x >>= 1
99 99 return c
100 100
101 101
102 102 _htab = [_hweight(x) for x in pycompat.xrange(256)]
103 103
104 104
105 105 def _hamming(a, b):
106 106 '''find the hamming distance between two longs'''
107 107 d = a ^ b
108 108 c = 0
109 109 while d:
110 110 c += _htab[d & 0xFF]
111 111 d >>= 8
112 112 return c
113 113
114 114
115 115 def _mergevec(x, y, c):
116 116 # Ideally, this function would be x ^ y ^ ancestor, but finding
117 117 # ancestors is a nuisance. So instead we find the minimal number
118 118 # of changes to balance the depth and hamming distance
119 119
120 120 d1, v1 = x
121 121 d2, v2 = y
122 122 if d1 < d2:
123 123 d1, d2, v1, v2 = d2, d1, v2, v1
124 124
125 125 hdist = _hamming(v1, v2)
126 126 ddist = d1 - d2
127 127 v = v1
128 128 m = v1 ^ v2 # mask of different bits
129 129 i = 1
130 130
131 131 if hdist > ddist:
132 132 # if delta = 10 and hdist = 100, then we need to go up 55 steps
133 133 # to the ancestor and down 45
134 134 changes = (hdist - ddist + 1) / 2
135 135 else:
136 136 # must make at least one change
137 137 changes = 1
138 138 depth = d1 + changes
139 139
140 140 # copy changes from v2
141 141 if m:
142 142 while changes:
143 143 if m & i:
144 144 v ^= i
145 145 changes -= 1
146 146 i <<= 1
147 147 else:
148 148 v = _flipbit(v, c)
149 149
150 150 return depth, v
151 151
152 152
153 153 def _flipbit(v, node):
154 154 # converting bit strings to longs is slow
155 155 bit = (hash(node) & 0xFFFFFFFF) % _vecbits
156 156 return v ^ (1 << bit)
157 157
158 158
159 159 def ctxpvec(ctx):
160 160 '''construct a pvec for ctx while filling in the cache'''
161 161 r = ctx.repo()
162 if not util.safehasattr(r, b"_pveccache"):
162 if not util.safehasattr(r, "_pveccache"):
163 163 r._pveccache = {}
164 164 pvc = r._pveccache
165 165 if ctx.rev() not in pvc:
166 166 cl = r.changelog
167 167 for n in pycompat.xrange(ctx.rev() + 1):
168 168 if n not in pvc:
169 169 node = cl.node(n)
170 170 p1, p2 = cl.parentrevs(n)
171 171 if p1 == nullrev:
172 172 # start with a 'random' vector at root
173 173 pvc[n] = (0, _bin((node * 3)[:_vecbytes]))
174 174 elif p2 == nullrev:
175 175 d, v = pvc[p1]
176 176 pvc[n] = (d + 1, _flipbit(v, node))
177 177 else:
178 178 pvc[n] = _mergevec(pvc[p1], pvc[p2], node)
179 179 bs = _join(*pvc[ctx.rev()])
180 180 return pvec(util.b85encode(bs))
181 181
182 182
183 183 class pvec(object):
184 184 def __init__(self, hashorctx):
185 185 if isinstance(hashorctx, str):
186 186 self._bs = hashorctx
187 187 self._depth, self._vec = _split(util.b85decode(hashorctx))
188 188 else:
189 189 self._vec = ctxpvec(hashorctx)
190 190
191 191 def __str__(self):
192 192 return self._bs
193 193
194 194 def __eq__(self, b):
195 195 return self._vec == b._vec and self._depth == b._depth
196 196
197 197 def __lt__(self, b):
198 198 delta = b._depth - self._depth
199 199 if delta < 0:
200 200 return False # always correct
201 201 if _hamming(self._vec, b._vec) > delta:
202 202 return False
203 203 return True
204 204
205 205 def __gt__(self, b):
206 206 return b < self
207 207
208 208 def __or__(self, b):
209 209 delta = abs(b._depth - self._depth)
210 210 if _hamming(self._vec, b._vec) <= delta:
211 211 return False
212 212 return True
213 213
214 214 def __sub__(self, b):
215 215 if self | b:
216 216 raise ValueError(b"concurrent pvecs")
217 217 return self._depth - b._depth
218 218
219 219 def distance(self, b):
220 220 d = abs(b._depth - self._depth)
221 221 h = _hamming(self._vec, b._vec)
222 222 return max(d, h)
223 223
224 224 def near(self, b):
225 225 dist = abs(b.depth - self._depth)
226 226 if dist > _radius or _hamming(self._vec, b._vec) > _radius:
227 227 return False
@@ -1,534 +1,534 b''
1 1 # registrar.py - utilities to register function for specific purpose
2 2 #
3 3 # Copyright FUJIWARA Katsunori <foozy@lares.dti.ne.jp> and others
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 from __future__ import absolute_import
9 9
10 10 from . import (
11 11 configitems,
12 12 error,
13 13 pycompat,
14 14 util,
15 15 )
16 16
17 17 # unlike the other registered items, config options are neither functions or
18 18 # classes. Registering the option is just small function call.
19 19 #
20 20 # We still add the official API to the registrar module for consistency with
21 21 # the other items extensions want might to register.
22 22 configitem = configitems.getitemregister
23 23
24 24
25 25 class _funcregistrarbase(object):
26 26 """Base of decorator to register a function for specific purpose
27 27
28 28 This decorator stores decorated functions into own dict 'table'.
29 29
30 30 The least derived class can be defined by overriding 'formatdoc',
31 31 for example::
32 32
33 33 class keyword(_funcregistrarbase):
34 34 _docformat = ":%s: %s"
35 35
36 36 This should be used as below:
37 37
38 38 keyword = registrar.keyword()
39 39
40 40 @keyword('bar')
41 41 def barfunc(*args, **kwargs):
42 42 '''Explanation of bar keyword ....
43 43 '''
44 44 pass
45 45
46 46 In this case:
47 47
48 48 - 'barfunc' is stored as 'bar' in '_table' of an instance 'keyword' above
49 49 - 'barfunc.__doc__' becomes ":bar: Explanation of bar keyword"
50 50 """
51 51
52 52 def __init__(self, table=None):
53 53 if table is None:
54 54 self._table = {}
55 55 else:
56 56 self._table = table
57 57
58 58 def __call__(self, decl, *args, **kwargs):
59 59 return lambda func: self._doregister(func, decl, *args, **kwargs)
60 60
61 61 def _doregister(self, func, decl, *args, **kwargs):
62 62 name = self._getname(decl)
63 63
64 64 if name in self._table:
65 65 msg = b'duplicate registration for name: "%s"' % name
66 66 raise error.ProgrammingError(msg)
67 67
68 if func.__doc__ and not util.safehasattr(func, b'_origdoc'):
68 if func.__doc__ and not util.safehasattr(func, '_origdoc'):
69 69 func._origdoc = func.__doc__.strip()
70 70 doc = pycompat.sysbytes(func._origdoc)
71 71 func.__doc__ = pycompat.sysstr(self._formatdoc(decl, doc))
72 72
73 73 self._table[name] = func
74 74 self._extrasetup(name, func, *args, **kwargs)
75 75
76 76 return func
77 77
78 78 def _merge(self, registrarbase):
79 79 """Merge the entries of the given registrar object into this one.
80 80
81 81 The other registrar object must not contain any entries already in the
82 82 current one, or a ProgrammmingError is raised. Additionally, the types
83 83 of the two registrars must match.
84 84 """
85 85 if not isinstance(registrarbase, type(self)):
86 86 msg = b"cannot merge different types of registrar"
87 87 raise error.ProgrammingError(msg)
88 88
89 89 dups = set(registrarbase._table).intersection(self._table)
90 90
91 91 if dups:
92 92 msg = b'duplicate registration for names: "%s"' % b'", "'.join(dups)
93 93 raise error.ProgrammingError(msg)
94 94
95 95 self._table.update(registrarbase._table)
96 96
97 97 def _parsefuncdecl(self, decl):
98 98 """Parse function declaration and return the name of function in it
99 99 """
100 100 i = decl.find(b'(')
101 101 if i >= 0:
102 102 return decl[:i]
103 103 else:
104 104 return decl
105 105
106 106 def _getname(self, decl):
107 107 """Return the name of the registered function from decl
108 108
109 109 Derived class should override this, if it allows more
110 110 descriptive 'decl' string than just a name.
111 111 """
112 112 return decl
113 113
114 114 _docformat = None
115 115
116 116 def _formatdoc(self, decl, doc):
117 117 """Return formatted document of the registered function for help
118 118
119 119 'doc' is '__doc__.strip()' of the registered function.
120 120 """
121 121 return self._docformat % (decl, doc)
122 122
123 123 def _extrasetup(self, name, func):
124 124 """Execute exra setup for registered function, if needed
125 125 """
126 126
127 127
128 128 class command(_funcregistrarbase):
129 129 """Decorator to register a command function to table
130 130
131 131 This class receives a command table as its argument. The table should
132 132 be a dict.
133 133
134 134 The created object can be used as a decorator for adding commands to
135 135 that command table. This accepts multiple arguments to define a command.
136 136
137 137 The first argument is the command name (as bytes).
138 138
139 139 The `options` keyword argument is an iterable of tuples defining command
140 140 arguments. See ``mercurial.fancyopts.fancyopts()`` for the format of each
141 141 tuple.
142 142
143 143 The `synopsis` argument defines a short, one line summary of how to use the
144 144 command. This shows up in the help output.
145 145
146 146 There are three arguments that control what repository (if any) is found
147 147 and passed to the decorated function: `norepo`, `optionalrepo`, and
148 148 `inferrepo`.
149 149
150 150 The `norepo` argument defines whether the command does not require a
151 151 local repository. Most commands operate against a repository, thus the
152 152 default is False. When True, no repository will be passed.
153 153
154 154 The `optionalrepo` argument defines whether the command optionally requires
155 155 a local repository. If no repository can be found, None will be passed
156 156 to the decorated function.
157 157
158 158 The `inferrepo` argument defines whether to try to find a repository from
159 159 the command line arguments. If True, arguments will be examined for
160 160 potential repository locations. See ``findrepo()``. If a repository is
161 161 found, it will be used and passed to the decorated function.
162 162
163 163 The `intents` argument defines a set of intended actions or capabilities
164 164 the command is taking. These intents can be used to affect the construction
165 165 of the repository object passed to the command. For example, commands
166 166 declaring that they are read-only could receive a repository that doesn't
167 167 have any methods allowing repository mutation. Other intents could be used
168 168 to prevent the command from running if the requested intent could not be
169 169 fulfilled.
170 170
171 171 If `helpcategory` is set (usually to one of the constants in the help
172 172 module), the command will be displayed under that category in the help's
173 173 list of commands.
174 174
175 175 The following intents are defined:
176 176
177 177 readonly
178 178 The command is read-only
179 179
180 180 The signature of the decorated function looks like this:
181 181 def cmd(ui[, repo] [, <args>] [, <options>])
182 182
183 183 `repo` is required if `norepo` is False.
184 184 `<args>` are positional args (or `*args`) arguments, of non-option
185 185 arguments from the command line.
186 186 `<options>` are keyword arguments (or `**options`) of option arguments
187 187 from the command line.
188 188
189 189 See the WritingExtensions and MercurialApi documentation for more exhaustive
190 190 descriptions and examples.
191 191 """
192 192
193 193 # Command categories for grouping them in help output.
194 194 # These can also be specified for aliases, like:
195 195 # [alias]
196 196 # myalias = something
197 197 # myalias:category = repo
198 198 CATEGORY_REPO_CREATION = b'repo'
199 199 CATEGORY_REMOTE_REPO_MANAGEMENT = b'remote'
200 200 CATEGORY_COMMITTING = b'commit'
201 201 CATEGORY_CHANGE_MANAGEMENT = b'management'
202 202 CATEGORY_CHANGE_ORGANIZATION = b'organization'
203 203 CATEGORY_FILE_CONTENTS = b'files'
204 204 CATEGORY_CHANGE_NAVIGATION = b'navigation'
205 205 CATEGORY_WORKING_DIRECTORY = b'wdir'
206 206 CATEGORY_IMPORT_EXPORT = b'import'
207 207 CATEGORY_MAINTENANCE = b'maintenance'
208 208 CATEGORY_HELP = b'help'
209 209 CATEGORY_MISC = b'misc'
210 210 CATEGORY_NONE = b'none'
211 211
212 212 def _doregister(
213 213 self,
214 214 func,
215 215 name,
216 216 options=(),
217 217 synopsis=None,
218 218 norepo=False,
219 219 optionalrepo=False,
220 220 inferrepo=False,
221 221 intents=None,
222 222 helpcategory=None,
223 223 helpbasic=False,
224 224 ):
225 225 func.norepo = norepo
226 226 func.optionalrepo = optionalrepo
227 227 func.inferrepo = inferrepo
228 228 func.intents = intents or set()
229 229 func.helpcategory = helpcategory
230 230 func.helpbasic = helpbasic
231 231 if synopsis:
232 232 self._table[name] = func, list(options), synopsis
233 233 else:
234 234 self._table[name] = func, list(options)
235 235 return func
236 236
237 237
238 238 INTENT_READONLY = b'readonly'
239 239
240 240
241 241 class revsetpredicate(_funcregistrarbase):
242 242 """Decorator to register revset predicate
243 243
244 244 Usage::
245 245
246 246 revsetpredicate = registrar.revsetpredicate()
247 247
248 248 @revsetpredicate('mypredicate(arg1, arg2[, arg3])')
249 249 def mypredicatefunc(repo, subset, x):
250 250 '''Explanation of this revset predicate ....
251 251 '''
252 252 pass
253 253
254 254 The first string argument is used also in online help.
255 255
256 256 Optional argument 'safe' indicates whether a predicate is safe for
257 257 DoS attack (False by default).
258 258
259 259 Optional argument 'takeorder' indicates whether a predicate function
260 260 takes ordering policy as the last argument.
261 261
262 262 Optional argument 'weight' indicates the estimated run-time cost, useful
263 263 for static optimization, default is 1. Higher weight means more expensive.
264 264 Usually, revsets that are fast and return only one revision has a weight of
265 265 0.5 (ex. a symbol); revsets with O(changelog) complexity and read only the
266 266 changelog have weight 10 (ex. author); revsets reading manifest deltas have
267 267 weight 30 (ex. adds); revset reading manifest contents have weight 100
268 268 (ex. contains). Note: those values are flexible. If the revset has a
269 269 same big-O time complexity as 'contains', but with a smaller constant, it
270 270 might have a weight of 90.
271 271
272 272 'revsetpredicate' instance in example above can be used to
273 273 decorate multiple functions.
274 274
275 275 Decorated functions are registered automatically at loading
276 276 extension, if an instance named as 'revsetpredicate' is used for
277 277 decorating in extension.
278 278
279 279 Otherwise, explicit 'revset.loadpredicate()' is needed.
280 280 """
281 281
282 282 _getname = _funcregistrarbase._parsefuncdecl
283 283 _docformat = b"``%s``\n %s"
284 284
285 285 def _extrasetup(self, name, func, safe=False, takeorder=False, weight=1):
286 286 func._safe = safe
287 287 func._takeorder = takeorder
288 288 func._weight = weight
289 289
290 290
291 291 class filesetpredicate(_funcregistrarbase):
292 292 """Decorator to register fileset predicate
293 293
294 294 Usage::
295 295
296 296 filesetpredicate = registrar.filesetpredicate()
297 297
298 298 @filesetpredicate('mypredicate()')
299 299 def mypredicatefunc(mctx, x):
300 300 '''Explanation of this fileset predicate ....
301 301 '''
302 302 pass
303 303
304 304 The first string argument is used also in online help.
305 305
306 306 Optional argument 'callstatus' indicates whether a predicate
307 307 implies 'matchctx.status()' at runtime or not (False, by
308 308 default).
309 309
310 310 Optional argument 'weight' indicates the estimated run-time cost, useful
311 311 for static optimization, default is 1. Higher weight means more expensive.
312 312 There are predefined weights in the 'filesetlang' module.
313 313
314 314 ====== =============================================================
315 315 Weight Description and examples
316 316 ====== =============================================================
317 317 0.5 basic match patterns (e.g. a symbol)
318 318 10 computing status (e.g. added()) or accessing a few files
319 319 30 reading file content for each (e.g. grep())
320 320 50 scanning working directory (ignored())
321 321 ====== =============================================================
322 322
323 323 'filesetpredicate' instance in example above can be used to
324 324 decorate multiple functions.
325 325
326 326 Decorated functions are registered automatically at loading
327 327 extension, if an instance named as 'filesetpredicate' is used for
328 328 decorating in extension.
329 329
330 330 Otherwise, explicit 'fileset.loadpredicate()' is needed.
331 331 """
332 332
333 333 _getname = _funcregistrarbase._parsefuncdecl
334 334 _docformat = b"``%s``\n %s"
335 335
336 336 def _extrasetup(self, name, func, callstatus=False, weight=1):
337 337 func._callstatus = callstatus
338 338 func._weight = weight
339 339
340 340
341 341 class _templateregistrarbase(_funcregistrarbase):
342 342 """Base of decorator to register functions as template specific one
343 343 """
344 344
345 345 _docformat = b":%s: %s"
346 346
347 347
348 348 class templatekeyword(_templateregistrarbase):
349 349 """Decorator to register template keyword
350 350
351 351 Usage::
352 352
353 353 templatekeyword = registrar.templatekeyword()
354 354
355 355 # new API (since Mercurial 4.6)
356 356 @templatekeyword('mykeyword', requires={'repo', 'ctx'})
357 357 def mykeywordfunc(context, mapping):
358 358 '''Explanation of this template keyword ....
359 359 '''
360 360 pass
361 361
362 362 The first string argument is used also in online help.
363 363
364 364 Optional argument 'requires' should be a collection of resource names
365 365 which the template keyword depends on.
366 366
367 367 'templatekeyword' instance in example above can be used to
368 368 decorate multiple functions.
369 369
370 370 Decorated functions are registered automatically at loading
371 371 extension, if an instance named as 'templatekeyword' is used for
372 372 decorating in extension.
373 373
374 374 Otherwise, explicit 'templatekw.loadkeyword()' is needed.
375 375 """
376 376
377 377 def _extrasetup(self, name, func, requires=()):
378 378 func._requires = requires
379 379
380 380
381 381 class templatefilter(_templateregistrarbase):
382 382 """Decorator to register template filer
383 383
384 384 Usage::
385 385
386 386 templatefilter = registrar.templatefilter()
387 387
388 388 @templatefilter('myfilter', intype=bytes)
389 389 def myfilterfunc(text):
390 390 '''Explanation of this template filter ....
391 391 '''
392 392 pass
393 393
394 394 The first string argument is used also in online help.
395 395
396 396 Optional argument 'intype' defines the type of the input argument,
397 397 which should be (bytes, int, templateutil.date, or None for any.)
398 398
399 399 'templatefilter' instance in example above can be used to
400 400 decorate multiple functions.
401 401
402 402 Decorated functions are registered automatically at loading
403 403 extension, if an instance named as 'templatefilter' is used for
404 404 decorating in extension.
405 405
406 406 Otherwise, explicit 'templatefilters.loadkeyword()' is needed.
407 407 """
408 408
409 409 def _extrasetup(self, name, func, intype=None):
410 410 func._intype = intype
411 411
412 412
413 413 class templatefunc(_templateregistrarbase):
414 414 """Decorator to register template function
415 415
416 416 Usage::
417 417
418 418 templatefunc = registrar.templatefunc()
419 419
420 420 @templatefunc('myfunc(arg1, arg2[, arg3])', argspec='arg1 arg2 arg3',
421 421 requires={'ctx'})
422 422 def myfuncfunc(context, mapping, args):
423 423 '''Explanation of this template function ....
424 424 '''
425 425 pass
426 426
427 427 The first string argument is used also in online help.
428 428
429 429 If optional 'argspec' is defined, the function will receive 'args' as
430 430 a dict of named arguments. Otherwise 'args' is a list of positional
431 431 arguments.
432 432
433 433 Optional argument 'requires' should be a collection of resource names
434 434 which the template function depends on.
435 435
436 436 'templatefunc' instance in example above can be used to
437 437 decorate multiple functions.
438 438
439 439 Decorated functions are registered automatically at loading
440 440 extension, if an instance named as 'templatefunc' is used for
441 441 decorating in extension.
442 442
443 443 Otherwise, explicit 'templatefuncs.loadfunction()' is needed.
444 444 """
445 445
446 446 _getname = _funcregistrarbase._parsefuncdecl
447 447
448 448 def _extrasetup(self, name, func, argspec=None, requires=()):
449 449 func._argspec = argspec
450 450 func._requires = requires
451 451
452 452
453 453 class internalmerge(_funcregistrarbase):
454 454 """Decorator to register in-process merge tool
455 455
456 456 Usage::
457 457
458 458 internalmerge = registrar.internalmerge()
459 459
460 460 @internalmerge('mymerge', internalmerge.mergeonly,
461 461 onfailure=None, precheck=None,
462 462 binary=False, symlink=False):
463 463 def mymergefunc(repo, mynode, orig, fcd, fco, fca,
464 464 toolconf, files, labels=None):
465 465 '''Explanation of this internal merge tool ....
466 466 '''
467 467 return 1, False # means "conflicted", "no deletion needed"
468 468
469 469 The first string argument is used to compose actual merge tool name,
470 470 ":name" and "internal:name" (the latter is historical one).
471 471
472 472 The second argument is one of merge types below:
473 473
474 474 ========== ======== ======== =========
475 475 merge type precheck premerge fullmerge
476 476 ========== ======== ======== =========
477 477 nomerge x x x
478 478 mergeonly o x o
479 479 fullmerge o o o
480 480 ========== ======== ======== =========
481 481
482 482 Optional argument 'onfailure' is the format of warning message
483 483 to be used at failure of merging (target filename is specified
484 484 at formatting). Or, None or so, if warning message should be
485 485 suppressed.
486 486
487 487 Optional argument 'precheck' is the function to be used
488 488 before actual invocation of internal merge tool itself.
489 489 It takes as same arguments as internal merge tool does, other than
490 490 'files' and 'labels'. If it returns false value, merging is aborted
491 491 immediately (and file is marked as "unresolved").
492 492
493 493 Optional argument 'binary' is a binary files capability of internal
494 494 merge tool. 'nomerge' merge type implies binary=True.
495 495
496 496 Optional argument 'symlink' is a symlinks capability of inetrnal
497 497 merge function. 'nomerge' merge type implies symlink=True.
498 498
499 499 'internalmerge' instance in example above can be used to
500 500 decorate multiple functions.
501 501
502 502 Decorated functions are registered automatically at loading
503 503 extension, if an instance named as 'internalmerge' is used for
504 504 decorating in extension.
505 505
506 506 Otherwise, explicit 'filemerge.loadinternalmerge()' is needed.
507 507 """
508 508
509 509 _docformat = b"``:%s``\n %s"
510 510
511 511 # merge type definitions:
512 512 nomerge = None
513 513 mergeonly = b'mergeonly' # just the full merge, no premerge
514 514 fullmerge = b'fullmerge' # both premerge and merge
515 515
516 516 def _extrasetup(
517 517 self,
518 518 name,
519 519 func,
520 520 mergetype,
521 521 onfailure=None,
522 522 precheck=None,
523 523 binary=False,
524 524 symlink=False,
525 525 ):
526 526 func.mergetype = mergetype
527 527 func.onfailure = onfailure
528 528 func.precheck = precheck
529 529
530 530 binarycap = binary or mergetype == self.nomerge
531 531 symlinkcap = symlink or mergetype == self.nomerge
532 532
533 533 # actual capabilities, which this internal merge tool has
534 534 func.capabilities = {b"binary": binarycap, b"symlink": symlinkcap}
@@ -1,633 +1,633 b''
1 1 # procutil.py - utility for managing processes and executable environment
2 2 #
3 3 # Copyright 2005 K. Thananchayan <thananck@yahoo.com>
4 4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
5 5 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
6 6 #
7 7 # This software may be used and distributed according to the terms of the
8 8 # GNU General Public License version 2 or any later version.
9 9
10 10 from __future__ import absolute_import
11 11
12 12 import contextlib
13 13 import errno
14 14 import imp
15 15 import io
16 16 import os
17 17 import signal
18 18 import subprocess
19 19 import sys
20 20 import time
21 21
22 22 from ..i18n import _
23 23 from ..pycompat import (
24 24 getattr,
25 25 open,
26 26 )
27 27
28 28 from .. import (
29 29 encoding,
30 30 error,
31 31 policy,
32 32 pycompat,
33 33 )
34 34
35 35 osutil = policy.importmod(r'osutil')
36 36
37 37 stderr = pycompat.stderr
38 38 stdin = pycompat.stdin
39 39 stdout = pycompat.stdout
40 40
41 41
42 42 def isatty(fp):
43 43 try:
44 44 return fp.isatty()
45 45 except AttributeError:
46 46 return False
47 47
48 48
49 49 # glibc determines buffering on first write to stdout - if we replace a TTY
50 50 # destined stdout with a pipe destined stdout (e.g. pager), we want line
51 51 # buffering (or unbuffered, on Windows)
52 52 if isatty(stdout):
53 53 if pycompat.iswindows:
54 54 # Windows doesn't support line buffering
55 55 stdout = os.fdopen(stdout.fileno(), r'wb', 0)
56 56 else:
57 57 stdout = os.fdopen(stdout.fileno(), r'wb', 1)
58 58
59 59 if pycompat.iswindows:
60 60 from .. import windows as platform
61 61
62 62 stdout = platform.winstdout(stdout)
63 63 else:
64 64 from .. import posix as platform
65 65
66 66 findexe = platform.findexe
67 67 _gethgcmd = platform.gethgcmd
68 68 getuser = platform.getuser
69 69 getpid = os.getpid
70 70 hidewindow = platform.hidewindow
71 71 quotecommand = platform.quotecommand
72 72 readpipe = platform.readpipe
73 73 setbinary = platform.setbinary
74 74 setsignalhandler = platform.setsignalhandler
75 75 shellquote = platform.shellquote
76 76 shellsplit = platform.shellsplit
77 77 spawndetached = platform.spawndetached
78 78 sshargs = platform.sshargs
79 79 testpid = platform.testpid
80 80
81 81 try:
82 82 setprocname = osutil.setprocname
83 83 except AttributeError:
84 84 pass
85 85 try:
86 86 unblocksignal = osutil.unblocksignal
87 87 except AttributeError:
88 88 pass
89 89
90 90 closefds = pycompat.isposix
91 91
92 92
93 93 def explainexit(code):
94 94 """return a message describing a subprocess status
95 95 (codes from kill are negative - not os.system/wait encoding)"""
96 96 if code >= 0:
97 97 return _(b"exited with status %d") % code
98 98 return _(b"killed by signal %d") % -code
99 99
100 100
101 101 class _pfile(object):
102 102 """File-like wrapper for a stream opened by subprocess.Popen()"""
103 103
104 104 def __init__(self, proc, fp):
105 105 self._proc = proc
106 106 self._fp = fp
107 107
108 108 def close(self):
109 109 # unlike os.popen(), this returns an integer in subprocess coding
110 110 self._fp.close()
111 111 return self._proc.wait()
112 112
113 113 def __iter__(self):
114 114 return iter(self._fp)
115 115
116 116 def __getattr__(self, attr):
117 117 return getattr(self._fp, attr)
118 118
119 119 def __enter__(self):
120 120 return self
121 121
122 122 def __exit__(self, exc_type, exc_value, exc_tb):
123 123 self.close()
124 124
125 125
126 126 def popen(cmd, mode=b'rb', bufsize=-1):
127 127 if mode == b'rb':
128 128 return _popenreader(cmd, bufsize)
129 129 elif mode == b'wb':
130 130 return _popenwriter(cmd, bufsize)
131 131 raise error.ProgrammingError(b'unsupported mode: %r' % mode)
132 132
133 133
134 134 def _popenreader(cmd, bufsize):
135 135 p = subprocess.Popen(
136 136 tonativestr(quotecommand(cmd)),
137 137 shell=True,
138 138 bufsize=bufsize,
139 139 close_fds=closefds,
140 140 stdout=subprocess.PIPE,
141 141 )
142 142 return _pfile(p, p.stdout)
143 143
144 144
145 145 def _popenwriter(cmd, bufsize):
146 146 p = subprocess.Popen(
147 147 tonativestr(quotecommand(cmd)),
148 148 shell=True,
149 149 bufsize=bufsize,
150 150 close_fds=closefds,
151 151 stdin=subprocess.PIPE,
152 152 )
153 153 return _pfile(p, p.stdin)
154 154
155 155
156 156 def popen2(cmd, env=None):
157 157 # Setting bufsize to -1 lets the system decide the buffer size.
158 158 # The default for bufsize is 0, meaning unbuffered. This leads to
159 159 # poor performance on Mac OS X: http://bugs.python.org/issue4194
160 160 p = subprocess.Popen(
161 161 tonativestr(cmd),
162 162 shell=True,
163 163 bufsize=-1,
164 164 close_fds=closefds,
165 165 stdin=subprocess.PIPE,
166 166 stdout=subprocess.PIPE,
167 167 env=tonativeenv(env),
168 168 )
169 169 return p.stdin, p.stdout
170 170
171 171
172 172 def popen3(cmd, env=None):
173 173 stdin, stdout, stderr, p = popen4(cmd, env)
174 174 return stdin, stdout, stderr
175 175
176 176
177 177 def popen4(cmd, env=None, bufsize=-1):
178 178 p = subprocess.Popen(
179 179 tonativestr(cmd),
180 180 shell=True,
181 181 bufsize=bufsize,
182 182 close_fds=closefds,
183 183 stdin=subprocess.PIPE,
184 184 stdout=subprocess.PIPE,
185 185 stderr=subprocess.PIPE,
186 186 env=tonativeenv(env),
187 187 )
188 188 return p.stdin, p.stdout, p.stderr, p
189 189
190 190
191 191 def pipefilter(s, cmd):
192 192 '''filter string S through command CMD, returning its output'''
193 193 p = subprocess.Popen(
194 194 tonativestr(cmd),
195 195 shell=True,
196 196 close_fds=closefds,
197 197 stdin=subprocess.PIPE,
198 198 stdout=subprocess.PIPE,
199 199 )
200 200 pout, perr = p.communicate(s)
201 201 return pout
202 202
203 203
204 204 def tempfilter(s, cmd):
205 205 '''filter string S through a pair of temporary files with CMD.
206 206 CMD is used as a template to create the real command to be run,
207 207 with the strings INFILE and OUTFILE replaced by the real names of
208 208 the temporary files generated.'''
209 209 inname, outname = None, None
210 210 try:
211 211 infd, inname = pycompat.mkstemp(prefix=b'hg-filter-in-')
212 212 fp = os.fdopen(infd, r'wb')
213 213 fp.write(s)
214 214 fp.close()
215 215 outfd, outname = pycompat.mkstemp(prefix=b'hg-filter-out-')
216 216 os.close(outfd)
217 217 cmd = cmd.replace(b'INFILE', inname)
218 218 cmd = cmd.replace(b'OUTFILE', outname)
219 219 code = system(cmd)
220 220 if pycompat.sysplatform == b'OpenVMS' and code & 1:
221 221 code = 0
222 222 if code:
223 223 raise error.Abort(
224 224 _(b"command '%s' failed: %s") % (cmd, explainexit(code))
225 225 )
226 226 with open(outname, b'rb') as fp:
227 227 return fp.read()
228 228 finally:
229 229 try:
230 230 if inname:
231 231 os.unlink(inname)
232 232 except OSError:
233 233 pass
234 234 try:
235 235 if outname:
236 236 os.unlink(outname)
237 237 except OSError:
238 238 pass
239 239
240 240
241 241 _filtertable = {
242 242 b'tempfile:': tempfilter,
243 243 b'pipe:': pipefilter,
244 244 }
245 245
246 246
247 247 def filter(s, cmd):
248 248 b"filter a string through a command that transforms its input to its output"
249 249 for name, fn in pycompat.iteritems(_filtertable):
250 250 if cmd.startswith(name):
251 251 return fn(s, cmd[len(name) :].lstrip())
252 252 return pipefilter(s, cmd)
253 253
254 254
255 255 def mainfrozen():
256 256 """return True if we are a frozen executable.
257 257
258 258 The code supports py2exe (most common, Windows only) and tools/freeze
259 259 (portable, not much used).
260 260 """
261 261 return (
262 pycompat.safehasattr(sys, b"frozen")
263 or pycompat.safehasattr(sys, b"importers") # new py2exe
262 pycompat.safehasattr(sys, "frozen")
263 or pycompat.safehasattr(sys, "importers") # new py2exe
264 264 or imp.is_frozen(r"__main__") # old py2exe
265 265 ) # tools/freeze
266 266
267 267
268 268 _hgexecutable = None
269 269
270 270
271 271 def hgexecutable():
272 272 """return location of the 'hg' executable.
273 273
274 274 Defaults to $HG or 'hg' in the search path.
275 275 """
276 276 if _hgexecutable is None:
277 277 hg = encoding.environ.get(b'HG')
278 278 mainmod = sys.modules[r'__main__']
279 279 if hg:
280 280 _sethgexecutable(hg)
281 281 elif mainfrozen():
282 282 if getattr(sys, 'frozen', None) == b'macosx_app':
283 283 # Env variable set by py2app
284 284 _sethgexecutable(encoding.environ[b'EXECUTABLEPATH'])
285 285 else:
286 286 _sethgexecutable(pycompat.sysexecutable)
287 287 elif (
288 288 not pycompat.iswindows
289 289 and os.path.basename(
290 290 pycompat.fsencode(getattr(mainmod, '__file__', b''))
291 291 )
292 292 == b'hg'
293 293 ):
294 294 _sethgexecutable(pycompat.fsencode(mainmod.__file__))
295 295 else:
296 296 _sethgexecutable(
297 297 findexe(b'hg') or os.path.basename(pycompat.sysargv[0])
298 298 )
299 299 return _hgexecutable
300 300
301 301
302 302 def _sethgexecutable(path):
303 303 """set location of the 'hg' executable"""
304 304 global _hgexecutable
305 305 _hgexecutable = path
306 306
307 307
308 308 def _testfileno(f, stdf):
309 309 fileno = getattr(f, 'fileno', None)
310 310 try:
311 311 return fileno and fileno() == stdf.fileno()
312 312 except io.UnsupportedOperation:
313 313 return False # fileno() raised UnsupportedOperation
314 314
315 315
316 316 def isstdin(f):
317 317 return _testfileno(f, sys.__stdin__)
318 318
319 319
320 320 def isstdout(f):
321 321 return _testfileno(f, sys.__stdout__)
322 322
323 323
324 324 def protectstdio(uin, uout):
325 325 """Duplicate streams and redirect original if (uin, uout) are stdio
326 326
327 327 If uin is stdin, it's redirected to /dev/null. If uout is stdout, it's
328 328 redirected to stderr so the output is still readable.
329 329
330 330 Returns (fin, fout) which point to the original (uin, uout) fds, but
331 331 may be copy of (uin, uout). The returned streams can be considered
332 332 "owned" in that print(), exec(), etc. never reach to them.
333 333 """
334 334 uout.flush()
335 335 fin, fout = uin, uout
336 336 if _testfileno(uin, stdin):
337 337 newfd = os.dup(uin.fileno())
338 338 nullfd = os.open(os.devnull, os.O_RDONLY)
339 339 os.dup2(nullfd, uin.fileno())
340 340 os.close(nullfd)
341 341 fin = os.fdopen(newfd, r'rb')
342 342 if _testfileno(uout, stdout):
343 343 newfd = os.dup(uout.fileno())
344 344 os.dup2(stderr.fileno(), uout.fileno())
345 345 fout = os.fdopen(newfd, r'wb')
346 346 return fin, fout
347 347
348 348
349 349 def restorestdio(uin, uout, fin, fout):
350 350 """Restore (uin, uout) streams from possibly duplicated (fin, fout)"""
351 351 uout.flush()
352 352 for f, uif in [(fin, uin), (fout, uout)]:
353 353 if f is not uif:
354 354 os.dup2(f.fileno(), uif.fileno())
355 355 f.close()
356 356
357 357
358 358 def shellenviron(environ=None):
359 359 """return environ with optional override, useful for shelling out"""
360 360
361 361 def py2shell(val):
362 362 b'convert python object into string that is useful to shell'
363 363 if val is None or val is False:
364 364 return b'0'
365 365 if val is True:
366 366 return b'1'
367 367 return pycompat.bytestr(val)
368 368
369 369 env = dict(encoding.environ)
370 370 if environ:
371 371 env.update((k, py2shell(v)) for k, v in pycompat.iteritems(environ))
372 372 env[b'HG'] = hgexecutable()
373 373 return env
374 374
375 375
376 376 if pycompat.iswindows:
377 377
378 378 def shelltonative(cmd, env):
379 379 return platform.shelltocmdexe(cmd, shellenviron(env))
380 380
381 381 tonativestr = encoding.strfromlocal
382 382 else:
383 383
384 384 def shelltonative(cmd, env):
385 385 return cmd
386 386
387 387 tonativestr = pycompat.identity
388 388
389 389
390 390 def tonativeenv(env):
391 391 '''convert the environment from bytes to strings suitable for Popen(), etc.
392 392 '''
393 393 return pycompat.rapply(tonativestr, env)
394 394
395 395
396 396 def system(cmd, environ=None, cwd=None, out=None):
397 397 '''enhanced shell command execution.
398 398 run with environment maybe modified, maybe in different dir.
399 399
400 400 if out is specified, it is assumed to be a file-like object that has a
401 401 write() method. stdout and stderr will be redirected to out.'''
402 402 try:
403 403 stdout.flush()
404 404 except Exception:
405 405 pass
406 406 cmd = quotecommand(cmd)
407 407 env = shellenviron(environ)
408 408 if out is None or isstdout(out):
409 409 rc = subprocess.call(
410 410 tonativestr(cmd),
411 411 shell=True,
412 412 close_fds=closefds,
413 413 env=tonativeenv(env),
414 414 cwd=pycompat.rapply(tonativestr, cwd),
415 415 )
416 416 else:
417 417 proc = subprocess.Popen(
418 418 tonativestr(cmd),
419 419 shell=True,
420 420 close_fds=closefds,
421 421 env=tonativeenv(env),
422 422 cwd=pycompat.rapply(tonativestr, cwd),
423 423 stdout=subprocess.PIPE,
424 424 stderr=subprocess.STDOUT,
425 425 )
426 426 for line in iter(proc.stdout.readline, b''):
427 427 out.write(line)
428 428 proc.wait()
429 429 rc = proc.returncode
430 430 if pycompat.sysplatform == b'OpenVMS' and rc & 1:
431 431 rc = 0
432 432 return rc
433 433
434 434
435 435 def gui():
436 436 '''Are we running in a GUI?'''
437 437 if pycompat.isdarwin:
438 438 if b'SSH_CONNECTION' in encoding.environ:
439 439 # handle SSH access to a box where the user is logged in
440 440 return False
441 441 elif getattr(osutil, 'isgui', None):
442 442 # check if a CoreGraphics session is available
443 443 return osutil.isgui()
444 444 else:
445 445 # pure build; use a safe default
446 446 return True
447 447 else:
448 448 return pycompat.iswindows or encoding.environ.get(b"DISPLAY")
449 449
450 450
451 451 def hgcmd():
452 452 """Return the command used to execute current hg
453 453
454 454 This is different from hgexecutable() because on Windows we want
455 455 to avoid things opening new shell windows like batch files, so we
456 456 get either the python call or current executable.
457 457 """
458 458 if mainfrozen():
459 459 if getattr(sys, 'frozen', None) == b'macosx_app':
460 460 # Env variable set by py2app
461 461 return [encoding.environ[b'EXECUTABLEPATH']]
462 462 else:
463 463 return [pycompat.sysexecutable]
464 464 return _gethgcmd()
465 465
466 466
467 467 def rundetached(args, condfn):
468 468 """Execute the argument list in a detached process.
469 469
470 470 condfn is a callable which is called repeatedly and should return
471 471 True once the child process is known to have started successfully.
472 472 At this point, the child process PID is returned. If the child
473 473 process fails to start or finishes before condfn() evaluates to
474 474 True, return -1.
475 475 """
476 476 # Windows case is easier because the child process is either
477 477 # successfully starting and validating the condition or exiting
478 478 # on failure. We just poll on its PID. On Unix, if the child
479 479 # process fails to start, it will be left in a zombie state until
480 480 # the parent wait on it, which we cannot do since we expect a long
481 481 # running process on success. Instead we listen for SIGCHLD telling
482 482 # us our child process terminated.
483 483 terminated = set()
484 484
485 485 def handler(signum, frame):
486 486 terminated.add(os.wait())
487 487
488 488 prevhandler = None
489 489 SIGCHLD = getattr(signal, 'SIGCHLD', None)
490 490 if SIGCHLD is not None:
491 491 prevhandler = signal.signal(SIGCHLD, handler)
492 492 try:
493 493 pid = spawndetached(args)
494 494 while not condfn():
495 495 if (pid in terminated or not testpid(pid)) and not condfn():
496 496 return -1
497 497 time.sleep(0.1)
498 498 return pid
499 499 finally:
500 500 if prevhandler is not None:
501 501 signal.signal(signal.SIGCHLD, prevhandler)
502 502
503 503
504 504 @contextlib.contextmanager
505 505 def uninterruptible(warn):
506 506 """Inhibit SIGINT handling on a region of code.
507 507
508 508 Note that if this is called in a non-main thread, it turns into a no-op.
509 509
510 510 Args:
511 511 warn: A callable which takes no arguments, and returns True if the
512 512 previous signal handling should be restored.
513 513 """
514 514
515 515 oldsiginthandler = [signal.getsignal(signal.SIGINT)]
516 516 shouldbail = []
517 517
518 518 def disabledsiginthandler(*args):
519 519 if warn():
520 520 signal.signal(signal.SIGINT, oldsiginthandler[0])
521 521 del oldsiginthandler[0]
522 522 shouldbail.append(True)
523 523
524 524 try:
525 525 try:
526 526 signal.signal(signal.SIGINT, disabledsiginthandler)
527 527 except ValueError:
528 528 # wrong thread, oh well, we tried
529 529 del oldsiginthandler[0]
530 530 yield
531 531 finally:
532 532 if oldsiginthandler:
533 533 signal.signal(signal.SIGINT, oldsiginthandler[0])
534 534 if shouldbail:
535 535 raise KeyboardInterrupt
536 536
537 537
538 538 if pycompat.iswindows:
539 539 # no fork on Windows, but we can create a detached process
540 540 # https://msdn.microsoft.com/en-us/library/windows/desktop/ms684863.aspx
541 541 # No stdlib constant exists for this value
542 542 DETACHED_PROCESS = 0x00000008
543 543 # Following creation flags might create a console GUI window.
544 544 # Using subprocess.CREATE_NEW_CONSOLE might helps.
545 545 # See https://phab.mercurial-scm.org/D1701 for discussion
546 546 _creationflags = DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
547 547
548 548 def runbgcommand(
549 549 script, env, shell=False, stdout=None, stderr=None, ensurestart=True
550 550 ):
551 551 '''Spawn a command without waiting for it to finish.'''
552 552 # we can't use close_fds *and* redirect stdin. I'm not sure that we
553 553 # need to because the detached process has no console connection.
554 554 subprocess.Popen(
555 555 tonativestr(script),
556 556 shell=shell,
557 557 env=tonativeenv(env),
558 558 close_fds=True,
559 559 creationflags=_creationflags,
560 560 stdout=stdout,
561 561 stderr=stderr,
562 562 )
563 563
564 564
565 565 else:
566 566
567 567 def runbgcommand(
568 568 cmd, env, shell=False, stdout=None, stderr=None, ensurestart=True
569 569 ):
570 570 '''Spawn a command without waiting for it to finish.'''
571 571 # double-fork to completely detach from the parent process
572 572 # based on http://code.activestate.com/recipes/278731
573 573 pid = os.fork()
574 574 if pid:
575 575 if not ensurestart:
576 576 return
577 577 # Parent process
578 578 (_pid, status) = os.waitpid(pid, 0)
579 579 if os.WIFEXITED(status):
580 580 returncode = os.WEXITSTATUS(status)
581 581 else:
582 582 returncode = -(os.WTERMSIG(status))
583 583 if returncode != 0:
584 584 # The child process's return code is 0 on success, an errno
585 585 # value on failure, or 255 if we don't have a valid errno
586 586 # value.
587 587 #
588 588 # (It would be slightly nicer to return the full exception info
589 589 # over a pipe as the subprocess module does. For now it
590 590 # doesn't seem worth adding that complexity here, though.)
591 591 if returncode == 255:
592 592 returncode = errno.EINVAL
593 593 raise OSError(
594 594 returncode,
595 595 b'error running %r: %s' % (cmd, os.strerror(returncode)),
596 596 )
597 597 return
598 598
599 599 returncode = 255
600 600 try:
601 601 # Start a new session
602 602 os.setsid()
603 603
604 604 stdin = open(os.devnull, b'r')
605 605 if stdout is None:
606 606 stdout = open(os.devnull, b'w')
607 607 if stderr is None:
608 608 stderr = open(os.devnull, b'w')
609 609
610 610 # connect stdin to devnull to make sure the subprocess can't
611 611 # muck up that stream for mercurial.
612 612 subprocess.Popen(
613 613 cmd,
614 614 shell=shell,
615 615 env=env,
616 616 close_fds=True,
617 617 stdin=stdin,
618 618 stdout=stdout,
619 619 stderr=stderr,
620 620 )
621 621 returncode = 0
622 622 except EnvironmentError as ex:
623 623 returncode = ex.errno & 0xFF
624 624 if returncode == 0:
625 625 # This shouldn't happen, but just in case make sure the
626 626 # return code is never 0 here.
627 627 returncode = 255
628 628 except Exception:
629 629 returncode = 255
630 630 finally:
631 631 # mission accomplished, this child needs to exit and not
632 632 # continue the hg process here.
633 633 os._exit(returncode)
General Comments 0
You need to be logged in to leave comments. Login now