Repository: pypa/setuptools_scm

Issue:
Dirty working directory not being correctly detected when tags are missing
I'm working with a "big repo" with many libraries/services. One service, "tasks," references the "model" library in its requirements.txt

    ../lib/model

If I run

    $ pex -r requirements.txt -o test.pex

Then the generated wheel in ~/.pex/build is called `model-0.1.dev239+n6e5cad8-py2-none-any.whl`, which doesn't have the timestamp that the documentation suggests, despite the fact that the repository is dirty:

    Changes to be committed:
      (use "git reset HEAD <file>..." to unstage)
    
        modified:   lib/model/setup.py
        modified:   workers/tasks/requirements.txt

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ef96782..4d2e62f 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,3 +1,8 @@
+v1.13.1
+=======
+
+* fix issue #86 - detect dirty git workdir without tags
+
 v1.13.0
 =======
 
diff --git a/setuptools_scm/git.py b/setuptools_scm/git.py
index 6864f96..10280e5 100644
--- a/setuptools_scm/git.py
+++ b/setuptools_scm/git.py
@@ -1,40 +1,69 @@
-from .utils import do, do_ex, trace
+from .utils import do_ex, trace
 from .version import meta
 from os.path import abspath, normcase, realpath
 
 
 FILES_COMMAND = 'git ls-files'
-DEFAULT_DESCRIBE = 'git describe --dirty --tags --long --match *.*'
+DEFAULT_DESCRIBE = 'git describe --tags --long --match *.*'
+
+
+def _normalized(path):
+    return normcase(abspath(realpath(path)))
+
+
+class GitWorkdir(object):
+    def __init__(self, path):
+        self.path = path
+
+    def do_ex(self, cmd):
+        return do_ex(cmd, cwd=self.path)
+
+    @classmethod
+    def from_potential_worktree(cls, wd):
+        real_wd, _, ret = do_ex('git rev-parse --show-toplevel', wd)
+        if ret:
+            return
+        trace('real root', real_wd)
+        if _normalized(real_wd) != _normalized(wd):
+            return
+
+        return cls(real_wd)
+
+    def is_dirty(self):
+        out, _, _ = self.do_ex("git status --porcelain")
+        return bool(out)
+
+    def node(self):
+        rev_node, _, ret = self.do_ex('git rev-parse --verify --quiet HEAD')
+        if not ret:
+            return rev_node[:7]
+
+    def count_all_nodes(self):
+        revs, _, _ = self.do_ex('git rev-list HEAD')
+        return revs.count('\n') + 1
 
 
 def parse(root, describe_command=DEFAULT_DESCRIBE):
-    real_root, _, ret = do_ex('git rev-parse --show-toplevel', root)
-    if ret:
-        return
-    trace('real root', real_root)
-    if (normcase(abspath(realpath(real_root))) !=
-            normcase(abspath(realpath(root)))):
-        return
-    rev_node, _, ret = do_ex('git rev-parse --verify --quiet HEAD', root)
-    if ret:
-        return meta('0.0')
-    rev_node = rev_node[:7]
+    wd = GitWorkdir(root)
+
+    rev_node = wd.node()
+    dirty = wd.is_dirty()
+
+    if rev_node is None:
+        return meta('0.0', dirty=dirty)
+
     out, err, ret = do_ex(describe_command, root)
-    if '-' not in out and '.' not in out:
-        revs = do('git rev-list HEAD', root)
-        count = revs.count('\n')
-        if ret:
-            out = rev_node
-        return meta('0.0', distance=count + 1, node=out)
     if ret:
-        return
-    dirty = out.endswith('-dirty')
-    if dirty:
-        out = out.rsplit('-', 1)[0]
+        return meta(
+            '0.0',
+            distance=wd.count_all_nodes(),
+            node=rev_node,
+            dirty=dirty,
+        )
 
     tag, number, node = out.rsplit('-', 2)
     number = int(number)
     if number:
         return meta(tag, distance=number, node=node, dirty=dirty)
     else:
-        return meta(tag, dirty=dirty, node=node)
+        return meta(tag, node=node, dirty=dirty)
diff --git a/setuptools_scm/utils.py b/setuptools_scm/utils.py
index 5623ff0..7ea068d 100644
--- a/setuptools_scm/utils.py
+++ b/setuptools_scm/utils.py
@@ -43,8 +43,11 @@ def _always_strings(env_dict):
 
 def do_ex(cmd, cwd='.'):
     trace('cmd', repr(cmd))
+    if not isinstance(cmd, (list, tuple)):
+        cmd = shlex.split(cmd)
+
     p = subprocess.Popen(
-        shlex.split(cmd),
+        cmd,
         stdout=subprocess.PIPE,
         stderr=subprocess.PIPE,
         cwd=str(cwd),
@@ -70,7 +73,6 @@ def do_ex(cmd, cwd='.'):
 def do(cmd, cwd='.'):
     out, err, ret = do_ex(cmd, cwd)
     if ret:
-        trace('ret', ret)
         print(err)
     return out
 


Test patch:
diff --git a/testing/test_git.py b/testing/test_git.py
index 5401b66..0819fad 100644
--- a/testing/test_git.py
+++ b/testing/test_git.py
@@ -1,5 +1,6 @@
 from setuptools_scm import integration
 import pytest
+from datetime import date
 
 
 @pytest.fixture
@@ -32,6 +33,17 @@ def test_version_from_git(wd):
     assert wd.version.startswith('0.2')
 
 
+@pytest.mark.issue(86)
+def test_git_dirty_notag(wd):
+    wd.commit_testfile()
+    wd.write('test.txt', 'test2')
+    wd("git add test.txt")
+    assert wd.version.startswith('0.1.dev1')
+    today = date.today()
+    # we are dirty, check for the tag
+    assert today.strftime('.d%Y%m%d') in wd.version
+
+
 def test_find_files_stop_at_root_git(wd):
     wd.commit_testfile()
     wd.cwd.ensure('project/setup.cfg')