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