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
 

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
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')

pip install -e .[dev]
parse_log_pytest
pytest
pytest
python setup.py egg_info
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
exceptiongroup @ file:///croot/exceptiongroup_1706031385326/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
packaging @ file:///croot/packaging_1734472117206/work
pluggy @ file:///croot/pluggy_1733169602837/work
pytest @ file:///croot/pytest_1738938843180/work
-e git+https://github.com/pypa/setuptools_scm.git@9f360e6f70b3818275580d3f4ca285a81a5b86ce#egg=setuptools_scm
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work

name: setuptools_scm
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - exceptiongroup=1.2.0=py39h06a4308_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - packaging=24.2=py39h06a4308_0
  - pip=25.0=py39h06a4308_0
  - pluggy=1.5.0=py39h06a4308_0
  - pytest=8.3.4=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py39h06a4308_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
prefix: /opt/conda/envs/setuptools_scm

testing/test_git.py::test_git_dirty_notag
testing/test_git.py::test_version_from_git testing/test_git.py::test_find_files_stop_at_root_git testing/test_git.py::test_alphanumeric_tags_match
CHANGELOG.rst setuptools_scm/utils.py setuptools_scm/git.py
CHANGELOG.rst setuptools_scm/utils.py setuptools_scm/git.py