absorb.py
1147 lines
| 40.5 KiB
| text/x-python
|
PythonLexer
/ hgext / absorb.py
|
r38953 | # absorb.py | ||
# | ||||
# Copyright 2016 Facebook, Inc. | ||||
# | ||||
# This software may be used and distributed according to the terms of the | ||||
# GNU General Public License version 2 or any later version. | ||||
"""apply working directory changes to changesets (EXPERIMENTAL) | ||||
The absorb extension provides a command to use annotate information to | ||||
amend modified chunks into the corresponding non-public changesets. | ||||
:: | ||||
[absorb] | ||||
# only check 50 recent non-public changesets at most | ||||
|
r38997 | max-stack-size = 50 | ||
|
r38953 | # whether to add noise to new commits to avoid obsolescence cycle | ||
|
r38997 | add-noise = 1 | ||
|
r38953 | # make `amend --correlated` a shortcut to the main command | ||
|
r38997 | amend-flag = correlated | ||
|
r38953 | |||
[color] | ||||
|
r40224 | absorb.description = yellow | ||
|
r38953 | absorb.node = blue bold | ||
absorb.path = bold | ||||
""" | ||||
|
r38958 | # TODO: | ||
# * Rename config items to [commands] namespace | ||||
# * Converge getdraftstack() with other code in core | ||||
# * move many attributes on fixupstate to be private | ||||
|
r38953 | from __future__ import absolute_import | ||
import collections | ||||
from mercurial.i18n import _ | ||||
from mercurial import ( | ||||
cmdutil, | ||||
commands, | ||||
context, | ||||
crecord, | ||||
error, | ||||
linelog, | ||||
mdiff, | ||||
node, | ||||
obsolete, | ||||
patch, | ||||
phases, | ||||
|
r38956 | pycompat, | ||
|
r38953 | registrar, | ||
|
r45684 | rewriteutil, | ||
|
r38953 | scmutil, | ||
util, | ||||
) | ||||
|
r43346 | from mercurial.utils import stringutil | ||
|
r38953 | |||
# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for | ||||
# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should | ||||
# be specifying the version(s) of Mercurial they are tested with, or | ||||
# leave the attribute unspecified. | ||||
|
r43347 | testedwith = b'ships-with-hg-core' | ||
|
r38953 | |||
cmdtable = {} | ||||
command = registrar.command(cmdtable) | ||||
configtable = {} | ||||
configitem = registrar.configitem(configtable) | ||||
|
r43347 | configitem(b'absorb', b'add-noise', default=True) | ||
configitem(b'absorb', b'amend-flag', default=None) | ||||
configitem(b'absorb', b'max-stack-size', default=50) | ||||
|
r38953 | |||
colortable = { | ||||
|
r43347 | b'absorb.description': b'yellow', | ||
b'absorb.node': b'blue bold', | ||||
b'absorb.path': b'bold', | ||||
|
r38953 | } | ||
defaultdict = collections.defaultdict | ||||
|
r43346 | |||
|
r38953 | class nullui(object): | ||
"""blank ui object doing nothing""" | ||||
|
r43346 | |||
|
r38953 | debugflag = False | ||
verbose = False | ||||
quiet = True | ||||
def __getitem__(name): | ||||
def nullfunc(*args, **kwds): | ||||
return | ||||
|
r43346 | |||
|
r38953 | return nullfunc | ||
|
r43346 | |||
|
r38953 | class emptyfilecontext(object): | ||
"""minimal filecontext representing an empty file""" | ||||
|
r43346 | |||
|
r38953 | def data(self): | ||
|
r43347 | return b'' | ||
|
r38953 | |||
def node(self): | ||||
return node.nullid | ||||
|
r43346 | |||
|
r38953 | def uniq(lst): | ||
"""list -> list. remove duplicated items without changing the order""" | ||||
seen = set() | ||||
result = [] | ||||
for x in lst: | ||||
if x not in seen: | ||||
seen.add(x) | ||||
result.append(x) | ||||
return result | ||||
|
r43346 | |||
|
r38953 | def getdraftstack(headctx, limit=None): | ||
"""(ctx, int?) -> [ctx]. get a linear stack of non-public changesets. | ||||
changesets are sorted in topo order, oldest first. | ||||
return at most limit items, if limit is a positive number. | ||||
merges are considered as non-draft as well. i.e. every commit | ||||
returned has and only has 1 parent. | ||||
""" | ||||
ctx = headctx | ||||
result = [] | ||||
while ctx.phase() != phases.public: | ||||
if limit and len(result) >= limit: | ||||
break | ||||
parents = ctx.parents() | ||||
if len(parents) != 1: | ||||
break | ||||
result.append(ctx) | ||||
ctx = parents[0] | ||||
result.reverse() | ||||
return result | ||||
|
r43346 | |||
|
r38954 | def getfilestack(stack, path, seenfctxs=None): | ||
|
r38953 | """([ctx], str, set) -> [fctx], {ctx: fctx} | ||
stack is a list of contexts, from old to new. usually they are what | ||||
"getdraftstack" returns. | ||||
follows renames, but not copies. | ||||
seenfctxs is a set of filecontexts that will be considered "immutable". | ||||
they are usually what this function returned in earlier calls, useful | ||||
to avoid issues that a file was "moved" to multiple places and was then | ||||
modified differently, like: "a" was copied to "b", "a" was also copied to | ||||
"c" and then "a" was deleted, then both "b" and "c" were "moved" from "a" | ||||
and we enforce only one of them to be able to affect "a"'s content. | ||||
return an empty list and an empty dict, if the specified path does not | ||||
exist in stack[-1] (the top of the stack). | ||||
otherwise, return a list of de-duplicated filecontexts, and the map to | ||||
convert ctx in the stack to fctx, for possible mutable fctxs. the first item | ||||
of the list would be outside the stack and should be considered immutable. | ||||
the remaining items are within the stack. | ||||
for example, given the following changelog and corresponding filelog | ||||
revisions: | ||||
changelog: 3----4----5----6----7 | ||||
filelog: x 0----1----1----2 (x: no such file yet) | ||||
- if stack = [5, 6, 7], returns ([0, 1, 2], {5: 1, 6: 1, 7: 2}) | ||||
- if stack = [3, 4, 5], returns ([e, 0, 1], {4: 0, 5: 1}), where "e" is a | ||||
dummy empty filecontext. | ||||
- if stack = [2], returns ([], {}) | ||||
- if stack = [7], returns ([1, 2], {7: 2}) | ||||
- if stack = [6, 7], returns ([1, 2], {6: 1, 7: 2}), although {6: 1} can be | ||||
removed, since 1 is immutable. | ||||
""" | ||||
|
r38954 | if seenfctxs is None: | ||
seenfctxs = set() | ||||
|
r38953 | assert stack | ||
if path not in stack[-1]: | ||||
return [], {} | ||||
fctxs = [] | ||||
fctxmap = {} | ||||
|
r43346 | pctx = stack[0].p1() # the public (immutable) ctx we stop at | ||
|
r38953 | for ctx in reversed(stack): | ||
|
r43346 | if path not in ctx: # the file is added in the next commit | ||
|
r38953 | pctx = ctx | ||
break | ||||
fctx = ctx[path] | ||||
fctxs.append(fctx) | ||||
|
r43346 | if fctx in seenfctxs: # treat fctx as the immutable one | ||
pctx = None # do not add another immutable fctx | ||||
|
r38953 | break | ||
|
r43346 | fctxmap[ctx] = fctx # only for mutable fctxs | ||
|
r41944 | copy = fctx.copysource() | ||
if copy: | ||||
|
r43346 | path = copy # follow rename | ||
if path in ctx: # but do not follow copy | ||||
|
r38953 | pctx = ctx.p1() | ||
break | ||||
|
r43346 | if pctx is not None: # need an extra immutable fctx | ||
|
r38953 | if path in pctx: | ||
fctxs.append(pctx[path]) | ||||
else: | ||||
fctxs.append(emptyfilecontext()) | ||||
fctxs.reverse() | ||||
# note: we rely on a property of hg: filerev is not reused for linear | ||||
# history. i.e. it's impossible to have: | ||||
# changelog: 4----5----6 (linear, no merges) | ||||
# filelog: 1----2----1 | ||||
# ^ reuse filerev (impossible) | ||||
# because parents are part of the hash. if that's not true, we need to | ||||
# remove uniq and find a different way to identify fctxs. | ||||
return uniq(fctxs), fctxmap | ||||
|
r43346 | |||
|
r38953 | class overlaystore(patch.filestore): | ||
"""read-only, hybrid store based on a dict and ctx. | ||||
memworkingcopy: {path: content}, overrides file contents. | ||||
""" | ||||
|
r43346 | |||
|
r38953 | def __init__(self, basectx, memworkingcopy): | ||
self.basectx = basectx | ||||
self.memworkingcopy = memworkingcopy | ||||
def getfile(self, path): | ||||
"""comply with mercurial.patch.filestore.getfile""" | ||||
if path not in self.basectx: | ||||
return None, None, None | ||||
fctx = self.basectx[path] | ||||
if path in self.memworkingcopy: | ||||
content = self.memworkingcopy[path] | ||||
else: | ||||
content = fctx.data() | ||||
mode = (fctx.islink(), fctx.isexec()) | ||||
|
r41944 | copy = fctx.copysource() | ||
return content, mode, copy | ||||
|
r38953 | |||
|
r43346 | |||
|
r38953 | def overlaycontext(memworkingcopy, ctx, parents=None, extra=None): | ||
"""({path: content}, ctx, (p1node, p2node)?, {}?) -> memctx | ||||
memworkingcopy overrides file contents. | ||||
""" | ||||
# parents must contain 2 items: (node1, node2) | ||||
if parents is None: | ||||
parents = ctx.repo().changelog.parents(ctx.node()) | ||||
if extra is None: | ||||
extra = ctx.extra() | ||||
date = ctx.date() | ||||
desc = ctx.description() | ||||
user = ctx.user() | ||||
|
r39023 | files = set(ctx.files()).union(memworkingcopy) | ||
|
r38953 | store = overlaystore(ctx, memworkingcopy) | ||
return context.memctx( | ||||
|
r43346 | repo=ctx.repo(), | ||
parents=parents, | ||||
text=desc, | ||||
files=files, | ||||
filectxfn=store, | ||||
user=user, | ||||
date=date, | ||||
branch=None, | ||||
extra=extra, | ||||
) | ||||
|
r38953 | |||
class filefixupstate(object): | ||||
"""state needed to apply fixups to a single file | ||||
internally, it keeps file contents of several revisions and a linelog. | ||||
the linelog uses odd revision numbers for original contents (fctxs passed | ||||
to __init__), and even revision numbers for fixups, like: | ||||
linelog rev 1: self.fctxs[0] (from an immutable "public" changeset) | ||||
linelog rev 2: fixups made to self.fctxs[0] | ||||
linelog rev 3: self.fctxs[1] (a child of fctxs[0]) | ||||
linelog rev 4: fixups made to self.fctxs[1] | ||||
... | ||||
a typical use is like: | ||||
1. call diffwith, to calculate self.fixups | ||||
2. (optionally), present self.fixups to the user, or change it | ||||
3. call apply, to apply changes | ||||
4. read results from "finalcontents", or call getfinalcontent | ||||
""" | ||||
|
r40223 | def __init__(self, fctxs, path, ui=None, opts=None): | ||
|
r38953 | """([fctx], ui or None) -> None | ||
fctxs should be linear, and sorted by topo order - oldest first. | ||||
fctxs[0] will be considered as "immutable" and will not be changed. | ||||
""" | ||||
self.fctxs = fctxs | ||||
|
r40223 | self.path = path | ||
|
r38953 | self.ui = ui or nullui() | ||
self.opts = opts or {} | ||||
# following fields are built from fctxs. they exist for perf reason | ||||
self.contents = [f.data() for f in fctxs] | ||||
|
r39023 | self.contentlines = pycompat.maplist(mdiff.splitnewlines, self.contents) | ||
|
r38953 | self.linelog = self._buildlinelog() | ||
if self.ui.debugflag: | ||||
assert self._checkoutlinelog() == self.contents | ||||
# following fields will be filled later | ||||
|
r43346 | self.chunkstats = [0, 0] # [adopted, total : int] | ||
self.targetlines = [] # [str] | ||||
self.fixups = [] # [(linelog rev, a1, a2, b1, b2)] | ||||
self.finalcontents = [] # [str] | ||||
|
r40224 | self.ctxaffected = set() | ||
|
r38953 | |||
|
r40223 | def diffwith(self, targetfctx, fm=None): | ||
|
r38953 | """calculate fixups needed by examining the differences between | ||
self.fctxs[-1] and targetfctx, chunk by chunk. | ||||
targetfctx is the target state we move towards. we may or may not be | ||||
able to get there because not all modified chunks can be amended into | ||||
a non-public fctx unambiguously. | ||||
call this only once, before apply(). | ||||
update self.fixups, self.chunkstats, and self.targetlines. | ||||
""" | ||||
a = self.contents[-1] | ||||
alines = self.contentlines[-1] | ||||
b = targetfctx.data() | ||||
blines = mdiff.splitnewlines(b) | ||||
self.targetlines = blines | ||||
self.linelog.annotate(self.linelog.maxrev) | ||||
|
r43346 | annotated = self.linelog.annotateresult # [(linelog rev, linenum)] | ||
|
r38953 | assert len(annotated) == len(alines) | ||
# add a dummy end line to make insertion at the end easier | ||||
if annotated: | ||||
dummyendline = (annotated[-1][0], annotated[-1][1] + 1) | ||||
annotated.append(dummyendline) | ||||
# analyse diff blocks | ||||
for chunk in self._alldiffchunks(a, b, alines, blines): | ||||
newfixups = self._analysediffchunk(chunk, annotated) | ||||
|
r43346 | self.chunkstats[0] += bool(newfixups) # 1 or 0 | ||
|
r38953 | self.chunkstats[1] += 1 | ||
self.fixups += newfixups | ||||
|
r40223 | if fm is not None: | ||
self._showchanges(fm, alines, blines, chunk, newfixups) | ||||
|
r38953 | |||
def apply(self): | ||||
"""apply self.fixups. update self.linelog, self.finalcontents. | ||||
call this only once, before getfinalcontent(), after diffwith(). | ||||
""" | ||||
# the following is unnecessary, as it's done by "diffwith": | ||||
# self.linelog.annotate(self.linelog.maxrev) | ||||
for rev, a1, a2, b1, b2 in reversed(self.fixups): | ||||
blines = self.targetlines[b1:b2] | ||||
if self.ui.debugflag: | ||||
idx = (max(rev - 1, 0)) // 2 | ||||
|
r43346 | self.ui.write( | ||
|
r43347 | _(b'%s: chunk %d:%d -> %d lines\n') | ||
|
r43346 | % (node.short(self.fctxs[idx].node()), a1, a2, len(blines)) | ||
) | ||||
|
r38953 | self.linelog.replacelines(rev, a1, a2, b1, b2) | ||
|
r43347 | if self.opts.get(b'edit_lines', False): | ||
|
r38953 | self.finalcontents = self._checkoutlinelogwithedits() | ||
else: | ||||
self.finalcontents = self._checkoutlinelog() | ||||
def getfinalcontent(self, fctx): | ||||
"""(fctx) -> str. get modified file content for a given filecontext""" | ||||
idx = self.fctxs.index(fctx) | ||||
return self.finalcontents[idx] | ||||
def _analysediffchunk(self, chunk, annotated): | ||||
"""analyse a different chunk and return new fixups found | ||||
return [] if no lines from the chunk can be safely applied. | ||||
the chunk (or lines) cannot be safely applied, if, for example: | ||||
- the modified (deleted) lines belong to a public changeset | ||||
(self.fctxs[0]) | ||||
- the chunk is a pure insertion and the adjacent lines (at most 2 | ||||
lines) belong to different non-public changesets, or do not belong | ||||
to any non-public changesets. | ||||
- the chunk is modifying lines from different changesets. | ||||
in this case, if the number of lines deleted equals to the number | ||||
of lines added, assume it's a simple 1:1 map (could be wrong). | ||||
otherwise, give up. | ||||
- the chunk is modifying lines from a single non-public changeset, | ||||
but other revisions touch the area as well. i.e. the lines are | ||||
not continuous as seen from the linelog. | ||||
""" | ||||
a1, a2, b1, b2 = chunk | ||||
# find involved indexes from annotate result | ||||
involved = annotated[a1:a2] | ||||
|
r43346 | if not involved and annotated: # a1 == a2 and a is not empty | ||
|
r38953 | # pure insertion, check nearby lines. ignore lines belong | ||
# to the public (first) changeset (i.e. annotated[i][0] == 1) | ||||
|
r38955 | nearbylinenums = {a2, max(0, a1 - 1)} | ||
|
r43346 | involved = [ | ||
annotated[i] for i in nearbylinenums if annotated[i][0] != 1 | ||||
] | ||||
|
r44937 | involvedrevs = list({r for r, l in involved}) | ||
|
r38953 | newfixups = [] | ||
if len(involvedrevs) == 1 and self._iscontinuous(a1, a2 - 1, True): | ||||
# chunk belongs to a single revision | ||||
rev = involvedrevs[0] | ||||
if rev > 1: | ||||
fixuprev = rev + 1 | ||||
newfixups.append((fixuprev, a1, a2, b1, b2)) | ||||
elif a2 - a1 == b2 - b1 or b1 == b2: | ||||
# 1:1 line mapping, or chunk was deleted | ||||
|
r38956 | for i in pycompat.xrange(a1, a2): | ||
|
r38953 | rev, linenum = annotated[i] | ||
if rev > 1: | ||||
|
r43346 | if b1 == b2: # deletion, simply remove that single line | ||
|
r38953 | nb1 = nb2 = 0 | ||
|
r43346 | else: # 1:1 line mapping, change the corresponding rev | ||
|
r38953 | nb1 = b1 + i - a1 | ||
nb2 = nb1 + 1 | ||||
fixuprev = rev + 1 | ||||
newfixups.append((fixuprev, i, i + 1, nb1, nb2)) | ||||
return self._optimizefixups(newfixups) | ||||
@staticmethod | ||||
def _alldiffchunks(a, b, alines, blines): | ||||
"""like mdiff.allblocks, but only care about differences""" | ||||
blocks = mdiff.allblocks(a, b, lines1=alines, lines2=blines) | ||||
for chunk, btype in blocks: | ||||
|
r43347 | if btype != b'!': | ||
|
r38953 | continue | ||
yield chunk | ||||
def _buildlinelog(self): | ||||
"""calculate the initial linelog based on self.content{,line}s. | ||||
this is similar to running a partial "annotate". | ||||
""" | ||||
llog = linelog.linelog() | ||||
|
r43347 | a, alines = b'', [] | ||
|
r38956 | for i in pycompat.xrange(len(self.contents)): | ||
|
r38953 | b, blines = self.contents[i], self.contentlines[i] | ||
llrev = i * 2 + 1 | ||||
chunks = self._alldiffchunks(a, b, alines, blines) | ||||
for a1, a2, b1, b2 in reversed(list(chunks)): | ||||
llog.replacelines(llrev, a1, a2, b1, b2) | ||||
a, alines = b, blines | ||||
return llog | ||||
def _checkoutlinelog(self): | ||||
"""() -> [str]. check out file contents from linelog""" | ||||
contents = [] | ||||
|
r38956 | for i in pycompat.xrange(len(self.contents)): | ||
|
r38953 | rev = (i + 1) * 2 | ||
self.linelog.annotate(rev) | ||||
|
r43347 | content = b''.join(map(self._getline, self.linelog.annotateresult)) | ||
|
r38953 | contents.append(content) | ||
return contents | ||||
def _checkoutlinelogwithedits(self): | ||||
"""() -> [str]. prompt all lines for edit""" | ||||
alllines = self.linelog.getalllines() | ||||
# header | ||||
|
r43346 | editortext = ( | ||
_( | ||||
|
r43347 | b'HG: editing %s\nHG: "y" means the line to the right ' | ||
b'exists in the changeset to the top\nHG:\n' | ||||
|
r43346 | ) | ||
% self.fctxs[-1].path() | ||||
) | ||||
|
r38953 | # [(idx, fctx)]. hide the dummy emptyfilecontext | ||
|
r43346 | visiblefctxs = [ | ||
(i, f) | ||||
for i, f in enumerate(self.fctxs) | ||||
if not isinstance(f, emptyfilecontext) | ||||
] | ||||
|
r38953 | for i, (j, f) in enumerate(visiblefctxs): | ||
|
r43347 | editortext += _(b'HG: %s/%s %s %s\n') % ( | ||
b'|' * i, | ||||
b'-' * (len(visiblefctxs) - i + 1), | ||||
|
r43346 | node.short(f.node()), | ||
|
r43347 | f.description().split(b'\n', 1)[0], | ||
|
r43346 | ) | ||
|
r43347 | editortext += _(b'HG: %s\n') % (b'|' * len(visiblefctxs)) | ||
|
r38953 | # figure out the lifetime of a line, this is relatively inefficient, | ||
# but probably fine | ||||
|
r43346 | lineset = defaultdict(lambda: set()) # {(llrev, linenum): {llrev}} | ||
|
r38953 | for i, f in visiblefctxs: | ||
self.linelog.annotate((i + 1) * 2) | ||||
for l in self.linelog.annotateresult: | ||||
lineset[l].add(i) | ||||
# append lines | ||||
for l in alllines: | ||||
|
r43347 | editortext += b' %s : %s' % ( | ||
b''.join( | ||||
|
r43346 | [ | ||
|
r43347 | (b'y' if i in lineset[l] else b' ') | ||
|
r43346 | for i, _f in visiblefctxs | ||
] | ||||
), | ||||
self._getline(l), | ||||
) | ||||
|
r38953 | # run editor | ||
|
r43347 | editedtext = self.ui.edit(editortext, b'', action=b'absorb') | ||
|
r38953 | if not editedtext: | ||
|
r43347 | raise error.Abort(_(b'empty editor text')) | ||
|
r38953 | # parse edited result | ||
|
r44435 | contents = [b''] * len(self.fctxs) | ||
|
r38953 | leftpadpos = 4 | ||
colonpos = leftpadpos + len(visiblefctxs) + 1 | ||||
for l in mdiff.splitnewlines(editedtext): | ||||
|
r43347 | if l.startswith(b'HG:'): | ||
|
r38953 | continue | ||
|
r43347 | if l[colonpos - 1 : colonpos + 2] != b' : ': | ||
raise error.Abort(_(b'malformed line: %s') % l) | ||||
|
r43346 | linecontent = l[colonpos + 2 :] | ||
|
r41296 | for i, ch in enumerate( | ||
|
r43346 | pycompat.bytestr(l[leftpadpos : colonpos - 1]) | ||
): | ||||
|
r43347 | if ch == b'y': | ||
|
r38953 | contents[visiblefctxs[i][0]] += linecontent | ||
# chunkstats is hard to calculate if anything changes, therefore | ||||
# set them to just a simple value (1, 1). | ||||
if editedtext != editortext: | ||||
self.chunkstats = [1, 1] | ||||
return contents | ||||
def _getline(self, lineinfo): | ||||
"""((rev, linenum)) -> str. convert rev+line number to line content""" | ||||
rev, linenum = lineinfo | ||||
|
r43346 | if rev & 1: # odd: original line taken from fctxs | ||
|
r38953 | return self.contentlines[rev // 2][linenum] | ||
|
r43346 | else: # even: fixup line from targetfctx | ||
|
r38953 | return self.targetlines[linenum] | ||
def _iscontinuous(self, a1, a2, closedinterval=False): | ||||
"""(a1, a2 : int) -> bool | ||||
check if these lines are continuous. i.e. no other insertions or | ||||
deletions (from other revisions) among these lines. | ||||
closedinterval decides whether a2 should be included or not. i.e. is | ||||
it [a1, a2), or [a1, a2] ? | ||||
""" | ||||
if a1 >= a2: | ||||
return True | ||||
llog = self.linelog | ||||
offset1 = llog.getoffset(a1) | ||||
offset2 = llog.getoffset(a2) + int(closedinterval) | ||||
linesinbetween = llog.getalllines(offset1, offset2) | ||||
return len(linesinbetween) == a2 - a1 + int(closedinterval) | ||||
def _optimizefixups(self, fixups): | ||||
"""[(rev, a1, a2, b1, b2)] -> [(rev, a1, a2, b1, b2)]. | ||||
merge adjacent fixups to make them less fragmented. | ||||
""" | ||||
result = [] | ||||
pcurrentchunk = [[-1, -1, -1, -1, -1]] | ||||
def pushchunk(): | ||||
if pcurrentchunk[0][0] != -1: | ||||
result.append(tuple(pcurrentchunk[0])) | ||||
for i, chunk in enumerate(fixups): | ||||
rev, a1, a2, b1, b2 = chunk | ||||
lastrev = pcurrentchunk[0][0] | ||||
lasta2 = pcurrentchunk[0][2] | ||||
lastb2 = pcurrentchunk[0][4] | ||||
|
r43346 | if ( | ||
a1 == lasta2 | ||||
and b1 == lastb2 | ||||
and rev == lastrev | ||||
and self._iscontinuous(max(a1 - 1, 0), a1) | ||||
): | ||||
|
r38953 | # merge into currentchunk | ||
pcurrentchunk[0][2] = a2 | ||||
pcurrentchunk[0][4] = b2 | ||||
else: | ||||
pushchunk() | ||||
pcurrentchunk[0] = list(chunk) | ||||
pushchunk() | ||||
return result | ||||
|
r40223 | def _showchanges(self, fm, alines, blines, chunk, fixups): | ||
def trim(line): | ||||
|
r43347 | if line.endswith(b'\n'): | ||
|
r38953 | line = line[:-1] | ||
|
r40223 | return line | ||
|
r38953 | |||
# this is not optimized for perf but _showchanges only gets executed | ||||
# with an extra command-line flag. | ||||
a1, a2, b1, b2 = chunk | ||||
aidxs, bidxs = [0] * (a2 - a1), [0] * (b2 - b1) | ||||
for idx, fa1, fa2, fb1, fb2 in fixups: | ||||
|
r38956 | for i in pycompat.xrange(fa1, fa2): | ||
|
r38953 | aidxs[i - a1] = (max(idx, 1) - 1) // 2 | ||
|
r38956 | for i in pycompat.xrange(fb1, fb2): | ||
|
r38953 | bidxs[i - b1] = (max(idx, 1) - 1) // 2 | ||
|
r40223 | fm.startitem() | ||
|
r43346 | fm.write( | ||
|
r43347 | b'hunk', | ||
b' %s\n', | ||||
b'@@ -%d,%d +%d,%d @@' % (a1, a2 - a1, b1, b2 - b1), | ||||
label=b'diff.hunk', | ||||
|
r43346 | ) | ||
|
r43347 | fm.data(path=self.path, linetype=b'hunk') | ||
|
r40223 | |||
def writeline(idx, diffchar, line, linetype, linelabel): | ||||
fm.startitem() | ||||
|
r43347 | node = b'' | ||
|
r40223 | if idx: | ||
ctx = self.fctxs[idx] | ||||
fm.context(fctx=ctx) | ||||
node = ctx.hex() | ||||
|
r40224 | self.ctxaffected.add(ctx.changectx()) | ||
|
r43347 | fm.write(b'node', b'%-7.7s ', node, label=b'absorb.node') | ||
|
r43346 | fm.write( | ||
|
r43347 | b'diffchar ' + linetype, | ||
b'%s%s\n', | ||||
|
r43346 | diffchar, | ||
line, | ||||
label=linelabel, | ||||
) | ||||
|
r40223 | fm.data(path=self.path, linetype=linetype) | ||
for i in pycompat.xrange(a1, a2): | ||||
|
r43346 | writeline( | ||
|
r43347 | aidxs[i - a1], | ||
b'-', | ||||
trim(alines[i]), | ||||
b'deleted', | ||||
b'diff.deleted', | ||||
|
r43346 | ) | ||
|
r40223 | for i in pycompat.xrange(b1, b2): | ||
|
r43346 | writeline( | ||
|
r43347 | bidxs[i - b1], | ||
b'+', | ||||
trim(blines[i]), | ||||
b'inserted', | ||||
b'diff.inserted', | ||||
|
r43346 | ) | ||
|
r38953 | |||
class fixupstate(object): | ||||
"""state needed to run absorb | ||||
internally, it keeps paths and filefixupstates. | ||||
a typical use is like filefixupstates: | ||||
1. call diffwith, to calculate fixups | ||||
2. (optionally), present fixups to the user, or edit fixups | ||||
3. call apply, to apply changes to memory | ||||
4. call commit, to commit changes to hg database | ||||
""" | ||||
def __init__(self, stack, ui=None, opts=None): | ||||
"""([ctx], ui or None) -> None | ||||
stack: should be linear, and sorted by topo order - oldest first. | ||||
all commits in stack are considered mutable. | ||||
""" | ||||
assert stack | ||||
self.ui = ui or nullui() | ||||
self.opts = opts or {} | ||||
self.stack = stack | ||||
self.repo = stack[-1].repo().unfiltered() | ||||
# following fields will be filled later | ||||
|
r43346 | self.paths = [] # [str] | ||
self.status = None # ctx.status output | ||||
self.fctxmap = {} # {path: {ctx: fctx}} | ||||
self.fixupmap = {} # {path: filefixupstate} | ||||
self.replacemap = {} # {oldnode: newnode or None} | ||||
self.finalnode = None # head after all fixups | ||||
self.ctxaffected = set() # ctx that will be absorbed into | ||||
|
r38953 | |||
|
r40223 | def diffwith(self, targetctx, match=None, fm=None): | ||
|
r38953 | """diff and prepare fixups. update self.fixupmap, self.paths""" | ||
# only care about modified files | ||||
self.status = self.stack[-1].status(targetctx, match) | ||||
self.paths = [] | ||||
# but if --edit-lines is used, the user may want to edit files | ||||
# even if they are not modified | ||||
|
r43347 | editopt = self.opts.get(b'edit_lines') | ||
|
r38953 | if not self.status.modified and editopt and match: | ||
interestingpaths = match.files() | ||||
else: | ||||
interestingpaths = self.status.modified | ||||
# prepare the filefixupstate | ||||
seenfctxs = set() | ||||
# sorting is necessary to eliminate ambiguity for the "double move" | ||||
# case: "hg cp A B; hg cp A C; hg rm A", then only "B" can affect "A". | ||||
for path in sorted(interestingpaths): | ||||
|
r43347 | self.ui.debug(b'calculating fixups for %s\n' % path) | ||
|
r38953 | targetfctx = targetctx[path] | ||
fctxs, ctx2fctx = getfilestack(self.stack, path, seenfctxs) | ||||
# ignore symbolic links or binary, or unchanged files | ||||
|
r43346 | if any( | ||
f.islink() or stringutil.binary(f.data()) | ||||
for f in [targetfctx] + fctxs | ||||
if not isinstance(f, emptyfilecontext) | ||||
): | ||||
|
r38953 | continue | ||
if targetfctx.data() == fctxs[-1].data() and not editopt: | ||||
continue | ||||
seenfctxs.update(fctxs[1:]) | ||||
self.fctxmap[path] = ctx2fctx | ||||
|
r40223 | fstate = filefixupstate(fctxs, path, ui=self.ui, opts=self.opts) | ||
if fm is not None: | ||||
fm.startitem() | ||||
|
r43347 | fm.plain(b'showing changes for ') | ||
fm.write(b'path', b'%s\n', path, label=b'absorb.path') | ||||
fm.data(linetype=b'path') | ||||
|
r40223 | fstate.diffwith(targetfctx, fm) | ||
|
r38953 | self.fixupmap[path] = fstate | ||
self.paths.append(path) | ||||
|
r40224 | self.ctxaffected.update(fstate.ctxaffected) | ||
|
r38953 | |||
def apply(self): | ||||
"""apply fixups to individual filefixupstates""" | ||||
|
r43375 | for path, state in pycompat.iteritems(self.fixupmap): | ||
|
r38953 | if self.ui.debugflag: | ||
|
r43347 | self.ui.write(_(b'applying fixups to %s\n') % path) | ||
|
r38953 | state.apply() | ||
@property | ||||
def chunkstats(self): | ||||
"""-> {path: chunkstats}. collect chunkstats from filefixupstates""" | ||||
|
r44937 | return { | ||
path: state.chunkstats | ||||
|
r43375 | for path, state in pycompat.iteritems(self.fixupmap) | ||
|
r44937 | } | ||
|
r38953 | |||
def commit(self): | ||||
"""commit changes. update self.finalnode, self.replacemap""" | ||||
|
r43347 | with self.repo.transaction(b'absorb') as tr: | ||
|
r42297 | self._commitstack() | ||
self._movebookmarks(tr) | ||||
|
r43347 | if self.repo[b'.'].node() in self.replacemap: | ||
|
r42297 | self._moveworkingdirectoryparent() | ||
self._cleanupoldcommits() | ||||
|
r38953 | return self.finalnode | ||
def printchunkstats(self): | ||||
"""print things like '1 of 2 chunk(s) applied'""" | ||||
ui = self.ui | ||||
chunkstats = self.chunkstats | ||||
if ui.verbose: | ||||
# chunkstats for each file | ||||
|
r43375 | for path, stat in pycompat.iteritems(chunkstats): | ||
|
r38953 | if stat[0]: | ||
|
r43346 | ui.write( | ||
|
r43347 | _(b'%s: %d of %d chunk(s) applied\n') | ||
|
r43346 | % (path, stat[0], stat[1]) | ||
) | ||||
|
r38953 | elif not ui.quiet: | ||
# a summary for all files | ||||
stats = chunkstats.values() | ||||
applied, total = (sum(s[i] for s in stats) for i in (0, 1)) | ||||
|
r43347 | ui.write(_(b'%d of %d chunk(s) applied\n') % (applied, total)) | ||
|
r38953 | |||
def _commitstack(self): | ||||
"""make new commits. update self.finalnode, self.replacemap. | ||||
it is splitted from "commit" to avoid too much indentation. | ||||
""" | ||||
# last node (20-char) committed by us | ||||
lastcommitted = None | ||||
# p1 which overrides the parent of the next commit, "None" means use | ||||
# the original parent unchanged | ||||
nextp1 = None | ||||
for ctx in self.stack: | ||||
memworkingcopy = self._getnewfilecontents(ctx) | ||||
if not memworkingcopy and not lastcommitted: | ||||
# nothing changed, nothing commited | ||||
nextp1 = ctx | ||||
continue | ||||
|
r45685 | willbecomenoop = ctx.files() and self._willbecomenoop( | ||
memworkingcopy, ctx, nextp1 | ||||
) | ||||
if self.skip_empty_successor and willbecomenoop: | ||||
|
r38953 | # changeset is no longer necessary | ||
self.replacemap[ctx.node()] = None | ||||
|
r43347 | msg = _(b'became empty and was dropped') | ||
|
r38953 | else: | ||
# changeset needs re-commit | ||||
nodestr = self._commitsingle(memworkingcopy, ctx, p1=nextp1) | ||||
lastcommitted = self.repo[nodestr] | ||||
nextp1 = lastcommitted | ||||
self.replacemap[ctx.node()] = lastcommitted.node() | ||||
if memworkingcopy: | ||||
|
r45685 | if willbecomenoop: | ||
|
r45730 | msg = _(b'%d file(s) changed, became empty as %s') | ||
|
r45685 | else: | ||
msg = _(b'%d file(s) changed, became %s') | ||||
msg = msg % ( | ||||
|
r43346 | len(memworkingcopy), | ||
self._ctx2str(lastcommitted), | ||||
) | ||||
|
r38953 | else: | ||
|
r43347 | msg = _(b'became %s') % self._ctx2str(lastcommitted) | ||
|
r38953 | if self.ui.verbose and msg: | ||
|
r43347 | self.ui.write(_(b'%s: %s\n') % (self._ctx2str(ctx), msg)) | ||
|
r38953 | self.finalnode = lastcommitted and lastcommitted.node() | ||
def _ctx2str(self, ctx): | ||||
if self.ui.debugflag: | ||||
|
r43347 | return b'%d:%s' % (ctx.rev(), ctx.hex()) | ||
|
r38953 | else: | ||
|
r43347 | return b'%d:%s' % (ctx.rev(), node.short(ctx.node())) | ||
|
r38953 | |||
def _getnewfilecontents(self, ctx): | ||||
"""(ctx) -> {path: str} | ||||
fetch file contents from filefixupstates. | ||||
return the working copy overrides - files different from ctx. | ||||
""" | ||||
result = {} | ||||
for path in self.paths: | ||||
|
r43346 | ctx2fctx = self.fctxmap[path] # {ctx: fctx} | ||
|
r38953 | if ctx not in ctx2fctx: | ||
continue | ||||
fctx = ctx2fctx[ctx] | ||||
content = fctx.data() | ||||
newcontent = self.fixupmap[path].getfinalcontent(fctx) | ||||
if content != newcontent: | ||||
result[fctx.path()] = newcontent | ||||
return result | ||||
def _movebookmarks(self, tr): | ||||
repo = self.repo | ||||
|
r43346 | needupdate = [ | ||
(name, self.replacemap[hsh]) | ||||
|
r43375 | for name, hsh in pycompat.iteritems(repo._bookmarks) | ||
|
r43346 | if hsh in self.replacemap | ||
] | ||||
|
r38953 | changes = [] | ||
for name, hsh in needupdate: | ||||
if hsh: | ||||
changes.append((name, hsh)) | ||||
if self.ui.verbose: | ||||
|
r43346 | self.ui.write( | ||
|
r43347 | _(b'moving bookmark %s to %s\n') % (name, node.hex(hsh)) | ||
|
r43346 | ) | ||
|
r38953 | else: | ||
changes.append((name, None)) | ||||
if self.ui.verbose: | ||||
|
r43347 | self.ui.write(_(b'deleting bookmark %s\n') % name) | ||
|
r38953 | repo._bookmarks.applychanges(repo, tr, changes) | ||
def _moveworkingdirectoryparent(self): | ||||
if not self.finalnode: | ||||
# Find the latest not-{obsoleted,stripped} parent. | ||||
|
r43347 | revs = self.repo.revs(b'max(::. - %ln)', self.replacemap.keys()) | ||
|
r38953 | ctx = self.repo[revs.first()] | ||
self.finalnode = ctx.node() | ||||
else: | ||||
ctx = self.repo[self.finalnode] | ||||
dirstate = self.repo.dirstate | ||||
# dirstate.rebuild invalidates fsmonitorstate, causing "hg status" to | ||||
# be slow. in absorb's case, no need to invalidate fsmonitorstate. | ||||
noop = lambda: 0 | ||||
restore = noop | ||||
|
r43385 | if util.safehasattr(dirstate, '_fsmonitorstate'): | ||
|
r38953 | bak = dirstate._fsmonitorstate.invalidate | ||
|
r43346 | |||
|
r38953 | def restore(): | ||
dirstate._fsmonitorstate.invalidate = bak | ||||
|
r43346 | |||
|
r38953 | dirstate._fsmonitorstate.invalidate = noop | ||
try: | ||||
with dirstate.parentchange(): | ||||
dirstate.rebuild(ctx.node(), ctx.manifest(), self.paths) | ||||
finally: | ||||
restore() | ||||
@staticmethod | ||||
def _willbecomenoop(memworkingcopy, ctx, pctx=None): | ||||
"""({path: content}, ctx, ctx) -> bool. test if a commit will be noop | ||||
if it will become an empty commit (does not change anything, after the | ||||
memworkingcopy overrides), return True. otherwise return False. | ||||
""" | ||||
if not pctx: | ||||
parents = ctx.parents() | ||||
if len(parents) != 1: | ||||
return False | ||||
pctx = parents[0] | ||||
|
r45516 | if ctx.branch() != pctx.branch(): | ||
return False | ||||
|
r45517 | if ctx.extra().get(b'close'): | ||
return False | ||||
|
r38953 | # ctx changes more files (not a subset of memworkingcopy) | ||
|
r39023 | if not set(ctx.files()).issubset(set(memworkingcopy)): | ||
|
r38953 | return False | ||
|
r43375 | for path, content in pycompat.iteritems(memworkingcopy): | ||
|
r38953 | if path not in pctx or path not in ctx: | ||
return False | ||||
fctx = ctx[path] | ||||
pfctx = pctx[path] | ||||
if pfctx.flags() != fctx.flags(): | ||||
return False | ||||
if pfctx.data() != content: | ||||
return False | ||||
return True | ||||
def _commitsingle(self, memworkingcopy, ctx, p1=None): | ||||
"""(ctx, {path: content}, node) -> node. make a single commit | ||||
the commit is a clone from ctx, with a (optionally) different p1, and | ||||
different file contents replaced by memworkingcopy. | ||||
""" | ||||
parents = p1 and (p1, node.nullid) | ||||
extra = ctx.extra() | ||||
|
r43347 | if self._useobsolete and self.ui.configbool(b'absorb', b'add-noise'): | ||
extra[b'absorb_source'] = ctx.hex() | ||||
|
r38953 | mctx = overlaycontext(memworkingcopy, ctx, parents, extra=extra) | ||
|
r41979 | return mctx.commit() | ||
|
r38953 | |||
@util.propertycache | ||||
def _useobsolete(self): | ||||
"""() -> bool""" | ||||
return obsolete.isenabled(self.repo, obsolete.createmarkersopt) | ||||
|
r41978 | def _cleanupoldcommits(self): | ||
|
r43346 | replacements = { | ||
k: ([v] if v is not None else []) | ||||
|
r43375 | for k, v in pycompat.iteritems(self.replacemap) | ||
|
r43346 | } | ||
|
r41977 | if replacements: | ||
|
r43346 | scmutil.cleanupnodes( | ||
|
r43347 | self.repo, replacements, operation=b'absorb', fixphase=True | ||
|
r43346 | ) | ||
|
r45684 | @util.propertycache | ||
def skip_empty_successor(self): | ||||
return rewriteutil.skip_empty_successor(self.ui, b'absorb') | ||||
|
r38953 | |||
def _parsechunk(hunk): | ||||
"""(crecord.uihunk or patch.recordhunk) -> (path, (a1, a2, [bline]))""" | ||||
if type(hunk) not in (crecord.uihunk, patch.recordhunk): | ||||
return None, None | ||||
path = hunk.header.filename() | ||||
a1 = hunk.fromline + len(hunk.before) - 1 | ||||
# remove before and after context | ||||
hunk.before = hunk.after = [] | ||||
buf = util.stringio() | ||||
hunk.write(buf) | ||||
patchlines = mdiff.splitnewlines(buf.getvalue()) | ||||
# hunk.prettystr() will update hunk.removed | ||||
a2 = a1 + hunk.removed | ||||
|
r43347 | blines = [l[1:] for l in patchlines[1:] if not l.startswith(b'-')] | ||
|
r38953 | return path, (a1, a2, blines) | ||
|
r43346 | |||
|
r38953 | def overlaydiffcontext(ctx, chunks): | ||
"""(ctx, [crecord.uihunk]) -> memctx | ||||
return a memctx with some [1] patches (chunks) applied to ctx. | ||||
[1]: modifications are handled. renames, mode changes, etc. are ignored. | ||||
""" | ||||
# sadly the applying-patch logic is hardly reusable, and messy: | ||||
# 1. the core logic "_applydiff" is too heavy - it writes .rej files, it | ||||
# needs a file stream of a patch and will re-parse it, while we have | ||||
# structured hunk objects at hand. | ||||
# 2. a lot of different implementations about "chunk" (patch.hunk, | ||||
# patch.recordhunk, crecord.uihunk) | ||||
# as we only care about applying changes to modified files, no mode | ||||
# change, no binary diff, and no renames, it's probably okay to | ||||
# re-invent the logic using much simpler code here. | ||||
|
r43346 | memworkingcopy = {} # {path: content} | ||
patchmap = defaultdict(lambda: []) # {path: [(a1, a2, [bline])]} | ||||
|
r38953 | for path, info in map(_parsechunk, chunks): | ||
if not path or not info: | ||||
continue | ||||
patchmap[path].append(info) | ||||
|
r43375 | for path, patches in pycompat.iteritems(patchmap): | ||
|
r38953 | if path not in ctx or not patches: | ||
continue | ||||
patches.sort(reverse=True) | ||||
lines = mdiff.splitnewlines(ctx[path].data()) | ||||
for a1, a2, blines in patches: | ||||
lines[a1:a2] = blines | ||||
|
r43347 | memworkingcopy[path] = b''.join(lines) | ||
|
r38953 | return overlaycontext(memworkingcopy, ctx) | ||
|
r43346 | |||
|
r38953 | def absorb(ui, repo, stack=None, targetctx=None, pats=None, opts=None): | ||
"""pick fixup chunks from targetctx, apply them to stack. | ||||
if targetctx is None, the working copy context will be used. | ||||
if stack is None, the current draft stack will be used. | ||||
return fixupstate. | ||||
""" | ||||
if stack is None: | ||||
|
r43347 | limit = ui.configint(b'absorb', b'max-stack-size') | ||
headctx = repo[b'.'] | ||||
|
r42452 | if len(headctx.parents()) > 1: | ||
|
r43347 | raise error.Abort(_(b'cannot absorb into a merge')) | ||
|
r42452 | stack = getdraftstack(headctx, limit) | ||
|
r38953 | if limit and len(stack) >= limit: | ||
|
r43346 | ui.warn( | ||
|
r43347 | _( | ||
b'absorb: only the recent %d changesets will ' | ||||
b'be analysed\n' | ||||
) | ||||
|
r43346 | % limit | ||
) | ||||
|
r38953 | if not stack: | ||
|
r43347 | raise error.Abort(_(b'no mutable changeset to change')) | ||
|
r43346 | if targetctx is None: # default to working copy | ||
|
r38953 | targetctx = repo[None] | ||
if pats is None: | ||||
pats = () | ||||
if opts is None: | ||||
opts = {} | ||||
state = fixupstate(stack, ui=ui, opts=opts) | ||||
matcher = scmutil.match(targetctx, pats, opts) | ||||
|
r43347 | if opts.get(b'interactive'): | ||
|
r38953 | diff = patch.diff(repo, stack[-1].node(), targetctx.node(), matcher) | ||
origchunks = patch.parsepatch(diff) | ||||
|
r42543 | chunks = cmdutil.recordfilter(ui, origchunks, matcher)[0] | ||
|
r38953 | targetctx = overlaydiffcontext(stack[-1], chunks) | ||
|
r40223 | fm = None | ||
|
r43347 | if opts.get(b'print_changes') or not opts.get(b'apply_changes'): | ||
fm = ui.formatter(b'absorb', opts) | ||||
|
r40223 | state.diffwith(targetctx, matcher, fm) | ||
if fm is not None: | ||||
|
r40224 | fm.startitem() | ||
|
r43347 | fm.write( | ||
b"count", b"\n%d changesets affected\n", len(state.ctxaffected) | ||||
) | ||||
fm.data(linetype=b'summary') | ||||
|
r40224 | for ctx in reversed(stack): | ||
if ctx not in state.ctxaffected: | ||||
continue | ||||
fm.startitem() | ||||
fm.context(ctx=ctx) | ||||
|
r43347 | fm.data(linetype=b'changeset') | ||
fm.write(b'node', b'%-7.7s ', ctx.hex(), label=b'absorb.node') | ||||
|
r40224 | descfirstline = ctx.description().splitlines()[0] | ||
|
r43346 | fm.write( | ||
|
r43347 | b'descfirstline', | ||
b'%s\n', | ||||
|
r43346 | descfirstline, | ||
|
r43347 | label=b'absorb.description', | ||
|
r43346 | ) | ||
|
r40223 | fm.end() | ||
|
r43347 | if not opts.get(b'dry_run'): | ||
|
r43346 | if ( | ||
|
r43347 | not opts.get(b'apply_changes') | ||
|
r43346 | and state.ctxaffected | ||
|
r43347 | and ui.promptchoice( | ||
|
r45524 | b"apply changes (y/N)? $$ &Yes $$ &No", default=1 | ||
|
r43347 | ) | ||
|
r43346 | ): | ||
|
r43347 | raise error.Abort(_(b'absorb cancelled\n')) | ||
|
r40226 | |||
|
r38953 | state.apply() | ||
if state.commit(): | ||||
state.printchunkstats() | ||||
elif not ui.quiet: | ||||
|
r43347 | ui.write(_(b'nothing applied\n')) | ||
|
r38953 | return state | ||
|
r43346 | |||
@command( | ||||
|
r43347 | b'absorb', | ||
|
r43346 | [ | ||
( | ||||
|
r43347 | b'a', | ||
b'apply-changes', | ||||
|
r43346 | None, | ||
|
r43347 | _(b'apply changes without prompting for confirmation'), | ||
|
r43346 | ), | ||
( | ||||
|
r43347 | b'p', | ||
b'print-changes', | ||||
|
r43346 | None, | ||
|
r43347 | _(b'always print which changesets are modified by which changes'), | ||
|
r43346 | ), | ||
( | ||||
|
r43347 | b'i', | ||
b'interactive', | ||||
|
r43346 | None, | ||
|
r44713 | _(b'interactively select which chunks to apply'), | ||
|
r43346 | ), | ||
( | ||||
|
r43347 | b'e', | ||
b'edit-lines', | ||||
|
r43346 | None, | ||
_( | ||||
|
r43347 | b'edit what lines belong to which changesets before commit ' | ||
b'(EXPERIMENTAL)' | ||||
|
r43346 | ), | ||
), | ||||
] | ||||
+ commands.dryrunopts | ||||
+ commands.templateopts | ||||
+ commands.walkopts, | ||||
|
r43347 | _(b'hg absorb [OPTION] [FILE]...'), | ||
|
r43346 | helpcategory=command.CATEGORY_COMMITTING, | ||
helpbasic=True, | ||||
) | ||||
|
r38953 | def absorbcmd(ui, repo, *pats, **opts): | ||
"""incorporate corrections into the stack of draft changesets | ||||
absorb analyzes each change in your working directory and attempts to | ||||
amend the changed lines into the changesets in your stack that first | ||||
introduced those lines. | ||||
If absorb cannot find an unambiguous changeset to amend for a change, | ||||
that change will be left in the working directory, untouched. They can be | ||||
observed by :hg:`status` or :hg:`diff` afterwards. In other words, | ||||
absorb does not write to the working directory. | ||||
Changesets outside the revset `::. and not public() and not merge()` will | ||||
not be changed. | ||||
Changesets that become empty after applying the changes will be deleted. | ||||
|
r40246 | By default, absorb will show what it plans to do and prompt for | ||
confirmation. If you are confident that the changes will be absorbed | ||||
to the correct place, run :hg:`absorb -a` to apply the changes | ||||
immediately. | ||||
|
r38953 | |||
Returns 0 on success, 1 if all chunks were ignored and nothing amended. | ||||
""" | ||||
|
r39822 | opts = pycompat.byteskwargs(opts) | ||
|
r42297 | |||
with repo.wlock(), repo.lock(): | ||||
|
r43347 | if not opts[b'dry_run']: | ||
|
r42297 | cmdutil.checkunfinished(repo) | ||
state = absorb(ui, repo, pats=pats, opts=opts) | ||||
if sum(s[0] for s in state.chunkstats.values()) == 0: | ||||
return 1 | ||||