Repository: conan-io/conan

Issue:
tools.patch(..., strip=n) does not work with new/deleted files
This seems related to #1807

#1898 has added code in conan to support creating new files, but this code does not adjust the source/target paths per strip=n before calling save/unlink.

conan 1.11.2, windows 7

Specifically, the code here needs to trim `strip` leading components off the path before joining it to base_path:

https://github.com/conan-io/conan/blob/fd0507594838413e09abbf8bb7be128c23dd6378/conans/client/tools/files.py#L206-L215

A simple example based on the `conan new` template is at https://gist.github.com/puetzk/d8553b06827ec4f2071c2428b118bd88
`conan source .` runs, and the second hunk of example.patch is applied to hello/hello.cpp correctly (stripping src), but the first hunk is not stripped and therefore creates src/hello/goodbye.cpp instead of hello/goodbye.cpp

- [x] I've read the [CONTRIBUTING guide](https://github.com/conan-io/conan/blob/develop/.github/CONTRIBUTING.md).
- [x] I've specified the Conan version, operating system version and any tool that can be relevant.
- [x] I've explained the steps to reproduce the error or the motivation/use case of the question/suggestion.




Discussion:

Resolving patch:
diff --git a/conans/client/tools/files.py b/conans/client/tools/files.py
--- a/conans/client/tools/files.py
+++ b/conans/client/tools/files.py
@@ -194,24 +194,30 @@ def emit(self, record):
     if not patchset:
         raise ConanException("Failed to parse patch: %s" % (patch_file if patch_file else "string"))
 
+    def decode_clean(path, prefix):
+        path = path.decode("utf-8").replace("\\", "/")
+        if path.startswith(prefix):
+            path = path[2:]
+        return path
+
+    def strip_path(path):
+        tokens = path.split("/")[strip:]
+        path = "/".join(tokens)
+        if base_path:
+            path = os.path.join(base_path, path)
+        return path
     # account for new and deleted files, upstream dep won't fix them
     items = []
     for p in patchset:
-        source = p.source.decode("utf-8")
-        if source.startswith("a/"):
-            source = source[2:]
-        target = p.target.decode("utf-8")
-        if target.startswith("b/"):
-            target = target[2:]
+        source = decode_clean(p.source, "a/")
+        target = decode_clean(p.target, "b/")
         if "dev/null" in source:
-            if base_path:
-                target = os.path.join(base_path, target)
+            target = strip_path(target)
             hunks = [s.decode("utf-8") for s in p.hunks[0].text]
             new_file = "".join(hunk[1:] for hunk in hunks)
             save(target, new_file)
         elif "dev/null" in target:
-            if base_path:
-                source = os.path.join(base_path, source)
+            source = strip_path(source)
             os.unlink(source)
         else:
             items.append(p)


Test patch:
diff --git a/conans/test/unittests/tools/__init__.py b/conans/test/unittests/tools/__init__.py
new file mode 100644
diff --git a/conans/test/unittests/tools/files_patch_test.py b/conans/test/unittests/tools/files_patch_test.py
new file mode 100644
--- /dev/null
+++ b/conans/test/unittests/tools/files_patch_test.py
@@ -0,0 +1,171 @@
+import os
+import unittest
+from textwrap import dedent
+
+from parameterized.parameterized import parameterized
+
+from conans.client.graph.python_requires import ConanPythonRequire
+from conans.client.loader import ConanFileLoader
+from conans.test.utils.test_files import temp_folder
+from conans.test.utils.tools import TestClient, TestBufferConanOutput,\
+    test_processed_profile
+from conans.util.files import save, load
+
+base_conanfile = '''
+from conans import ConanFile
+from conans.tools import patch, replace_in_file
+import os
+
+class ConanFileToolsTest(ConanFile):
+    name = "test"
+    version = "1.9.10"
+'''
+
+
+class ToolsFilesPatchTest(unittest.TestCase):
+
+    @parameterized.expand([(0, ), (1, )])
+    def test_patch_from_file(self, strip):
+        if strip:
+            file_content = base_conanfile + '''
+    def build(self):
+        patch(patch_file="file.patch", strip=%s)
+''' % strip
+            patch_content = '''--- %s/text.txt\t2016-01-25 17:57:11.452848309 +0100
++++ %s/text_new.txt\t2016-01-25 17:57:28.839869950 +0100
+@@ -1 +1 @@
+-ONE TWO THREE
++ONE TWO FOUR''' % ("old_path", "new_path")
+        else:
+            file_content = base_conanfile + '''
+    def build(self):
+        patch(patch_file="file.patch")
+'''
+            patch_content = '''--- text.txt\t2016-01-25 17:57:11.452848309 +0100
++++ text_new.txt\t2016-01-25 17:57:28.839869950 +0100
+@@ -1 +1 @@
+-ONE TWO THREE
++ONE TWO FOUR'''
+
+        tmp_dir, file_path, text_file = self._save_files(file_content)
+        patch_file = os.path.join(tmp_dir, "file.patch")
+        save(patch_file, patch_content)
+        self._build_and_check(tmp_dir, file_path, text_file, "ONE TWO FOUR")
+
+    def test_patch_from_str(self):
+        file_content = base_conanfile + '''
+    def build(self):
+        patch_content = \'''--- text.txt\t2016-01-25 17:57:11.452848309 +0100
++++ text_new.txt\t2016-01-25 17:57:28.839869950 +0100
+@@ -1 +1 @@
+-ONE TWO THREE
++ONE TWO DOH!\'''
+        patch(patch_string=patch_content)
+
+'''
+        tmp_dir, file_path, text_file = self._save_files(file_content)
+        self._build_and_check(tmp_dir, file_path, text_file, "ONE TWO DOH!")
+
+    def test_patch_strip_new(self):
+        conanfile = dedent("""
+            from conans import ConanFile, tools
+            class PatchConan(ConanFile):
+                def source(self):
+                    tools.patch(self.source_folder, "example.patch", strip=1)""")
+        patch = dedent("""
+            --- /dev/null
+            +++ b/src/newfile
+            @@ -0,0 +0,1 @@
+            +New file!""")
+
+        client = TestClient()
+        client.save({"conanfile.py": conanfile,
+                     "example.patch": patch})
+        client.run("source .")
+        self.assertEqual(load(os.path.join(client.current_folder, "newfile")),
+                         "New file!")
+
+    def test_patch_strip_delete(self):
+        conanfile = dedent("""
+            from conans import ConanFile, tools
+            class PatchConan(ConanFile):
+                def source(self):
+                    tools.patch(self.source_folder, "example.patch", strip=1)""")
+        patch = dedent("""
+            --- a\src\oldfile
+            +++ b/dev/null
+            @@ -0,1 +0,0 @@
+            -legacy code""")
+        client = TestClient()
+        client.save({"conanfile.py": conanfile,
+                     "example.patch": patch,
+                     "oldfile": "legacy code"})
+        path = os.path.join(client.current_folder, "oldfile")
+        self.assertTrue(os.path.exists(path))
+        client.run("source .")
+        self.assertFalse(os.path.exists(path))
+
+    def test_patch_new_delete(self):
+        conanfile = base_conanfile + '''
+    def build(self):
+        from conans.tools import load, save
+        save("oldfile", "legacy code")
+        assert(os.path.exists("oldfile"))
+        patch_content = """--- /dev/null
++++ b/newfile
+@@ -0,0 +0,3 @@
++New file!
++New file!
++New file!
+--- a/oldfile
++++ b/dev/null
+@@ -0,1 +0,0 @@
+-legacy code
+"""
+        patch(patch_string=patch_content)
+        self.output.info("NEW FILE=%s" % load("newfile"))
+        self.output.info("OLD FILE=%s" % os.path.exists("oldfile"))
+'''
+        client = TestClient()
+        client.save({"conanfile.py": conanfile})
+        client.run("create . user/testing")
+        self.assertIn("test/1.9.10@user/testing: NEW FILE=New file!\nNew file!\nNew file!\n",
+                      client.out)
+        self.assertIn("test/1.9.10@user/testing: OLD FILE=False", client.out)
+
+    def test_error_patch(self):
+        file_content = base_conanfile + '''
+    def build(self):
+        patch_content = "some corrupted patch"
+        patch(patch_string=patch_content, output=self.output)
+
+'''
+        client = TestClient()
+        client.save({"conanfile.py": file_content})
+        client.run("install .")
+        client.run("build .", assert_error=True)
+        self.assertIn("patch: error: no patch data found!", client.out)
+        self.assertIn("ERROR: conanfile.py (test/1.9.10@None/None): "
+                      "Error in build() method, line 12", client.out)
+        self.assertIn("Failed to parse patch: string", client.out)
+
+    def _save_files(self, file_content):
+        tmp_dir = temp_folder()
+        file_path = os.path.join(tmp_dir, "conanfile.py")
+        text_file = os.path.join(tmp_dir, "text.txt")
+        save(file_path, file_content)
+        save(text_file, "ONE TWO THREE")
+        return tmp_dir, file_path, text_file
+
+    def _build_and_check(self, tmp_dir, file_path, text_file, msg):
+        loader = ConanFileLoader(None, TestBufferConanOutput(), ConanPythonRequire(None, None))
+        ret = loader.load_consumer(file_path, test_processed_profile())
+        curdir = os.path.abspath(os.curdir)
+        os.chdir(tmp_dir)
+        try:
+            ret.build()
+        finally:
+            os.chdir(curdir)
+
+        content = load(text_file)
+        self.assertEquals(content, msg)
diff --git a/conans/test/unittests/util/conanfile_tools_test.py b/conans/test/unittests/util/conanfile_tools_test.py
--- a/conans/test/unittests/util/conanfile_tools_test.py
+++ b/conans/test/unittests/util/conanfile_tools_test.py
@@ -2,16 +2,14 @@
 import sys
 import unittest
 
-from parameterized.parameterized import parameterized
 
 from conans.client import tools
-from conans.client.graph.python_requires import ConanPythonRequire
-from conans.client.loader import ConanFileLoader
 from conans.client.output import ConanOutput
 from conans.test.utils.test_files import temp_folder
-from conans.test.utils.tools import TestClient, test_processed_profile,\
-    TestBufferConanOutput
+from conans.test.utils.tools import TestClient, TestBufferConanOutput
 from conans.util.files import load, save
+from conans.client.tools.files import replace_in_file
+
 
 base_conanfile = '''
 from conans import ConanFile
@@ -61,116 +59,9 @@ def test_untar(self):
         self.assertEqual(content, "Hello world!")
 
     def test_replace_in_file(self):
-        file_content = base_conanfile + '''
-    def build(self):
-        replace_in_file("text.txt", "ONE TWO THREE", "FOUR FIVE SIX")
-'''
-        tmp_dir, file_path, text_file = self._save_files(file_content)
-        self._build_and_check(tmp_dir, file_path, text_file, "FOUR FIVE SIX")
-
-    @parameterized.expand([(0, ), (1, )])
-    def test_patch_from_file(self, strip):
-        if strip:
-            file_content = base_conanfile + '''
-    def build(self):
-        patch(patch_file="file.patch", strip=%s)
-''' % strip
-            patch_content = '''--- %s/text.txt\t2016-01-25 17:57:11.452848309 +0100
-+++ %s/text_new.txt\t2016-01-25 17:57:28.839869950 +0100
-@@ -1 +1 @@
--ONE TWO THREE
-+ONE TWO FOUR''' % ("old_path", "new_path")
-        else:
-            file_content = base_conanfile + '''
-    def build(self):
-        patch(patch_file="file.patch")
-'''
-            patch_content = '''--- text.txt\t2016-01-25 17:57:11.452848309 +0100
-+++ text_new.txt\t2016-01-25 17:57:28.839869950 +0100
-@@ -1 +1 @@
--ONE TWO THREE
-+ONE TWO FOUR'''
-
-        tmp_dir, file_path, text_file = self._save_files(file_content)
-        patch_file = os.path.join(tmp_dir, "file.patch")
-        save(patch_file, patch_content)
-        self._build_and_check(tmp_dir, file_path, text_file, "ONE TWO FOUR")
-
-    def test_patch_from_str(self):
-        file_content = base_conanfile + '''
-    def build(self):
-        patch_content = \'''--- text.txt\t2016-01-25 17:57:11.452848309 +0100
-+++ text_new.txt\t2016-01-25 17:57:28.839869950 +0100
-@@ -1 +1 @@
--ONE TWO THREE
-+ONE TWO DOH!\'''
-        patch(patch_string=patch_content)
-
-'''
-        tmp_dir, file_path, text_file = self._save_files(file_content)
-        self._build_and_check(tmp_dir, file_path, text_file, "ONE TWO DOH!")
-
-    def test_patch_new_delete(self):
-        conanfile = base_conanfile + '''
-    def build(self):
-        from conans.tools import load, save
-        save("oldfile", "legacy code")
-        assert(os.path.exists("oldfile"))
-        patch_content = """--- /dev/null
-+++ b/newfile
-@@ -0,0 +0,3 @@
-+New file!
-+New file!
-+New file!
---- a/oldfile
-+++ b/dev/null
-@@ -0,1 +0,0 @@
--legacy code
-"""
-        patch(patch_string=patch_content)
-        self.output.info("NEW FILE=%s" % load("newfile"))
-        self.output.info("OLD FILE=%s" % os.path.exists("oldfile"))
-'''
-        client = TestClient()
-        client.save({"conanfile.py": conanfile})
-        client.run("create . user/testing")
-        self.assertIn("test/1.9.10@user/testing: NEW FILE=New file!\nNew file!\nNew file!\n",
-                      client.out)
-        self.assertIn("test/1.9.10@user/testing: OLD FILE=False", client.out)
-
-    def test_error_patch(self):
-        file_content = base_conanfile + '''
-    def build(self):
-        patch_content = "some corrupted patch"
-        patch(patch_string=patch_content, output=self.output)
-
-'''
-        client = TestClient()
-        client.save({"conanfile.py": file_content})
-        client.run("install .")
-        client.run("build .", assert_error=True)
-        self.assertIn("patch: error: no patch data found!", client.user_io.out)
-        self.assertIn("ERROR: conanfile.py (test/1.9.10@None/None): Error in build() method, line 12",
-                      client.user_io.out)
-        self.assertIn("Failed to parse patch: string", client.user_io.out)
-
-    def _save_files(self, file_content):
         tmp_dir = temp_folder()
-        file_path = os.path.join(tmp_dir, "conanfile.py")
         text_file = os.path.join(tmp_dir, "text.txt")
-        save(file_path, file_content)
         save(text_file, "ONE TWO THREE")
-        return tmp_dir, file_path, text_file
-
-    def _build_and_check(self, tmp_dir, file_path, text_file, msg):
-        loader = ConanFileLoader(None, TestBufferConanOutput(), ConanPythonRequire(None, None))
-        ret = loader.load_consumer(file_path, test_processed_profile())
-        curdir = os.path.abspath(os.curdir)
-        os.chdir(tmp_dir)
-        try:
-            ret.build()
-        finally:
-            os.chdir(curdir)
-
-        content = load(text_file)
-        self.assertEquals(content, msg)
+        replace_in_file(text_file, "ONE TWO THREE", "FOUR FIVE SIX",
+                        output=TestBufferConanOutput())
+        self.assertEqual(load(text_file), "FOUR FIVE SIX")