changeset 34555:989e884d1be9

merge: check for path conflicts when merging (issue5628) When merging, check for any path conflicts introduced by the manifest merge and rename the conflicting file to a safe name. Differential Revision: https://phab.mercurial-scm.org/D784
author Mark Thomas <mbthomas@fb.com>
date Mon, 02 Oct 2017 14:05:30 -0700
parents 6f11a74d489f
children 7a8a16f8ea22
files mercurial/merge.py tests/test-audit-path.t tests/test-commandserver.t tests/test-pathconflicts-basic.t
diffstat 4 files changed, 137 insertions(+), 13 deletions(-) [+]
line wrap: on
line diff
--- a/mercurial/merge.py	Mon Oct 02 14:05:30 2017 -0700
+++ b/mercurial/merge.py	Mon Oct 02 14:05:30 2017 -0700
@@ -851,6 +851,107 @@
     This is currently not implemented -- it's an extension point."""
     return True
 
+def _filesindirs(repo, manifest, dirs):
+    """
+    Generator that yields pairs of all the files in the manifest that are found
+    inside the directories listed in dirs, and which directory they are found
+    in.
+    """
+    for f in manifest:
+        for p in util.finddirs(f):
+            if p in dirs:
+                yield f, p
+                break
+
+def checkpathconflicts(repo, wctx, mctx, actions):
+    """
+    Check if any actions introduce path conflicts in the repository, updating
+    actions to record or handle the path conflict accordingly.
+    """
+    mf = wctx.manifest()
+
+    # The set of local files that conflict with a remote directory.
+    localconflicts = set()
+
+    # The set of directories that conflict with a remote file, and so may cause
+    # conflicts if they still contain any files after the merge.
+    remoteconflicts = set()
+
+    # The set of directories that appear as both a file and a directory in the
+    # remote manifest.  These indicate an invalid remote manifest, which
+    # can't be updated to cleanly.
+    invalidconflicts = set()
+
+    # The set of files deleted by all the actions.
+    deletedfiles = set()
+
+    for f, (m, args, msg) in actions.items():
+        if m in ('c', 'dc', 'm', 'cm'):
+            # This action may create a new local file.
+            if mf.hasdir(f):
+                # The file aliases a local directory.  This might be ok if all
+                # the files in the local directory are being deleted.  This
+                # will be checked once we know what all the deleted files are.
+                remoteconflicts.add(f)
+            for p in util.finddirs(f):
+                if p in mf:
+                    if p in mctx:
+                        # The file is in a directory which aliases both a local
+                        # and a remote file.  This is an internal inconsistency
+                        # within the remote manifest.
+                        invalidconflicts.add(p)
+                    else:
+                        # The file is in a directory which aliases a local file.
+                        # We will need to rename the local file.
+                        localconflicts.add(p)
+                if p in actions and actions[p][0] in ('c', 'dc', 'm', 'cm'):
+                    # The file is in a directory which aliases a remote file.
+                    # This is an internal inconsistency within the remote
+                    # manifest.
+                    invalidconflicts.add(p)
+
+        # Track the names of all deleted files.
+        if m == 'r':
+            deletedfiles.add(f)
+        if m == 'm':
+            f1, f2, fa, move, anc = args
+            if move:
+                deletedfiles.add(f1)
+        if m == 'dm':
+            f2, flags = args
+            deletedfiles.add(f2)
+
+    # Rename all local conflicting files that have not been deleted.
+    for p in localconflicts:
+        if p not in deletedfiles:
+            ctxname = str(wctx).rstrip('+')
+            pnew = util.safename(p, ctxname, wctx, set(actions.keys()))
+            actions[pnew] = ('pr', (p,), "local path conflict")
+            actions[p] = ('p', (pnew, 'l'), "path conflict")
+
+    if remoteconflicts:
+        # Check if all files in the conflicting directories have been removed.
+        ctxname = str(mctx).rstrip('+')
+        for f, p in _filesindirs(repo, mf, remoteconflicts):
+            if f not in deletedfiles:
+                m, args, msg = actions[p]
+                pnew = util.safename(p, ctxname, wctx, set(actions.keys()))
+                if m in ('dc', 'm'):
+                    # Action was merge, just update target.
+                    actions[pnew] = (m, args, msg)
+                else:
+                    # Action was create, change to renamed get action.
+                    fl = args[0]
+                    actions[pnew] = ('dg', (p, fl), "remote path conflict")
+                actions[p] = ('p', (pnew, 'r'), "path conflict")
+                remoteconflicts.remove(p)
+                break
+
+    if invalidconflicts:
+        for p in invalidconflicts:
+            repo.ui.warn(_("%s: is both a file and a directory\n") % p)
+        raise error.Abort(_("destination manifest contains path conflicts"))
+
 def manifestmerge(repo, wctx, p2, pa, branchmerge, force, matcher,
                   acceptremote, followcopies, forcefulldiff=False):
     """
@@ -1026,6 +1127,9 @@
                     actions[f] = ('dc', (None, f, f, False, pa.node()),
                                   "prompt deleted/changed")
 
+    # If we are merging, look for path conflicts.
+    checkpathconflicts(repo, wctx, p2, actions)
+
     return actions, diverge, renamedelete
 
 def _resolvetrivial(repo, wctx, mctx, ancestor, actions):
--- a/tests/test-audit-path.t	Mon Oct 02 14:05:30 2017 -0700
+++ b/tests/test-audit-path.t	Mon Oct 02 14:05:30 2017 -0700
@@ -103,7 +103,8 @@
   back/test
 #if symlink
   $ hg update -Cr2
-  abort: path 'back/test' traverses symbolic link 'back'
+  back: is both a file and a directory
+  abort: destination manifest contains path conflicts
   [255]
 #else
 ('back' will be a file and cause some other system specific error)
@@ -160,8 +161,12 @@
 
   $ hg up -qC 1
   $ hg merge 2
-  abort: path 'a/poisoned' traverses symbolic link 'a'
-  [255]
+  a: path conflict - a file or link has the same name as a directory
+  the local file has been renamed to a~aa04623eb0c3
+  resolve manually then use 'hg resolve --mark a'
+  1 files updated, 0 files merged, 0 files removed, 1 files unresolved
+  use 'hg resolve' to retry unresolved file merges or 'hg update -C .' to abandon
+  [1]
 
 try rebase onto other revision: cache of audited paths should be discarded,
 and the rebase should fail (issue5628)
@@ -169,8 +174,11 @@
   $ hg up -qC 2
   $ hg rebase -s 2 -d 1 --config extensions.rebase=
   rebasing 2:e73c21d6b244 "file a/poisoned" (tip)
-  abort: path 'a/poisoned' traverses symbolic link 'a'
-  [255]
+  a: path conflict - a file or link has the same name as a directory
+  the local file has been renamed to a~aa04623eb0c3
+  resolve manually then use 'hg resolve --mark a'
+  unresolved conflicts (see hg resolve, then hg rebase --continue)
+  [1]
   $ ls ../merge-symlink-out
 
   $ cd ..
@@ -202,7 +210,8 @@
 
   $ hg up -qC 0
   $ hg up 1
-  abort: path 'a/b' traverses symbolic link 'a'
+  a: is both a file and a directory
+  abort: destination manifest contains path conflicts
   [255]
 
 try linear update including symlinked directory and its content: paths are
@@ -211,7 +220,8 @@
 
   $ hg up -qC null
   $ hg up 1
-  abort: path 'a/b' traverses symbolic link 'a'
+  a: is both a file and a directory
+  abort: destination manifest contains path conflicts
   [255]
   $ ls ../update-symlink-out
 
@@ -222,7 +232,8 @@
   $ rm -f a
   $ hg up -qC 2
   $ hg up 1
-  abort: path 'a/b' traverses symbolic link 'a'
+  a: is both a file and a directory
+  abort: destination manifest contains path conflicts
   [255]
   $ ls ../update-symlink-out
 
--- a/tests/test-commandserver.t	Mon Oct 02 14:05:30 2017 -0700
+++ b/tests/test-commandserver.t	Mon Oct 02 14:05:30 2017 -0700
@@ -966,8 +966,12 @@
   *** runcommand up -qC 2
   *** runcommand up -qC 1
   *** runcommand merge 2
-  abort: path 'a/poisoned' traverses symbolic link 'a'
-   [255]
+  a: path conflict - a file or link has the same name as a directory
+  the local file has been renamed to a~aa04623eb0c3
+  resolve manually then use 'hg resolve --mark a'
+  1 files updated, 0 files merged, 0 files removed, 1 files unresolved
+  use 'hg resolve' to retry unresolved file merges or 'hg update -C .' to abandon
+   [1]
   $ ls ../merge-symlink-out
 
 cache of repo.auditor should be discarded, so matcher would never traverse
--- a/tests/test-pathconflicts-basic.t	Mon Oct 02 14:05:30 2017 -0700
+++ b/tests/test-pathconflicts-basic.t	Mon Oct 02 14:05:30 2017 -0700
@@ -25,11 +25,16 @@
   $ hg bookmark -i
   $ hg merge --verbose dir
   resolving manifests
+  a: path conflict - a file or link has the same name as a directory
+  the local file has been renamed to a~853701544ac3
+  resolve manually then use 'hg resolve --mark a'
+  moving a to a~853701544ac3
   getting a/b
-  abort: *: '$TESTTMP/repo/a/b' (glob)
-  [255]
+  1 files updated, 0 files merged, 0 files removed, 1 files unresolved
+  use 'hg resolve' to retry unresolved file merges or 'hg update -C .' to abandon
+  [1]
   $ hg update --clean .
-  0 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  1 files updated, 0 files merged, 1 files removed, 0 files unresolved
 
 Basic update - local directory conflicts with remote file