Repository: conan-io/conan

Issue:
[SCM] Support svn
We currently use Subversion and want to make use of the source() method for retrieving the _exact_ source for the package i.e. we don’t want to use exports_sources due to it only being a snapshot and we can’t use the scm attribute. We also want our source co-located with the recipe. Therefore we would like something similar to the following in our repo:

- conanfile.py
- include
    - foo.h
- lib
    - foo.cpp

Within the recipe we would have something like:

NOTE: pseudo code
```
def source(self):
    svn co url-to-check-out .

def build(self)
    # make sure we have the latest source
    svn update

    # now build
    cmake = CMake(self)
    …
```
Question 1: This appears to be against recommended practice, i.e. exports_sources appears to be recommended for co-located source and recipe. So, is this a bad idea? If so, why?

Question 2: For active development, we think we will need to do ‘svn update’ within the build() method due to the caching of source. Is this a bad idea? We actually agree with @liberforce and the issue described within #3084, in that it appears strange there is no way to override the caching of source as during active development the source will change often (tracking HEAD) but the recipe will not.

Discussion:

Patch:
diff --git a/.gitignore b/.gitignore
index ee466be1d..aa6c567e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,6 +43,7 @@ htmlcov/
 nosetests.xml
 coverage.xml
 *,cover
+.pytest_cache/
 
 # Translations
 *.mo
diff --git a/conans/client/cmd/export.py b/conans/client/cmd/export.py
index 5f508dab7..65e0d709d 100644
--- a/conans/client/cmd/export.py
+++ b/conans/client/cmd/export.py
@@ -76,15 +76,16 @@ def _capture_export_scm_data(conanfile, conanfile_dir, destination_folder, outpu
     scm = SCM(scm_data, conanfile_dir)
 
     if scm_data.url == "auto":
-        origin = scm.get_remote_url()
+        origin = scm.get_qualified_remote_url()
         if not origin:
             raise ConanException("Repo origin cannot be deduced by 'auto'")
-        if os.path.exists(origin):
+        if scm.is_local_repository():
             output.warn("Repo origin looks like a local path: %s" % origin)
-            origin = origin.replace("\\", "/")
         output.success("Repo origin deduced by 'auto': %s" % origin)
         scm_data.url = origin
     if scm_data.revision == "auto":
+        if not scm.is_pristine():
+            output.warn("Repo status is not pristine: there might be modified files")
         scm_data.revision = scm.get_revision()
         output.success("Revision deduced by 'auto': %s" % scm_data.revision)
 
diff --git a/conans/client/source.py b/conans/client/source.py
index 103141b04..8d28db9f4 100644
--- a/conans/client/source.py
+++ b/conans/client/source.py
@@ -198,6 +198,5 @@ def _fetch_scm(scm_data, dest_dir, local_sources_path, output):
     else:
         output.info("Getting sources from url: '%s'" % scm_data.url)
         scm = SCM(scm_data, dest_dir)
-        scm.clone()
         scm.checkout()
     _clean_source_folder(dest_dir)
diff --git a/conans/client/tools/scm.py b/conans/client/tools/scm.py
index a59f5a29c..132c7de7d 100644
--- a/conans/client/tools/scm.py
+++ b/conans/client/tools/scm.py
@@ -1,19 +1,25 @@
 
 import os
+import sys
+import re
+
 import subprocess
-from six.moves.urllib.parse import urlparse, quote_plus
+from six.moves.urllib.parse import urlparse, quote_plus, unquote
 from subprocess import CalledProcessError, PIPE, STDOUT
+import platform
 
 from conans.client.tools.env import no_op, environment_append
 from conans.client.tools.files import chdir
 from conans.errors import ConanException
+from conans.model.version import Version
 from conans.util.files import decode_text, to_file_bytes, walk
 
 
-class Git(object):
+class SCMBase(object):
+    cmd_command = None
 
-    def __init__(self, folder=None, verify_ssl=True, username=None, password=None,
-                 force_english=True, runner=None):
+    def __init__(self, folder=None, verify_ssl=True, username=None, password=None, force_english=True,
+                 runner=None):
         self.folder = folder or os.getcwd()
         if not os.path.exists(self.folder):
             os.makedirs(self.folder)
@@ -24,17 +30,14 @@ class Git(object):
         self._runner = runner
 
     def run(self, command):
-        command = "git %s" % command
+        command = "%s %s" % (self.cmd_command, command)
         with chdir(self.folder) if self.folder else no_op():
             with environment_append({"LC_ALL": "en_US.UTF-8"}) if self._force_eng else no_op():
                 if not self._runner:
-                    return subprocess.check_output(command, shell=True).decode().strip()
+                    return decode_text(subprocess.check_output(command, shell=True).strip())
                 else:
                     return self._runner(command)
 
-    def get_repo_root(self):
-        return self.run("rev-parse --show-toplevel")
-
     def get_url_with_credentials(self, url):
         if not self._username or not self._password:
             return url
@@ -46,7 +49,12 @@ class Git(object):
         url = url.replace("://", "://" + user_enc + ":" + pwd_enc + "@", 1)
         return url
 
+
+class Git(SCMBase):
+    cmd_command = "git"
+
     def _configure_ssl_verify(self):
+        # TODO: This should be a context manager
         return self.run("config http.sslVerify %s" % ("true" if self._verify_ssl else "false"))
 
     def clone(self, url, branch=None):
@@ -106,20 +114,18 @@ class Git(object):
     def get_remote_url(self, remote_name=None):
         self._check_git_repo()
         remote_name = remote_name or "origin"
-        try:
-            remotes = self.run("remote -v")
-            for remote in remotes.splitlines():
-                try:
-                    name, url = remote.split(None, 1)
-                    url, _ = url.rsplit(None, 1)
-                    if name == remote_name:
-                        return url
-                except Exception:
-                    pass
-        except subprocess.CalledProcessError:
-            pass
+        remotes = self.run("remote -v")
+        for remote in remotes.splitlines():
+            name, url = remote.split(None, 1)
+            if name == remote_name:
+                url, _ = url.rsplit(None, 1)
+                return url
         return None
 
+    def is_local_repository(self):
+        url = self.get_remote_url()
+        return os.path.exists(url)   
+
     def get_commit(self):
         self._check_git_repo()
         try:
@@ -127,15 +133,21 @@ class Git(object):
             commit = commit.strip()
             return commit
         except Exception as e:
-            raise ConanException("Unable to get git commit from %s\n%s" % (self.folder, str(e)))
+            raise ConanException("Unable to get git commit from '%s': %s" % (self.folder, str(e)))
 
     get_revision = get_commit
 
-    def _check_git_repo(self):
-        try:
-            self.run("status")
-        except Exception:
-            raise ConanException("Not a valid git repository")
+    def is_pristine(self):
+        self._check_git_repo()
+        status = self.run("status --porcelain").strip()
+        if not status:
+            return True
+        else:
+            return False
+        
+    def get_repo_root(self):
+        self._check_git_repo()
+        return self.run("rev-parse --show-toplevel")
 
     def get_branch(self):
         self._check_git_repo()
@@ -145,4 +157,136 @@ class Git(object):
             branch = status.splitlines()[0].split("...")[0].strip("#").strip()
             return branch
         except Exception as e:
-            raise ConanException("Unable to get git branch from %s\n%s" % (self.folder, str(e)))
+            raise ConanException("Unable to get git branch from %s: %s" % (self.folder, str(e)))
+
+    def _check_git_repo(self):
+        try:
+            self.run("status")
+        except Exception:
+            raise ConanException("Not a valid git repository")
+
+
+class SVN(SCMBase):
+    cmd_command = "svn"
+    file_protocol = 'file:///' if platform.system() == "Windows" else 'file://'
+    API_CHANGE_VERSION = Version("1.10")  # CLI changes in 1.9.x
+
+    def __init__(self, folder=None, runner=None, *args, **kwargs):
+        def runner_no_strip(command):
+            return decode_text(subprocess.check_output(command, shell=True))
+        runner = runner or runner_no_strip
+        super(SVN, self).__init__(folder=folder, runner=runner, *args, **kwargs)
+
+    @staticmethod
+    def get_version():
+        try:
+            out, err = subprocess.Popen(["svn", "--version"], stdout=subprocess.PIPE).communicate()
+            version_line = decode_text(out).split('\n', 1)[0]
+            version_str = version_line.split(' ', 3)[2]
+            return Version(version_str)
+        except Exception as e:
+            raise ConanException("Error retrieving SVN version: '{}'".format(e))
+
+    @property
+    def version(self):
+        if not hasattr(self, '_version'):
+            version = SVN.get_version()
+            setattr(self, '_version', version)
+        return getattr(self, '_version')
+
+    def run(self, command):
+        # Ensure we always pass some params
+        extra_options = " --no-auth-cache --non-interactive"
+        if not self._verify_ssl:
+            if self.version >= SVN.API_CHANGE_VERSION:
+                extra_options += " --trust-server-cert-failures=unknown-ca"
+            else:
+                extra_options += " --trust-server-cert"
+        return super(SVN, self).run(command="{} {}".format(command, extra_options))
+
+    def checkout(self, url, revision="HEAD"):
+        output = ""
+        try:
+            self._check_svn_repo()
+        except ConanException:
+            output += self.run('co "{url}" .'.format(url=url))
+        else:
+            assert url.lower() == self.get_remote_url().lower(), \
+                "%s != %s" % (url, self.get_remote_url())
+            output += self.run("revert . --recursive")
+        finally:
+            output += self.update(revision=revision)
+        return output
+
+    def update(self, revision='HEAD'):
+        self._check_svn_repo()
+        return self.run("update -r {rev}".format(rev=revision))
+
+    def excluded_files(self):
+        self._check_svn_repo()
+        excluded_list = []
+        output = self.run("status --no-ignore")
+        for it in output.splitlines():
+            if it[0] == 'I':  # Only ignored files
+                filepath = it[8:].strip()
+                excluded_list.append(os.path.normpath(filepath))
+        return excluded_list
+
+    def get_remote_url(self):
+        return self.run("info --show-item url").strip()
+
+    def get_qualified_remote_url(self):
+        # Return url with peg revision
+        url = self.get_remote_url()
+        revision = self.get_last_changed_revision()
+        return "{url}@{revision}".format(url=url, revision=revision)
+        
+    def is_local_repository(self):
+        url = self.get_remote_url()
+        return url.startswith(self.file_protocol) and \
+               os.path.exists(unquote(url[len(self.file_protocol):]))
+
+    def is_pristine(self):
+        # Check if working copy is pristine/consistent
+        output = self.run("status -u -r {}".format(self.get_revision()))
+        offending_columns = [0, 1, 2, 3, 4, 6, 7, 8]  # 5th column informs if the file is locked (7th is always blank)
+
+        for item in output.splitlines()[:-1]:
+            if item[0] == '?':  # Untracked file
+                continue
+            if any(item[i] != ' ' for i in offending_columns):
+                return False
+
+        return True
+
+    def get_revision(self):
+        return self.run("info --show-item revision").strip()
+
+    def get_repo_root(self):
+        return self.run("info --show-item wc-root").strip()
+
+    def get_last_changed_revision(self, use_wc_root=True):
+        if use_wc_root:
+            return self.run('info "{root}" --show-item last-changed-revision'.format(
+                root=self.get_repo_root())).strip()
+        else:
+            return self.run("info --show-item last-changed-revision").strip()
+
+    def get_branch(self):
+        url = self.run("info --show-item relative-url").strip()
+        try:
+            pattern = "(tags|branches)/[^/]+|trunk"
+            branch = re.search(pattern, url)
+            
+            if branch is None:
+                return None
+            else:
+                return branch.group(0)
+        except Exception as e:
+            raise ConanException("Unable to get svn branch from %s: %s" % (self.folder, str(e)))
+
+    def _check_svn_repo(self):
+        try:
+            self.run("info")
+        except Exception:
+            raise ConanException("Not a valid SVN repository")
\ No newline at end of file
diff --git a/conans/model/scm.py b/conans/model/scm.py
index 3e1231026..07ae934b7 100644
--- a/conans/model/scm.py
+++ b/conans/model/scm.py
@@ -1,6 +1,7 @@
 import json
+import sys
 
-from conans.client.tools.scm import Git
+from conans.client.tools.scm import Git, SVN
 from conans.errors import ConanException
 
 
@@ -44,22 +45,25 @@ class SCM(object):
         self.repo = self._get_repo()
 
     def _get_repo(self):
-        repo = {"git": Git(self.repo_folder, verify_ssl=self._data.verify_ssl,
-                           username=self._data.username,
-                           password=self._data.password)}.get(self._data.type)
-        if not repo:
+        repo_class = {"git": Git, "svn": SVN}.get(self._data.type)
+        if not repo_class:
             raise ConanException("SCM not supported: %s" % self._data.type)
-        return repo
+
+        return repo_class(folder=self.repo_folder, verify_ssl=self._data.verify_ssl,
+                          username=self._data.username, password=self._data.password)
 
     @property
     def excluded_files(self):
         return self.repo.excluded_files()
 
-    def clone(self):
-        return self.repo.clone(self._data.url)
-
     def checkout(self):
-        return self.repo.checkout(self._data.revision, submodule=self._data.submodule)
+        output= ""
+        if self._data.type == "git":
+            output += self.repo.clone(url=self._data.url)
+            output += self.repo.checkout(element=self._data.revision, submodule=self._data.submodule)
+        else:
+            output += self.repo.checkout(url=self._data.url, revision=self._data.revision)
+        return output
 
     def get_remote_url(self):
         return self.repo.get_remote_url()
@@ -67,5 +71,18 @@ class SCM(object):
     def get_revision(self):
         return self.repo.get_revision()
 
+    def is_pristine(self):
+        return self.repo.is_pristine()
+
     def get_repo_root(self):
         return self.repo.get_repo_root()
+
+    def get_qualified_remote_url(self):
+        if self._data.type == "git":
+            return self.repo.get_remote_url()
+        else:
+            return self.repo.get_qualified_remote_url()
+
+    def is_local_repository(self):
+        return self.repo.is_local_repository()
+


Test patch:
diff --git a/conans/test/functional/scm_test.py b/conans/test/functional/scm_test.py
index 559223f72..cc73eb620 100644
--- a/conans/test/functional/scm_test.py
+++ b/conans/test/functional/scm_test.py
@@ -3,11 +3,11 @@ import os
 import unittest
 from collections import namedtuple
 
-from conans.client.tools.scm import Git
+from conans.client.tools.scm import Git, SVN
 from conans.model.ref import ConanFileReference, PackageReference
 from conans.model.scm import SCMData
 from conans.test.utils.test_files import temp_folder
-from conans.test.utils.tools import TestClient, TestServer, create_local_git_repo
+from conans.test.utils.tools import TestClient, TestServer, create_local_git_repo, SVNLocalRepoTestCase
 from conans.util.files import load, rmdir, save, to_file_bytes
 from conans.client.tools.win import get_cased_path
 
@@ -21,7 +21,7 @@ class ConanLib(ConanFile):
     version = "0.1"
     short_paths = True
     scm = {{
-        "type": "git",
+        "type": "%s",
         "url": "{url}",
         "revision": "{revision}",
     }}
@@ -32,8 +32,11 @@ class ConanLib(ConanFile):
         self.output.warn(tools.load(path))
 '''
 
+base_git = base % "git"
+base_svn = base % "svn"
+
 
-class SCMTest(unittest.TestCase):
+class GitSCMTest(unittest.TestCase):
 
     def setUp(self):
         self.reference = ConanFileReference.loads("lib/0.1@user/channel")
@@ -88,7 +91,7 @@ class ConanLib(ConanFile):
 
     def test_auto_filesystem_remote_git(self):
         # https://github.com/conan-io/conan/issues/3109
-        conanfile = base.format(directory="None", url="auto", revision="auto")
+        conanfile = base_git.format(directory="None", url="auto", revision="auto")
         repo = temp_folder()
         self.client.save({"conanfile.py": conanfile, "myfile.txt": "My file is copied"}, repo)
         self.client.runner("git init .", cwd=repo)
@@ -105,7 +108,7 @@ class ConanLib(ConanFile):
 
     def test_auto_git(self):
         curdir = get_cased_path(self.client.current_folder).replace("\\", "/")
-        conanfile = base.format(directory="None", url="auto", revision="auto")
+        conanfile = base_git.format(directory="None", url="auto", revision="auto")
         self.client.save({"conanfile.py": conanfile, "myfile.txt": "My file is copied"})
         self._commit_contents()
         error = self.client.run("export . user/channel", ignore_error=True)
@@ -132,7 +135,7 @@ class ConanLib(ConanFile):
 
         # Export again but now with absolute reference, so no pointer file is created nor kept
         git = Git(curdir)
-        self.client.save({"conanfile.py": base.format(url=curdir, revision=git.get_revision())})
+        self.client.save({"conanfile.py": base_git.format(url=curdir, revision=git.get_revision())})
         self.client.run("create . user/channel")
         sources_dir = self.client.client_cache.scm_folder(self.reference)
         self.assertFalse(os.path.exists(sources_dir))
@@ -143,9 +146,9 @@ class ConanLib(ConanFile):
 
     def test_auto_subfolder(self):
         curdir = self.client.current_folder.replace("\\", "/")
-        conanfile = base.replace('"revision": "{revision}"',
-                                 '"revision": "{revision}",\n        '
-                                 '"subfolder": "mysub"')
+        conanfile = base_git.replace('"revision": "{revision}"',
+                                     '"revision": "{revision}",\n        '
+                                     '"subfolder": "mysub"')
         conanfile = conanfile.replace("short_paths = True", "short_paths = False")
         conanfile = conanfile.format(directory="None", url="auto", revision="auto")
         self.client.save({"conanfile.py": conanfile, "myfile.txt": "My file is copied"})
@@ -162,7 +165,7 @@ class ConanLib(ConanFile):
         Conanfile is not in the root of the repo: https://github.com/conan-io/conan/issues/3465
         """
         curdir = get_cased_path(self.client.current_folder).replace("\\", "/")
-        conanfile = base.format(url="auto", revision="auto")
+        conanfile = base_git.format(url="auto", revision="auto")
         self.client.save({"conan/conanfile.py": conanfile, "myfile.txt": "content of my file"})
         self._commit_contents()
         self.client.runner('git remote add origin https://myrepo.com.git', cwd=curdir)
@@ -175,7 +178,7 @@ class ConanLib(ConanFile):
     def test_deleted_source_folder(self):
         path, commit = create_local_git_repo({"myfile": "contents"}, branch="my_release")
         curdir = self.client.current_folder.replace("\\", "/")
-        conanfile = base.format(url="auto", revision="auto")
+        conanfile = base_git.format(url="auto", revision="auto")
         self.client.save({"conanfile.py": conanfile, "myfile.txt": "My file is copied"})
         self._commit_contents()
         self.client.runner('git remote add origin "%s"' % path.replace("\\", "/"), cwd=curdir)
@@ -189,8 +192,8 @@ class ConanLib(ConanFile):
         self.assertTrue(error)
         self.assertIn("Getting sources from url: '%s'" % path.replace("\\", "/"), self.client.out)
 
-    def test_excluded_repo_files(self):
-        conanfile = base.format(url="auto", revision="auto")
+    def test_excluded_repo_fies(self):
+        conanfile = base_git.format(url="auto", revision="auto")
         conanfile = conanfile.replace("short_paths = True", "short_paths = False")
         path, commit = create_local_git_repo({"myfile": "contents",
                                               "ignored.pyc": "bin",
@@ -222,7 +225,7 @@ other_folder/excluded_subfolder
 
     def test_local_source(self):
         curdir = self.client.current_folder
-        conanfile = base.format(url="auto", revision="auto")
+        conanfile = base_git.format(url="auto", revision="auto")
         conanfile += """
     def source(self):
         self.output.warn("SOURCE METHOD CALLED")
@@ -240,7 +243,7 @@ other_folder/excluded_subfolder
 
         # Export again but now with absolute reference, so no pointer file is created nor kept
         git = Git(curdir.replace("\\", "/"))
-        conanfile = base.format(url=curdir.replace("\\", "/"), revision=git.get_revision())
+        conanfile = base_git.format(url=curdir.replace("\\", "/"), revision=git.get_revision())
         conanfile += """
     def source(self):
         self.output.warn("SOURCE METHOD CALLED")
@@ -257,9 +260,9 @@ other_folder/excluded_subfolder
 
     def test_local_source_subfolder(self):
         curdir = self.client.current_folder
-        conanfile = base.replace('"revision": "{revision}"',
-                                 '"revision": "{revision}",\n        '
-                                 '"subfolder": "mysub"')
+        conanfile = base_git.replace('"revision": "{revision}"',
+                                     '"revision": "{revision}",\n        '
+                                     '"subfolder": "mysub"')
         conanfile = conanfile.format(url="auto", revision="auto")
         conanfile += """
     def source(self):
@@ -279,7 +282,7 @@ other_folder/excluded_subfolder
         self.client = TestClient(servers=self.servers, users={"myremote": [("lasote", "mypass")]})
 
         curdir = self.client.current_folder.replace("\\", "/")
-        conanfile = base.format(url="auto", revision="auto")
+        conanfile = base_git.format(url="auto", revision="auto")
         self.client.save({"conanfile.py": conanfile, "myfile.txt": "My file is copied"})
         self._commit_contents()
         cmd = 'git remote add origin "%s"' % curdir
@@ -293,7 +296,6 @@ other_folder/excluded_subfolder
         self.assertIn("My file is copied", client2.out)
 
     def test_source_removed_in_local_cache(self):
-
         conanfile = '''
 from conans import ConanFile, tools
 
@@ -303,11 +305,11 @@ class ConanLib(ConanFile):
         "url": "auto",
         "revision": "auto",
     }
-    
+
     def build(self):
         contents = tools.load("myfile")
         self.output.warn("Contents: %s" % contents)
-        
+
 '''
         path, commit = create_local_git_repo({"myfile": "contents", "conanfile.py": conanfile},
                                              branch="my_release")
@@ -330,10 +332,10 @@ class ConanLib(ConanFile):
 
         def _relative_paths(folder):
             submodule_path = os.path.join(
-                folder, 
+                folder,
                 os.path.basename(os.path.normpath(submodule)))
             subsubmodule_path = os.path.join(
-                submodule_path, 
+                submodule_path,
                 os.path.basename(os.path.normpath(subsubmodule)))
             return submodule_path, subsubmodule_path
 
@@ -473,3 +475,296 @@ class ConanLib(ConanFile):
         the_json = str(scm_data)
         data2 = json.loads(the_json)
         self.assertEquals(data, data2)
+
+
+class SVNSCMTest(SVNLocalRepoTestCase):
+
+    def setUp(self):
+        self.reference = ConanFileReference.loads("lib/0.1@user/channel")
+        self.client = TestClient()
+
+    def _commit_contents(self):
+        # self.client.runner('svn co "{url}" "{path}"'.format(url=self.repo_url, path=self.client.current_folder))
+        self.client.runner("svn add *", cwd=self.client.current_folder)
+        self.client.runner('svn commit -m  "commiting"', cwd=self.client.current_folder)
+
+    def test_scm_other_type_ignored(self):
+        conanfile = '''
+from conans import ConanFile, tools
+
+class ConanLib(ConanFile):
+    name = "lib"
+    version = "0.1"
+    scm = ["Other stuff"]
+
+'''
+        self.client.save({"conanfile.py": conanfile})
+        # nothing breaks
+        self.client.run("export . user/channel")
+
+    def test_repeat_clone_changing_subfolder(self):
+        tmp = '''
+from conans import ConanFile, tools
+
+class ConanLib(ConanFile):
+    name = "lib"
+    version = "0.1"
+    scm = {{
+        "type": "svn",
+        "url": "{url}",
+        "revision": "{revision}",
+        "subfolder": "onesubfolder"
+    }}
+'''
+        project_url, rev = self.create_project(files={"myfile": "contents"})
+        conanfile = tmp.format(url=project_url, revision=rev)
+        self.client.save({"conanfile.py": conanfile,
+                          "myfile.txt": "My file is copied"})
+        self.client.run("create . user/channel")
+        conanfile = conanfile.replace('"onesubfolder"', '"othersubfolder"')
+        self.client.save({"conanfile.py": conanfile})
+        self.client.run("create . user/channel")
+        folder = self.client.client_cache.source(ConanFileReference.loads("lib/0.1@user/channel"))
+        self.assertIn("othersubfolder", os.listdir(folder))
+        self.assertTrue(os.path.exists(os.path.join(folder, "othersubfolder", "myfile")))
+
+    def test_auto_filesystem_remote_svn(self):
+        # SVN origin will never be a local path (local repo has at least protocol file:///)
+        pass
+
+    def test_auto_svn(self):
+        conanfile = base_svn.format(directory="None", url="auto", revision="auto")
+        project_url, rev = self.create_project(files={"conanfile.py": conanfile, "myfile.txt": "My file is copied"})
+        project_url = project_url.replace(" ", "%20")
+        self.client.runner('svn co "{url}" "{path}"'.format(url=project_url, path=self.client.current_folder))
+
+        curdir = self.client.current_folder.replace("\\", "/")
+        # Create the package, will copy the sources from the local folder
+        self.client.run("create . user/channel")
+        sources_dir = self.client.client_cache.scm_folder(self.reference)
+        self.assertEquals(load(sources_dir), curdir)
+        self.assertIn("Repo origin deduced by 'auto': {}".format(project_url).lower(),
+            str(self.client.out).lower())
+        self.assertIn("Revision deduced by 'auto'", self.client.out)
+        self.assertIn("Getting sources from folder: %s" % curdir, self.client.out)
+        self.assertIn("My file is copied", self.client.out)
+
+        # Export again but now with absolute reference, so no pointer file is created nor kept
+        svn = SVN(curdir)
+        self.client.save({"conanfile.py": base_svn.format(url=svn.get_remote_url(), revision=svn.get_revision())})
+        self.client.run("create . user/channel", ignore_error=False)
+        sources_dir = self.client.client_cache.scm_folder(self.reference)
+        self.assertFalse(os.path.exists(sources_dir))
+        self.assertNotIn("Repo origin deduced by 'auto'", self.client.out)
+        self.assertNotIn("Revision deduced by 'auto'", self.client.out)
+        self.assertIn("Getting sources from url: '{}'".format(project_url).lower(),
+                      str(self.client.out).lower())
+        self.assertIn("My file is copied", self.client.out)
+
+    def test_auto_subfolder(self):
+        conanfile = base_svn.replace('"revision": "{revision}"',
+                                     '"revision": "{revision}",\n        '
+                                     '"subfolder": "mysub"')
+        conanfile = conanfile.replace("short_paths = True", "short_paths = False")
+        conanfile = conanfile.format(directory="None", url="auto", revision="auto")
+
+        project_url, rev = self.create_project(files={"conanfile.py": conanfile, "myfile.txt": "My file is copied"})
+        project_url = project_url.replace(" ", "%20")
+        self.client.runner('svn co "{url}" "{path}"'.format(url=project_url, path=self.client.current_folder))
+        self.client.run("create . user/channel")
+
+        folder = self.client.client_cache.source(ConanFileReference.loads("lib/0.1@user/channel"))
+        self.assertTrue(os.path.exists(os.path.join(folder, "mysub", "myfile.txt")))
+        self.assertFalse(os.path.exists(os.path.join(folder, "mysub", "conanfile.py")))
+
+    def test_auto_conanfile_no_root(self):
+        """
+        Conanfile is not in the root of the repo: https://github.com/conan-io/conan/issues/3465
+        """
+        curdir = self.client.current_folder
+        conanfile = base_svn.format(url="auto", revision="auto")
+        project_url, rev = self.create_project(files={"conan/conanfile.py": conanfile,
+                                                      "myfile.txt": "My file is copied"})
+        self.client.runner('svn co "{url}" "{path}"'.format(url=project_url,
+                                                            path=self.client.current_folder))
+        self.client.run("create conan/ user/channel")
+
+        sources_dir = self.client.client_cache.scm_folder(self.reference)
+        self.assertEquals(load(sources_dir), curdir.replace('\\', '/'))  # Root of git is 'curdir'
+
+    def test_deleted_source_folder(self):
+        # SVN will always retrieve from 'remote'
+        pass
+
+    def test_excluded_repo_fies(self):
+        conanfile = base_svn.format(url="auto", revision="auto")
+        conanfile = conanfile.replace("short_paths = True", "short_paths = False")
+        project_url, rev = self.create_project(files={"myfile": "contents",
+                                                      "ignored.pyc": "bin",  # SVN ignores pyc files by default: http://blogs.collab.net/subversion/repository-dictated-configuration-day-3-global-ignores
+                                                      # ".gitignore": "*.pyc\n",
+                                                      "myfile.txt": "My file!",
+                                                      "conanfile.py": conanfile})
+        project_url = project_url.replace(" ", "%20")
+        self.client.runner('svn co "{url}" "{path}"'.format(url=project_url, path=self.client.current_folder))
+
+        self.client.run("create . user/channel")
+        self.assertIn("Copying sources to build folder", self.client.out)
+        pref = PackageReference(ConanFileReference.loads("lib/0.1/user/channel"),
+                                "5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9")
+        bf = self.client.client_cache.build(pref)
+        self.assertTrue(os.path.exists(os.path.join(bf, "myfile.txt")))
+        self.assertTrue(os.path.exists(os.path.join(bf, "myfile")))
+        self.assertTrue(os.path.exists(os.path.join(bf, ".svn")))
+        self.assertFalse(os.path.exists(os.path.join(bf, "ignored.pyc")))
+
+    def test_local_source(self):
+        curdir = self.client.current_folder
+        conanfile = base_svn.format(url="auto", revision="auto")
+        conanfile += """
+    def source(self):
+        self.output.warn("SOURCE METHOD CALLED")
+"""
+        project_url, rev = self.create_project(files={"conanfile.py": conanfile, "myfile.txt": "My file is copied"})
+        project_url = project_url.replace(" ", "%20")
+        self.client.runner('svn co "{url}" "{path}"'.format(url=project_url, path=self.client.current_folder))
+        self.client.save({"aditional_file.txt": "contents"})
+
+        self.client.run("source . --source-folder=./source")
+        self.assertTrue(os.path.exists(os.path.join(curdir, "source", "myfile.txt")))
+        self.assertIn("SOURCE METHOD CALLED", self.client.out)
+        # Even the not commited files are copied
+        self.assertTrue(os.path.exists(os.path.join(curdir, "source", "aditional_file.txt")))
+        self.assertIn("Getting sources from folder: %s" % curdir, self.client.out)
+
+        # Export again but now with absolute reference, so no pointer file is created nor kept
+        svn = SVN(curdir.replace("\\", "/"))
+        conanfile = base_svn.format(url=svn.get_remote_url(), revision=svn.get_revision())
+        conanfile += """
+    def source(self):
+        self.output.warn("SOURCE METHOD CALLED")
+"""
+        self.client.save({"conanfile.py": conanfile,
+                          "myfile2.txt": "My file is copied"})
+        self._commit_contents()
+        self.client.run("source . --source-folder=./source2")
+        # myfile2 is no in the specified commit
+        self.assertFalse(os.path.exists(os.path.join(curdir, "source2", "myfile2.txt")))
+        self.assertTrue(os.path.exists(os.path.join(curdir, "source2", "myfile.txt")))
+        self.assertIn("Getting sources from url: '{}'".format(project_url).lower(),
+                      str(self.client.out).lower())
+        self.assertIn("SOURCE METHOD CALLED", self.client.out)
+
+    def test_local_source_subfolder(self):
+        curdir = self.client.current_folder
+        conanfile = base_svn.replace('"revision": "{revision}"',
+                                     '"revision": "{revision}",\n        '
+                                     '"subfolder": "mysub"')
+        conanfile = conanfile.format(url="auto", revision="auto")
+        conanfile += """
+    def source(self):
+        self.output.warn("SOURCE METHOD CALLED")
+"""
+        project_url, rev = self.create_project(files={"conanfile.py": conanfile, "myfile.txt": "My file is copied"})
+        project_url = project_url.replace(" ", "%20")
+        self.client.runner('svn co "{url}" "{path}"'.format(url=project_url, path=self.client.current_folder))
+
+        self.client.run("source . --source-folder=./source")
+        self.assertFalse(os.path.exists(os.path.join(curdir, "source", "myfile.txt")))
+        self.assertTrue(os.path.exists(os.path.join(curdir, "source", "mysub", "myfile.txt")))
+        self.assertIn("SOURCE METHOD CALLED", self.client.out)
+
+    def test_install_checked_out(self):
+        test_server = TestServer()
+        self.servers = {"myremote": test_server}
+        self.client = TestClient(servers=self.servers, users={"myremote": [("lasote", "mypass")]})
+
+        curdir = self.client.current_folder.replace("\\", "/")
+        conanfile = base_svn.format(url="auto", revision="auto")
+        project_url, rev = self.create_project(files={"conanfile.py": conanfile, "myfile.txt": "My file is copied"})
+        project_url = project_url.replace(" ", "%20")
+        self.client.runner('svn co "{url}" "{path}"'.format(url=project_url, path=self.client.current_folder))
+        self.client.run("export . lasote/channel")
+        self.client.run("upload lib* -c")
+
+        # Take other client, the old client folder will be used as a remote
+        client2 = TestClient(servers=self.servers, users={"myremote": [("lasote", "mypass")]})
+        client2.run("install lib/0.1@lasote/channel --build")
+        self.assertIn("My file is copied", client2.out)
+
+    def test_source_removed_in_local_cache(self):
+        conanfile = '''
+from conans import ConanFile, tools
+
+class ConanLib(ConanFile):
+    scm = {
+        "type": "svn",
+        "url": "auto",
+        "revision": "auto",
+    }
+
+    def build(self):
+        contents = tools.load("myfile")
+        self.output.warn("Contents: %s" % contents)
+
+'''
+        project_url, rev = self.create_project(files={"myfile": "contents", "conanfile.py": conanfile})
+        project_url = project_url.replace(" ", "%20")
+        self.client.runner('svn co "{url}" "{path}"'.format(url=project_url, path=self.client.current_folder))
+
+        self.client.run("create . lib/1.0@user/channel")
+        self.assertIn("Contents: contents", self.client.out)
+        self.client.save({"myfile": "Contents 2"})
+        self.client.run("create . lib/1.0@user/channel")
+        self.assertIn("Contents: Contents 2", self.client.out)
+        self.assertIn("Detected 'scm' auto in conanfile, trying to remove source folder",
+                      self.client.out)
+
+    def test_submodule(self):
+        # SVN has no submodules, may add something related to svn:external?
+        pass
+
+    def test_source_method_export_sources_and_scm_mixed(self):
+        project_url, rev = self.create_project(files={"myfile": "contents"})
+        project_url = project_url.replace(" ", "%20")
+        self.client.runner('svn co "{url}" "{path}"'.format(url=project_url, path=self.client.current_folder))
+
+        conanfile = '''
+import os
+from conans import ConanFile, tools
+
+class ConanLib(ConanFile):
+    name = "lib"
+    version = "0.1"
+    exports_sources = "file.txt"
+    scm = {{
+        "type": "svn",
+        "url": "{url}",
+        "revision": "{rev}",
+        "subfolder": "src"
+    }}
+
+    def source(self):
+        self.output.warn("SOURCE METHOD CALLED")
+        assert(os.path.exists("file.txt"))
+        assert(os.path.exists(os.path.join("src", "myfile")))
+        tools.save("cosa.txt", "contents")
+
+    def build(self):
+        assert(os.path.exists("file.txt"))
+        assert(os.path.exists("cosa.txt"))
+        self.output.warn("BUILD METHOD CALLED")
+'''.format(url=project_url, rev=rev)
+        self.client.save({"conanfile.py": conanfile, "file.txt": "My file is copied"})
+        self.client.run("create . user/channel")
+        self.assertIn("SOURCE METHOD CALLED", self.client.out)
+        self.assertIn("BUILD METHOD CALLED", self.client.out)
+
+    def test_scm_serialization(self):
+        data = {"url": "myurl", "revision": "23", "username": "myusername",
+                "password": "mypassword", "type": "svn", "verify_ssl": True,
+                "subfolder": "mysubfolder"}
+        conanfile = namedtuple("ConanfileMock", "scm")(data)
+        scm_data = SCMData(conanfile)
+        the_json = str(scm_data)
+        data2 = json.loads(the_json)
+        self.assertEquals(data, data2)
diff --git a/conans/test/util/tools_test.py b/conans/test/util/tools_test.py
index 825d8467f..7a41d25f4 100644
--- a/conans/test/util/tools_test.py
+++ b/conans/test/util/tools_test.py
@@ -4,12 +4,14 @@ import mock
 import os
 import platform
 import unittest
+import uuid
 
 from collections import namedtuple
 
 import six
 from mock.mock import patch, mock_open
 from six import StringIO
+from six.moves.urllib.parse import unquote
 
 from conans.client.client_cache import CONAN_CONF
 
@@ -18,7 +20,7 @@ from conans.client.conan_api import ConanAPIV1
 from conans.client.conf import default_settings_yml, default_client_conf
 from conans.client.output import ConanOutput
 from conans.client.tools.win import vcvars_dict, vswhere
-from conans.client.tools.scm import Git
+from conans.client.tools.scm import Git, SVN
 
 from conans.errors import ConanException, NotFoundException
 from conans.model.build_info import CppInfo
@@ -28,7 +30,7 @@ from conans.test.build_helpers.cmake_test import ConanFileMock
 from conans.test.utils.runner import TestRunner
 from conans.test.utils.test_files import temp_folder
 from conans.test.utils.tools import TestClient, TestBufferConanOutput, create_local_git_repo, \
-    StoppableThreadBottle
+    SVNLocalRepoTestCase, StoppableThreadBottle
 
 from conans.tools import which
 from conans.tools import OSInfo, SystemPackageTool, replace_in_file, AptTool, ChocolateyTool,\
@@ -1197,6 +1199,75 @@ ProgramFiles(x86)=C:\Program Files (x86)
             self.assertTrue(os.path.exists("test_folder"))
         thread.stop()
 
+    def unix_to_dos_unit_test(self):
+
+        def save_file(contents):
+            tmp = temp_folder()
+            filepath = os.path.join(tmp, "a_file.txt")
+            save(filepath, contents)
+            return filepath
+
+        fp = save_file(b"a line\notherline\n")
+        if not tools.os_info.is_windows:
+            import subprocess
+            output = subprocess.check_output(["file", fp], stderr=subprocess.STDOUT)
+            self.assertIn("ASCII text", str(output))
+            self.assertNotIn("CRLF", str(output))
+
+            tools.unix2dos(fp)
+            output = subprocess.check_output(["file", fp], stderr=subprocess.STDOUT)
+            self.assertIn("ASCII text", str(output))
+            self.assertIn("CRLF", str(output))
+        else:
+            fc = tools.load(fp)
+            self.assertNotIn("\r\n", fc)
+            tools.unix2dos(fp)
+            fc = tools.load(fp)
+            self.assertIn("\r\n", fc)
+
+        self.assertEquals("a line\r\notherline\r\n", str(tools.load(fp)))
+
+        fp = save_file(b"a line\r\notherline\r\n")
+        if not tools.os_info.is_windows:
+            import subprocess
+            output = subprocess.check_output(["file", fp], stderr=subprocess.STDOUT)
+            self.assertIn("ASCII text", str(output))
+            self.assertIn("CRLF", str(output))
+
+            tools.dos2unix(fp)
+            output = subprocess.check_output(["file", fp], stderr=subprocess.STDOUT)
+            self.assertIn("ASCII text", str(output))
+            self.assertNotIn("CRLF", str(output))
+        else:
+            fc = tools.load(fp)
+            self.assertIn("\r\n", fc)
+            tools.dos2unix(fp)
+            fc = tools.load(fp)
+            self.assertNotIn("\r\n", fc)
+
+        self.assertEquals("a line\notherline\n", str(tools.load(fp)))
+
+    def unix_to_dos_conanfile_test(self):
+        client = TestClient()
+        conanfile = """
+import os
+from conans import ConanFile, tools
+
+class HelloConan(ConanFile):
+    name = "Hello"
+    version = "0.1"
+    exports_sources = "file.txt"
+
+    def build(self):
+        assert("\\r\\n" in tools.load("file.txt"))
+        tools.dos2unix("file.txt")
+        assert("\\r\\n" not in tools.load("file.txt"))
+        tools.unix2dos("file.txt")
+        assert("\\r\\n" in tools.load("file.txt"))
+"""
+        client.save({"conanfile.py": conanfile, "file.txt": "hello\r\n"})
+        client.run("create . user/channel")
+
 
 class GitToolTest(unittest.TestCase):
 
@@ -1213,6 +1284,29 @@ class GitToolTest(unittest.TestCase):
         git = Git(subfolder)
         self.assertEqual(root_path, git.get_repo_root())
 
+    def test_is_pristine(self):
+        root_path, _ = create_local_git_repo({"myfile": "anything"})
+
+        git = Git(root_path)
+        self.assertTrue(git.is_pristine())
+
+        save(os.path.join(root_path, "other_file"), "content")
+        self.assertFalse(git.is_pristine())
+
+        git.run("add .")
+        self.assertFalse(git.is_pristine())
+
+        git.run('commit -m "commit"')
+        self.assertTrue(git.is_pristine())
+
+    def test_is_local_repository(self):
+        root_path, _ = create_local_git_repo({"myfile": "anything"})
+
+        git = Git(temp_folder())
+        git.clone(root_path)
+        self.assertTrue(git.is_local_repository())
+        # TODO: Check that with remote one it is working too
+
     def test_clone_git(self):
         path, _ = create_local_git_repo({"myfile": "contents"})
         tmp = temp_folder()
@@ -1415,75 +1509,324 @@ class HelloConan(ConanFile):
         client.run("create . user/channel", ignore_error=True)
         self.assertIn("specify a branch to checkout", client.out)
 
-    def unix_to_dos_unit_test(self):
-
-        def save_file(contents):
-            tmp = temp_folder()
-            filepath = os.path.join(tmp, "a_file.txt")
-            save(filepath, contents)
-            return filepath
-
-        fp = save_file(b"a line\notherline\n")
-        if not tools.os_info.is_windows:
-            import subprocess
-            output = subprocess.check_output(["file", fp], stderr=subprocess.STDOUT)
-            self.assertIn("ASCII text", str(output))
-            self.assertNotIn("CRLF", str(output))
-
-            tools.unix2dos(fp)
-            output = subprocess.check_output(["file", fp], stderr=subprocess.STDOUT)
-            self.assertIn("ASCII text", str(output))
-            self.assertIn("CRLF", str(output))
-        else:
-            fc = tools.load(fp)
-            self.assertNotIn("\r\n", fc)
-            tools.unix2dos(fp)
-            fc = tools.load(fp)
-            self.assertIn("\r\n", fc)
 
-        self.assertEquals("a line\r\notherline\r\n", str(tools.load(fp)))
+class SVNToolTestsBasic(SVNLocalRepoTestCase):
+
+    def test_clone(self):
+        project_url, _ = self.create_project(files={'myfile': "contents"})
+        tmp_folder = self.gimme_tmp()
+        svn = SVN(folder=tmp_folder)
+        svn.checkout(url=project_url)
+        self.assertTrue(os.path.exists(os.path.join(tmp_folder, 'myfile')))
+
+    def test_revision_number(self):
+        svn = SVN(folder=self.gimme_tmp())
+        svn.checkout(url=self.repo_url)
+        rev = int(svn.get_revision())
+        self.create_project(files={'another_file': "content"})
+        svn.run("update")
+        rev2 = int(svn.get_revision())
+        self.assertEqual(rev2, rev + 1)
+
+    def test_repo_url(self):
+        svn = SVN(folder=self.gimme_tmp())
+        svn.checkout(url=self.repo_url)
+        remote_url = svn.get_remote_url()
+        self.assertEqual(remote_url.lower(), self.repo_url.lower())
+
+        svn2 = SVN(folder=self.gimme_tmp(create=False))
+        svn2.checkout(url=remote_url)  # clone using quoted url
+        self.assertEqual(svn2.get_remote_url().lower(), self.repo_url.lower())
+
+    def test_repo_project_url(self):
+        project_url, _ = self.create_project(files={"myfile": "content"})
+        svn = SVN(folder=self.gimme_tmp())
+        svn.checkout(url=project_url)
+        self.assertEqual(svn.get_remote_url().lower(), project_url.lower())
+
+    def test_checkout(self):
+        # Ensure we have several revisions in the repository
+        self.create_project(files={'file': "content"})
+        self.create_project(files={'file': "content"})
+        svn = SVN(folder=self.gimme_tmp())
+        svn.checkout(url=self.repo_url)
+        rev = int(svn.get_revision())
+        svn.update(revision=rev - 1)  # Checkout previous revision
+        self.assertTrue(int(svn.get_revision()), rev-1)
+
+    def test_clone_over_dirty_directory(self):
+        project_url, _ = self.create_project(files={'myfile': "contents"})
+        tmp_folder = self.gimme_tmp()
+        svn = SVN(folder=tmp_folder)
+        svn.checkout(url=project_url)
+
+        new_file = os.path.join(tmp_folder, "new_file")
+        with open(new_file, "w") as f:
+            f.write("content")
+
+        mod_file = os.path.join(tmp_folder, "myfile")
+        with open(mod_file, "a") as f:
+            f.write("new content")
+
+        self.assertFalse(svn.is_pristine())
+        svn.checkout(url=project_url)  # SVN::clone over a dirty repo reverts all changes (but it doesn't delete non versioned files)
+        self.assertTrue(svn.is_pristine())
+        # self.assertFalse(os.path.exists(new_file))
+
+    def test_excluded_files(self):
+        project_url, _ = self.create_project(files={'myfile': "contents"})
+        tmp_folder = self.gimme_tmp()
+        svn = SVN(folder=tmp_folder)
+        svn.checkout(url=project_url)
+
+        # Add untracked file
+        new_file = os.path.join(tmp_folder, str(uuid.uuid4()))
+        with open(new_file, "w") as f:
+            f.write("content")
+
+        # Add ignore file
+        file_to_ignore = str(uuid.uuid4())
+        with open(os.path.join(tmp_folder, file_to_ignore), "w") as f:
+            f.write("content")
+        svn.run("propset svn:ignore {} .".format(file_to_ignore))
+        svn.run('commit -m "add ignored file"')
+
+        excluded_files = svn.excluded_files()
+        self.assertIn(file_to_ignore, excluded_files)
+        self.assertNotIn('.svn', excluded_files)
+        self.assertEqual(len(excluded_files), 1)
 
-        fp = save_file(b"a line\r\notherline\r\n")
-        if not tools.os_info.is_windows:
-            import subprocess
-            output = subprocess.check_output(["file", fp], stderr=subprocess.STDOUT)
-            self.assertIn("ASCII text", str(output))
-            self.assertIn("CRLF", str(output))
+    def test_credentials(self):
+        svn = SVN(folder=self.gimme_tmp(), username="ada", password="lovelace")
+        url_credentials = svn.get_url_with_credentials("https://some.url.com")
+        self.assertEquals(url_credentials, "https://ada:lovelace@some.url.com")
 
-            tools.dos2unix(fp)
-            output = subprocess.check_output(["file", fp], stderr=subprocess.STDOUT)
-            self.assertIn("ASCII text", str(output))
-            self.assertNotIn("CRLF", str(output))
+    def test_verify_ssl(self):
+        class MyRunner(object):
+            def __init__(self, svn):
+                self.calls = []
+                self._runner = svn._runner
+                svn._runner = self
+
+            def __call__(self, command, *args, **kwargs):
+                self.calls.append(command)
+                return self._runner(command, *args, **kwargs)
+
+        project_url, _ = self.create_project(files={'myfile': "contents",
+                                                    'subdir/otherfile': "content"})
+
+        svn = SVN(folder=self.gimme_tmp(), username="peter", password="otool", verify_ssl=True)
+        runner = MyRunner(svn)
+        svn.checkout(url=project_url)
+        self.assertNotIn("--trust-server-cert-failures=unknown-ca", runner.calls[1])
+
+        svn = SVN(folder=self.gimme_tmp(), username="peter", password="otool", verify_ssl=False)
+        runner = MyRunner(svn)
+        svn.checkout(url=project_url)
+        if SVN.get_version() >= SVN.API_CHANGE_VERSION:
+            self.assertIn("--trust-server-cert-failures=unknown-ca", runner.calls[1])
         else:
-            fc = tools.load(fp)
-            self.assertIn("\r\n", fc)
-            tools.dos2unix(fp)
-            fc = tools.load(fp)
-            self.assertNotIn("\r\n", fc)
+            self.assertIn("--trust-server-cert", runner.calls[1])
 
-        self.assertEquals("a line\notherline\n", str(tools.load(fp)))
-
-    def unix_to_dos_conanfile_test(self):
-        client = TestClient()
-        conanfile = """
+    def test_repo_root(self):
+        project_url, _ = self.create_project(files={'myfile': "contents",
+                                                    'subdir/otherfile': "content"})
+        tmp_folder = self.gimme_tmp()
+        svn = SVN(folder=tmp_folder)
+        svn.checkout(url=project_url)
+
+        path = os.path.realpath(tmp_folder).replace('\\', '/').lower()
+        self.assertEqual(path, svn.get_repo_root().lower())
+
+        # SVN instantiated in a subfolder
+        svn2 = SVN(folder=os.path.join(tmp_folder, 'subdir'))
+        self.assertFalse(svn2.folder == tmp_folder)
+        path = os.path.realpath(tmp_folder).replace('\\', '/').lower()
+        self.assertEqual(path, svn2.get_repo_root().lower())
+
+    def test_is_local_repository(self):
+        svn = SVN(folder=self.gimme_tmp())
+        svn.checkout(url=self.repo_url)
+        self.assertTrue(svn.is_local_repository())
+
+        # TODO: Test not local repository
+
+    def test_last_changed_revision(self):
+        project_url, _ = self.create_project(files={'project1/myfile': "contents",
+                                                    'project2/myfile': "content",
+                                                    'project2/subdir1/myfile': "content",
+                                                    'project2/subdir2/myfile': "content",
+                                                    })
+        prj1 = SVN(folder=self.gimme_tmp())
+        prj1.checkout(url='/'.join([project_url, 'project1']))
+
+        prj2 = SVN(folder=self.gimme_tmp())
+        prj2.checkout(url='/'.join([project_url, 'project2']))
+
+        self.assertEqual(prj1.get_last_changed_revision(), prj2.get_last_changed_revision())
+
+        # Modify file in one subfolder of prj2
+        with open(os.path.join(prj2.folder, "subdir1", "myfile"), "a") as f:
+            f.write("new content")
+        prj2.run('commit -m "add to file"')
+        prj2.run('update')
+        prj1.run('update')
+
+        self.assertNotEqual(prj1.get_last_changed_revision(), prj2.get_last_changed_revision())
+        self.assertEqual(prj1.get_revision(), prj2.get_revision())
+
+        # Instantiate a SVN in the other subfolder
+        prj2_subdir2 = SVN(folder=os.path.join(prj2.folder, "subdir2"))
+        prj2_subdir2.run('update')
+        self.assertEqual(prj2.get_last_changed_revision(),
+                         prj2_subdir2.get_last_changed_revision())
+        self.assertNotEqual(prj2.get_last_changed_revision(use_wc_root=False),
+                            prj2_subdir2.get_last_changed_revision(use_wc_root=False))
+
+    def test_branch(self):
+        project_url, _ = self.create_project(files={'prj1/trunk/myfile': "contents",
+                                                    'prj1/branches/my_feature/myfile': "",
+                                                    'prj1/branches/issue3434/myfile': "",
+                                                    'prj1/tags/v12.3.4/myfile': "",
+                                                    })
+        svn = SVN(folder=self.gimme_tmp())
+        svn.checkout(url='/'.join([project_url, 'prj1', 'trunk']))
+        self.assertEqual("trunk", svn.get_branch())
+
+        svn = SVN(folder=self.gimme_tmp())
+        svn.checkout(url='/'.join([project_url, 'prj1', 'branches', 'my_feature']))
+        self.assertEqual("branches/my_feature", svn.get_branch())
+
+        svn = SVN(folder=self.gimme_tmp())
+        svn.checkout(url='/'.join([project_url, 'prj1', 'branches', 'issue3434']))
+        self.assertEqual("branches/issue3434", svn.get_branch())
+
+        svn = SVN(folder=self.gimme_tmp())
+        svn.checkout(url='/'.join([project_url, 'prj1', 'tags', 'v12.3.4']))
+        self.assertEqual("tags/v12.3.4", svn.get_branch())
+
+
+class SVNToolTestsPristine(SVNLocalRepoTestCase):
+
+    def test_checkout(self):
+        svn = SVN(folder=self.gimme_tmp())
+        svn.checkout(url=self.repo_url)
+        self.assertTrue(svn.is_pristine())
+
+    def test_checkout_project(self):
+        project_url, _ = self.create_project(files={'myfile': "contents"})
+
+        tmp_folder = self.gimme_tmp()
+        svn = SVN(folder=tmp_folder)
+        svn.checkout(url=project_url)
+        self.assertTrue(svn.is_pristine())
+
+    def test_modified_file(self):
+        project_url, _ = self.create_project(files={'myfile': "contents"})
+        tmp_folder = self.gimme_tmp()
+        svn = SVN(folder=tmp_folder)
+        svn.checkout(url=project_url)
+        with open(os.path.join(tmp_folder, "myfile"), "a") as f:
+            f.write("new content")
+        self.assertFalse(svn.is_pristine())
+
+    def test_untracked_file(self):
+        self.create_project(files={'myfile': "contents"})
+        tmp_folder = self.gimme_tmp()
+        svn = SVN(folder=tmp_folder)
+        svn.checkout(url=self.repo_url)
+        with open(os.path.join(tmp_folder, "not_tracked.txt"), "w") as f:
+            f.write("content")
+        self.assertTrue(svn.is_pristine())
+
+    def test_ignored_file(self):
+        tmp_folder = self.gimme_tmp()
+        svn = SVN(folder=self.gimme_tmp())
+        svn.checkout(url=self.repo_url)
+        file_to_ignore = "secret.txt"
+        with open(os.path.join(tmp_folder, file_to_ignore), "w") as f:
+            f.write("content")
+        svn.run("propset svn:ignore {} .".format(file_to_ignore))
+        self.assertFalse(svn.is_pristine())  # Folder properties have been modified
+        svn.run('commit -m "add ignored file"')
+        self.assertTrue(svn.is_pristine())
+
+    def test_conflicted_file(self):
+        project_url, _ = self.create_project(files={'myfile': "contents"})
+
+        def work_on_project(tmp_folder):
+            svn = SVN(folder=tmp_folder)
+            svn.checkout(url=project_url)
+            self.assertTrue(svn.is_pristine())
+            with open(os.path.join(tmp_folder, "myfile"), "a") as f:
+                f.write("random content: {}".format(uuid.uuid4()))
+            return svn
+
+        # Two users working on the same project
+        svn1 = work_on_project(self.gimme_tmp())
+        svn2 = work_on_project(self.gimme_tmp())
+
+        # User1 is faster
+        svn1.run('commit -m "user1 commit"')
+        self.assertFalse(svn1.is_pristine())
+        svn1.run('update')  # Yes, we need to update local copy in order to have the same revision everywhere.
+        self.assertTrue(svn1.is_pristine())
+
+        # User2 updates and get a conflicted file
+        svn2.run('update')
+        self.assertFalse(svn2.is_pristine())
+        svn2.run('revert . -R')
+        self.assertTrue(svn2.is_pristine())
+
+
+class SVNToolsTestsRecipe(SVNLocalRepoTestCase):
+
+    conanfile = """
 import os
 from conans import ConanFile, tools
 
 class HelloConan(ConanFile):
     name = "Hello"
     version = "0.1"
-    exports_sources = "file.txt"
+    exports_sources = "other"
+
+    def source(self):
+        svn = tools.SVN({svn_folder})
+        svn.checkout(url="{svn_url}")
 
     def build(self):
-        assert("\\r\\n" in tools.load("file.txt"))
-        tools.dos2unix("file.txt")
-        assert("\\r\\n" not in tools.load("file.txt"))
-        tools.unix2dos("file.txt")
-        assert("\\r\\n" in tools.load("file.txt"))
+        assert(os.path.exists("{file_path}"))
+        assert(os.path.exists("other"))
 """
-        client.save({"conanfile.py": conanfile, "file.txt": "hello\r\n"})
+
+    def test_clone_root_folder(self):
+        tmp_folder = self.gimme_tmp()
+        client = TestClient()
+        client.runner('svn co "{}" "{}"'.format(self.repo_url, tmp_folder))
+        save(os.path.join(tmp_folder, "file.h"), "contents")
+        client.runner("svn add file.h", cwd=tmp_folder)
+        client.runner('svn commit -m "message"', cwd=tmp_folder)
+
+        conanfile = self.conanfile.format(svn_folder="", svn_url=self.repo_url,
+                                          file_path="file.h")
+        client.save({"conanfile.py": conanfile, "other": "hello"})
         client.run("create . user/channel")
 
+    def test_clone_subfolder(self):
+        tmp_folder = self.gimme_tmp()
+        client = TestClient()
+        client.runner('svn co "{}" "{}"'.format(self.repo_url, tmp_folder))
+        save(os.path.join(tmp_folder, "file.h"), "contents")
+        client.runner("svn add file.h", cwd=tmp_folder)
+        client.runner('svn commit -m "message"', cwd=tmp_folder)
+
+        conanfile = self.conanfile.format(svn_folder="\"src\"", svn_url=self.repo_url,
+                                          file_path="src/file.h")
+        client.save({"conanfile.py": conanfile, "other": "hello"})
+        client.run("create . user/channel")
+
+
+class CollectLibTestCase(unittest.TestCase):
     def collect_libs_test(self):
         conanfile = ConanFileMock()
         # Without package_folder
@@ -1546,3 +1889,4 @@ class HelloConan(ConanFile):
         self.assertEqual(["mylib"], result)
         self.assertIn("WARN: Lib folder doesn't exist, can't collect libraries: %s"
                       % no_folder_path, conanfile.output)
+
diff --git a/conans/test/utils/tools.py b/conans/test/utils/tools.py
index df7b77c08..629a6bee5 100644
--- a/conans/test/utils/tools.py
+++ b/conans/test/utils/tools.py
@@ -5,16 +5,22 @@ import shutil
 import sys
 import threading
 import uuid
+import errno
+import stat
 from collections import Counter
 from contextlib import contextmanager
 from io import StringIO
+import subprocess
+import unittest
+import tempfile
+import platform
 
 import bottle
 import requests
 import six
 import time
 from mock import Mock
-from six.moves.urllib.parse import urlsplit, urlunsplit
+from six.moves.urllib.parse import urlsplit, urlunsplit, quote
 from webtest.app import TestApp
 
 from conans import __version__ as CLIENT_VERSION, tools
@@ -28,8 +34,9 @@ from conans.client.plugin_manager import PluginManager
 from conans.client.remote_registry import RemoteRegistry
 from conans.client.rest.conan_requester import ConanRequester
 from conans.client.rest.uploader_downloader import IterableToFileAdapter
-from conans.client.tools.scm import Git
+from conans.client.tools.scm import Git, SVN
 from conans.client.userio import UserIO
+from conans.client.tools.files import chdir
 from conans.model.version import Version
 from conans.test.server.utils.server_launcher import (TESTING_REMOTE_PRIVATE_USER,
                                                       TESTING_REMOTE_PRIVATE_PASS,
@@ -45,6 +52,7 @@ from conans.model.manifest import FileTreeManifest
 from conans.client.tools.win import get_cased_path
 
 
+
 def inc_recipe_manifest_timestamp(client_cache, conan_ref, inc_time):
     conan_ref = ConanFileReference.loads(str(conan_ref))
     path = client_cache.export(conan_ref)
@@ -306,6 +314,60 @@ def create_local_git_repo(files=None, branch=None, submodules=None, folder=None)
     return tmp.replace("\\", "/"), git.get_revision()
 
 
+def handleRemoveReadonly(func, path, exc):  # TODO: May promote to conan tools?
+    # Credit: https://stackoverflow.com/questions/1213706/what-user-do-python-scripts-run-as-in-windows
+    excvalue = exc[1]
+    if func in (os.rmdir, os.remove, os.unlink) and excvalue.errno == errno.EACCES:
+        os.chmod(path, stat.S_IRWXU| stat.S_IRWXG| stat.S_IRWXO) # 0777
+        func(path)
+    else:
+        raise
+
+
+class SVNLocalRepoTestCase(unittest.TestCase):
+    path_with_spaces = True
+
+    def _create_local_svn_repo(self):
+        repo_url = os.path.join(self._tmp_folder, 'repo_server')
+        subprocess.check_output('svnadmin create "{}"'.format(repo_url), shell=True)
+        return SVN.file_protocol + quote(repo_url.replace("\\", "/"), safe='/:')
+
+    def gimme_tmp(self, create=True):
+        tmp = os.path.join(self._tmp_folder, str(uuid.uuid4()))
+        if create:
+            os.makedirs(tmp)
+        return tmp
+
+    def create_project(self, files, rel_project_path=None, commit_msg='default commit message', delete_checkout=True):
+        tmp_dir = self.gimme_tmp()
+        try:
+            rel_project_path = rel_project_path or str(uuid.uuid4())
+            # Do not use SVN class as it is what we will be testing
+            subprocess.check_output('svn co "{url}" "{path}"'.format(url=self.repo_url, path=tmp_dir), shell=True)
+            tmp_project_dir = os.path.join(tmp_dir, rel_project_path)
+            os.makedirs(tmp_project_dir)
+            save_files(tmp_project_dir, files)
+            with chdir(tmp_project_dir):
+                subprocess.check_output("svn add .", shell=True)
+                subprocess.check_output('svn commit -m "{}"'.format(commit_msg), shell=True)
+                rev = subprocess.check_output("svn info --show-item revision", shell=True).decode().strip()
+            project_url = self.repo_url + "/" + quote(rel_project_path.replace("\\", "/"))
+            return project_url, rev
+        finally:
+            if delete_checkout:
+                shutil.rmtree(tmp_dir, ignore_errors=False, onerror=handleRemoveReadonly)
+
+    def run(self, *args, **kwargs):
+        tmp_folder = tempfile.mkdtemp(suffix='_conans')
+        try:
+            self._tmp_folder = os.path.join(tmp_folder, 'path with spaces' if self.path_with_spaces else 'pathwithoutspaces')
+            os.makedirs(self._tmp_folder)
+            self.repo_url = self._create_local_svn_repo()
+            super(SVNLocalRepoTestCase, self).run(*args, **kwargs)
+        finally:
+            shutil.rmtree(tmp_folder, ignore_errors=False, onerror=handleRemoveReadonly)
+
+
 class MockedUserIO(UserIO):
 
     """