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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
351 | if util.safehasattr(op, '_widen_bundle'): | |
|
352 | 352 | handlechangegroup_widen(op, inpart) |
|
353 |
if util.safehasattr(op, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
263 |
or pycompat.safehasattr(sys, |
|
|
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