##// END OF EJS Templates
absorb: use pycompat to get xrange...
Augie Fackler -
r38956:a5c8c547 default
parent child Browse files
Show More
@@ -1,1043 +1,1044 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 maxstacksize = 50
18 18 # whether to add noise to new commits to avoid obsolescence cycle
19 19 addnoise = 1
20 20 # make `amend --correlated` a shortcut to the main command
21 21 amendflag = correlated
22 22
23 23 [color]
24 24 absorb.node = blue bold
25 25 absorb.path = bold
26 26 """
27 27
28 28 from __future__ import absolute_import
29 29
30 30 import collections
31 31
32 32 from mercurial.i18n import _
33 33 from mercurial import (
34 34 cmdutil,
35 35 commands,
36 36 context,
37 37 crecord,
38 38 error,
39 39 extensions,
40 40 linelog,
41 41 mdiff,
42 42 node,
43 43 obsolete,
44 44 patch,
45 45 phases,
46 pycompat,
46 47 registrar,
47 48 repair,
48 49 scmutil,
49 50 util,
50 51 )
51 52 from mercurial.utils import (
52 53 stringutil,
53 54 )
54 55
55 56 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
56 57 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
57 58 # be specifying the version(s) of Mercurial they are tested with, or
58 59 # leave the attribute unspecified.
59 60 testedwith = 'ships-with-hg-core'
60 61
61 62 cmdtable = {}
62 63 command = registrar.command(cmdtable)
63 64
64 65 configtable = {}
65 66 configitem = registrar.configitem(configtable)
66 67
67 68 configitem('absorb', 'addnoise', default=True)
68 69 configitem('absorb', 'amendflag', default=None)
69 70 configitem('absorb', 'maxstacksize', default=50)
70 71
71 72 colortable = {
72 73 'absorb.node': 'blue bold',
73 74 'absorb.path': 'bold',
74 75 }
75 76
76 77 defaultdict = collections.defaultdict
77 78
78 79 class nullui(object):
79 80 """blank ui object doing nothing"""
80 81 debugflag = False
81 82 verbose = False
82 83 quiet = True
83 84
84 85 def __getitem__(name):
85 86 def nullfunc(*args, **kwds):
86 87 return
87 88 return nullfunc
88 89
89 90 class emptyfilecontext(object):
90 91 """minimal filecontext representing an empty file"""
91 92 def data(self):
92 93 return ''
93 94
94 95 def node(self):
95 96 return node.nullid
96 97
97 98 def uniq(lst):
98 99 """list -> list. remove duplicated items without changing the order"""
99 100 seen = set()
100 101 result = []
101 102 for x in lst:
102 103 if x not in seen:
103 104 seen.add(x)
104 105 result.append(x)
105 106 return result
106 107
107 108 def getdraftstack(headctx, limit=None):
108 109 """(ctx, int?) -> [ctx]. get a linear stack of non-public changesets.
109 110
110 111 changesets are sorted in topo order, oldest first.
111 112 return at most limit items, if limit is a positive number.
112 113
113 114 merges are considered as non-draft as well. i.e. every commit
114 115 returned has and only has 1 parent.
115 116 """
116 117 ctx = headctx
117 118 result = []
118 119 while ctx.phase() != phases.public:
119 120 if limit and len(result) >= limit:
120 121 break
121 122 parents = ctx.parents()
122 123 if len(parents) != 1:
123 124 break
124 125 result.append(ctx)
125 126 ctx = parents[0]
126 127 result.reverse()
127 128 return result
128 129
129 130 def getfilestack(stack, path, seenfctxs=None):
130 131 """([ctx], str, set) -> [fctx], {ctx: fctx}
131 132
132 133 stack is a list of contexts, from old to new. usually they are what
133 134 "getdraftstack" returns.
134 135
135 136 follows renames, but not copies.
136 137
137 138 seenfctxs is a set of filecontexts that will be considered "immutable".
138 139 they are usually what this function returned in earlier calls, useful
139 140 to avoid issues that a file was "moved" to multiple places and was then
140 141 modified differently, like: "a" was copied to "b", "a" was also copied to
141 142 "c" and then "a" was deleted, then both "b" and "c" were "moved" from "a"
142 143 and we enforce only one of them to be able to affect "a"'s content.
143 144
144 145 return an empty list and an empty dict, if the specified path does not
145 146 exist in stack[-1] (the top of the stack).
146 147
147 148 otherwise, return a list of de-duplicated filecontexts, and the map to
148 149 convert ctx in the stack to fctx, for possible mutable fctxs. the first item
149 150 of the list would be outside the stack and should be considered immutable.
150 151 the remaining items are within the stack.
151 152
152 153 for example, given the following changelog and corresponding filelog
153 154 revisions:
154 155
155 156 changelog: 3----4----5----6----7
156 157 filelog: x 0----1----1----2 (x: no such file yet)
157 158
158 159 - if stack = [5, 6, 7], returns ([0, 1, 2], {5: 1, 6: 1, 7: 2})
159 160 - if stack = [3, 4, 5], returns ([e, 0, 1], {4: 0, 5: 1}), where "e" is a
160 161 dummy empty filecontext.
161 162 - if stack = [2], returns ([], {})
162 163 - if stack = [7], returns ([1, 2], {7: 2})
163 164 - if stack = [6, 7], returns ([1, 2], {6: 1, 7: 2}), although {6: 1} can be
164 165 removed, since 1 is immutable.
165 166 """
166 167 if seenfctxs is None:
167 168 seenfctxs = set()
168 169 assert stack
169 170
170 171 if path not in stack[-1]:
171 172 return [], {}
172 173
173 174 fctxs = []
174 175 fctxmap = {}
175 176
176 177 pctx = stack[0].p1() # the public (immutable) ctx we stop at
177 178 for ctx in reversed(stack):
178 179 if path not in ctx: # the file is added in the next commit
179 180 pctx = ctx
180 181 break
181 182 fctx = ctx[path]
182 183 fctxs.append(fctx)
183 184 if fctx in seenfctxs: # treat fctx as the immutable one
184 185 pctx = None # do not add another immutable fctx
185 186 break
186 187 fctxmap[ctx] = fctx # only for mutable fctxs
187 188 renamed = fctx.renamed()
188 189 if renamed:
189 190 path = renamed[0] # follow rename
190 191 if path in ctx: # but do not follow copy
191 192 pctx = ctx.p1()
192 193 break
193 194
194 195 if pctx is not None: # need an extra immutable fctx
195 196 if path in pctx:
196 197 fctxs.append(pctx[path])
197 198 else:
198 199 fctxs.append(emptyfilecontext())
199 200
200 201 fctxs.reverse()
201 202 # note: we rely on a property of hg: filerev is not reused for linear
202 203 # history. i.e. it's impossible to have:
203 204 # changelog: 4----5----6 (linear, no merges)
204 205 # filelog: 1----2----1
205 206 # ^ reuse filerev (impossible)
206 207 # because parents are part of the hash. if that's not true, we need to
207 208 # remove uniq and find a different way to identify fctxs.
208 209 return uniq(fctxs), fctxmap
209 210
210 211 class overlaystore(patch.filestore):
211 212 """read-only, hybrid store based on a dict and ctx.
212 213 memworkingcopy: {path: content}, overrides file contents.
213 214 """
214 215 def __init__(self, basectx, memworkingcopy):
215 216 self.basectx = basectx
216 217 self.memworkingcopy = memworkingcopy
217 218
218 219 def getfile(self, path):
219 220 """comply with mercurial.patch.filestore.getfile"""
220 221 if path not in self.basectx:
221 222 return None, None, None
222 223 fctx = self.basectx[path]
223 224 if path in self.memworkingcopy:
224 225 content = self.memworkingcopy[path]
225 226 else:
226 227 content = fctx.data()
227 228 mode = (fctx.islink(), fctx.isexec())
228 229 renamed = fctx.renamed() # False or (path, node)
229 230 return content, mode, (renamed and renamed[0])
230 231
231 232 def overlaycontext(memworkingcopy, ctx, parents=None, extra=None):
232 233 """({path: content}, ctx, (p1node, p2node)?, {}?) -> memctx
233 234 memworkingcopy overrides file contents.
234 235 """
235 236 # parents must contain 2 items: (node1, node2)
236 237 if parents is None:
237 238 parents = ctx.repo().changelog.parents(ctx.node())
238 239 if extra is None:
239 240 extra = ctx.extra()
240 241 date = ctx.date()
241 242 desc = ctx.description()
242 243 user = ctx.user()
243 244 files = set(ctx.files()).union(memworkingcopy.iterkeys())
244 245 store = overlaystore(ctx, memworkingcopy)
245 246 return context.memctx(
246 247 repo=ctx.repo(), parents=parents, text=desc,
247 248 files=files, filectxfn=store, user=user, date=date,
248 249 branch=None, extra=extra)
249 250
250 251 class filefixupstate(object):
251 252 """state needed to apply fixups to a single file
252 253
253 254 internally, it keeps file contents of several revisions and a linelog.
254 255
255 256 the linelog uses odd revision numbers for original contents (fctxs passed
256 257 to __init__), and even revision numbers for fixups, like:
257 258
258 259 linelog rev 1: self.fctxs[0] (from an immutable "public" changeset)
259 260 linelog rev 2: fixups made to self.fctxs[0]
260 261 linelog rev 3: self.fctxs[1] (a child of fctxs[0])
261 262 linelog rev 4: fixups made to self.fctxs[1]
262 263 ...
263 264
264 265 a typical use is like:
265 266
266 267 1. call diffwith, to calculate self.fixups
267 268 2. (optionally), present self.fixups to the user, or change it
268 269 3. call apply, to apply changes
269 270 4. read results from "finalcontents", or call getfinalcontent
270 271 """
271 272
272 273 def __init__(self, fctxs, ui=None, opts=None):
273 274 """([fctx], ui or None) -> None
274 275
275 276 fctxs should be linear, and sorted by topo order - oldest first.
276 277 fctxs[0] will be considered as "immutable" and will not be changed.
277 278 """
278 279 self.fctxs = fctxs
279 280 self.ui = ui or nullui()
280 281 self.opts = opts or {}
281 282
282 283 # following fields are built from fctxs. they exist for perf reason
283 284 self.contents = [f.data() for f in fctxs]
284 285 self.contentlines = map(mdiff.splitnewlines, self.contents)
285 286 self.linelog = self._buildlinelog()
286 287 if self.ui.debugflag:
287 288 assert self._checkoutlinelog() == self.contents
288 289
289 290 # following fields will be filled later
290 291 self.chunkstats = [0, 0] # [adopted, total : int]
291 292 self.targetlines = [] # [str]
292 293 self.fixups = [] # [(linelog rev, a1, a2, b1, b2)]
293 294 self.finalcontents = [] # [str]
294 295
295 296 def diffwith(self, targetfctx, showchanges=False):
296 297 """calculate fixups needed by examining the differences between
297 298 self.fctxs[-1] and targetfctx, chunk by chunk.
298 299
299 300 targetfctx is the target state we move towards. we may or may not be
300 301 able to get there because not all modified chunks can be amended into
301 302 a non-public fctx unambiguously.
302 303
303 304 call this only once, before apply().
304 305
305 306 update self.fixups, self.chunkstats, and self.targetlines.
306 307 """
307 308 a = self.contents[-1]
308 309 alines = self.contentlines[-1]
309 310 b = targetfctx.data()
310 311 blines = mdiff.splitnewlines(b)
311 312 self.targetlines = blines
312 313
313 314 self.linelog.annotate(self.linelog.maxrev)
314 315 annotated = self.linelog.annotateresult # [(linelog rev, linenum)]
315 316 assert len(annotated) == len(alines)
316 317 # add a dummy end line to make insertion at the end easier
317 318 if annotated:
318 319 dummyendline = (annotated[-1][0], annotated[-1][1] + 1)
319 320 annotated.append(dummyendline)
320 321
321 322 # analyse diff blocks
322 323 for chunk in self._alldiffchunks(a, b, alines, blines):
323 324 newfixups = self._analysediffchunk(chunk, annotated)
324 325 self.chunkstats[0] += bool(newfixups) # 1 or 0
325 326 self.chunkstats[1] += 1
326 327 self.fixups += newfixups
327 328 if showchanges:
328 329 self._showchanges(alines, blines, chunk, newfixups)
329 330
330 331 def apply(self):
331 332 """apply self.fixups. update self.linelog, self.finalcontents.
332 333
333 334 call this only once, before getfinalcontent(), after diffwith().
334 335 """
335 336 # the following is unnecessary, as it's done by "diffwith":
336 337 # self.linelog.annotate(self.linelog.maxrev)
337 338 for rev, a1, a2, b1, b2 in reversed(self.fixups):
338 339 blines = self.targetlines[b1:b2]
339 340 if self.ui.debugflag:
340 341 idx = (max(rev - 1, 0)) // 2
341 342 self.ui.write(_('%s: chunk %d:%d -> %d lines\n')
342 343 % (node.short(self.fctxs[idx].node()),
343 344 a1, a2, len(blines)))
344 345 self.linelog.replacelines(rev, a1, a2, b1, b2)
345 346 if self.opts.get('edit_lines', False):
346 347 self.finalcontents = self._checkoutlinelogwithedits()
347 348 else:
348 349 self.finalcontents = self._checkoutlinelog()
349 350
350 351 def getfinalcontent(self, fctx):
351 352 """(fctx) -> str. get modified file content for a given filecontext"""
352 353 idx = self.fctxs.index(fctx)
353 354 return self.finalcontents[idx]
354 355
355 356 def _analysediffchunk(self, chunk, annotated):
356 357 """analyse a different chunk and return new fixups found
357 358
358 359 return [] if no lines from the chunk can be safely applied.
359 360
360 361 the chunk (or lines) cannot be safely applied, if, for example:
361 362 - the modified (deleted) lines belong to a public changeset
362 363 (self.fctxs[0])
363 364 - the chunk is a pure insertion and the adjacent lines (at most 2
364 365 lines) belong to different non-public changesets, or do not belong
365 366 to any non-public changesets.
366 367 - the chunk is modifying lines from different changesets.
367 368 in this case, if the number of lines deleted equals to the number
368 369 of lines added, assume it's a simple 1:1 map (could be wrong).
369 370 otherwise, give up.
370 371 - the chunk is modifying lines from a single non-public changeset,
371 372 but other revisions touch the area as well. i.e. the lines are
372 373 not continuous as seen from the linelog.
373 374 """
374 375 a1, a2, b1, b2 = chunk
375 376 # find involved indexes from annotate result
376 377 involved = annotated[a1:a2]
377 378 if not involved and annotated: # a1 == a2 and a is not empty
378 379 # pure insertion, check nearby lines. ignore lines belong
379 380 # to the public (first) changeset (i.e. annotated[i][0] == 1)
380 381 nearbylinenums = {a2, max(0, a1 - 1)}
381 382 involved = [annotated[i]
382 383 for i in nearbylinenums if annotated[i][0] != 1]
383 384 involvedrevs = list(set(r for r, l in involved))
384 385 newfixups = []
385 386 if len(involvedrevs) == 1 and self._iscontinuous(a1, a2 - 1, True):
386 387 # chunk belongs to a single revision
387 388 rev = involvedrevs[0]
388 389 if rev > 1:
389 390 fixuprev = rev + 1
390 391 newfixups.append((fixuprev, a1, a2, b1, b2))
391 392 elif a2 - a1 == b2 - b1 or b1 == b2:
392 393 # 1:1 line mapping, or chunk was deleted
393 for i in xrange(a1, a2):
394 for i in pycompat.xrange(a1, a2):
394 395 rev, linenum = annotated[i]
395 396 if rev > 1:
396 397 if b1 == b2: # deletion, simply remove that single line
397 398 nb1 = nb2 = 0
398 399 else: # 1:1 line mapping, change the corresponding rev
399 400 nb1 = b1 + i - a1
400 401 nb2 = nb1 + 1
401 402 fixuprev = rev + 1
402 403 newfixups.append((fixuprev, i, i + 1, nb1, nb2))
403 404 return self._optimizefixups(newfixups)
404 405
405 406 @staticmethod
406 407 def _alldiffchunks(a, b, alines, blines):
407 408 """like mdiff.allblocks, but only care about differences"""
408 409 blocks = mdiff.allblocks(a, b, lines1=alines, lines2=blines)
409 410 for chunk, btype in blocks:
410 411 if btype != '!':
411 412 continue
412 413 yield chunk
413 414
414 415 def _buildlinelog(self):
415 416 """calculate the initial linelog based on self.content{,line}s.
416 417 this is similar to running a partial "annotate".
417 418 """
418 419 llog = linelog.linelog()
419 420 a, alines = '', []
420 for i in xrange(len(self.contents)):
421 for i in pycompat.xrange(len(self.contents)):
421 422 b, blines = self.contents[i], self.contentlines[i]
422 423 llrev = i * 2 + 1
423 424 chunks = self._alldiffchunks(a, b, alines, blines)
424 425 for a1, a2, b1, b2 in reversed(list(chunks)):
425 426 llog.replacelines(llrev, a1, a2, b1, b2)
426 427 a, alines = b, blines
427 428 return llog
428 429
429 430 def _checkoutlinelog(self):
430 431 """() -> [str]. check out file contents from linelog"""
431 432 contents = []
432 for i in xrange(len(self.contents)):
433 for i in pycompat.xrange(len(self.contents)):
433 434 rev = (i + 1) * 2
434 435 self.linelog.annotate(rev)
435 436 content = ''.join(map(self._getline, self.linelog.annotateresult))
436 437 contents.append(content)
437 438 return contents
438 439
439 440 def _checkoutlinelogwithedits(self):
440 441 """() -> [str]. prompt all lines for edit"""
441 442 alllines = self.linelog.getalllines()
442 443 # header
443 444 editortext = (_('HG: editing %s\nHG: "y" means the line to the right '
444 445 'exists in the changeset to the top\nHG:\n')
445 446 % self.fctxs[-1].path())
446 447 # [(idx, fctx)]. hide the dummy emptyfilecontext
447 448 visiblefctxs = [(i, f)
448 449 for i, f in enumerate(self.fctxs)
449 450 if not isinstance(f, emptyfilecontext)]
450 451 for i, (j, f) in enumerate(visiblefctxs):
451 452 editortext += (_('HG: %s/%s %s %s\n') %
452 453 ('|' * i, '-' * (len(visiblefctxs) - i + 1),
453 454 node.short(f.node()),
454 455 f.description().split('\n',1)[0]))
455 456 editortext += _('HG: %s\n') % ('|' * len(visiblefctxs))
456 457 # figure out the lifetime of a line, this is relatively inefficient,
457 458 # but probably fine
458 459 lineset = defaultdict(lambda: set()) # {(llrev, linenum): {llrev}}
459 460 for i, f in visiblefctxs:
460 461 self.linelog.annotate((i + 1) * 2)
461 462 for l in self.linelog.annotateresult:
462 463 lineset[l].add(i)
463 464 # append lines
464 465 for l in alllines:
465 466 editortext += (' %s : %s' %
466 467 (''.join([('y' if i in lineset[l] else ' ')
467 468 for i, _f in visiblefctxs]),
468 469 self._getline(l)))
469 470 # run editor
470 471 editedtext = self.ui.edit(editortext, '', action='absorb')
471 472 if not editedtext:
472 473 raise error.Abort(_('empty editor text'))
473 474 # parse edited result
474 475 contents = ['' for i in self.fctxs]
475 476 leftpadpos = 4
476 477 colonpos = leftpadpos + len(visiblefctxs) + 1
477 478 for l in mdiff.splitnewlines(editedtext):
478 479 if l.startswith('HG:'):
479 480 continue
480 481 if l[colonpos - 1:colonpos + 2] != ' : ':
481 482 raise error.Abort(_('malformed line: %s') % l)
482 483 linecontent = l[colonpos + 2:]
483 484 for i, ch in enumerate(l[leftpadpos:colonpos - 1]):
484 485 if ch == 'y':
485 486 contents[visiblefctxs[i][0]] += linecontent
486 487 # chunkstats is hard to calculate if anything changes, therefore
487 488 # set them to just a simple value (1, 1).
488 489 if editedtext != editortext:
489 490 self.chunkstats = [1, 1]
490 491 return contents
491 492
492 493 def _getline(self, lineinfo):
493 494 """((rev, linenum)) -> str. convert rev+line number to line content"""
494 495 rev, linenum = lineinfo
495 496 if rev & 1: # odd: original line taken from fctxs
496 497 return self.contentlines[rev // 2][linenum]
497 498 else: # even: fixup line from targetfctx
498 499 return self.targetlines[linenum]
499 500
500 501 def _iscontinuous(self, a1, a2, closedinterval=False):
501 502 """(a1, a2 : int) -> bool
502 503
503 504 check if these lines are continuous. i.e. no other insertions or
504 505 deletions (from other revisions) among these lines.
505 506
506 507 closedinterval decides whether a2 should be included or not. i.e. is
507 508 it [a1, a2), or [a1, a2] ?
508 509 """
509 510 if a1 >= a2:
510 511 return True
511 512 llog = self.linelog
512 513 offset1 = llog.getoffset(a1)
513 514 offset2 = llog.getoffset(a2) + int(closedinterval)
514 515 linesinbetween = llog.getalllines(offset1, offset2)
515 516 return len(linesinbetween) == a2 - a1 + int(closedinterval)
516 517
517 518 def _optimizefixups(self, fixups):
518 519 """[(rev, a1, a2, b1, b2)] -> [(rev, a1, a2, b1, b2)].
519 520 merge adjacent fixups to make them less fragmented.
520 521 """
521 522 result = []
522 523 pcurrentchunk = [[-1, -1, -1, -1, -1]]
523 524
524 525 def pushchunk():
525 526 if pcurrentchunk[0][0] != -1:
526 527 result.append(tuple(pcurrentchunk[0]))
527 528
528 529 for i, chunk in enumerate(fixups):
529 530 rev, a1, a2, b1, b2 = chunk
530 531 lastrev = pcurrentchunk[0][0]
531 532 lasta2 = pcurrentchunk[0][2]
532 533 lastb2 = pcurrentchunk[0][4]
533 534 if (a1 == lasta2 and b1 == lastb2 and rev == lastrev and
534 535 self._iscontinuous(max(a1 - 1, 0), a1)):
535 536 # merge into currentchunk
536 537 pcurrentchunk[0][2] = a2
537 538 pcurrentchunk[0][4] = b2
538 539 else:
539 540 pushchunk()
540 541 pcurrentchunk[0] = list(chunk)
541 542 pushchunk()
542 543 return result
543 544
544 545 def _showchanges(self, alines, blines, chunk, fixups):
545 546 ui = self.ui
546 547
547 548 def label(line, label):
548 549 if line.endswith('\n'):
549 550 line = line[:-1]
550 551 return ui.label(line, label)
551 552
552 553 # this is not optimized for perf but _showchanges only gets executed
553 554 # with an extra command-line flag.
554 555 a1, a2, b1, b2 = chunk
555 556 aidxs, bidxs = [0] * (a2 - a1), [0] * (b2 - b1)
556 557 for idx, fa1, fa2, fb1, fb2 in fixups:
557 for i in xrange(fa1, fa2):
558 for i in pycompat.xrange(fa1, fa2):
558 559 aidxs[i - a1] = (max(idx, 1) - 1) // 2
559 for i in xrange(fb1, fb2):
560 for i in pycompat.xrange(fb1, fb2):
560 561 bidxs[i - b1] = (max(idx, 1) - 1) // 2
561 562
562 563 buf = [] # [(idx, content)]
563 564 buf.append((0, label('@@ -%d,%d +%d,%d @@'
564 565 % (a1, a2 - a1, b1, b2 - b1), 'diff.hunk')))
565 566 buf += [(aidxs[i - a1], label('-' + alines[i], 'diff.deleted'))
566 for i in xrange(a1, a2)]
567 for i in pycompat.xrange(a1, a2)]
567 568 buf += [(bidxs[i - b1], label('+' + blines[i], 'diff.inserted'))
568 for i in xrange(b1, b2)]
569 for i in pycompat.xrange(b1, b2)]
569 570 for idx, line in buf:
570 571 shortnode = idx and node.short(self.fctxs[idx].node()) or ''
571 572 ui.write(ui.label(shortnode[0:7].ljust(8), 'absorb.node') +
572 573 line + '\n')
573 574
574 575 class fixupstate(object):
575 576 """state needed to run absorb
576 577
577 578 internally, it keeps paths and filefixupstates.
578 579
579 580 a typical use is like filefixupstates:
580 581
581 582 1. call diffwith, to calculate fixups
582 583 2. (optionally), present fixups to the user, or edit fixups
583 584 3. call apply, to apply changes to memory
584 585 4. call commit, to commit changes to hg database
585 586 """
586 587
587 588 def __init__(self, stack, ui=None, opts=None):
588 589 """([ctx], ui or None) -> None
589 590
590 591 stack: should be linear, and sorted by topo order - oldest first.
591 592 all commits in stack are considered mutable.
592 593 """
593 594 assert stack
594 595 self.ui = ui or nullui()
595 596 self.opts = opts or {}
596 597 self.stack = stack
597 598 self.repo = stack[-1].repo().unfiltered()
598 599
599 600 # following fields will be filled later
600 601 self.paths = [] # [str]
601 602 self.status = None # ctx.status output
602 603 self.fctxmap = {} # {path: {ctx: fctx}}
603 604 self.fixupmap = {} # {path: filefixupstate}
604 605 self.replacemap = {} # {oldnode: newnode or None}
605 606 self.finalnode = None # head after all fixups
606 607
607 608 def diffwith(self, targetctx, match=None, showchanges=False):
608 609 """diff and prepare fixups. update self.fixupmap, self.paths"""
609 610 # only care about modified files
610 611 self.status = self.stack[-1].status(targetctx, match)
611 612 self.paths = []
612 613 # but if --edit-lines is used, the user may want to edit files
613 614 # even if they are not modified
614 615 editopt = self.opts.get('edit_lines')
615 616 if not self.status.modified and editopt and match:
616 617 interestingpaths = match.files()
617 618 else:
618 619 interestingpaths = self.status.modified
619 620 # prepare the filefixupstate
620 621 seenfctxs = set()
621 622 # sorting is necessary to eliminate ambiguity for the "double move"
622 623 # case: "hg cp A B; hg cp A C; hg rm A", then only "B" can affect "A".
623 624 for path in sorted(interestingpaths):
624 625 if self.ui.debugflag:
625 626 self.ui.write(_('calculating fixups for %s\n') % path)
626 627 targetfctx = targetctx[path]
627 628 fctxs, ctx2fctx = getfilestack(self.stack, path, seenfctxs)
628 629 # ignore symbolic links or binary, or unchanged files
629 630 if any(f.islink() or stringutil.binary(f.data())
630 631 for f in [targetfctx] + fctxs
631 632 if not isinstance(f, emptyfilecontext)):
632 633 continue
633 634 if targetfctx.data() == fctxs[-1].data() and not editopt:
634 635 continue
635 636 seenfctxs.update(fctxs[1:])
636 637 self.fctxmap[path] = ctx2fctx
637 638 fstate = filefixupstate(fctxs, ui=self.ui, opts=self.opts)
638 639 if showchanges:
639 640 colorpath = self.ui.label(path, 'absorb.path')
640 641 header = 'showing changes for ' + colorpath
641 642 self.ui.write(header + '\n')
642 643 fstate.diffwith(targetfctx, showchanges=showchanges)
643 644 self.fixupmap[path] = fstate
644 645 self.paths.append(path)
645 646
646 647 def apply(self):
647 648 """apply fixups to individual filefixupstates"""
648 649 for path, state in self.fixupmap.iteritems():
649 650 if self.ui.debugflag:
650 651 self.ui.write(_('applying fixups to %s\n') % path)
651 652 state.apply()
652 653
653 654 @property
654 655 def chunkstats(self):
655 656 """-> {path: chunkstats}. collect chunkstats from filefixupstates"""
656 657 return dict((path, state.chunkstats)
657 658 for path, state in self.fixupmap.iteritems())
658 659
659 660 def commit(self):
660 661 """commit changes. update self.finalnode, self.replacemap"""
661 662 with self.repo.wlock(), self.repo.lock():
662 663 with self.repo.transaction('absorb') as tr:
663 664 self._commitstack()
664 665 self._movebookmarks(tr)
665 666 if self.repo['.'].node() in self.replacemap:
666 667 self._moveworkingdirectoryparent()
667 668 if self._useobsolete:
668 669 self._obsoleteoldcommits()
669 670 if not self._useobsolete: # strip must be outside transactions
670 671 self._stripoldcommits()
671 672 return self.finalnode
672 673
673 674 def printchunkstats(self):
674 675 """print things like '1 of 2 chunk(s) applied'"""
675 676 ui = self.ui
676 677 chunkstats = self.chunkstats
677 678 if ui.verbose:
678 679 # chunkstats for each file
679 680 for path, stat in chunkstats.iteritems():
680 681 if stat[0]:
681 682 ui.write(_('%s: %d of %d chunk(s) applied\n')
682 683 % (path, stat[0], stat[1]))
683 684 elif not ui.quiet:
684 685 # a summary for all files
685 686 stats = chunkstats.values()
686 687 applied, total = (sum(s[i] for s in stats) for i in (0, 1))
687 688 ui.write(_('%d of %d chunk(s) applied\n') % (applied, total))
688 689
689 690 def _commitstack(self):
690 691 """make new commits. update self.finalnode, self.replacemap.
691 692 it is splitted from "commit" to avoid too much indentation.
692 693 """
693 694 # last node (20-char) committed by us
694 695 lastcommitted = None
695 696 # p1 which overrides the parent of the next commit, "None" means use
696 697 # the original parent unchanged
697 698 nextp1 = None
698 699 for ctx in self.stack:
699 700 memworkingcopy = self._getnewfilecontents(ctx)
700 701 if not memworkingcopy and not lastcommitted:
701 702 # nothing changed, nothing commited
702 703 nextp1 = ctx
703 704 continue
704 705 msg = ''
705 706 if self._willbecomenoop(memworkingcopy, ctx, nextp1):
706 707 # changeset is no longer necessary
707 708 self.replacemap[ctx.node()] = None
708 709 msg = _('became empty and was dropped')
709 710 else:
710 711 # changeset needs re-commit
711 712 nodestr = self._commitsingle(memworkingcopy, ctx, p1=nextp1)
712 713 lastcommitted = self.repo[nodestr]
713 714 nextp1 = lastcommitted
714 715 self.replacemap[ctx.node()] = lastcommitted.node()
715 716 if memworkingcopy:
716 717 msg = _('%d file(s) changed, became %s') % (
717 718 len(memworkingcopy), self._ctx2str(lastcommitted))
718 719 else:
719 720 msg = _('became %s') % self._ctx2str(lastcommitted)
720 721 if self.ui.verbose and msg:
721 722 self.ui.write(_('%s: %s\n') % (self._ctx2str(ctx), msg))
722 723 self.finalnode = lastcommitted and lastcommitted.node()
723 724
724 725 def _ctx2str(self, ctx):
725 726 if self.ui.debugflag:
726 727 return ctx.hex()
727 728 else:
728 729 return node.short(ctx.node())
729 730
730 731 def _getnewfilecontents(self, ctx):
731 732 """(ctx) -> {path: str}
732 733
733 734 fetch file contents from filefixupstates.
734 735 return the working copy overrides - files different from ctx.
735 736 """
736 737 result = {}
737 738 for path in self.paths:
738 739 ctx2fctx = self.fctxmap[path] # {ctx: fctx}
739 740 if ctx not in ctx2fctx:
740 741 continue
741 742 fctx = ctx2fctx[ctx]
742 743 content = fctx.data()
743 744 newcontent = self.fixupmap[path].getfinalcontent(fctx)
744 745 if content != newcontent:
745 746 result[fctx.path()] = newcontent
746 747 return result
747 748
748 749 def _movebookmarks(self, tr):
749 750 repo = self.repo
750 751 needupdate = [(name, self.replacemap[hsh])
751 752 for name, hsh in repo._bookmarks.iteritems()
752 753 if hsh in self.replacemap]
753 754 changes = []
754 755 for name, hsh in needupdate:
755 756 if hsh:
756 757 changes.append((name, hsh))
757 758 if self.ui.verbose:
758 759 self.ui.write(_('moving bookmark %s to %s\n')
759 760 % (name, node.hex(hsh)))
760 761 else:
761 762 changes.append((name, None))
762 763 if self.ui.verbose:
763 764 self.ui.write(_('deleting bookmark %s\n') % name)
764 765 repo._bookmarks.applychanges(repo, tr, changes)
765 766
766 767 def _moveworkingdirectoryparent(self):
767 768 if not self.finalnode:
768 769 # Find the latest not-{obsoleted,stripped} parent.
769 770 revs = self.repo.revs('max(::. - %ln)', self.replacemap.keys())
770 771 ctx = self.repo[revs.first()]
771 772 self.finalnode = ctx.node()
772 773 else:
773 774 ctx = self.repo[self.finalnode]
774 775
775 776 dirstate = self.repo.dirstate
776 777 # dirstate.rebuild invalidates fsmonitorstate, causing "hg status" to
777 778 # be slow. in absorb's case, no need to invalidate fsmonitorstate.
778 779 noop = lambda: 0
779 780 restore = noop
780 781 if util.safehasattr(dirstate, '_fsmonitorstate'):
781 782 bak = dirstate._fsmonitorstate.invalidate
782 783 def restore():
783 784 dirstate._fsmonitorstate.invalidate = bak
784 785 dirstate._fsmonitorstate.invalidate = noop
785 786 try:
786 787 with dirstate.parentchange():
787 788 dirstate.rebuild(ctx.node(), ctx.manifest(), self.paths)
788 789 finally:
789 790 restore()
790 791
791 792 @staticmethod
792 793 def _willbecomenoop(memworkingcopy, ctx, pctx=None):
793 794 """({path: content}, ctx, ctx) -> bool. test if a commit will be noop
794 795
795 796 if it will become an empty commit (does not change anything, after the
796 797 memworkingcopy overrides), return True. otherwise return False.
797 798 """
798 799 if not pctx:
799 800 parents = ctx.parents()
800 801 if len(parents) != 1:
801 802 return False
802 803 pctx = parents[0]
803 804 # ctx changes more files (not a subset of memworkingcopy)
804 805 if not set(ctx.files()).issubset(set(memworkingcopy.iterkeys())):
805 806 return False
806 807 for path, content in memworkingcopy.iteritems():
807 808 if path not in pctx or path not in ctx:
808 809 return False
809 810 fctx = ctx[path]
810 811 pfctx = pctx[path]
811 812 if pfctx.flags() != fctx.flags():
812 813 return False
813 814 if pfctx.data() != content:
814 815 return False
815 816 return True
816 817
817 818 def _commitsingle(self, memworkingcopy, ctx, p1=None):
818 819 """(ctx, {path: content}, node) -> node. make a single commit
819 820
820 821 the commit is a clone from ctx, with a (optionally) different p1, and
821 822 different file contents replaced by memworkingcopy.
822 823 """
823 824 parents = p1 and (p1, node.nullid)
824 825 extra = ctx.extra()
825 826 if self._useobsolete and self.ui.configbool('absorb', 'addnoise'):
826 827 extra['absorb_source'] = ctx.hex()
827 828 mctx = overlaycontext(memworkingcopy, ctx, parents, extra=extra)
828 829 # preserve phase
829 830 with mctx.repo().ui.configoverride({
830 831 ('phases', 'new-commit'): ctx.phase()}):
831 832 return mctx.commit()
832 833
833 834 @util.propertycache
834 835 def _useobsolete(self):
835 836 """() -> bool"""
836 837 return obsolete.isenabled(self.repo, obsolete.createmarkersopt)
837 838
838 839 def _obsoleteoldcommits(self):
839 840 relations = [(self.repo[k], v and (self.repo[v],) or ())
840 841 for k, v in self.replacemap.iteritems()]
841 842 if relations:
842 843 obsolete.createmarkers(self.repo, relations)
843 844
844 845 def _stripoldcommits(self):
845 846 nodelist = self.replacemap.keys()
846 847 # make sure we don't strip innocent children
847 848 revs = self.repo.revs('%ln - (::(heads(%ln::)-%ln))', nodelist,
848 849 nodelist, nodelist)
849 850 tonode = self.repo.changelog.node
850 851 nodelist = [tonode(r) for r in revs]
851 852 if nodelist:
852 853 repair.strip(self.repo.ui, self.repo, nodelist)
853 854
854 855 def _parsechunk(hunk):
855 856 """(crecord.uihunk or patch.recordhunk) -> (path, (a1, a2, [bline]))"""
856 857 if type(hunk) not in (crecord.uihunk, patch.recordhunk):
857 858 return None, None
858 859 path = hunk.header.filename()
859 860 a1 = hunk.fromline + len(hunk.before) - 1
860 861 # remove before and after context
861 862 hunk.before = hunk.after = []
862 863 buf = util.stringio()
863 864 hunk.write(buf)
864 865 patchlines = mdiff.splitnewlines(buf.getvalue())
865 866 # hunk.prettystr() will update hunk.removed
866 867 a2 = a1 + hunk.removed
867 868 blines = [l[1:] for l in patchlines[1:] if l[0] != '-']
868 869 return path, (a1, a2, blines)
869 870
870 871 def overlaydiffcontext(ctx, chunks):
871 872 """(ctx, [crecord.uihunk]) -> memctx
872 873
873 874 return a memctx with some [1] patches (chunks) applied to ctx.
874 875 [1]: modifications are handled. renames, mode changes, etc. are ignored.
875 876 """
876 877 # sadly the applying-patch logic is hardly reusable, and messy:
877 878 # 1. the core logic "_applydiff" is too heavy - it writes .rej files, it
878 879 # needs a file stream of a patch and will re-parse it, while we have
879 880 # structured hunk objects at hand.
880 881 # 2. a lot of different implementations about "chunk" (patch.hunk,
881 882 # patch.recordhunk, crecord.uihunk)
882 883 # as we only care about applying changes to modified files, no mode
883 884 # change, no binary diff, and no renames, it's probably okay to
884 885 # re-invent the logic using much simpler code here.
885 886 memworkingcopy = {} # {path: content}
886 887 patchmap = defaultdict(lambda: []) # {path: [(a1, a2, [bline])]}
887 888 for path, info in map(_parsechunk, chunks):
888 889 if not path or not info:
889 890 continue
890 891 patchmap[path].append(info)
891 892 for path, patches in patchmap.iteritems():
892 893 if path not in ctx or not patches:
893 894 continue
894 895 patches.sort(reverse=True)
895 896 lines = mdiff.splitnewlines(ctx[path].data())
896 897 for a1, a2, blines in patches:
897 898 lines[a1:a2] = blines
898 899 memworkingcopy[path] = ''.join(lines)
899 900 return overlaycontext(memworkingcopy, ctx)
900 901
901 902 def absorb(ui, repo, stack=None, targetctx=None, pats=None, opts=None):
902 903 """pick fixup chunks from targetctx, apply them to stack.
903 904
904 905 if targetctx is None, the working copy context will be used.
905 906 if stack is None, the current draft stack will be used.
906 907 return fixupstate.
907 908 """
908 909 if stack is None:
909 910 limit = ui.configint('absorb', 'maxstacksize')
910 911 stack = getdraftstack(repo['.'], limit)
911 912 if limit and len(stack) >= limit:
912 913 ui.warn(_('absorb: only the recent %d changesets will '
913 914 'be analysed\n')
914 915 % limit)
915 916 if not stack:
916 917 raise error.Abort(_('no changeset to change'))
917 918 if targetctx is None: # default to working copy
918 919 targetctx = repo[None]
919 920 if pats is None:
920 921 pats = ()
921 922 if opts is None:
922 923 opts = {}
923 924 state = fixupstate(stack, ui=ui, opts=opts)
924 925 matcher = scmutil.match(targetctx, pats, opts)
925 926 if opts.get('interactive'):
926 927 diff = patch.diff(repo, stack[-1].node(), targetctx.node(), matcher)
927 928 origchunks = patch.parsepatch(diff)
928 929 chunks = cmdutil.recordfilter(ui, origchunks)[0]
929 930 targetctx = overlaydiffcontext(stack[-1], chunks)
930 931 state.diffwith(targetctx, matcher, showchanges=opts.get('print_changes'))
931 932 if not opts.get('dry_run'):
932 933 state.apply()
933 934 if state.commit():
934 935 state.printchunkstats()
935 936 elif not ui.quiet:
936 937 ui.write(_('nothing applied\n'))
937 938 return state
938 939
939 940 @command('^absorb|sf',
940 941 [('p', 'print-changes', None,
941 942 _('print which changesets are modified by which changes')),
942 943 ('i', 'interactive', None,
943 944 _('interactively select which chunks to apply (EXPERIMENTAL)')),
944 945 ('e', 'edit-lines', None,
945 946 _('edit what lines belong to which changesets before commit '
946 947 '(EXPERIMENTAL)')),
947 948 ] + commands.dryrunopts + commands.walkopts,
948 949 _('hg absorb [OPTION] [FILE]...'))
949 950 def absorbcmd(ui, repo, *pats, **opts):
950 951 """incorporate corrections into the stack of draft changesets
951 952
952 953 absorb analyzes each change in your working directory and attempts to
953 954 amend the changed lines into the changesets in your stack that first
954 955 introduced those lines.
955 956
956 957 If absorb cannot find an unambiguous changeset to amend for a change,
957 958 that change will be left in the working directory, untouched. They can be
958 959 observed by :hg:`status` or :hg:`diff` afterwards. In other words,
959 960 absorb does not write to the working directory.
960 961
961 962 Changesets outside the revset `::. and not public() and not merge()` will
962 963 not be changed.
963 964
964 965 Changesets that become empty after applying the changes will be deleted.
965 966
966 967 If in doubt, run :hg:`absorb -pn` to preview what changesets will
967 968 be amended by what changed lines, without actually changing anything.
968 969
969 970 Returns 0 on success, 1 if all chunks were ignored and nothing amended.
970 971 """
971 972 state = absorb(ui, repo, pats=pats, opts=opts)
972 973 if sum(s[0] for s in state.chunkstats.values()) == 0:
973 974 return 1
974 975
975 976 def _wrapamend(flag):
976 977 """add flag to amend, which will be a shortcut to the absorb command"""
977 978 if not flag:
978 979 return
979 980 amendcmd = extensions.bind(_amendcmd, flag)
980 981 # the amend command can exist in evolve, or fbamend
981 982 for extname in ['evolve', 'fbamend', None]:
982 983 try:
983 984 if extname is None:
984 985 cmdtable = commands.table
985 986 else:
986 987 ext = extensions.find(extname)
987 988 cmdtable = ext.cmdtable
988 989 except (KeyError, AttributeError):
989 990 continue
990 991 try:
991 992 entry = extensions.wrapcommand(cmdtable, 'amend', amendcmd)
992 993 options = entry[1]
993 994 msg = _('incorporate corrections into stack. '
994 995 'see \'hg help absorb\' for details')
995 996 options.append(('', flag, None, msg))
996 997 return
997 998 except error.UnknownCommand:
998 999 pass
999 1000
1000 1001 def _amendcmd(flag, orig, ui, repo, *pats, **opts):
1001 1002 if not opts.get(flag):
1002 1003 return orig(ui, repo, *pats, **opts)
1003 1004 # use absorb
1004 1005 for k, v in opts.iteritems(): # check unsupported flags
1005 1006 if v and k not in ['interactive', flag]:
1006 1007 raise error.Abort(_('--%s does not support --%s')
1007 1008 % (flag, k.replace('_', '-')))
1008 1009 state = absorb(ui, repo, pats=pats, opts=opts)
1009 1010 # different from the original absorb, tell users what chunks were
1010 1011 # ignored and were left. it's because users usually expect "amend" to
1011 1012 # take all of their changes and will feel strange otherwise.
1012 1013 # the original "absorb" command faces more-advanced users knowing
1013 1014 # what's going on and is less verbose.
1014 1015 adoptedsum = 0
1015 1016 messages = []
1016 1017 for path, (adopted, total) in state.chunkstats.iteritems():
1017 1018 adoptedsum += adopted
1018 1019 if adopted == total:
1019 1020 continue
1020 1021 reason = _('%d modified chunks were ignored') % (total - adopted)
1021 1022 messages.append(('M', 'modified', path, reason))
1022 1023 for idx, word, symbol in [(0, 'modified', 'M'), (1, 'added', 'A'),
1023 1024 (2, 'removed', 'R'), (3, 'deleted', '!')]:
1024 1025 paths = set(state.status[idx]) - set(state.paths)
1025 1026 for path in sorted(paths):
1026 1027 if word == 'modified':
1027 1028 reason = _('unsupported file type (ex. binary or link)')
1028 1029 else:
1029 1030 reason = _('%s files were ignored') % word
1030 1031 messages.append((symbol, word, path, reason))
1031 1032 if messages:
1032 1033 ui.write(_('\n# changes not applied and left in '
1033 1034 'working directory:\n'))
1034 1035 for symbol, word, path, reason in messages:
1035 1036 ui.write(_('# %s %s : %s\n') % (
1036 1037 ui.label(symbol, 'status.' + word),
1037 1038 ui.label(path, 'status.' + word), reason))
1038 1039
1039 1040 if adoptedsum == 0:
1040 1041 return 1
1041 1042
1042 1043 def extsetup(ui):
1043 1044 _wrapamend(ui.config('absorb', 'amendflag'))
General Comments 0
You need to be logged in to leave comments. Login now