vcs-versioning 2.2.2__tar.gz → 2.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/CHANGELOG.md +24 -0
  2. {vcs_versioning-2.2.2/src/vcs_versioning.egg-info → vcs_versioning-2.2.4}/PKG-INFO +2 -2
  3. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/pyproject.toml +2 -2
  4. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_backends/_git.py +15 -10
  5. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_backends/_hg_git.py +1 -1
  6. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_backends/_jj.py +91 -82
  7. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_cli/__init__.py +0 -1
  8. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_file_finders/_git.py +2 -2
  9. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_file_finders/_hg.py +1 -1
  10. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_file_finders/_jj.py +1 -1
  11. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_get_version_impl.py +1 -1
  12. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_legacy_parse.py +2 -2
  13. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_paths.py +1 -1
  14. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_run_cmd.py +1 -0
  15. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_version_schemes/_standard.py +2 -3
  16. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/overrides.py +10 -4
  17. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4/src/vcs_versioning.egg-info}/PKG-INFO +2 -2
  18. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning.egg-info/requires.txt +1 -1
  19. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_git.py +19 -3
  20. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_jj.py +63 -4
  21. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_mercurial.py +1 -1
  22. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_version.py +1 -1
  23. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/uv.lock +1 -1
  24. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/LICENSE.txt +0 -0
  25. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/MANIFEST.in +0 -0
  26. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/README.md +0 -0
  27. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/setup.cfg +0 -0
  28. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/setup.py +0 -0
  29. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/__init__.py +0 -0
  30. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/__main__.py +0 -0
  31. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_backends/__init__.py +0 -0
  32. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_backends/_discover_vcs.py +0 -0
  33. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_backends/_hg.py +0 -0
  34. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_backends/_scm_workdir.py +0 -0
  35. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_cli/_args.py +0 -0
  36. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_cli/git_archival_full.txt +0 -0
  37. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_cli/git_archival_stable.txt +0 -0
  38. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_compat.py +0 -0
  39. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_config.py +0 -0
  40. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_discover.py +0 -0
  41. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_dump_version.py +0 -0
  42. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_entrypoints.py +0 -0
  43. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_environment.py +0 -0
  44. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_exceptions.py +0 -0
  45. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_fallback_workdir.py +0 -0
  46. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_fallbacks.py +0 -0
  47. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_file_finders/__init__.py +3 -3
  48. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_integration.py +0 -0
  49. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_integrator_helpers.py +0 -0
  50. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_log.py +0 -0
  51. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_modify_version.py +0 -0
  52. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_node_utils.py +0 -0
  53. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_overrides.py +0 -0
  54. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_project_overrides.py +0 -0
  55. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_protocols.py +0 -0
  56. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_pyproject_reading.py +0 -0
  57. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_requirement_cls.py +0 -0
  58. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_scm_metadata.py +0 -0
  59. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_scm_version.py +0 -0
  60. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_test_utils.py +0 -0
  61. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_toml.py +0 -0
  62. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_types.py +3 -3
  63. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_version_cls.py +0 -0
  64. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_version_fields.py +0 -0
  65. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_version_inference.py +0 -0
  66. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_version_schemes/__init__.py +0 -0
  67. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_version_schemes/_common.py +0 -0
  68. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_version_schemes/_towncrier.py +0 -0
  69. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/_worktree_discovery.py +0 -0
  70. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/py.typed +0 -0
  71. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning/test_api.py +0 -0
  72. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning.egg-info/SOURCES.txt +0 -0
  73. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning.egg-info/dependency_links.txt +0 -0
  74. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning.egg-info/entry_points.txt +0 -0
  75. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/src/vcs_versioning.egg-info/top_level.txt +0 -0
  76. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/__init__.py +0 -0
  77. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/conftest.py +0 -0
  78. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_better_root_errors.py +0 -0
  79. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_chain_api.py +0 -0
  80. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_compat.py +0 -0
  81. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_config.py +0 -0
  82. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_expect_parse.py +0 -0
  83. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_file_finders.py +0 -0
  84. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_hg_git.py +0 -0
  85. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_integrator_helpers.py +0 -0
  86. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_internal_log_level.py +0 -0
  87. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_legacy_parse.py +0 -0
  88. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_overrides_api.py +0 -0
  89. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_overrides_env_reader.py +0 -0
  90. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_project_overrides.py +0 -0
  91. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_project_path.py +0 -0
  92. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_regressions.py +0 -0
  93. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_scm_metadata.py +0 -0
  94. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_tag_config.py +0 -0
  95. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_version_scheme_towncrier.py +0 -0
  96. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_version_schemes.py +0 -0
  97. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_workdir_api.py +0 -0
  98. {vcs_versioning-2.2.2 → vcs_versioning-2.2.4}/testing_vcs/test_workdir_discovery.py +0 -0
@@ -2,6 +2,30 @@
2
2
 
3
3
  <!-- towncrier release notes start -->
4
4
 
5
+ ## 2.2.4 (2026-08-07)
6
+
7
+ ### Fixed
8
+
9
+ - Speed up jujutsu version inference in large repositories.
10
+
11
+ The jj backend filtered whole-ancestry revsets with `empty()`, which makes
12
+ jj diff every commit in the history and took minutes on large repos. The
13
+ scan for the newest real commit is now bounded to the most recent
14
+ generations, distances are counted without the filter, and the working copy
15
+ is snapshotted only once per run (later queries pass
16
+ `--ignore-working-copy`).
17
+
18
+ As a side effect distances now match `git describe --long`: merge commits
19
+ count even though jj considers them empty. ([#1477](https://github.com/pypa/setuptools-scm/issues/1477))
20
+ - Improve reproducibility of scm_file_list.json ([#1488](https://github.com/pypa/setuptools-scm/issues/1488))
21
+ - Route the hg-git tag mismatch warning through the module logger instead of the root logger. ([#1489](https://github.com/pypa/setuptools-scm/issues/1489))
22
+
23
+ ## 2.2.3 (2026-07-28)
24
+
25
+ ### Fixed
26
+
27
+ - Fix `ValueError` when parsing `.git_archival.txt` of a tagged commit whose tag contains more than one dash (e.g. `llvmorg-23.1.0-rc2`) - the `git describe` suffix is now matched precisely instead of splitting on the last two dashes. ([#1481](https://github.com/pypa/setuptools-scm/issues/1481))
28
+
5
29
  ## 2.2.2 (2026-06-29)
6
30
 
7
31
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vcs-versioning
3
- Version: 2.2.2
3
+ Version: 2.2.4
4
4
  Summary: the blessed package to manage your versions by vcs metadata
5
5
  Author-email: Ronny Pfannschmidt <opensource@ronnypfannschmidt.de>
6
6
  License: MIT
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.14
20
20
  Requires-Python: >=3.8
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE.txt
23
- Requires-Dist: packaging>=20
23
+ Requires-Dist: packaging>=26.2
24
24
  Requires-Dist: tomli>=1; python_version < "3.11"
25
25
  Requires-Dist: typing-extensions>=4.1; python_version < "3.11"
26
26
  Dynamic: license-file
@@ -7,7 +7,7 @@
7
7
  build-backend = "setuptools.build_meta"
8
8
  requires = [
9
9
  "setuptools>=45",
10
- "packaging>=20",
10
+ "packaging>=26.2",
11
11
  'typing-extensions>=4.1; python_version < "3.11"',
12
12
  ]
13
13
 
@@ -38,7 +38,7 @@ dynamic = [
38
38
  "version",
39
39
  ]
40
40
  dependencies = [
41
- "packaging>=20",
41
+ "packaging>=26.2",
42
42
  'tomli>=1; python_version < "3.11"',
43
43
  'typing-extensions>=4.1; python_version < "3.11"',
44
44
  ]
@@ -33,6 +33,11 @@ log = logging.getLogger(__name__)
33
33
  REF_TAG_RE = re.compile(r"(?<=\btag: )([^,]+)\b")
34
34
  DESCRIBE_UNSUPPORTED = "%(describe"
35
35
 
36
+ # ``git describe`` appends ``-<distance>-g<abbreviated node>`` to the tag name.
37
+ # Tags may contain dashes themselves, so the suffix has to be matched precisely
38
+ # instead of just splitting on the last two dashes.
39
+ DESCRIBE_SUFFIX_RE = re.compile(r"^(?P<tag>.+)-(?P<distance>\d+)-g(?P<node>[0-9a-f]+)$")
40
+
36
41
 
37
42
  # If testing command in shell make sure to quote the match argument like
38
43
  # '*[0-9]*' as it will expand before being sent to git if there are any matching
@@ -250,7 +255,7 @@ class GitWorkdir(Workdir):
250
255
  git_files, git_dirs = _git_ls_files_and_dirs(
251
256
  str(self.path), timeout=self._subprocess_timeout
252
257
  )
253
- return scm_find_files(base, git_files, git_dirs)
258
+ return sorted(scm_find_files(base, git_files, git_dirs))
254
259
 
255
260
  def is_file_tracked(self, path: Path) -> bool:
256
261
  res = self.run_git(
@@ -473,15 +478,15 @@ def _git_parse_describe(
473
478
  else:
474
479
  dirty = False
475
480
 
476
- split = describe_output.rsplit("-", 2)
477
- if len(split) < 3: # probably a tagged commit
478
- tag = describe_output
479
- number = 0
480
- node = None
481
- else:
482
- tag, number_, node = split
483
- number = int(number_)
484
- return tag, number, node, dirty
481
+ match = DESCRIBE_SUFFIX_RE.match(describe_output)
482
+ if match is None: # probably a tagged commit
483
+ return describe_output, 0, None, dirty
484
+ return (
485
+ match.group("tag"),
486
+ int(match.group("distance")),
487
+ "g" + match.group("node"),
488
+ dirty,
489
+ )
485
490
 
486
491
 
487
492
  def archival_to_version(
@@ -191,7 +191,7 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir):
191
191
  tag = hg_tag
192
192
  break
193
193
  else:
194
- logging.warning("tag not found hg=%s git=%s", hg_tags, git_tags)
194
+ log.warning("tag not found hg=%s git=%s", hg_tags, git_tags)
195
195
  return _FAKE_GIT_DESCRIBE_ERROR
196
196
 
197
197
  res = self.run_hg(["log", "-r", f"'{tag}'::.", "-T", "."])
@@ -35,6 +35,16 @@ if TYPE_CHECKING:
35
35
 
36
36
  log = logging.getLogger(__name__)
37
37
 
38
+ ANCESTOR_SCAN_LIMIT = 20
39
+ """Generations of ancestors examined when looking for the newest real commit.
40
+
41
+ Revsets filtered by ``empty()`` force jj to diff every candidate commit
42
+ against its parents, so applying such a filter to the full ancestry costs
43
+ O(history) and takes minutes in large repositories (issue #1477). Empty
44
+ commits only occur near the head in practice -- ``@`` itself, plus the
45
+ occasional ``jj new`` chain -- so a small window suffices.
46
+ """
47
+
38
48
 
39
49
  def run_jj(
40
50
  args: Sequence[str | os.PathLike[str]],
@@ -42,27 +52,45 @@ def run_jj(
42
52
  *,
43
53
  check: bool = False,
44
54
  timeout: int | None = None,
55
+ ignore_working_copy: bool = False,
45
56
  ) -> _CompletedProcess:
46
- return _run(
47
- ["jj", "--no-pager", "--repository", str(repo), *args],
48
- cwd=repo,
49
- check=check,
50
- timeout=timeout,
51
- )
57
+ cmd: list[str | os.PathLike[str]] = ["jj", "--no-pager", "--repository", str(repo)]
58
+ if ignore_working_copy:
59
+ cmd.append("--ignore-working-copy")
60
+ cmd.extend(args)
61
+ return _run(cmd, cwd=repo, check=check, timeout=timeout)
52
62
 
53
63
 
54
64
  class JjWorkdir(Workdir):
55
65
  """Work directory backed by Jujutsu (jj)."""
56
66
 
67
+ _snapshot_taken: bool = False
68
+ """Whether a command in this process refreshed the working-copy commit."""
69
+
57
70
  def run_jj(
58
71
  self,
59
72
  args: Sequence[str | os.PathLike[str]],
60
73
  *,
61
74
  check: bool = False,
62
75
  timeout: int | None = None,
76
+ needs_snapshot: bool = False,
63
77
  ) -> _CompletedProcess:
78
+ """Run a jj command in this work directory.
79
+
80
+ jj snapshots the working copy on every invocation, which scans the
81
+ whole tree and writes an operation-log entry. One snapshot per
82
+ process is enough, so history queries following it pass
83
+ ``--ignore-working-copy``. Commands that must observe uncommitted
84
+ edits set ``needs_snapshot``.
85
+ """
86
+ ignore_working_copy = self._snapshot_taken and not needs_snapshot
87
+ self._snapshot_taken = True
64
88
  return run_jj(
65
- args, self.path, check=check, timeout=timeout or self._subprocess_timeout
89
+ args,
90
+ self.path,
91
+ check=check,
92
+ timeout=timeout or self._subprocess_timeout,
93
+ ignore_working_copy=ignore_working_copy,
66
94
  )
67
95
 
68
96
  @classmethod
@@ -74,7 +102,7 @@ class JjWorkdir(Workdir):
74
102
  return None
75
103
 
76
104
  timeout = config.env.subprocess_timeout if config is not None else None
77
- res = run_jj(["root"], wd, timeout=timeout)
105
+ res = run_jj(["root"], wd, timeout=timeout, ignore_working_copy=True)
78
106
  root = res.parse_success(parse=str)
79
107
  if root is None:
80
108
  return None
@@ -84,7 +112,7 @@ class JjWorkdir(Workdir):
84
112
  return result
85
113
 
86
114
  def is_dirty(self) -> bool:
87
- res = self.run_jj(["diff", "--summary"])
115
+ res = self.run_jj(["diff", "--summary"], needs_snapshot=True)
88
116
  return res.parse_success(parse=bool, default=False)
89
117
 
90
118
  def get_branch(self) -> str | None:
@@ -133,25 +161,41 @@ class JjWorkdir(Workdir):
133
161
  error_msg="failed to get jj head date",
134
162
  )
135
163
 
164
+ def _count_revset(self, revset: str) -> int:
165
+ """Count the commits a revset resolves to."""
166
+ res = self.run_jj(["log", "--no-graph", "-r", revset, "-T", '"x\\n"'])
167
+ output = res.parse_success(parse=str)
168
+ if not output:
169
+ return 0
170
+ return output.count("\n") + 1
171
+
136
172
  def node(self) -> str | None:
137
- res = self.run_jj(
138
- [
139
- "log",
140
- "--no-graph",
141
- "-r",
142
- "latest(::@ ~ (empty() ~ tags()))",
143
- "-T",
144
- "commit_id",
145
- ],
146
- )
147
- result = res.parse_success(parse=str)
148
- return result if result else None
173
+ """Return the newest ancestor of ``@`` that is a real commit.
149
174
 
150
- def _find_latest_tag(self) -> tuple[str | None, str | None]:
151
- """Find the latest tagged ancestor of the working copy.
175
+ This is jj's equivalent of git's ``HEAD``: the working-copy commit
176
+ ``@`` is skipped while it holds no changes, as are the empty
177
+ commits a ``jj new`` chain leaves behind. Merges and tagged
178
+ commits are kept even when empty -- git counts them too.
152
179
 
153
- Returns (tag_name, commit_id) or (None, None) if no tags found.
180
+ Only ``ANCESTOR_SCAN_LIMIT`` generations are examined; the
181
+ unbounded query remains as a fallback for histories that are
182
+ empty all the way down.
154
183
  """
184
+ skip = "empty() ~ tags() ~ merges()"
185
+ revsets = [
186
+ f"latest(ancestors(@, {ANCESTOR_SCAN_LIMIT}) ~ ({skip}))",
187
+ f"latest(::@ ~ ({skip}))",
188
+ ]
189
+ for revset in revsets:
190
+ res = self.run_jj(["log", "--no-graph", "-r", revset, "-T", "commit_id"])
191
+ result = res.parse_success(parse=str)
192
+ if result:
193
+ return result
194
+ log.debug("no non-empty commit found in %s", revset)
195
+ return None
196
+
197
+ def _find_latest_tag(self) -> str | None:
198
+ """Find the tag of the latest tagged ancestor of the working copy."""
155
199
  res = self.run_jj(
156
200
  [
157
201
  "log",
@@ -159,93 +203,58 @@ class JjWorkdir(Workdir):
159
203
  "-r",
160
204
  "latest(heads(::@ & tags()))",
161
205
  "-T",
162
- 'tags.map(|t| t.name()).join(",") ++ "\\n" ++ commit_id',
206
+ 'tags.map(|t| t.name()).join(",")',
163
207
  ],
164
208
  )
165
- output = res.parse_success(parse=str)
166
- if not output:
167
- return None, None
168
-
169
- lines = output.strip().split("\n")
170
- if len(lines) < 2:
171
- return None, None
172
-
173
- tag_names = lines[0].strip()
174
- commit_id = lines[1].strip()
209
+ tag_names = res.parse_success(parse=str)
175
210
  if not tag_names:
176
- return None, None
211
+ return None
177
212
 
178
213
  # Take the first tag if multiple point at the same commit
179
- tag = tag_names.split(",")[0].strip()
180
- return tag, commit_id
214
+ return tag_names.split(",")[0].strip() or None
181
215
 
182
- def _compute_distance(self, tag_name: str) -> int:
183
- """Count non-empty commits between a tag and the working copy.
216
+ def _compute_distance(self, tag_name: str, node: str) -> int:
217
+ """Count the commits between a tag and ``node``.
184
218
 
185
- In jj's model the working copy ``@`` is a real commit. If it
186
- contains changes it is counted as one commit of distance, which
187
- is the semantically correct representation.
219
+ Mirrors ``git describe --long``: every commit in the range is
220
+ counted, the tagged commit itself is not.
188
221
  """
189
- res = self.run_jj(
190
- [
191
- "log",
192
- "--no-graph",
193
- "-r",
194
- f'"{tag_name}"::@ ~ empty()',
195
- "-T",
196
- 'commit_id ++ "\\n"',
197
- ],
198
- )
199
- output = res.parse_success(parse=str)
200
- if not output:
201
- return 0
222
+ return max(0, self._count_revset(f'"{tag_name}"::{node}') - 1)
202
223
 
203
- # Each non-empty line is a commit; subtract 1 for the tagged commit itself
204
- commits = [line for line in output.strip().split("\n") if line.strip()]
205
- return max(0, len(commits) - 1)
224
+ def _count_ancestors(self, node: str) -> int:
225
+ """Count the ancestors of ``node``, excluding jj's virtual root."""
226
+ return self._count_revset(f"::{node} ~ root()")
206
227
 
207
228
  def count_all_nodes(self) -> int:
208
- res = self.run_jj(
209
- [
210
- "log",
211
- "--no-graph",
212
- "-r",
213
- "::@ ~ empty()",
214
- "-T",
215
- 'commit_id ++ "\\n"',
216
- ],
217
- )
218
- output = res.parse_success(parse=str)
219
- if not output:
220
- return 0
221
- return len([line for line in output.strip().split("\n") if line.strip()])
229
+ node = self.node()
230
+ return self._count_ancestors(node) if node is not None else 0
222
231
 
223
232
  def get_scm_version(self) -> ScmVersion | None:
224
233
  config = self.config
225
234
 
226
- tag_name, _tag_commit = self._find_latest_tag()
235
+ # first command in the process -- refreshes the working-copy commit
236
+ # so that the emptiness of ``@`` below reflects uncommitted edits
227
237
  dirty = self.is_dirty()
228
238
 
239
+ node = self.node()
240
+ tag_name = self._find_latest_tag()
241
+
229
242
  if tag_name is not None:
230
- distance = self._compute_distance(tag_name)
231
- node = self.node()
232
- if node:
233
- node = "j" + node[:12]
243
+ distance = 0 if node is None else self._compute_distance(tag_name, node)
234
244
  version = meta(
235
245
  tag=tag_name,
236
246
  distance=distance,
237
247
  dirty=dirty,
238
- node=node,
248
+ node=None if node is None else "j" + node[:12],
239
249
  config=config,
240
250
  )
241
251
  else:
242
252
  tag = config.version_cls(config.fallback_version or "0.0")
243
- node = self.node()
244
253
  if node is None:
245
254
  distance = 0
246
255
  dirty = True
247
256
  else:
248
- distance = self.count_all_nodes()
257
+ distance = self._count_ancestors(node)
249
258
  node = "j" + node[:12]
250
259
  version = meta(
251
260
  tag=tag, distance=distance, dirty=dirty, node=node, config=config
@@ -270,7 +279,7 @@ class JjWorkdir(Workdir):
270
279
  return scm_find_files(base, jj_files, jj_dirs)
271
280
 
272
281
  def is_file_tracked(self, path: Path) -> bool:
273
- res = self.run_jj(["file", "list", str(path)])
282
+ res = self.run_jj(["file", "list", str(path)], needs_snapshot=True)
274
283
  output = res.parse_success(parse=str)
275
284
  return bool(output)
276
285
 
@@ -80,7 +80,6 @@ def main(
80
80
  return command(opts, version, config)
81
81
 
82
82
 
83
- # flake8: noqa: C901
84
83
  def command(opts: CliNamespace, version: str, config: Configuration) -> int:
85
84
  data: OutputData = {}
86
85
 
@@ -21,7 +21,7 @@ def _git_toplevel(path: str) -> str | None:
21
21
  res = _run(["git", "rev-parse", "HEAD"], cwd=cwd)
22
22
  if res.returncode:
23
23
  # This catches you being in a git directory, but the
24
- # permissions being incorrect. With modern contanizered
24
+ # permissions being incorrect. With modern containerized
25
25
  # CI environments you can easily end up in a cloned repo
26
26
  # with incorrect permissions and we don't want to silently
27
27
  # ignore files.
@@ -119,4 +119,4 @@ def git_archive_find_files(path: _t.PathT = "") -> list[str]:
119
119
  return scm_find_files(path, set(), set(), force_all_files=True)
120
120
 
121
121
 
122
- __all__ = ["git_find_files", "git_archive_find_files"]
122
+ __all__ = ["git_archive_find_files", "git_find_files"]
@@ -68,4 +68,4 @@ def hg_archive_find_files(path: _t.PathT = "") -> list[str]:
68
68
  return scm_find_files(path, set(), set(), force_all_files=True)
69
69
 
70
70
 
71
- __all__ = ["hg_find_files", "hg_archive_find_files"]
71
+ __all__ = ["hg_archive_find_files", "hg_find_files"]
@@ -21,7 +21,7 @@ log = logging.getLogger(__name__)
21
21
  def _jj_toplevel(path: str) -> str | None:
22
22
  try:
23
23
  cwd = os.path.abspath(path or ".")
24
- res = _run(["jj", "root", "--no-pager"], cwd=cwd)
24
+ res = _run(["jj", "root", "--no-pager", "--ignore-working-copy"], cwd=cwd)
25
25
  if res.returncode:
26
26
  return None
27
27
  toplevel = res.stdout.strip()
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
20
20
 
21
21
  # Backward-compat re-export used by vcs-versioning/setup.py
22
22
  from ._legacy_parse import (
23
- resolved_fallback_root as resolved_fallback_root, # noqa: F401
23
+ resolved_fallback_root as resolved_fallback_root,
24
24
  )
25
25
  from ._overrides import _apply_metadata_overrides, _read_pretended_version_for
26
26
  from ._scm_version import ScmVersion
@@ -80,8 +80,8 @@ def parse_scm_version(config: Configuration) -> ScmVersion | None:
80
80
  entrypoint="setuptools_scm.parse_scm",
81
81
  root=config.absolute_root,
82
82
  )
83
- except _run_cmd.CommandNotFoundError as e:
84
- log.exception("command %s not found while parsing the scm, using fallbacks", e)
83
+ except _run_cmd.CommandNotFoundError:
84
+ log.exception("command not found while parsing the scm, using fallbacks")
85
85
  return None
86
86
 
87
87
 
@@ -116,7 +116,7 @@ def _check_absolute_root(root: _t.PathT, relative_to: _t.PathT | None) -> str:
116
116
  if (
117
117
  os.path.isabs(root)
118
118
  and os.path.isabs(relative_to)
119
- and not os.path.commonpath([root, relative_to]) == root
119
+ and os.path.commonpath([root, relative_to]) != root
120
120
  ):
121
121
  warnings.warn(
122
122
  f"absolute root path '{root}' overrides relative_to '{relative_to}'",
@@ -157,6 +157,7 @@ def run(
157
157
  timeout = _get_timeout(os.environ)
158
158
  res = subprocess.run(
159
159
  cmd,
160
+ check=False, # handled below via CompletedProcess.check_returncode
160
161
  capture_output=True,
161
162
  cwd=os.fspath(cwd),
162
163
  env=dict(
@@ -174,9 +174,8 @@ def guess_next_date_ver(
174
174
  # deduct date format if not provided
175
175
  if date_fmt is None:
176
176
  date_fmt = "%Y.%m.%d" if len(match.group("year")) == 4 else "%y.%m.%d"
177
- if prefix := match.group("prefix"):
178
- if not date_fmt.startswith(prefix):
179
- date_fmt = prefix + date_fmt
177
+ if (prefix := match.group("prefix")) and not date_fmt.startswith(prefix):
178
+ date_fmt = prefix + date_fmt
180
179
 
181
180
  today = version.time.date()
182
181
  head_date = node_date or today
@@ -20,11 +20,17 @@ from __future__ import annotations
20
20
  import contextvars
21
21
  import logging
22
22
  import os
23
+ import sys
23
24
  from collections.abc import Mapping, MutableMapping
24
25
  from contextlib import ContextDecorator
25
26
  from datetime import datetime
26
27
  from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, overload
27
28
 
29
+ if sys.version_info >= (3, 11):
30
+ from typing import Self
31
+ else:
32
+ from typing_extensions import Self
33
+
28
34
  from packaging.utils import canonicalize_name
29
35
 
30
36
  from ._overrides import (
@@ -262,7 +268,7 @@ class GlobalOverrides:
262
268
  version = get_version(...)
263
269
  """
264
270
 
265
- __slots__ = ("vcs_env", "tool", "dist_name", "_token")
271
+ __slots__ = ("_token", "dist_name", "tool", "vcs_env")
266
272
 
267
273
  def __init__(
268
274
  self,
@@ -361,13 +367,13 @@ class GlobalOverrides:
361
367
  # Context manager
362
368
  # ------------------------------------------------------------------
363
369
 
364
- def __enter__(self) -> GlobalOverrides:
370
+ def __enter__(self) -> Self:
365
371
  """Enter context: set this as the active override and configure logging."""
366
372
  self._token = _active_overrides.set(self)
367
373
  self.vcs_env.configure_logging()
368
374
  return self
369
375
 
370
- def __exit__(self, *exc_info: Any) -> None:
376
+ def __exit__(self, *exc_info: object) -> None:
371
377
  """Exit context: restore previous override state."""
372
378
  if self._token is not None:
373
379
  _active_overrides.reset(self._token)
@@ -523,7 +529,7 @@ class ensure_context(ContextDecorator):
523
529
  )
524
530
  return self._context.__enter__()
525
531
 
526
- def __exit__(self, *exc_info: Any) -> None:
532
+ def __exit__(self, *exc_info: object) -> None:
527
533
  """Exit context: only exit if we created the context."""
528
534
  if self._created_context and self._context is not None:
529
535
  self._context.__exit__(*exc_info)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vcs-versioning
3
- Version: 2.2.2
3
+ Version: 2.2.4
4
4
  Summary: the blessed package to manage your versions by vcs metadata
5
5
  Author-email: Ronny Pfannschmidt <opensource@ronnypfannschmidt.de>
6
6
  License: MIT
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.14
20
20
  Requires-Python: >=3.8
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE.txt
23
- Requires-Dist: packaging>=20
23
+ Requires-Dist: packaging>=26.2
24
24
  Requires-Dist: tomli>=1; python_version < "3.11"
25
25
  Requires-Dist: typing-extensions>=4.1; python_version < "3.11"
26
26
  Dynamic: license-file
@@ -1,4 +1,4 @@
1
- packaging>=20
1
+ packaging>=26.2
2
2
 
3
3
  [:python_version < "3.11"]
4
4
  tomli>=1
@@ -14,7 +14,7 @@ from unittest.mock import Mock, patch
14
14
  import pytest
15
15
 
16
16
  # File finder imports (now in vcs_versioning)
17
- import vcs_versioning._file_finders # noqa: F401
17
+ import vcs_versioning._file_finders
18
18
  import vcs_versioning._file_finders._git
19
19
  from vcs_versioning import Configuration
20
20
  from vcs_versioning._backends import _git
@@ -50,10 +50,15 @@ def wd(wd: WorkDir, monkeypatch: pytest.MonkeyPatch, debug_mode: DebugMode) -> W
50
50
  [
51
51
  ("3.3.1-rc26-0-g9df187b", "3.3.1-rc26", 0, "g9df187b", False),
52
52
  ("17.33.0-rc-17-g38c3047c0", "17.33.0-rc", 17, "g38c3047c0", False),
53
+ ("v1.15.1rc1-37-g9bd1298-dirty", "v1.15.1rc1", 37, "g9bd1298", True),
54
+ # bare tags containing dashes must not be mistaken for describe output
55
+ ("llvmorg-23.1.0-rc2", "llvmorg-23.1.0-rc2", 0, None, False),
56
+ ("llvmorg-23.1.0-rc2-dirty", "llvmorg-23.1.0-rc2", 0, None, True),
57
+ ("release-1.2.2", "release-1.2.2", 0, None, False),
53
58
  ],
54
59
  )
55
60
  def test_parse_describe_output(
56
- given: str, tag: str, number: int, node: str, dirty: bool
61
+ given: str, tag: str, number: int, node: str | None, dirty: bool
57
62
  ) -> None:
58
63
  parsed = _git._git_parse_describe(given)
59
64
  assert parsed == (tag, number, node, dirty)
@@ -499,7 +504,6 @@ def test_non_dotted_tag_no_version_match(wd: WorkDir) -> None:
499
504
 
500
505
  @pytest.mark.issue("https://github.com/pypa/setuptools-scm/issues/381")
501
506
  def test_gitdir(monkeypatch: pytest.MonkeyPatch, wd: WorkDir) -> None:
502
- """ """
503
507
  wd.commit_testfile()
504
508
  normal = wd.get_version()
505
509
  # git hooks set this and break subsequent setuptools-scm unless we clean
@@ -650,6 +654,18 @@ def test_git_archival_to_version(expected: str, from_data: dict[str, str]) -> No
650
654
  assert format_version(version) == expected
651
655
 
652
656
 
657
+ @pytest.mark.issue("https://github.com/pypa/setuptools-scm/issues/1481")
658
+ def test_git_archival_bare_tag_with_dashes() -> None:
659
+ config = Configuration(tag_regex=r"^llvmorg-(?P<version>.+)$")
660
+ version = archival_to_version(
661
+ {"describe-name": "llvmorg-23.1.0-rc2"}, config=config
662
+ )
663
+ assert version is not None
664
+ assert str(version.tag) == "23.1.0rc2"
665
+ assert version.distance == 0
666
+ assert version.node is None
667
+
668
+
653
669
  @pytest.mark.issue("https://github.com/pypa/setuptools-scm/issues/727")
654
670
  def test_git_archival_node_missing_no_version() -> None:
655
671
  config = Configuration()
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  from pathlib import Path
7
+ from typing import Any
7
8
  from unittest.mock import patch
8
9
 
9
10
  import pytest
@@ -11,7 +12,7 @@ from vcs_versioning import Configuration
11
12
  from vcs_versioning._backends import _jj as jj
12
13
  from vcs_versioning._backends._discover_vcs import discover
13
14
  from vcs_versioning._file_finders._jj import jj_find_files
14
- from vcs_versioning._run_cmd import CommandNotFoundError
15
+ from vcs_versioning._run_cmd import CommandNotFoundError, CompletedProcess, run
15
16
  from vcs_versioning.test_api import DebugMode, WorkDir
16
17
 
17
18
 
@@ -104,6 +105,65 @@ def test_jj_distance(wd: WorkDir) -> None:
104
105
  assert str(version.tag) == "1.0.0"
105
106
 
106
107
 
108
+ @pytest.mark.issue(1477)
109
+ def test_jj_distance_counts_merges_like_git(wd: WorkDir) -> None:
110
+ """Merges are empty in jj terms but git counts them -- so do we."""
111
+ wd.commit_testfile()
112
+ wd("jj tag set v1.0.0 -r @-")
113
+ wd.commit_testfile()
114
+ main = wd("jj log --no-graph -r @- -T commit_id")
115
+
116
+ wd("jj new v1.0.0 -m side")
117
+ wd.write("side.txt", "side")
118
+ wd.add_and_commit("side")
119
+ side = wd("jj log --no-graph -r @- -T commit_id")
120
+
121
+ wd(f"jj new {main} {side} -m merge")
122
+ wd("jj commit -m merged")
123
+
124
+ version = jj.parse(wd.cwd, Configuration())
125
+ assert version is not None
126
+ assert version.distance == int(wd("git rev-list --count v1.0.0..HEAD"))
127
+ assert version.node == "j" + wd("git rev-parse HEAD")[:12]
128
+
129
+
130
+ @pytest.mark.issue(1477)
131
+ def test_jj_avoids_scanning_the_whole_history(
132
+ wd: WorkDir, monkeypatch: pytest.MonkeyPatch
133
+ ) -> None:
134
+ """``empty()`` costs one commit diff per candidate -- keep it bounded.
135
+
136
+ An unbounded ``empty()`` filter turns version inference into a
137
+ minutes-long operation on large repositories.
138
+ """
139
+ wd.commit_testfile()
140
+ wd("jj tag set v1.0.0 -r @-")
141
+ wd.commit_testfile()
142
+
143
+ commands: list[list[str]] = []
144
+
145
+ def record(cmd: list[str], cwd: Path, **kwargs: Any) -> CompletedProcess:
146
+ commands.append([str(part) for part in cmd])
147
+ return run(cmd, cwd, **kwargs)
148
+
149
+ monkeypatch.setattr("vcs_versioning._backends._jj._run", record)
150
+ assert jj.parse(wd.cwd, Configuration()) is not None
151
+
152
+ revsets = [
153
+ cmd[index + 1]
154
+ for cmd in commands
155
+ for index, arg in enumerate(cmd)
156
+ if arg == "-r"
157
+ ]
158
+ assert revsets
159
+ bound = f"ancestors(@, {jj.ANCESTOR_SCAN_LIMIT})"
160
+ assert all("empty()" not in revset or bound in revset for revset in revsets)
161
+
162
+ # the working copy is snapshotted once, the history queries reuse it
163
+ snapshotting = [cmd for cmd in commands if "--ignore-working-copy" not in cmd]
164
+ assert len(snapshotting) == 1
165
+
166
+
107
167
  def test_jj_branch_detection(wd: WorkDir) -> None:
108
168
  wd.commit_testfile()
109
169
  wd("jj bookmark create test-branch -r @-")
@@ -144,9 +204,8 @@ def test_jj_missing_binary_errors(tmp_path: Path) -> None:
144
204
 
145
205
  with patch(
146
206
  "vcs_versioning._backends._discover_vcs.has_command", return_value=False
147
- ):
148
- with pytest.raises(LookupError, match="jj.*not available"):
149
- discover(tmp_path, config=config)
207
+ ), pytest.raises(LookupError, match="jj.*not available"):
208
+ discover(tmp_path, config=config)
150
209
 
151
210
 
152
211
  def test_jj_colocated_prefers_jj(wd: WorkDir) -> None:
@@ -4,7 +4,7 @@ import os
4
4
  import warnings
5
5
 
6
6
  import pytest
7
- import vcs_versioning._file_finders # noqa: F401
7
+ import vcs_versioning._file_finders
8
8
  from vcs_versioning import Configuration
9
9
  from vcs_versioning._backends._hg import archival_to_version, parse
10
10
  from vcs_versioning._run_cmd import CommandNotFoundError
@@ -233,7 +233,7 @@ def test_only_version(version: str) -> None:
233
233
  def test_tag_regex1(tag: str, expected: str) -> None:
234
234
  if "+" in tag:
235
235
  # pytest bug wrt cardinality
236
- with pytest.warns(UserWarning): # noqa: PT030
236
+ with pytest.warns(UserWarning):
237
237
  result = meta(tag, config=c)
238
238
  else:
239
239
  result = meta(tag, config=c)
@@ -317,7 +317,7 @@ test = [
317
317
 
318
318
  [package.metadata]
319
319
  requires-dist = [
320
- { name = "packaging", specifier = ">=20" },
320
+ { name = "packaging", specifier = ">=26.2" },
321
321
  { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1" },
322
322
  { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.1" },
323
323
  ]
File without changes
File without changes
File without changes
@@ -167,8 +167,8 @@ def collect_files_and_dirs(
167
167
 
168
168
 
169
169
  __all__ = [
170
- "scm_find_files",
171
- "is_toplevel_acceptable",
172
- "find_files",
173
170
  "collect_files_and_dirs",
171
+ "find_files",
172
+ "is_toplevel_acceptable",
173
+ "scm_find_files",
174
174
  ]
@@ -16,12 +16,12 @@ if TYPE_CHECKING:
16
16
  from ._compat import PathT as PathT # noqa: PLC0414
17
17
 
18
18
  __all__ = [
19
- "PathT",
20
19
  "CMD_TYPE",
20
+ "GIT_PRE_PARSE",
21
+ "SCMVERSION",
21
22
  "VERSION_SCHEME",
22
23
  "VERSION_SCHEMES",
23
- "SCMVERSION",
24
- "GIT_PRE_PARSE",
24
+ "PathT",
25
25
  ]
26
26
 
27
27
  if TYPE_CHECKING: