copies.py
524 lines
| 17.7 KiB
| text/x-python
|
PythonLexer
/ mercurial / copies.py
|
r6274 | # copies.py - copy detection for Mercurial | ||
# | ||||
# Copyright 2008 Matt Mackall <mpm@selenic.com> | ||||
# | ||||
|
r8225 | # This software may be used and distributed according to the terms of the | ||
|
r10263 | # GNU General Public License version 2 or any later version. | ||
|
r6274 | |||
|
r25924 | from __future__ import absolute_import | ||
|
r8312 | import heapq | ||
|
r6274 | |||
|
r25924 | from . import ( | ||
pathutil, | ||||
util, | ||||
) | ||||
|
r6431 | def _findlimit(repo, a, b): | ||
|
r23071 | """ | ||
Find the last revision that needs to be checked to ensure that a full | ||||
transitive closure for file copies can be properly calculated. | ||||
Generally, this means finding the earliest revision number that's an | ||||
ancestor of a or b but not both, except when a or b is a direct descendent | ||||
of the other, in which case we can return the minimum revnum of a and b. | ||||
|
r10179 | None if no such revision exists. | ||
""" | ||||
|
r23071 | |||
|
r6429 | # basic idea: | ||
# - mark a and b with different sides | ||||
# - if a parent's children are all on the same side, the parent is | ||||
# on that side, otherwise it is on no side | ||||
# - walk the graph in topological order with the help of a heap; | ||||
# - add unseen parents to side map | ||||
# - clear side of any parent that has children on different sides | ||||
|
r6431 | # - track number of interesting revs that might still be on a side | ||
# - track the lowest interesting rev seen | ||||
# - quit when interesting revs is zero | ||||
|
r6430 | |||
cl = repo.changelog | ||||
|
r6750 | working = len(cl) # pseudo rev for the working directory | ||
|
r6430 | if a is None: | ||
a = working | ||||
if b is None: | ||||
b = working | ||||
|
r6429 | |||
side = {a: -1, b: 1} | ||||
visit = [-a, -b] | ||||
heapq.heapify(visit) | ||||
interesting = len(visit) | ||||
|
r10179 | hascommonancestor = False | ||
|
r6431 | limit = working | ||
|
r6429 | |||
while interesting: | ||||
r = -heapq.heappop(visit) | ||||
|
r6430 | if r == working: | ||
parents = [cl.rev(p) for p in repo.dirstate.parents()] | ||||
else: | ||||
parents = cl.parentrevs(r) | ||||
for p in parents: | ||||
|
r10179 | if p < 0: | ||
continue | ||||
|
r6429 | if p not in side: | ||
# first time we see p; add it to visit | ||||
side[p] = side[r] | ||||
if side[p]: | ||||
interesting += 1 | ||||
heapq.heappush(visit, -p) | ||||
elif side[p] and side[p] != side[r]: | ||||
# p was interesting but now we know better | ||||
side[p] = 0 | ||||
interesting -= 1 | ||||
|
r10179 | hascommonancestor = True | ||
|
r6430 | if side[r]: | ||
|
r6431 | limit = r # lowest rev visited | ||
|
r6430 | interesting -= 1 | ||
|
r10179 | |||
if not hascommonancestor: | ||||
return None | ||||
|
r23071 | |||
# Consider the following flow (see test-commit-amend.t under issue4405): | ||||
# 1/ File 'a0' committed | ||||
# 2/ File renamed from 'a0' to 'a1' in a new commit (call it 'a1') | ||||
# 3/ Move back to first commit | ||||
# 4/ Create a new commit via revert to contents of 'a1' (call it 'a1-amend') | ||||
# 5/ Rename file from 'a1' to 'a2' and commit --amend 'a1-msg' | ||||
# | ||||
# During the amend in step five, we will be in this state: | ||||
# | ||||
# @ 3 temporary amend commit for a1-amend | ||||
# | | ||||
# o 2 a1-amend | ||||
# | | ||||
# | o 1 a1 | ||||
# |/ | ||||
# o 0 a0 | ||||
# | ||||
|
r23139 | # When _findlimit is called, a and b are revs 3 and 0, so limit will be 2, | ||
|
r23071 | # yet the filelog has the copy information in rev 1 and we will not look | ||
# back far enough unless we also look at the a and b as candidates. | ||||
# This only occurs when a is a descendent of b or visa-versa. | ||||
return min(limit, a, b) | ||||
|
r6429 | |||
|
r15775 | def _chain(src, dst, a, b): | ||
'''chain two sets of copies a->b''' | ||||
t = a.copy() | ||||
for k, v in b.iteritems(): | ||||
if v in t: | ||||
# found a chain | ||||
if t[v] != k: | ||||
# file wasn't renamed back to itself | ||||
t[k] = t[v] | ||||
if v not in dst: | ||||
# chain was a rename, not a copy | ||||
del t[v] | ||||
if v in src: | ||||
# file is a copy of an existing file | ||||
t[k] = v | ||||
|
r15976 | |||
# remove criss-crossed copies | ||||
for k, v in t.items(): | ||||
if k in src and v in dst: | ||||
del t[k] | ||||
|
r15775 | return t | ||
|
r20294 | def _tracefile(fctx, am, limit=-1): | ||
'''return file context that is the ancestor of fctx present in ancestor | ||||
manifest am, stopping after the first ancestor lower than limit''' | ||||
|
r15775 | |||
for f in fctx.ancestors(): | ||||
if am.get(f.path(), None) == f.filenode(): | ||||
return f | ||||
|
r23982 | if limit >= 0 and f.linkrev() < limit and f.rev() < limit: | ||
|
r15775 | return None | ||
def _dirstatecopies(d): | ||||
ds = d._repo.dirstate | ||||
c = ds.copies().copy() | ||||
for k in c.keys(): | ||||
if ds[k] not in 'anm': | ||||
del c[k] | ||||
return c | ||||
|
r24782 | def _computeforwardmissing(a, b, match=None): | ||
|
r24011 | """Computes which files are in b but not a. | ||
This is its own function so extensions can easily wrap this call to see what | ||||
files _forwardcopies is about to process. | ||||
""" | ||||
|
r24782 | ma = a.manifest() | ||
mb = b.manifest() | ||||
if match: | ||||
ma = ma.matches(match) | ||||
mb = mb.matches(match) | ||||
return mb.filesnotin(ma) | ||||
|
r24011 | |||
|
r24782 | def _forwardcopies(a, b, match=None): | ||
|
r15775 | '''find {dst@b: src@a} copy mapping where a is an ancestor of b''' | ||
# check for working copy | ||||
w = None | ||||
if b.rev() is None: | ||||
w = b | ||||
b = w.p1() | ||||
if a == b: | ||||
# short-circuit to avoid issues with merge states | ||||
return _dirstatecopies(w) | ||||
|
r20294 | # files might have to be traced back to the fctx parent of the last | ||
# one-side-only changeset, but not further back than that | ||||
limit = _findlimit(a._repo, a.rev(), b.rev()) | ||||
if limit is None: | ||||
limit = -1 | ||||
am = a.manifest() | ||||
|
r15775 | # find where new files came from | ||
# we currently don't try to find where old files went, too expensive | ||||
# this means we can miss a case like 'hg rm b; hg cp a b' | ||||
cm = {} | ||||
|
r24782 | missing = _computeforwardmissing(a, b, match=match) | ||
|
r23980 | ancestrycontext = a._repo.changelog.ancestors([b.rev()], inclusive=True) | ||
|
r18878 | for f in missing: | ||
|
r23980 | fctx = b[f] | ||
fctx._ancestrycontext = ancestrycontext | ||||
ofctx = _tracefile(fctx, am, limit) | ||||
|
r18878 | if ofctx: | ||
cm[f] = ofctx.path() | ||||
|
r15775 | |||
# combine copies from dirstate if necessary | ||||
if w is not None: | ||||
cm = _chain(a, w, cm, _dirstatecopies(w)) | ||||
return cm | ||||
|
r18136 | def _backwardrenames(a, b): | ||
# Even though we're not taking copies into account, 1:n rename situations | ||||
# can still exist (e.g. hg cp a b; hg mv a c). In those cases we | ||||
# arbitrarily pick one of the renames. | ||||
|
r15775 | f = _forwardcopies(b, a) | ||
r = {} | ||||
|
r18355 | for k, v in sorted(f.iteritems()): | ||
|
r18136 | # remove copies | ||
if v in a: | ||||
continue | ||||
|
r15775 | r[v] = k | ||
return r | ||||
|
r24782 | def pathcopies(x, y, match=None): | ||
|
r15775 | '''find {dst@y: src@x} copy mapping for directed compare''' | ||
if x == y or not x or not y: | ||||
return {} | ||||
a = y.ancestor(x) | ||||
if a == x: | ||||
|
r24782 | return _forwardcopies(x, y, match=match) | ||
|
r15775 | if a == y: | ||
|
r18136 | return _backwardrenames(x, y) | ||
|
r24782 | return _chain(x, y, _backwardrenames(x, a), | ||
_forwardcopies(a, y, match=match)) | ||||
|
r15774 | |||
|
r24625 | def _computenonoverlap(repo, c1, c2, addedinm1, addedinm2): | ||
"""Computes, based on addedinm1 and addedinm2, the files exclusive to c1 | ||||
and c2. This is its own function so extensions can easily wrap this call | ||||
|
r24187 | to see what files mergecopies is about to process. | ||
|
r24273 | |||
|
r24625 | Even though c1 and c2 are not used in this function, they are useful in | ||
|
r24273 | other extensions for being able to read the file nodes of the changed files. | ||
|
r24010 | """ | ||
|
r24185 | u1 = sorted(addedinm1 - addedinm2) | ||
u2 = sorted(addedinm2 - addedinm1) | ||||
|
r24010 | |||
if u1: | ||||
repo.ui.debug(" unmatched files in local:\n %s\n" | ||||
% "\n ".join(u1)) | ||||
if u2: | ||||
repo.ui.debug(" unmatched files in other:\n %s\n" | ||||
% "\n ".join(u2)) | ||||
return u1, u2 | ||||
|
r16169 | def mergecopies(repo, c1, c2, ca): | ||
|
r6274 | """ | ||
|
r16168 | Find moves and copies between context c1 and c2 that are relevant | ||
for merging. | ||||
|
r18134 | Returns four dicts: "copy", "movewithdir", "diverge", and | ||
"renamedelete". | ||||
|
r16168 | |||
|
r16177 | "copy" is a mapping from destination name -> source name, | ||
|
r16168 | where source is in c1 and destination is in c2 or vice-versa. | ||
|
r18134 | "movewithdir" is a mapping from source name -> destination name, | ||
where the file at source present in one context but not the other | ||||
needs to be moved to destination by the merge process, because the | ||||
other context moved the directory it is in. | ||||
|
r16168 | "diverge" is a mapping of source name -> list of destination names | ||
for divergent renames. | ||||
|
r16794 | |||
"renamedelete" is a mapping of source name -> list of destination | ||||
names for files deleted in c1 that were renamed in c2 or vice-versa. | ||||
|
r6274 | """ | ||
# avoid silly behavior for update from empty dir | ||||
|
r6430 | if not c1 or not c2 or c1 == c2: | ||
|
r18134 | return {}, {}, {}, {} | ||
|
r6274 | |||
|
r6646 | # avoid silly behavior for parent -> working dir | ||
|
r13878 | if c2.node() is None and c1.node() == repo.dirstate.p1(): | ||
|
r18134 | return repo.dirstate.copies(), {}, {}, {} | ||
|
r6646 | |||
|
r6431 | limit = _findlimit(repo, c1.rev(), c2.rev()) | ||
|
r10179 | if limit is None: | ||
# no common ancestor, no copies | ||||
|
r18134 | return {}, {}, {}, {} | ||
|
r6274 | m1 = c1.manifest() | ||
m2 = c2.manifest() | ||||
ma = ca.manifest() | ||||
|
r24412 | |||
def setupctx(ctx): | ||||
"""return a 'makectx' function suitable for checkcopies usage from ctx | ||||
We have to re-setup the function building 'filectx' for each | ||||
'checkcopies' to ensure the linkrev adjustement is properly setup for | ||||
each. Linkrev adjustment is important to avoid bug in rename | ||||
detection. Moreover, having a proper '_ancestrycontext' setup ensures | ||||
the performance impact of this adjustment is kept limited. Without it, | ||||
each file could do a full dag traversal making the time complexity of | ||||
the operation explode (see issue4537). | ||||
|
r6274 | |||
|
r24412 | This function exists here mostly to limit the impact on stable. Feel | ||
free to refactor on default. | ||||
""" | ||||
rev = ctx.rev() | ||||
ac = getattr(ctx, '_ancestrycontext', None) | ||||
if ac is None: | ||||
revs = [rev] | ||||
if rev is None: | ||||
revs = [p.rev() for p in ctx.parents()] | ||||
ac = ctx._repo.changelog.ancestors(revs, inclusive=True) | ||||
ctx._ancestrycontext = ac | ||||
def makectx(f, n): | ||||
if len(n) != 20: # in a working context? | ||||
if c1.rev() is None: | ||||
return c1.filectx(f) | ||||
return c2.filectx(f) | ||||
fctx = repo.filectx(f, fileid=n) | ||||
# setup only needed for filectx not create from a changectx | ||||
fctx._ancestrycontext = ac | ||||
fctx._descendantrev = rev | ||||
return fctx | ||||
return util.lrucachefunc(makectx) | ||||
|
r6274 | copy = {} | ||
|
r18134 | movewithdir = {} | ||
|
r6274 | fullcopy = {} | ||
diverge = {} | ||||
|
r9467 | repo.ui.debug(" searching for copies back to rev %d\n" % limit) | ||
|
r6274 | |||
|
r24187 | addedinm1 = m1.filesnotin(ma) | ||
addedinm2 = m2.filesnotin(ma) | ||||
|
r24625 | u1, u2 = _computenonoverlap(repo, c1, c2, addedinm1, addedinm2) | ||
|
r6274 | |||
for f in u1: | ||||
|
r24412 | ctx = setupctx(c1) | ||
|
r20989 | checkcopies(ctx, f, m1, m2, ca, limit, diverge, copy, fullcopy) | ||
|
r6274 | for f in u2: | ||
|
r24412 | ctx = setupctx(c2) | ||
|
r20989 | checkcopies(ctx, f, m2, m1, ca, limit, diverge, copy, fullcopy) | ||
|
r6274 | |||
|
r16794 | renamedelete = {} | ||
|
r16795 | renamedelete2 = set() | ||
|
r8152 | diverge2 = set() | ||
|
r6274 | for of, fl in diverge.items(): | ||
|
r16792 | if len(fl) == 1 or of in c1 or of in c2: | ||
|
r12683 | del diverge[of] # not actually divergent, or not a rename | ||
|
r16794 | if of not in c1 and of not in c2: | ||
# renamed on one side, deleted on the other side, but filter | ||||
# out files that have been renamed and then deleted | ||||
renamedelete[of] = [f for f in fl if f in c1 or f in c2] | ||||
|
r16795 | renamedelete2.update(fl) # reverse map for below | ||
|
r6274 | else: | ||
|
r8152 | diverge2.update(fl) # reverse map for below | ||
|
r6274 | |||
|
r24186 | bothnew = sorted(addedinm1 & addedinm2) | ||
|
r20641 | if bothnew: | ||
repo.ui.debug(" unmatched files new in both:\n %s\n" | ||||
% "\n ".join(bothnew)) | ||||
bothdiverge, _copy, _fullcopy = {}, {}, {} | ||||
for f in bothnew: | ||||
|
r24412 | ctx = setupctx(c1) | ||
|
r20641 | checkcopies(ctx, f, m1, m2, ca, limit, bothdiverge, _copy, _fullcopy) | ||
|
r24412 | ctx = setupctx(c2) | ||
|
r20641 | checkcopies(ctx, f, m2, m1, ca, limit, bothdiverge, _copy, _fullcopy) | ||
for of, fl in bothdiverge.items(): | ||||
if len(fl) == 2 and fl[0] == fl[1]: | ||||
copy[fl[0]] = of # not actually divergent, just matching renames | ||||
|
r20990 | if fullcopy and repo.ui.debugflag: | ||
|
r16795 | repo.ui.debug(" all copies found (* = to merge, ! = divergent, " | ||
"% = renamed and deleted):\n") | ||||
|
r18362 | for f in sorted(fullcopy): | ||
|
r6274 | note = "" | ||
|
r10282 | if f in copy: | ||
note += "*" | ||||
if f in diverge2: | ||||
note += "!" | ||||
|
r16795 | if f in renamedelete2: | ||
note += "%" | ||||
|
r18135 | repo.ui.debug(" src: '%s' -> dst: '%s' %s\n" % (fullcopy[f], f, | ||
note)) | ||||
|
r6274 | del diverge2 | ||
|
r16169 | if not fullcopy: | ||
|
r18134 | return copy, movewithdir, diverge, renamedelete | ||
|
r6274 | |||
|
r9467 | repo.ui.debug(" checking for directory renames\n") | ||
|
r6274 | |||
# generate a directory move map | ||||
|
r16178 | d1, d2 = c1.dirs(), c2.dirs() | ||
|
r25288 | # Hack for adding '', which is not otherwise added, to d1 and d2 | ||
|
r18899 | d1.addpath('/') | ||
d2.addpath('/') | ||||
|
r17055 | invalid = set() | ||
|
r6274 | dirmove = {} | ||
# examine each file copy for a potential directory move, which is | ||||
# when all the files in a directory are moved to a new directory | ||||
|
r7622 | for dst, src in fullcopy.iteritems(): | ||
|
r25282 | dsrc, ddst = pathutil.dirname(src), pathutil.dirname(dst) | ||
|
r6274 | if dsrc in invalid: | ||
# already seen to be uninteresting | ||||
continue | ||||
elif dsrc in d1 and ddst in d1: | ||||
# directory wasn't entirely moved locally | ||||
|
r8468 | invalid.add(dsrc) | ||
|
r6274 | elif dsrc in d2 and ddst in d2: | ||
# directory wasn't entirely moved remotely | ||||
|
r8468 | invalid.add(dsrc) | ||
|
r6274 | elif dsrc in dirmove and dirmove[dsrc] != ddst: | ||
# files from the same directory moved to two different places | ||||
|
r8468 | invalid.add(dsrc) | ||
|
r6274 | else: | ||
# looks good so far | ||||
dirmove[dsrc + "/"] = ddst + "/" | ||||
for i in invalid: | ||||
if i in dirmove: | ||||
del dirmove[i] | ||||
del d1, d2, invalid | ||||
if not dirmove: | ||||
|
r18134 | return copy, movewithdir, diverge, renamedelete | ||
|
r6274 | |||
for d in dirmove: | ||||
|
r18135 | repo.ui.debug(" discovered dir src: '%s' -> dst: '%s'\n" % | ||
(d, dirmove[d])) | ||||
|
r6274 | |||
# check unaccounted nonoverlapping files against directory moves | ||||
for f in u1 + u2: | ||||
if f not in fullcopy: | ||||
for d in dirmove: | ||||
if f.startswith(d): | ||||
# new file added in a directory that was moved, move it | ||||
|
r6425 | df = dirmove[d] + f[len(d):] | ||
|
r6426 | if df not in copy: | ||
|
r18134 | movewithdir[f] = df | ||
|
r18135 | repo.ui.debug((" pending file src: '%s' -> " | ||
"dst: '%s'\n") % (f, df)) | ||||
|
r6274 | break | ||
|
r18134 | return copy, movewithdir, diverge, renamedelete | ||
|
r19178 | |||
def checkcopies(ctx, f, m1, m2, ca, limit, diverge, copy, fullcopy): | ||||
""" | ||||
check possible copies of f from m1 to m2 | ||||
ctx = function accepting (filename, node) that returns a filectx. | ||||
f = the filename to check | ||||
m1 = the source manifest | ||||
m2 = the destination manifest | ||||
ca = the changectx of the common ancestor | ||||
limit = the rev number to not search beyond | ||||
diverge = record all diverges in this dict | ||||
copy = record all non-divergent copies in this dict | ||||
fullcopy = record all copies in this dict | ||||
""" | ||||
ma = ca.manifest() | ||||
def _related(f1, f2, limit): | ||||
# Walk back to common ancestor to see if the two files originate | ||||
# from the same file. Since workingfilectx's rev() is None it messes | ||||
# up the integer comparison logic, hence the pre-step check for | ||||
# None (f1 and f2 can only be workingfilectx's initially). | ||||
if f1 == f2: | ||||
return f1 # a match | ||||
g1, g2 = f1.ancestors(), f2.ancestors() | ||||
try: | ||||
|
r25279 | f1r, f2r = f1.linkrev(), f2.linkrev() | ||
|
r19178 | |||
if f1r is None: | ||||
f1 = g1.next() | ||||
if f2r is None: | ||||
f2 = g2.next() | ||||
while True: | ||||
|
r25279 | f1r, f2r = f1.linkrev(), f2.linkrev() | ||
|
r19178 | if f1r > f2r: | ||
f1 = g1.next() | ||||
elif f2r > f1r: | ||||
f2 = g2.next() | ||||
elif f1 == f2: | ||||
return f1 # a match | ||||
elif f1r == f2r or f1r < limit or f2r < limit: | ||||
return False # copy no longer relevant | ||||
except StopIteration: | ||||
return False | ||||
of = None | ||||
seen = set([f]) | ||||
for oc in ctx(f, m1[f]).ancestors(): | ||||
|
r25279 | ocr = oc.linkrev() | ||
|
r19178 | of = oc.path() | ||
if of in seen: | ||||
# check limit late - grab last rename before | ||||
if ocr < limit: | ||||
break | ||||
continue | ||||
seen.add(of) | ||||
fullcopy[f] = of # remember for dir rename detection | ||||
if of not in m2: | ||||
continue # no match, keep looking | ||||
if m2[of] == ma.get(of): | ||||
break # no merge needed, quit early | ||||
c2 = ctx(of, m2[of]) | ||||
cr = _related(oc, c2, ca.rev()) | ||||
if cr and (of == f or of == c2.path()): # non-divergent | ||||
copy[f] = of | ||||
of = None | ||||
break | ||||
if of in ma: | ||||
diverge.setdefault(of, []).append(f) | ||||
|
r22901 | |||
def duplicatecopies(repo, rev, fromrev, skiprev=None): | ||||
'''reproduce copies from fromrev to rev in the dirstate | ||||
If skiprev is specified, it's a revision that should be used to | ||||
filter copy records. Any copies that occur between fromrev and | ||||
skiprev will not be duplicated, even if they appear in the set of | ||||
copies between fromrev and rev. | ||||
''' | ||||
exclude = {} | ||||
if skiprev is not None: | ||||
exclude = pathcopies(repo[fromrev], repo[skiprev]) | ||||
for dst, src in pathcopies(repo[fromrev], repo[rev]).iteritems(): | ||||
# copies.pathcopies returns backward renames, so dst might not | ||||
# actually be in the dirstate | ||||
if dst in exclude: | ||||
continue | ||||
if repo.dirstate[dst] in "nma": | ||||
repo.dirstate.copy(src, dst) | ||||