subrepoutil.py
510 lines
| 17.4 KiB
| text/x-python
|
PythonLexer
/ mercurial / subrepoutil.py
|
r36026 | # subrepoutil.py - sub-repository operations and substate handling | ||
# | ||||
|
r47575 | # Copyright 2009-2010 Olivia Mackall <olivia@selenic.com> | ||
|
r36026 | # | ||
# This software may be used and distributed according to the terms of the | ||||
# GNU General Public License version 2 or any later version. | ||||
from __future__ import absolute_import | ||||
import errno | ||||
import os | ||||
import posixpath | ||||
import re | ||||
from .i18n import _ | ||||
|
r43359 | from .pycompat import getattr | ||
|
r36026 | from . import ( | ||
config, | ||||
error, | ||||
filemerge, | ||||
pathutil, | ||||
phases, | ||||
|
r43376 | pycompat, | ||
|
r36026 | util, | ||
) | ||||
r47669 | from .utils import ( | |||
stringutil, | ||||
urlutil, | ||||
) | ||||
|
r36026 | |||
|
r43347 | nullstate = (b'', b'', b'empty') | ||
|
r36026 | |||
|
r47389 | if pycompat.TYPE_CHECKING: | ||
from typing import ( | ||||
Any, | ||||
Dict, | ||||
List, | ||||
Optional, | ||||
Set, | ||||
Tuple, | ||||
) | ||||
from . import ( | ||||
context, | ||||
localrepo, | ||||
match as matchmod, | ||||
scmutil, | ||||
subrepo, | ||||
ui as uimod, | ||||
) | ||||
Substate = Dict[bytes, Tuple[bytes, bytes, bytes]] | ||||
|
r43346 | |||
|
r36026 | def state(ctx, ui): | ||
|
r47389 | # type: (context.changectx, uimod.ui) -> Substate | ||
|
r36026 | """return a state dict, mapping subrepo paths configured in .hgsub | ||
to tuple: (source from .hgsub, revision from .hgsubstate, kind | ||||
(key in types dict)) | ||||
""" | ||||
p = config.config() | ||||
repo = ctx.repo() | ||||
|
r43346 | |||
|
r45817 | def read(f, sections=None, remap=None): | ||
|
r36026 | if f in ctx: | ||
try: | ||||
data = ctx[f].data() | ||||
except IOError as err: | ||||
if err.errno != errno.ENOENT: | ||||
raise | ||||
# handle missing subrepo spec files as removed | ||||
|
r43346 | ui.warn( | ||
|
r43347 | _(b"warning: subrepo spec file \'%s\' not found\n") | ||
|
r43346 | % repo.pathto(f) | ||
) | ||||
|
r36026 | return | ||
p.parse(f, data, sections, remap, read) | ||||
else: | ||||
|
r43346 | raise error.Abort( | ||
|
r43347 | _(b"subrepo spec file \'%s\' not found") % repo.pathto(f) | ||
|
r43346 | ) | ||
|
r43347 | if b'.hgsub' in ctx: | ||
|
r45817 | read(b'.hgsub') | ||
|
r36026 | |||
|
r43347 | for path, src in ui.configitems(b'subpaths'): | ||
p.set(b'subpaths', path, src, ui.configsource(b'subpaths', path)) | ||||
|
r36026 | |||
rev = {} | ||||
|
r43347 | if b'.hgsubstate' in ctx: | ||
|
r36026 | try: | ||
|
r43347 | for i, l in enumerate(ctx[b'.hgsubstate'].data().splitlines()): | ||
|
r36026 | l = l.lstrip() | ||
if not l: | ||||
continue | ||||
try: | ||||
|
r43347 | revision, path = l.split(b" ", 1) | ||
|
r36026 | except ValueError: | ||
|
r43346 | raise error.Abort( | ||
_( | ||||
|
r43347 | b"invalid subrepository revision " | ||
b"specifier in \'%s\' line %d" | ||||
|
r43346 | ) | ||
|
r43347 | % (repo.pathto(b'.hgsubstate'), (i + 1)) | ||
|
r43346 | ) | ||
|
r36026 | rev[path] = revision | ||
except IOError as err: | ||||
if err.errno != errno.ENOENT: | ||||
raise | ||||
def remap(src): | ||||
|
r47389 | # type: (bytes) -> bytes | ||
|
r43347 | for pattern, repl in p.items(b'subpaths'): | ||
|
r36026 | # Turn r'C:\foo\bar' into r'C:\\foo\\bar' since re.sub | ||
# does a string decode. | ||||
|
r37102 | repl = stringutil.escapestr(repl) | ||
|
r36026 | # However, we still want to allow back references to go | ||
# through unharmed, so we turn r'\\1' into r'\1'. Again, | ||||
# extra escapes are needed because re.sub string decodes. | ||||
repl = re.sub(br'\\\\([0-9]+)', br'\\\1', repl) | ||||
try: | ||||
src = re.sub(pattern, repl, src, 1) | ||||
except re.error as e: | ||||
|
r43346 | raise error.Abort( | ||
|
r43347 | _(b"bad subrepository pattern in %s: %s") | ||
|
r43346 | % ( | ||
|
r43347 | p.source(b'subpaths', pattern), | ||
|
r43346 | stringutil.forcebytestr(e), | ||
) | ||||
) | ||||
|
r36026 | return src | ||
state = {} | ||||
|
r47389 | for path, src in p.items(b''): # type: bytes | ||
|
r43347 | kind = b'hg' | ||
if src.startswith(b'['): | ||||
if b']' not in src: | ||||
raise error.Abort(_(b'missing ] in subrepository source')) | ||||
kind, src = src.split(b']', 1) | ||||
|
r36026 | kind = kind[1:] | ||
|
r43346 | src = src.lstrip() # strip any extra whitespace after ']' | ||
|
r36026 | |||
r47669 | if not urlutil.url(src).isabs(): | |||
|
r36026 | parent = _abssource(repo, abort=False) | ||
if parent: | ||||
r47669 | parent = urlutil.url(parent) | |||
|
r43347 | parent.path = posixpath.join(parent.path or b'', src) | ||
|
r36026 | parent.path = posixpath.normpath(parent.path) | ||
|
r37604 | joined = bytes(parent) | ||
|
r36026 | # Remap the full joined path and use it if it changes, | ||
# else remap the original source. | ||||
remapped = remap(joined) | ||||
if remapped == joined: | ||||
src = remap(src) | ||||
else: | ||||
src = remapped | ||||
src = remap(src) | ||||
|
r43347 | state[util.pconvert(path)] = (src.strip(), rev.get(path, b''), kind) | ||
|
r36026 | |||
return state | ||||
|
r43346 | |||
|
r36026 | def writestate(repo, state): | ||
|
r47389 | # type: (localrepo.localrepository, Substate) -> None | ||
|
r36026 | """rewrite .hgsubstate in (outer) repo with these subrepo states""" | ||
|
r43346 | lines = [ | ||
|
r43347 | b'%s %s\n' % (state[s][1], s) | ||
|
r43346 | for s in sorted(state) | ||
if state[s][1] != nullstate[1] | ||||
] | ||||
|
r43347 | repo.wwrite(b'.hgsubstate', b''.join(lines), b'') | ||
|
r36026 | |||
|
r43346 | |||
|
r36026 | def submerge(repo, wctx, mctx, actx, overwrite, labels=None): | ||
|
r47389 | # type: (localrepo.localrepository, context.workingctx, context.changectx, context.changectx, bool, Optional[Any]) -> Substate | ||
# TODO: type the `labels` arg | ||||
|
r36026 | """delegated from merge.applyupdates: merging of .hgsubstate file | ||
in working context, merging context and ancestor context""" | ||||
|
r43346 | if mctx == actx: # backwards? | ||
|
r36026 | actx = wctx.p1() | ||
s1 = wctx.substate | ||||
s2 = mctx.substate | ||||
sa = actx.substate | ||||
sm = {} | ||||
|
r43347 | repo.ui.debug(b"subrepo merge %s %s %s\n" % (wctx, mctx, actx)) | ||
|
r36026 | |||
|
r43347 | def debug(s, msg, r=b""): | ||
|
r36026 | if r: | ||
|
r43347 | r = b"%s:%s:%s" % r | ||
repo.ui.debug(b" subrepo %s: %s %s\n" % (s, msg, r)) | ||||
|
r36026 | |||
promptssrc = filemerge.partextras(labels) | ||||
|
r43376 | for s, l in sorted(pycompat.iteritems(s1)): | ||
|
r36026 | a = sa.get(s, nullstate) | ||
|
r43346 | ld = l # local state with possible dirty flag for compares | ||
|
r36026 | if wctx.sub(s).dirty(): | ||
|
r43347 | ld = (l[0], l[1] + b"+") | ||
|
r43346 | if wctx == actx: # overwrite | ||
|
r36026 | a = ld | ||
prompts = promptssrc.copy() | ||||
|
r43347 | prompts[b's'] = s | ||
|
r36026 | if s in s2: | ||
r = s2[s] | ||||
|
r43346 | if ld == r or r == a: # no change or local is newer | ||
|
r36026 | sm[s] = l | ||
continue | ||||
|
r43346 | elif ld == a: # other side changed | ||
|
r43347 | debug(s, b"other changed, get", r) | ||
|
r36026 | wctx.sub(s).get(r, overwrite) | ||
sm[s] = r | ||||
|
r43346 | elif ld[0] != r[0]: # sources differ | ||
|
r43347 | prompts[b'lo'] = l[0] | ||
prompts[b'ro'] = r[0] | ||||
|
r36026 | if repo.ui.promptchoice( | ||
|
r43346 | _( | ||
|
r43347 | b' subrepository sources for %(s)s differ\n' | ||
b'you can use (l)ocal%(l)s source (%(lo)s)' | ||||
b' or (r)emote%(o)s source (%(ro)s).\n' | ||||
b'what do you want to do?' | ||||
b'$$ &Local $$ &Remote' | ||||
|
r43346 | ) | ||
% prompts, | ||||
0, | ||||
): | ||||
|
r43347 | debug(s, b"prompt changed, get", r) | ||
|
r36026 | wctx.sub(s).get(r, overwrite) | ||
sm[s] = r | ||||
|
r43346 | elif ld[1] == a[1]: # local side is unchanged | ||
|
r43347 | debug(s, b"other side changed, get", r) | ||
|
r36026 | wctx.sub(s).get(r, overwrite) | ||
sm[s] = r | ||||
else: | ||||
|
r43347 | debug(s, b"both sides changed") | ||
|
r36026 | srepo = wctx.sub(s) | ||
|
r43347 | prompts[b'sl'] = srepo.shortid(l[1]) | ||
prompts[b'sr'] = srepo.shortid(r[1]) | ||||
|
r36026 | option = repo.ui.promptchoice( | ||
|
r43346 | _( | ||
|
r43347 | b' subrepository %(s)s diverged (local revision: %(sl)s, ' | ||
b'remote revision: %(sr)s)\n' | ||||
b'you can (m)erge, keep (l)ocal%(l)s or keep ' | ||||
b'(r)emote%(o)s.\n' | ||||
b'what do you want to do?' | ||||
b'$$ &Merge $$ &Local $$ &Remote' | ||||
|
r43346 | ) | ||
% prompts, | ||||
0, | ||||
) | ||||
|
r36026 | if option == 0: | ||
wctx.sub(s).merge(r) | ||||
sm[s] = l | ||||
|
r43347 | debug(s, b"merge with", r) | ||
|
r36026 | elif option == 1: | ||
sm[s] = l | ||||
|
r43347 | debug(s, b"keep local subrepo revision", l) | ||
|
r36026 | else: | ||
wctx.sub(s).get(r, overwrite) | ||||
sm[s] = r | ||||
|
r43347 | debug(s, b"get remote subrepo revision", r) | ||
|
r43346 | elif ld == a: # remote removed, local unchanged | ||
|
r43347 | debug(s, b"remote removed, remove") | ||
|
r36026 | wctx.sub(s).remove() | ||
|
r43346 | elif a == nullstate: # not present in remote or ancestor | ||
|
r43347 | debug(s, b"local added, keep") | ||
|
r36026 | sm[s] = l | ||
continue | ||||
else: | ||||
if repo.ui.promptchoice( | ||||
|
r43346 | _( | ||
|
r43347 | b' local%(l)s changed subrepository %(s)s' | ||
b' which remote%(o)s removed\n' | ||||
b'use (c)hanged version or (d)elete?' | ||||
b'$$ &Changed $$ &Delete' | ||||
|
r43346 | ) | ||
% prompts, | ||||
0, | ||||
): | ||||
|
r43347 | debug(s, b"prompt remove") | ||
|
r36026 | wctx.sub(s).remove() | ||
for s, r in sorted(s2.items()): | ||||
if s in s1: | ||||
continue | ||||
elif s not in sa: | ||||
|
r43347 | debug(s, b"remote added, get", r) | ||
|
r36026 | mctx.sub(s).get(r) | ||
sm[s] = r | ||||
elif r != sa[s]: | ||||
prompts = promptssrc.copy() | ||||
|
r43347 | prompts[b's'] = s | ||
|
r43346 | if ( | ||
repo.ui.promptchoice( | ||||
_( | ||||
|
r43347 | b' remote%(o)s changed subrepository %(s)s' | ||
b' which local%(l)s removed\n' | ||||
b'use (c)hanged version or (d)elete?' | ||||
b'$$ &Changed $$ &Delete' | ||||
|
r43346 | ) | ||
% prompts, | ||||
0, | ||||
) | ||||
== 0 | ||||
): | ||||
|
r43347 | debug(s, b"prompt recreate", r) | ||
|
r36026 | mctx.sub(s).get(r) | ||
sm[s] = r | ||||
# record merged .hgsubstate | ||||
writestate(repo, sm) | ||||
return sm | ||||
|
r43346 | |||
|
r36026 | def precommit(ui, wctx, status, match, force=False): | ||
|
r47389 | # type: (uimod.ui, context.workingcommitctx, scmutil.status, matchmod.basematcher, bool) -> Tuple[List[bytes], Set[bytes], Substate] | ||
|
r36026 | """Calculate .hgsubstate changes that should be applied before committing | ||
Returns (subs, commitsubs, newstate) where | ||||
- subs: changed subrepos (including dirty ones) | ||||
- commitsubs: dirty subrepos which the caller needs to commit recursively | ||||
- newstate: new state dict which the caller must write to .hgsubstate | ||||
This also updates the given status argument. | ||||
""" | ||||
subs = [] | ||||
commitsubs = set() | ||||
newstate = wctx.substate.copy() | ||||
# only manage subrepos and .hgsubstate if .hgsub is present | ||||
|
r43347 | if b'.hgsub' in wctx: | ||
|
r36026 | # we'll decide whether to track this ourselves, thanks | ||
for c in status.modified, status.added, status.removed: | ||||
|
r43347 | if b'.hgsubstate' in c: | ||
c.remove(b'.hgsubstate') | ||||
|
r36026 | |||
# compare current state to last committed state | ||||
# build new substate based on last committed state | ||||
oldstate = wctx.p1().substate | ||||
for s in sorted(newstate.keys()): | ||||
if not match(s): | ||||
# ignore working copy, use old state if present | ||||
if s in oldstate: | ||||
newstate[s] = oldstate[s] | ||||
continue | ||||
if not force: | ||||
raise error.Abort( | ||||
|
r43347 | _(b"commit with new subrepo %s excluded") % s | ||
|
r43346 | ) | ||
|
r36026 | dirtyreason = wctx.sub(s).dirtyreason(True) | ||
if dirtyreason: | ||||
|
r43347 | if not ui.configbool(b'ui', b'commitsubrepos'): | ||
|
r43346 | raise error.Abort( | ||
dirtyreason, | ||||
|
r43347 | hint=_(b"use --subrepos for recursive commit"), | ||
|
r43346 | ) | ||
|
r36026 | subs.append(s) | ||
commitsubs.add(s) | ||||
else: | ||||
bs = wctx.sub(s).basestate() | ||||
newstate[s] = (newstate[s][0], bs, newstate[s][2]) | ||||
if oldstate.get(s, (None, None, None))[1] != bs: | ||||
subs.append(s) | ||||
# check for removed subrepos | ||||
for p in wctx.parents(): | ||||
r = [s for s in p.substate if s not in newstate] | ||||
subs += [s for s in r if match(s)] | ||||
if subs: | ||||
|
r43347 | if not match(b'.hgsub') and b'.hgsub' in ( | ||
|
r43346 | wctx.modified() + wctx.added() | ||
): | ||||
|
r43347 | raise error.Abort(_(b"can't commit subrepos without .hgsub")) | ||
status.modified.insert(0, b'.hgsubstate') | ||||
|
r36026 | |||
|
r43347 | elif b'.hgsub' in status.removed: | ||
|
r36026 | # clean up .hgsubstate when .hgsub is removed | ||
|
r43347 | if b'.hgsubstate' in wctx and b'.hgsubstate' not in ( | ||
|
r43346 | status.modified + status.added + status.removed | ||
): | ||||
|
r43347 | status.removed.insert(0, b'.hgsubstate') | ||
|
r36026 | |||
return subs, commitsubs, newstate | ||||
|
r43346 | |||
r47691 | def repo_rel_or_abs_source(repo): | |||
"""return the source of this repo | ||||
Either absolute or relative the outermost repo""" | ||||
parent = repo | ||||
chunks = [] | ||||
while util.safehasattr(parent, b'_subparent'): | ||||
source = urlutil.url(parent._subsource) | ||||
chunks.append(bytes(source)) | ||||
if source.isabs(): | ||||
break | ||||
parent = parent._subparent | ||||
chunks.reverse() | ||||
path = posixpath.join(*chunks) | ||||
return posixpath.normpath(path) | ||||
|
r36026 | def reporelpath(repo): | ||
|
r47389 | # type: (localrepo.localrepository) -> bytes | ||
|
r36026 | """return path to this (sub)repo as seen from outermost repo""" | ||
parent = repo | ||||
|
r43347 | while util.safehasattr(parent, b'_subparent'): | ||
|
r36026 | parent = parent._subparent | ||
|
r43346 | return repo.root[len(pathutil.normasprefix(parent.root)) :] | ||
|
r36026 | |||
def subrelpath(sub): | ||||
|
r47389 | # type: (subrepo.abstractsubrepo) -> bytes | ||
|
r36026 | """return path to this subrepo as seen from outermost repo""" | ||
return sub._relpath | ||||
|
r43346 | |||
|
r36026 | def _abssource(repo, push=False, abort=True): | ||
|
r47389 | # type: (localrepo.localrepository, bool, bool) -> Optional[bytes] | ||
|
r36026 | """return pull/push path of repo - either based on parent repo .hgsub info | ||
or on the top repo config. Abort or return None if no source found.""" | ||||
|
r43347 | if util.safehasattr(repo, b'_subparent'): | ||
r47669 | source = urlutil.url(repo._subsource) | |||
|
r36026 | if source.isabs(): | ||
return bytes(source) | ||||
source.path = posixpath.normpath(source.path) | ||||
parent = _abssource(repo._subparent, push, abort=False) | ||||
if parent: | ||||
r47669 | parent = urlutil.url(util.pconvert(parent)) | |||
|
r43347 | parent.path = posixpath.join(parent.path or b'', source.path) | ||
|
r36026 | parent.path = posixpath.normpath(parent.path) | ||
return bytes(parent) | ||||
|
r43346 | else: # recursion reached top repo | ||
|
r36026 | path = None | ||
|
r43347 | if util.safehasattr(repo, b'_subtoppath'): | ||
|
r36026 | path = repo._subtoppath | ||
|
r43347 | elif push and repo.ui.config(b'paths', b'default-push'): | ||
path = repo.ui.config(b'paths', b'default-push') | ||||
elif repo.ui.config(b'paths', b'default'): | ||||
path = repo.ui.config(b'paths', b'default') | ||||
|
r36026 | elif repo.shared(): | ||
# chop off the .hg component to get the default path form. This has | ||||
# already run through vfsmod.vfs(..., realpath=True), so it doesn't | ||||
# have problems with 'C:' | ||||
return os.path.dirname(repo.sharedpath) | ||||
if path: | ||||
# issue5770: 'C:\' and 'C:' are not equivalent paths. The former is | ||||
# as expected: an absolute path to the root of the C: drive. The | ||||
# latter is a relative path, and works like so: | ||||
# | ||||
# C:\>cd C:\some\path | ||||
# C:\>D: | ||||
# D:\>python -c "import os; print os.path.abspath('C:')" | ||||
# C:\some\path | ||||
# | ||||
# D:\>python -c "import os; print os.path.abspath('C:relative')" | ||||
# C:\some\path\relative | ||||
r47669 | if urlutil.hasdriveletter(path): | |||
|
r36026 | if len(path) == 2 or path[2:3] not in br'\/': | ||
path = os.path.abspath(path) | ||||
return path | ||||
if abort: | ||||
|
r43347 | raise error.Abort(_(b"default path for subrepository not found")) | ||
|
r36026 | |||
|
r43346 | |||
|
r36026 | def newcommitphase(ui, ctx): | ||
|
r47389 | # type: (uimod.ui, context.changectx) -> int | ||
|
r36026 | commitphase = phases.newcommitphase(ui) | ||
substate = getattr(ctx, "substate", None) | ||||
if not substate: | ||||
return commitphase | ||||
|
r43347 | check = ui.config(b'phases', b'checksubrepos') | ||
if check not in (b'ignore', b'follow', b'abort'): | ||||
|
r43346 | raise error.Abort( | ||
|
r43347 | _(b'invalid phases.checksubrepos configuration: %s') % check | ||
|
r43346 | ) | ||
|
r43347 | if check == b'ignore': | ||
|
r36026 | return commitphase | ||
maxphase = phases.public | ||||
maxsub = None | ||||
for s in sorted(substate): | ||||
sub = ctx.sub(s) | ||||
subphase = sub.phase(substate[s][1]) | ||||
if maxphase < subphase: | ||||
maxphase = subphase | ||||
maxsub = s | ||||
if commitphase < maxphase: | ||||
|
r43347 | if check == b'abort': | ||
|
r43346 | raise error.Abort( | ||
_( | ||||
|
r43347 | b"can't commit in %s phase" | ||
b" conflicting %s from subrepository %s" | ||||
|
r43346 | ) | ||
% ( | ||||
phases.phasenames[commitphase], | ||||
phases.phasenames[maxphase], | ||||
maxsub, | ||||
) | ||||
) | ||||
ui.warn( | ||||
_( | ||||
|
r43347 | b"warning: changes are committed in" | ||
b" %s phase from subrepository %s\n" | ||||
|
r43346 | ) | ||
% (phases.phasenames[maxphase], maxsub) | ||||
) | ||||
|
r36026 | return maxphase | ||
return commitphase | ||||