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