Repository: dbt-labs/dbt-core

Issue:
Support commit hashes in dbt deps coming from a Git source
### Describe the feature

Ability to specify a commit hash as the `revision` for a dbt package coming from git.

### Describe alternatives you've considered

Use a branch or tag.

### Additional context

We are restructuring the way we use `dbt` at Earnest, part of it is having multiple autonomous teams with their own isolated `dbt` project (in team-specific GitHub repositories) depending on some common macros and conformed dimensions.

Right now we would have to create a new GitHub release to make a revision "available", and we would like to avoid that overhead. 

### Who will this benefit?

Teams that have private dbt libraries and don't want the overhead of releases.
Or folks that need a specific feature existing in a public library but pending release.

### Are you interested in contributing this feature?

Yes! I just worked on a [similar PR](https://github.com/fishtown-analytics/dbt/pull/3267), and I think the change would be relatively straightforward.
The code [here](https://github.com/fishtown-analytics/dbt/blob/develop/core/dbt/clients/git.py#L13) needs to change. 



Discussion:
Thanks @dmateusp, this makes a lot of sense!

> Right now we would have to create a new GitHub release to make a revision "available", and we would like to avoid that overhead.

I buy it. Of course, some teams may choose to semantically version and release all internal packages, or develop detailed branch-naming conventions, but I agree that it's overkill for many—and I can see lots of situations (e.g. development, integration testing) where referencing a specific commit could come in handy.

I'd love to review a contribution for this, just as soon as I get a few minutes to review #3267 :)

Resolving patch:
diff --git a/core/dbt/clients/git.py b/core/dbt/clients/git.py
--- a/core/dbt/clients/git.py
+++ b/core/dbt/clients/git.py
@@ -6,11 +6,19 @@
 import dbt.exceptions
 
 
-def clone(repo, cwd, dirname=None, remove_git_dir=False, branch=None):
+def _is_commit(revision: str) -> bool:
+    # match SHA-1 git commit
+    return bool(re.match(r"\b[0-9a-f]{40}\b", revision))
+
+
+def clone(repo, cwd, dirname=None, remove_git_dir=False, revision=None):
+    has_revision = revision is not None
+    is_commit = _is_commit(revision or "")
+
     clone_cmd = ['git', 'clone', '--depth', '1']
 
-    if branch is not None:
-        clone_cmd.extend(['--branch', branch])
+    if has_revision and not is_commit:
+        clone_cmd.extend(['--branch', revision])
 
     clone_cmd.append(repo)
 
@@ -31,33 +39,38 @@ def list_tags(cwd):
     return tags
 
 
-def _checkout(cwd, repo, branch):
-    logger.debug('  Checking out branch {}.'.format(branch))
+def _checkout(cwd, repo, revision):
+    logger.debug('  Checking out revision {}.'.format(revision))
 
-    run_cmd(cwd, ['git', 'remote', 'set-branches', 'origin', branch])
-    run_cmd(cwd, ['git', 'fetch', '--tags', '--depth', '1', 'origin', branch])
+    fetch_cmd = ["git", "fetch", "origin", "--depth", "1"]
 
-    tags = list_tags(cwd)
+    if _is_commit(revision):
+        run_cmd(cwd, fetch_cmd + [revision])
+    else:
+        run_cmd(cwd, ['git', 'remote', 'set-branches', 'origin', revision])
+        run_cmd(cwd, fetch_cmd + ["--tags", revision])
 
+    if _is_commit(revision):
+        spec = revision
     # Prefer tags to branches if one exists
-    if branch in tags:
-        spec = 'tags/{}'.format(branch)
+    elif revision in list_tags(cwd):
+        spec = 'tags/{}'.format(revision)
     else:
-        spec = 'origin/{}'.format(branch)
+        spec = 'origin/{}'.format(revision)
 
     out, err = run_cmd(cwd, ['git', 'reset', '--hard', spec],
                        env={'LC_ALL': 'C'})
     return out, err
 
 
-def checkout(cwd, repo, branch=None):
-    if branch is None:
-        branch = 'HEAD'
+def checkout(cwd, repo, revision=None):
+    if revision is None:
+        revision = 'HEAD'
     try:
-        return _checkout(cwd, repo, branch)
+        return _checkout(cwd, repo, revision)
     except dbt.exceptions.CommandResultError as exc:
         stderr = exc.stderr.decode('utf-8').strip()
-    dbt.exceptions.bad_package_spec(repo, branch, stderr)
+    dbt.exceptions.bad_package_spec(repo, revision, stderr)
 
 
 def get_current_sha(cwd):
@@ -71,7 +84,7 @@ def remove_remote(cwd):
 
 
 def clone_and_checkout(repo, cwd, dirname=None, remove_git_dir=False,
-                       branch=None):
+                       revision=None):
     exists = None
     try:
         _, err = clone(repo, cwd, dirname=dirname,
@@ -97,7 +110,7 @@ def clone_and_checkout(repo, cwd, dirname=None, remove_git_dir=False,
         logger.debug('Pulling new dependency {}.', directory)
     full_path = os.path.join(cwd, directory)
     start_sha = get_current_sha(full_path)
-    checkout(full_path, repo, branch)
+    checkout(full_path, repo, revision)
     end_sha = get_current_sha(full_path)
     if exists:
         if start_sha == end_sha:
diff --git a/core/dbt/deps/git.py b/core/dbt/deps/git.py
--- a/core/dbt/deps/git.py
+++ b/core/dbt/deps/git.py
@@ -49,7 +49,7 @@ def get_version(self):
 
     def nice_version_name(self):
         if self.revision == 'HEAD':
-            return 'HEAD (default branch)'
+            return 'HEAD (default revision)'
         else:
             return 'revision {}'.format(self.revision)
 
@@ -68,7 +68,7 @@ def _checkout(self):
         the path to the checked out directory."""
         try:
             dir_ = git.clone_and_checkout(
-                self.git, get_downloads_path(), branch=self.revision,
+                self.git, get_downloads_path(), revision=self.revision,
                 dirname=self._checkout_name
             )
         except ExecutableError as exc:
diff --git a/core/dbt/task/init.py b/core/dbt/task/init.py
--- a/core/dbt/task/init.py
+++ b/core/dbt/task/init.py
@@ -42,7 +42,7 @@ def clone_starter_repo(self, project_name):
             cwd='.',
             dirname=project_name,
             remove_git_dir=True,
-            branch=STARTER_BRANCH,
+            revision=STARTER_BRANCH,
         )
 
     def create_profiles_dir(self, profiles_dir):


Test patch:
diff --git a/test/rpc/test_deps.py b/test/rpc/test_deps.py
--- a/test/rpc/test_deps.py
+++ b/test/rpc/test_deps.py
@@ -98,3 +98,16 @@ def test_rpc_deps_git(project_root, profiles_root, dbt_profile, unique_schema):
     }]
     deps_with_packages(packages, bad_packages, project_root, profiles_root, unique_schema)
 
+
+@pytest.mark.supported('postgres')
+def test_rpc_deps_git_commit(project_root, profiles_root, dbt_profile, unique_schema):
+    packages = [{
+        'git': 'https://github.com/fishtown-analytics/dbt-utils.git',
+        'revision': 'b736cf6acdbf80d2de69b511a51c8d7fe214ee79'
+    }]
+    # don't use short commits
+    bad_packages = [{
+        'git': 'https://github.com/fishtown-analytics/dbt-utils.git',
+        'revision': 'b736cf6'
+    }]
+    deps_with_packages(packages, bad_packages, project_root, profiles_root, unique_schema)