Repository: conan-io/conan
Pull request: Allow to specify revision_mode for each recipe
Changelog: Feature: Allow to specify `revision_mode` for each recipe, values accepted are `scm` or `hash` (default)
Docs: https://github.com/conan-io/docs/pull/1126

- closes #4413: revision mode per recipe 
- closes #4728: default revision mode is `hash` 

Problem statement:
Revisions: Choose revision_mode
But then, if you use the `scm` feature for third-party source code, you would be using the hash of the sources as the recipe revision, and not taking into account the recipe changes.

Should we provide a mechanism to indicate that the sources are not from the recipe?

`scm = {"url": "auto", "revision": "foo", "recipe_repo": False}` Other?

Should the default revision mode be "hash"?
Maybe it is a better default than the scm, it doesn't depend on external tools, not "dirty/pristine" issues... the scm mode with revisions could be the opt-in.

Hints from issue discussion:
Maybe an attribute to the conanfile:

```
revision_mode = "auto" # Will choose between scm (if auto)/hash automatically
revision_mode = "scm" # Will use the scm revision ALWAYS
revision_mode = "hash" # Will use the hash of the exported
```

Commit message: default for revision_mode is 'hash', value 'auto' is no longer needed

Patch to review:
diff --git a/conans/client/cmd/export.py b/conans/client/cmd/export.py
index 7bd0e0840f0..6c040e15c53 100644
--- a/conans/client/cmd/export.py
+++ b/conans/client/cmd/export.py
@@ -18,12 +18,14 @@
 
 
 def export_alias(package_layout, target_ref, output, revisions_enabled):
+    revision_mode = "hash"
     conanfile = """
 from conans import ConanFile
 
 class AliasConanfile(ConanFile):
     alias = "%s"
-""" % target_ref.full_repr()
+    revision_mode = "%s"
+""" % (target_ref.full_repr(), revision_mode)
 
     save(package_layout.conanfile(), conanfile)
     digest = FileTreeManifest.create(package_layout.export())
@@ -31,7 +33,8 @@ class AliasConanfile(ConanFile):
 
     # Create the metadata for the alias
     _update_revision_in_metadata(package_layout=package_layout, revisions_enabled=revisions_enabled,
-                                 output=output, path=None, digest=digest)
+                                 output=output, path=None, digest=digest,
+                                 revision_mode=revision_mode)
 
 
 def check_casing_conflict(cache, ref):
@@ -97,7 +100,8 @@ def cmd_export(package_layout, conanfile_path, conanfile, keep_source, revisions
                                             revisions_enabled=revisions_enabled,
                                             output=output,
                                             path=os.path.dirname(conanfile_path),
-                                            digest=digest)
+                                            digest=digest,
+                                            revision_mode=conanfile.revision_mode)
 
     # FIXME: Conan 2.0 Clear the registry entry if the recipe has changed
     source_folder = package_layout.source()
@@ -239,19 +243,30 @@ def _detect_scm_revision(path):
     return repo_obj.get_revision(), repo_type, repo_obj.is_pristine()
 
 
-def _update_revision_in_metadata(package_layout, revisions_enabled, output, path, digest):
+def _update_revision_in_metadata(package_layout, revisions_enabled, output, path, digest,
+                                 revision_mode):
+    if revision_mode not in ["scm", "hash"]:
+        raise ConanException("Revision mode should be one of 'hash' (default) or 'scm'")
 
-    scm_revision_detected, repo_type, is_pristine = _detect_scm_revision(path)
-    revision = scm_revision_detected or digest.summary_hash
-    if revisions_enabled:
-        if scm_revision_detected:
-            output.info("Using {} commit as the recipe"
-                        " revision: {} ".format(repo_type, revision))
-            if not is_pristine:
-                output.warn("Repo status is not pristine: there might be modified files")
-        else:
+    # Use the proper approach depending on 'revision_mode'
+    if revision_mode == "hash":
+        revision = digest.summary_hash
+        if revisions_enabled:
             output.info("Using the exported files summary hash as the recipe"
                         " revision: {} ".format(revision))
+    else:
+        rev_detected, repo_type, is_pristine = _detect_scm_revision(path)
+        if not rev_detected:
+            raise ConanException("Cannot detect revision using '{}' mode"
+                                 " from repository at '{}'".format(revision_mode, path))
+
+        revision = rev_detected
+
+        if revisions_enabled:
+            output.info("Using %s commit as the recipe revision: %s" % (repo_type, revision))
+            if not is_pristine:
+                output.warn("Repo status is not pristine: there might be modified files")
+
     with package_layout.update_metadata() as metadata:
         metadata.recipe.revision = revision
 
diff --git a/conans/client/conan_api.py b/conans/client/conan_api.py
index 58d3c01556b..2c8fd593de8 100644
--- a/conans/client/conan_api.py
+++ b/conans/client/conan_api.py
@@ -300,8 +300,8 @@ def inspect(self, path, attributes, remote_name=None):
         if not attributes:
             attributes = ['name', 'version', 'url', 'homepage', 'license', 'author',
                           'description', 'topics', 'generators', 'exports', 'exports_sources',
-                          'short_paths', 'apply_env', 'build_policy', 'settings', 'options',
-                          'default_options']
+                          'short_paths', 'apply_env', 'build_policy', 'revision_mode', 'settings',
+                          'options', 'default_options']
         for attribute in attributes:
             try:
                 attr = getattr(conanfile, attribute)
diff --git a/conans/model/conan_file.py b/conans/model/conan_file.py
index 4df47fea3ce..848ea67b279 100644
--- a/conans/model/conan_file.py
+++ b/conans/model/conan_file.py
@@ -97,6 +97,7 @@ class ConanFile(object):
     exports = None
     exports_sources = None
     generators = ["txt"]
+    revision_mode = "hash"
 
     # Vars to control the build steps (build(), package())
     should_configure = True
diff --git a/conans/test/functional/command/export_test.py b/conans/test/functional/command/export_test.py
index 1e797d62fe0..ba6401c88aa 100644
--- a/conans/test/functional/command/export_test.py
+++ b/conans/test/functional/command/export_test.py
@@ -1,5 +1,6 @@
 import os
 import stat
+import textwrap
 import unittest
 
 from parameterized import parameterized
@@ -9,6 +10,7 @@
 from conans.paths import CONANFILE, CONAN_MANIFEST
 from conans.test.utils.cpp_test_files import cpp_hello_conan_files
 from conans.test.utils.tools import TestClient
+from conans.test.utils.tools import create_local_git_repo
 from conans.util.files import load, save
 
 
@@ -419,3 +421,45 @@ def _create_packages_and_builds(self):
                 save(file_path, content)
                 file_list.append(file_path)
         return file_list
+
+
+class ExportMetadataTest(unittest.TestCase):
+    conanfile = textwrap.dedent("""
+        from conans import ConanFile
+        
+        class Lib(ConanFile):
+            revision_mode = "{revision_mode}"
+    """)
+
+    summary_hash = {"hash": "bfe8b4a6a2a74966c0c4e0b34705004a",
+                    "auto": "9a5aa68b863d3f6d774b13af32abc6c1"}
+
+    def test_revision_mode_hash(self):
+        t = TestClient()
+        t.save({'conanfile.py': self.conanfile.format(revision_mode="hash")})
+
+        ref = ConanFileReference.loads("name/version@user/channel")
+        t.run("export . {}".format(ref))
+
+        meta = t.cache.package_layout(ref, short_paths=False).load_metadata()
+        self.assertEqual(meta.recipe.revision, self.summary_hash["hash"])
+
+    def test_revision_mode_scm(self):
+        path, rev = create_local_git_repo(
+            files={'conanfile.py': self.conanfile.format(revision_mode="scm")})
+        t = TestClient(current_folder=path)
+
+        ref = ConanFileReference.loads("name/version@user/channel")
+        t.run("export . {}".format(ref))
+
+        meta = t.cache.package_layout(ref, short_paths=False).load_metadata()
+        self.assertEqual(meta.recipe.revision, rev)
+
+    def test_revision_mode_invalid(self):
+        conanfile = self.conanfile.format(revision_mode="auto")
+
+        t = TestClient()
+        t.save({'conanfile.py': conanfile})
+        ref = ConanFileReference.loads("name/version@user/channel")
+        t.run("export . {}".format(ref), assert_error=True)
+        self.assertIn("ERROR: Revision mode should be one of 'hash' (default) or 'scm'", t.out)
diff --git a/conans/test/functional/command/inspect_test.py b/conans/test/functional/command/inspect_test.py
index ae41a84ceeb..3aecaa2b2e3 100644
--- a/conans/test/functional/command/inspect_test.py
+++ b/conans/test/functional/command/inspect_test.py
@@ -83,6 +83,8 @@ class Pkg(ConanFile):
         self.assertIn("version: None", client.out)
         client.run("inspect . -a=settings")
         self.assertIn("settings: ('os', 'compiler', 'arch')", client.out)
+        client.run("inspect . -a=revision_mode")
+        self.assertIn("revision_mode: hash", client.out)
 
         client.run("inspect . -a=unexisting_attr", assert_error=True)
         self.assertIn("ERROR: 'Pkg' object has no attribute 'unexisting_attr'", client.out)
@@ -147,6 +149,7 @@ def build(self):
 short_paths: False
 apply_env: True
 build_policy: None
+revision_mode: hash
 settings: None
 options: None
 default_options: None
@@ -169,6 +172,7 @@ class Pkg(ConanFile):
     options = {"foo": [True, False], "bar": [True, False]}
     default_options = {"foo": True, "bar": False}
     _private = "Nothing"
+    revision_mode = "scm"
     def build(self):
         pass
 """
@@ -188,6 +192,7 @@ def build(self):
 short_paths: False
 apply_env: True
 build_policy: None
+revision_mode: scm
 settings: ('os', 'arch', 'build_type', 'compiler')
 options:
     bar: [True, False]
@@ -229,6 +234,7 @@ class OpenSSLConan(ConanFile):
 short_paths: False
 apply_env: True
 build_policy: None
+revision_mode: hash
 settings: ('os', 'compiler', 'arch', 'build_type')
 options:
     386: [True, False]
@@ -276,6 +282,7 @@ def build(self):
 short_paths: False
 apply_env: True
 build_policy: None
+revision_mode: hash
 settings: ('os', 'arch', 'build_type', 'compiler')
 options:
     bar: [True, False]
@@ -302,6 +309,7 @@ def build(self):
 short_paths: False
 apply_env: True
 build_policy: None
+revision_mode: hash
 settings: ('os', 'arch', 'build_type', 'compiler')
 options:
     bar: [True, False]
@@ -309,4 +317,4 @@ def build(self):
 default_options:
     bar: True
     foo: True
-""", client.out)
\ No newline at end of file
+""", client.out)
diff --git a/conans/test/unittests/client/cmd/export/test_update_revision_in_metadata.py b/conans/test/unittests/client/cmd/export/test_update_revision_in_metadata.py
index 8ebfeee7078..9e01e97fc87 100644
--- a/conans/test/unittests/client/cmd/export/test_update_revision_in_metadata.py
+++ b/conans/test/unittests/client/cmd/export/test_update_revision_in_metadata.py
@@ -2,26 +2,64 @@
 
 
 import unittest
+from collections import namedtuple
 
+import six
 from mock import mock
 
 from conans.client.cmd.export import _update_revision_in_metadata
 from conans.model.ref import ConanFileReference
 from conans.paths.package_layouts.package_cache_layout import PackageCacheLayout
 from conans.test.utils.test_files import temp_folder
+from conans.errors import ConanException
 from conans.test.utils.tools import TestBufferConanOutput
 
 
 class UpdateRevisionInMetadataTests(unittest.TestCase):
 
-    def test_warn_not_pristine(self):
-        output = TestBufferConanOutput()
+    def setUp(self):
+        ref = ConanFileReference.loads("lib/version@user/channel")
+        self.package_layout = PackageCacheLayout(base_folder=temp_folder(), ref=ref,
+                                                 short_paths=False, no_lock=True)
+        self.output = TestBufferConanOutput()
 
+    def test_scm_warn_not_pristine(self):
         with mock.patch("conans.client.cmd.export._detect_scm_revision",
                         return_value=("revision", "git", False)):
-            path = digest = None
-            ref = ConanFileReference.loads("lib/version@user/channel")
-            package_layout = PackageCacheLayout(base_folder=temp_folder(), ref=ref,
-                                                short_paths=False, no_lock=True)
-            _update_revision_in_metadata(package_layout, True, output, path, digest)
-            self.assertIn("WARN: Repo status is not pristine: there might be modified files", output)
+            path = None
+            digest = namedtuple("Digest", "summary_hash")
+            _update_revision_in_metadata(self.package_layout, True, self.output,
+                                         path, digest, "scm")
+            self.assertIn("WARN: Repo status is not pristine: there might be modified files",
+                          self.output)
+
+    def test_scm_behavior(self):
+        revision_mode = "scm"
+
+        digest = None
+        path = None
+        with mock.patch("conans.client.cmd.export._detect_scm_revision",
+                        return_value=("1234", "git", True)):
+            rev = _update_revision_in_metadata(self.package_layout, True, self.output,
+                                               path, digest, revision_mode)
+        self.assertEqual(rev, "1234")
+        self.assertIn("Using git commit as the recipe revision", self.output)
+
+    def test_hash_behavior(self):
+        revision_mode = "hash"
+
+        digest = namedtuple("Digest", "summary_hash")
+        digest.summary_hash = "1234"
+        path = None
+        rev = _update_revision_in_metadata(self.package_layout, True, self.output,
+                                           path, digest, revision_mode)
+        self.assertEqual(rev, "1234")
+        self.assertIn("Using the exported files summary hash as the recipe revision", self.output)
+
+    def test_invalid_behavior(self):
+        revision_mode = "auto"
+        digest = path = None
+
+        with six.assertRaisesRegex(self, ConanException, "Revision mode should be"):
+            _update_revision_in_metadata(self.package_layout, True, self.output,
+                                           path, digest, revision_mode)


Code review comments:
diff_hunk: @@ -419,3 +421,45 @@ def _create_packages_and_builds(self):
                 save(file_path, content)
                 file_list.append(file_path)
         return file_list
+
+
+class ExportMetadataTest(unittest.TestCase):
+    conanfile = textwrap.dedent("""
+        from conans import ConanFile
+        
+        class Lib(ConanFile):
+            revision_mode = "{revision_mode}"
+    """)
+
+    summary_hash = {"hash": "bfe8b4a6a2a74966c0c4e0b34705004a",
+                    "auto": "9a5aa68b863d3f6d774b13af32abc6c1"}
path: conans/test/functional/command/export_test.py
text: @user1:
``auto`` is no longer valid