Repository: pypa/setuptools_scm
Pull request #253 (2018-05-10 15:35:32)

file finder: symlink edge cases
It turns out we had a edge case to handle, as the algorithm
included scm controlled files reachable through symlinks while the
symlink itself was not scm controlled.

Additionally, better handle situations where we include a directory
below 'path' as well as a symlink to it. Before it would randomly
return the original directory, or include it under the symlink.


Patch:
diff --git a/setuptools_scm/file_finder.py b/setuptools_scm/file_finder.py
index fb7648a..b470283 100644
--- a/setuptools_scm/file_finder.py
+++ b/setuptools_scm/file_finder.py
@@ -5,8 +5,10 @@ def scm_find_files(path, scm_files, scm_dirs):
     """ setuptools compatible file finder that follows symlinks
 
     - path: the root directory from which to search
-    - scm_files: set of scm controlled files
-    - scm_files: set of scm controlled directories
+    - scm_files: set of scm controlled files and symlinks
+      (including symlinks to directories)
+    - scm_dirs: set of scm controlled directories
+      (including directories containing no scm controlled files)
 
     scm_files and scm_dirs must be absolute with symlinks resolved (realpath),
     with normalized case (normcase)
@@ -20,10 +22,31 @@ def scm_find_files(path, scm_files, scm_dirs):
     for dirpath, dirnames, filenames in os.walk(realpath, followlinks=True):
         # dirpath with symlinks resolved
         realdirpath = os.path.normcase(os.path.realpath(dirpath))
-        if realdirpath not in scm_dirs or realdirpath in seen:
+
+        def _link_not_in_scm(n):
+            fn = os.path.join(realdirpath, os.path.normcase(n))
+            return os.path.islink(fn) and fn not in scm_files
+
+        if realdirpath not in scm_dirs:
+            # directory not in scm, don't walk it's content
+            dirnames[:] = []
+            continue
+        if os.path.islink(dirpath) and \
+                not os.path.relpath(realdirpath, realpath).startswith(os.pardir):
+            # a symlink to a directory not outside path:
+            # we keep it in the result and don't walk its content
+            res.append(
+                os.path.join(path, os.path.relpath(dirpath, path)))
+            dirnames[:] = []
+            continue
+        if realdirpath in seen:
+            # symlink loop protection
             dirnames[:] = []
             continue
+        dirnames[:] = [dn for dn in dirnames if not _link_not_in_scm(dn)]
         for filename in filenames:
+            if _link_not_in_scm(filename):
+                continue
             # dirpath + filename with symlinks preserved
             fullfilename = os.path.join(dirpath, filename)
             if os.path.normcase(os.path.realpath(fullfilename)) in scm_files:


Test patch:
diff --git a/testing/test_file_finder.py b/testing/test_file_finder.py
index 700c7d4..830deaa 100644
--- a/testing/test_file_finder.py
+++ b/testing/test_file_finder.py
@@ -91,7 +91,7 @@ def test_symlink_file(inwd):
     inwd.add_and_commit()
     assert set(find_files('adir')) == _sep({
         'adir/filea',
-        'adir/file1link',
+        'adir/file1link',  # -> ../file1
     })
 
 
@@ -102,6 +102,7 @@ def test_symlink_loop(inwd):
     inwd.add_and_commit()
     assert set(find_files('adir')) == _sep({
         'adir/filea',
+        'adir/loop',  # -> ../adir
     })
 
 
@@ -145,3 +146,39 @@ def test_empty_subdir(inwd):
         'adir/filea',
         'adir/emptysubdir/subdir/xfile',
     })
+
+
+@pytest.mark.skipif(sys.platform == 'win32',
+                    reason="symlinks to files not supported on windows")
+def test_double_include_through_symlink(inwd):
+    (inwd.cwd / 'data').ensure(dir=True)
+    (inwd.cwd / 'data' / 'datafile').ensure(file=True)
+    (inwd.cwd / 'adir' / 'datalink').mksymlinkto('../data')
+    (inwd.cwd / 'adir' / 'filealink').mksymlinkto('filea')
+    inwd.add_and_commit()
+    assert set(find_files()) == _sep({
+        'file1',
+        'adir/datalink',  # -> ../data
+        'adir/filealink',  # -> filea
+        'adir/filea',
+        'bdir/fileb',
+        'data/datafile',
+    })
+
+
+@pytest.mark.skipif(sys.platform == 'win32',
+                    reason="symlinks to files not supported on windows")
+def test_symlink_not_in_scm_while_target_is(inwd):
+    (inwd.cwd / 'data').ensure(dir=True)
+    (inwd.cwd / 'data' / 'datafile').ensure(file=True)
+    inwd.add_and_commit()
+    (inwd.cwd / 'adir' / 'datalink').mksymlinkto('../data')
+    (inwd.cwd / 'adir' / 'filealink').mksymlinkto('filea')
+    assert set(find_files()) == _sep({
+        'file1',
+        'adir/filea',
+        # adir/datalink and adir/afilelink not included
+        # because the symlink themselves are not in scm
+        'bdir/fileb',
+        'data/datafile',
+    })