gitcalver 20260418.5__py3-none-any.whl

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.
gitcalver/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ # Copyright © 2026 Michael Shields
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from gitcalver._errors import EXIT_DIRTY, EXIT_ERROR, EXIT_WRONG_BRANCH, ExitError
5
+ from gitcalver._format import Format
6
+ from gitcalver._version import forward, reverse
7
+
8
+
9
+ def get_version(
10
+ *,
11
+ revision: str | None = None,
12
+ prefix: str = "",
13
+ dirty: str = "",
14
+ dirty_hash: bool = True,
15
+ branch: str | None = None,
16
+ repo: str | None = None,
17
+ ) -> str:
18
+ fmt = Format(prefix=prefix, dirty_suffix=dirty or None, dirty_hash=dirty_hash)
19
+ return forward(
20
+ dir=repo,
21
+ revision=revision,
22
+ fmt=fmt,
23
+ branch_override=branch or None,
24
+ )
25
+
26
+
27
+ def find_commit(
28
+ version: str,
29
+ *,
30
+ prefix: str = "",
31
+ branch: str | None = None,
32
+ repo: str | None = None,
33
+ short: bool = False,
34
+ ) -> str:
35
+ return reverse(
36
+ dir=repo,
37
+ version_str=version.removeprefix(prefix),
38
+ branch_override=branch or None,
39
+ short=short,
40
+ )
41
+
42
+
43
+ __all__ = [
44
+ "EXIT_DIRTY",
45
+ "EXIT_ERROR",
46
+ "EXIT_WRONG_BRANCH",
47
+ "ExitError",
48
+ "find_commit",
49
+ "get_version",
50
+ ]
gitcalver/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # Copyright © 2026 Michael Shields
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from gitcalver.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
gitcalver/_branch.py ADDED
@@ -0,0 +1,59 @@
1
+ # Copyright © 2026 Michael Shields
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from gitcalver import _git
5
+ from gitcalver._errors import ExitError
6
+
7
+
8
+ def detect_branch(
9
+ dir: str | None = None, override: str | None = None
10
+ ) -> tuple[str, str]:
11
+ if override is not None:
12
+ if "/" in override:
13
+ candidates = [override]
14
+ else:
15
+ candidates = [
16
+ f"refs/remotes/origin/{override}",
17
+ f"refs/heads/{override}",
18
+ ]
19
+ for ref in candidates:
20
+ hash_ = _git.try_ref_hash(ref, dir=dir)
21
+ if hash_ is not None:
22
+ name = override.rsplit("/", 1)[-1]
23
+ return name, hash_
24
+ msg = f"branch not found: {override}"
25
+ raise ExitError(msg)
26
+
27
+ target = _git.symbolic_ref("refs/remotes/origin/HEAD", dir=dir)
28
+ if target:
29
+ hash_ = _git.try_ref_hash(target, dir=dir)
30
+ if hash_ is not None:
31
+ name = target.removeprefix("refs/remotes/origin/")
32
+ return name, hash_
33
+
34
+ for name in ("main", "master"):
35
+ hash_ = _git.try_ref_hash(f"refs/remotes/origin/{name}", dir=dir)
36
+ if hash_ is not None:
37
+ return name, hash_
38
+
39
+ for name in ("main", "master"):
40
+ hash_ = _git.try_ref_hash(f"refs/heads/{name}", dir=dir)
41
+ if hash_ is not None:
42
+ return name, hash_
43
+
44
+ msg = "cannot determine default branch"
45
+ raise ExitError(msg)
46
+
47
+
48
+ def is_on_branch(
49
+ target_hash: str,
50
+ branch_hash: str,
51
+ dir: str | None = None,
52
+ ) -> bool:
53
+ # Optimization: git treats a commit as its own ancestor, so the
54
+ # is_ancestor call below would handle this case correctly, but
55
+ # this avoids spawning git for the common same-hash case.
56
+ if target_hash == branch_hash:
57
+ return True
58
+
59
+ return _git.is_ancestor(target_hash, branch_hash, dir=dir)
gitcalver/_errors.py ADDED
@@ -0,0 +1,13 @@
1
+ # Copyright © 2026 Michael Shields
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ EXIT_ERROR = 1
5
+ EXIT_DIRTY = 2
6
+ EXIT_WRONG_BRANCH = 3
7
+
8
+
9
+ class ExitError(Exception):
10
+ def __init__(self, message: str, code: int = EXIT_ERROR) -> None:
11
+ super().__init__(message)
12
+ self.code = code
13
+ self.message = message
gitcalver/_format.py ADDED
@@ -0,0 +1,24 @@
1
+ # Copyright © 2026 Michael Shields
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Format:
9
+ prefix: str
10
+ # None means dirty workspaces are not allowed; otherwise the string
11
+ # is appended to versions built from a dirty workspace.
12
+ dirty_suffix: str | None
13
+ dirty_hash: bool
14
+
15
+
16
+ def format_version(
17
+ fmt: Format, date: str, count: int, dirty: bool, short_hash: str
18
+ ) -> str:
19
+ version = f"{fmt.prefix}{date}.{count}"
20
+ if dirty and fmt.dirty_suffix is not None:
21
+ version += fmt.dirty_suffix
22
+ if fmt.dirty_hash:
23
+ version += f".{short_hash}"
24
+ return version
gitcalver/_git.py ADDED
@@ -0,0 +1,130 @@
1
+ # Copyright © 2026 Michael Shields
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import os
5
+ import subprocess
6
+ from collections.abc import Generator
7
+
8
+
9
+ class GitError(Exception):
10
+ pass
11
+
12
+
13
+ _SHORT_HASH_LEN = 7
14
+
15
+
16
+ def _os_error_message(e: OSError) -> str:
17
+ if e.filename == "git":
18
+ return "git not found on PATH"
19
+ return str(e)
20
+
21
+
22
+ def _run(*args: str, dir: str | None = None) -> subprocess.CompletedProcess[str]:
23
+ try:
24
+ return subprocess.run(
25
+ ["git", *args],
26
+ capture_output=True,
27
+ text=True,
28
+ cwd=dir,
29
+ check=False,
30
+ )
31
+ except OSError as e:
32
+ raise GitError(_os_error_message(e)) from e
33
+
34
+
35
+ def git(*args: str, dir: str | None = None) -> str:
36
+ result = _run(*args, dir=dir)
37
+ if result.returncode != 0:
38
+ raise GitError(result.stderr.strip())
39
+ return result.stdout.strip()
40
+
41
+
42
+ def git_ok(*args: str, dir: str | None = None) -> bool:
43
+ return _run(*args, dir=dir).returncode == 0
44
+
45
+
46
+ def rev_parse(rev: str, dir: str | None = None) -> str:
47
+ return git("rev-parse", rev, dir=dir)
48
+
49
+
50
+ def rev_parse_short(rev: str, dir: str | None = None) -> str:
51
+ # Pin the minimum length so output doesn't vary with core.abbrev.
52
+ return git("rev-parse", f"--short={_SHORT_HASH_LEN}", rev, dir=dir)
53
+
54
+
55
+ def is_git_repo(dir: str | None = None) -> bool:
56
+ return git_ok("rev-parse", "--git-dir", dir=dir)
57
+
58
+
59
+ def is_shallow(dir: str | None = None) -> bool:
60
+ return git("rev-parse", "--is-shallow-repository", dir=dir) == "true"
61
+
62
+
63
+ def has_commits(dir: str | None = None) -> bool:
64
+ return git_ok("rev-parse", "--verify", "HEAD", dir=dir)
65
+
66
+
67
+ def is_dirty(dir: str | None = None) -> bool:
68
+ try:
69
+ return git("status", "--porcelain", dir=dir) != ""
70
+ except GitError:
71
+ return False
72
+
73
+
74
+ def symbolic_ref(ref: str, dir: str | None = None) -> str | None:
75
+ try:
76
+ return git("symbolic-ref", ref, dir=dir)
77
+ except GitError:
78
+ return None
79
+
80
+
81
+ def try_ref_hash(ref: str, dir: str | None = None) -> str | None:
82
+ try:
83
+ return git("rev-parse", "--verify", ref, dir=dir)
84
+ except GitError:
85
+ return None
86
+
87
+
88
+ def is_ancestor(commit: str, ancestor_of: str, dir: str | None = None) -> bool:
89
+ return git_ok("merge-base", "--is-ancestor", commit, ancestor_of, dir=dir)
90
+
91
+
92
+ def merge_base(rev1: str, rev2: str, dir: str | None = None) -> str | None:
93
+ try:
94
+ return git("merge-base", rev1, rev2, dir=dir)
95
+ except GitError:
96
+ return None
97
+
98
+
99
+ def first_parent_log(
100
+ rev: str, dir: str | None = None
101
+ ) -> Generator[tuple[str, str], None, None]:
102
+ env = {**os.environ, "TZ": "UTC"}
103
+ try:
104
+ proc = subprocess.Popen(
105
+ [
106
+ "git",
107
+ "log",
108
+ rev,
109
+ "--first-parent",
110
+ "--format=%H %cd",
111
+ "--date=format-local:%Y%m%d",
112
+ ],
113
+ stdout=subprocess.PIPE,
114
+ stderr=subprocess.DEVNULL,
115
+ text=True,
116
+ cwd=dir,
117
+ env=env,
118
+ )
119
+ except OSError as e:
120
+ raise GitError(_os_error_message(e)) from e
121
+ with proc:
122
+ if proc.stdout is None:
123
+ return
124
+ for line in proc.stdout:
125
+ hash_, _, date = line.strip().partition(" ")
126
+ if date:
127
+ yield hash_, date
128
+ if proc.wait() != 0:
129
+ msg = f"git log {rev} failed"
130
+ raise GitError(msg)
@@ -0,0 +1,18 @@
1
+ # Copyright © 2026 Michael Shields
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import TYPE_CHECKING
7
+
8
+ from hatchling.plugin import hookimpl
9
+
10
+ if TYPE_CHECKING:
11
+ from hatchling.version.source.plugin.interface import VersionSourceInterface
12
+
13
+
14
+ @hookimpl
15
+ def hatch_register_version_source() -> type[VersionSourceInterface]:
16
+ from gitcalver._hatch_source import GitCalverSource
17
+
18
+ return GitCalverSource
@@ -0,0 +1,28 @@
1
+ # Copyright © 2026 Michael Shields
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from hatchling.version.source.plugin.interface import VersionSourceInterface
5
+
6
+ from gitcalver import ExitError, get_version
7
+
8
+
9
+ class GitCalverSource(VersionSourceInterface):
10
+ PLUGIN_NAME = "gitcalver"
11
+
12
+ def get_version_data(self) -> dict[str, str]:
13
+ config = self.config
14
+ if config.get("no-dirty-hash", False) and not config.get("dirty", ""):
15
+ msg = "gitcalver: no-dirty-hash requires dirty"
16
+ raise RuntimeError(msg)
17
+ try:
18
+ version = get_version(
19
+ prefix=str(config.get("prefix", "")),
20
+ dirty=str(config.get("dirty", "")),
21
+ dirty_hash=not config.get("no-dirty-hash", False),
22
+ branch=config.get("branch") or None,
23
+ repo=self.root,
24
+ )
25
+ except ExitError as e:
26
+ msg = f"gitcalver: {e.message}"
27
+ raise RuntimeError(msg) from e
28
+ return {"version": version}
gitcalver/_version.py ADDED
@@ -0,0 +1,183 @@
1
+ # Copyright © 2026 Michael Shields
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import contextlib
5
+ import datetime
6
+ import re
7
+
8
+ from gitcalver import _git
9
+ from gitcalver._branch import detect_branch, is_on_branch
10
+ from gitcalver._errors import EXIT_DIRTY, EXIT_WRONG_BRANCH, ExitError
11
+ from gitcalver._format import Format, format_version
12
+
13
+ VERSION_RE = re.compile(r"^(\d{8})\.([1-9]\d*)$")
14
+
15
+
16
+ def is_version_string(s: str) -> bool:
17
+ return VERSION_RE.match(s) is not None
18
+
19
+
20
+ def _date_went_backwards(older: str, newer: str) -> ExitError:
21
+ msg = (
22
+ f"committer date not monotonic: "
23
+ f"older commit dated {older} has a later date than "
24
+ f"newer commit dated {newer}"
25
+ )
26
+ return ExitError(msg)
27
+
28
+
29
+ def _validate_repo(dir: str | None) -> None:
30
+ try:
31
+ is_repo = _git.is_git_repo(dir=dir)
32
+ except _git.GitError as e:
33
+ msg = str(e)
34
+ raise ExitError(msg) from e
35
+ if not is_repo:
36
+ msg = "not a git repository"
37
+ raise ExitError(msg)
38
+ try:
39
+ shallow = _git.is_shallow(dir=dir)
40
+ except _git.GitError as e:
41
+ msg = f"cannot determine repository state: {e}"
42
+ raise ExitError(msg) from e
43
+ if shallow:
44
+ msg = (
45
+ "shallow clone detected; full history is required"
46
+ " (use git fetch --unshallow)"
47
+ )
48
+ raise ExitError(msg)
49
+ if not _git.has_commits(dir=dir):
50
+ msg = "no commits in repository"
51
+ raise ExitError(msg)
52
+
53
+
54
+ def forward(
55
+ *,
56
+ dir: str | None,
57
+ revision: str | None,
58
+ fmt: Format,
59
+ branch_override: str | None,
60
+ ) -> str:
61
+ _validate_repo(dir)
62
+
63
+ is_head = revision is None
64
+ rev_spec = f"{'HEAD' if is_head else revision}^{{commit}}"
65
+ try:
66
+ target_hash = _git.rev_parse(rev_spec, dir=dir)
67
+ except _git.GitError:
68
+ msg = (
69
+ "no commits in repository"
70
+ if is_head
71
+ else f"not a gitcalver version or git revision: {revision}"
72
+ )
73
+ raise ExitError(msg) from None
74
+
75
+ branch_name, branch_hash = detect_branch(dir=dir, override=branch_override)
76
+
77
+ version_rev = target_hash
78
+ off_branch = False
79
+
80
+ if not is_on_branch(target_hash, branch_hash, dir=dir):
81
+ if not is_head:
82
+ msg = f"{revision} is not on the default branch ({branch_name})"
83
+ raise ExitError(msg, EXIT_WRONG_BRANCH)
84
+ mb = _git.merge_base(target_hash, branch_hash, dir=dir)
85
+ if mb is None:
86
+ msg = f"HEAD is not traceable to the default branch ({branch_name})"
87
+ raise ExitError(msg, EXIT_WRONG_BRANCH)
88
+ off_branch = True
89
+ version_rev = mb
90
+
91
+ dirty = False
92
+ if off_branch or (is_head and _git.is_dirty(dir=dir)):
93
+ if fmt.dirty_suffix is None:
94
+ if off_branch:
95
+ msg = (
96
+ f"HEAD is off the default branch ({branch_name});"
97
+ " use --dirty to produce a divergent version"
98
+ )
99
+ else:
100
+ msg = "workspace is dirty; use --dirty to allow"
101
+ raise ExitError(msg, EXIT_DIRTY)
102
+ dirty = True
103
+
104
+ date, count = walk_first_parent(dir=dir, rev=version_rev)
105
+
106
+ short_hash = ""
107
+ if dirty and fmt.dirty_hash:
108
+ short_hash = _git.rev_parse_short(target_hash, dir=dir)
109
+
110
+ return format_version(fmt, date, count, dirty, short_hash)
111
+
112
+
113
+ def walk_first_parent(*, dir: str | None, rev: str) -> tuple[str, int]:
114
+ with contextlib.closing(_git.first_parent_log(rev, dir=dir)) as entries:
115
+ first = next(entries, None)
116
+ if first is None:
117
+ msg = "no commits found"
118
+ raise ExitError(msg)
119
+ date = first[1]
120
+ count = 1
121
+
122
+ # Only the first date transition matters: once the date changes,
123
+ # we're done counting. Monotonicity is only checked within the
124
+ # target date's run; earlier violations are not surfaced here.
125
+ for _, entry_date in entries:
126
+ if entry_date != date:
127
+ if entry_date > date:
128
+ raise _date_went_backwards(entry_date, date)
129
+ break
130
+ count += 1
131
+
132
+ return date, count
133
+
134
+
135
+ def reverse(
136
+ *,
137
+ dir: str | None,
138
+ version_str: str,
139
+ branch_override: str | None,
140
+ short: bool,
141
+ ) -> str:
142
+ _validate_repo(dir)
143
+
144
+ match = VERSION_RE.match(version_str)
145
+ if not match:
146
+ msg = f"not a gitcalver version or git revision: {version_str}"
147
+ raise ExitError(msg)
148
+
149
+ date_str = match.group(1)
150
+ n = int(match.group(2))
151
+
152
+ try:
153
+ datetime.date(int(date_str[:4]), int(date_str[4:6]), int(date_str[6:8]))
154
+ except ValueError:
155
+ msg = f"invalid date in version: {version_str}"
156
+ raise ExitError(msg) from None
157
+
158
+ _, branch_hash = detect_branch(dir=dir, override=branch_override)
159
+
160
+ candidates: list[str] = []
161
+ # git log is newest-first; a later date on an older commit is non-monotonic.
162
+ newer_date: str | None = None
163
+ with contextlib.closing(_git.first_parent_log(branch_hash, dir=dir)) as entries:
164
+ for commit_hash, commit_date in entries:
165
+ if newer_date is not None and commit_date > newer_date:
166
+ raise _date_went_backwards(commit_date, newer_date)
167
+ newer_date = commit_date
168
+ if commit_date == date_str:
169
+ candidates.append(commit_hash)
170
+ elif commit_date < date_str:
171
+ # Dates are non-increasing (checked above); no earlier matches.
172
+ break
173
+
174
+ if n > len(candidates):
175
+ msg = f"version not found: {version_str}"
176
+ raise ExitError(msg)
177
+
178
+ # N=1 is oldest on that date; candidates are newest-first.
179
+ target_hash = candidates[-n]
180
+
181
+ if short:
182
+ return _git.rev_parse_short(target_hash, dir=dir)
183
+ return target_hash
gitcalver/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ # Copyright © 2026 Michael Shields
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import argparse
5
+ import importlib.metadata
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from typing import NoReturn
9
+
10
+ from gitcalver._errors import ExitError
11
+ from gitcalver._format import Format
12
+ from gitcalver._version import forward, is_version_string, reverse
13
+
14
+ USAGE = """\
15
+ Usage: gitcalver [options] [REVISION | VERSION]
16
+
17
+ Compute a gitcalver version for a git commit, or find the commit for a version.
18
+
19
+ Options:
20
+ --prefix PREFIX Prepend PREFIX to the version (default: none)
21
+ --dirty STRING Allow dirty workspace; append STRING.HASH as suffix
22
+ --no-dirty Refuse dirty workspace (overrides --dirty)
23
+ --no-dirty-hash Suppress .HASH in dirty suffix (requires --dirty)
24
+ --branch BRANCH Override default branch detection
25
+ --short Output short commit hash (reverse mode only)
26
+ --version Show version and exit
27
+ --help Show this help
28
+ """
29
+
30
+
31
+ def _package_version() -> str:
32
+ try:
33
+ return importlib.metadata.version("gitcalver")
34
+ except importlib.metadata.PackageNotFoundError:
35
+ return "unknown"
36
+
37
+
38
+ @dataclass
39
+ class Args:
40
+ help: bool = False
41
+ version: bool = False
42
+ prefix: str = ""
43
+ dirty: str = ""
44
+ no_dirty: bool = False
45
+ no_dirty_hash: bool = False
46
+ branch: str | None = None
47
+ short: bool = False
48
+ positional: str | None = None
49
+
50
+
51
+ class _Parser(argparse.ArgumentParser):
52
+ def error(self, message: str) -> NoReturn:
53
+ raise ExitError(message)
54
+
55
+
56
+ class _NonEmptyStrAction(argparse.Action):
57
+ def __call__(
58
+ self,
59
+ parser: argparse.ArgumentParser,
60
+ namespace: argparse.Namespace,
61
+ values: object,
62
+ option_string: str | None = None,
63
+ ) -> None:
64
+ if not values:
65
+ parser.error(f"{option_string} requires a non-empty string")
66
+ setattr(namespace, self.dest, values)
67
+
68
+
69
+ # Options that take a string value; used by `_normalize_argv` to rewrite the
70
+ # space-separated form to `--opt=value` so argparse accepts values starting
71
+ # with `-`. Keep in sync with the non-boolean options declared in `_parse_args`.
72
+ _OPTS_TAKING_VALUE = frozenset({"--prefix", "--dirty", "--branch"})
73
+
74
+
75
+ def _normalize_argv(argv: list[str]) -> list[str]:
76
+ """Rewrite `--opt value` to `--opt=value` for options in `_OPTS_TAKING_VALUE`.
77
+
78
+ argparse otherwise refuses values that begin with `-` in the space-separated
79
+ form (so `--dirty -dirty` fails, though `--dirty=-dirty` works). Rewriting
80
+ makes both forms equivalent. Stops at `--` so values after the terminator
81
+ are passed through unchanged.
82
+ """
83
+ result: list[str] = []
84
+ i = 0
85
+ while i < len(argv):
86
+ arg = argv[i]
87
+ if arg == "--":
88
+ result.extend(argv[i:])
89
+ return result
90
+ if arg in _OPTS_TAKING_VALUE and i + 1 < len(argv):
91
+ result.append(f"{arg}={argv[i + 1]}")
92
+ i += 2
93
+ else:
94
+ result.append(arg)
95
+ i += 1
96
+ return result
97
+
98
+
99
+ def _parse_args(argv: list[str]) -> Args:
100
+ parser = _Parser(prog="gitcalver", add_help=False, allow_abbrev=False)
101
+ parser.add_argument("--help", action="store_true")
102
+ parser.add_argument("--version", action="store_true")
103
+ parser.add_argument("--prefix", default="")
104
+ parser.add_argument("--dirty", action=_NonEmptyStrAction, default="")
105
+ parser.add_argument("--no-dirty", action="store_true")
106
+ parser.add_argument("--no-dirty-hash", action="store_true")
107
+ parser.add_argument("--branch", default=None)
108
+ parser.add_argument("--short", action="store_true")
109
+ parser.add_argument("positional", nargs="?", default=None)
110
+
111
+ args = parser.parse_args(_normalize_argv(argv), namespace=Args())
112
+
113
+ if args.no_dirty_hash and not args.dirty:
114
+ parser.error("--no-dirty-hash requires --dirty")
115
+
116
+ return args
117
+
118
+
119
+ def _build_format(args: Args) -> Format:
120
+ dirty_suffix: str | None = None if args.no_dirty else (args.dirty or None)
121
+ return Format(
122
+ prefix=args.prefix,
123
+ dirty_suffix=dirty_suffix,
124
+ dirty_hash=not args.no_dirty_hash,
125
+ )
126
+
127
+
128
+ def run(argv: list[str], *, dir: str | None = None) -> tuple[str, int]:
129
+ try:
130
+ args = _parse_args(argv)
131
+ except ExitError as e:
132
+ return f"gitcalver: {e.message}", e.code
133
+
134
+ if args.help:
135
+ return USAGE.rstrip("\n"), 0
136
+
137
+ if args.version:
138
+ return f"gitcalver {_package_version()}", 0
139
+
140
+ lookup: str | None = None
141
+ # Require the prefix to match before reverse-lookup; avoids treating a
142
+ # bare `20260410.1` as a version when --prefix is set.
143
+ if args.positional is not None and args.positional.startswith(args.prefix):
144
+ candidate = args.positional.removeprefix(args.prefix)
145
+ if is_version_string(candidate):
146
+ lookup = candidate
147
+
148
+ if args.short and lookup is None:
149
+ return "gitcalver: --short is only valid in reverse lookup mode", 1
150
+
151
+ try:
152
+ if lookup is not None:
153
+ result = reverse(
154
+ dir=dir,
155
+ version_str=lookup,
156
+ branch_override=args.branch,
157
+ short=args.short,
158
+ )
159
+ else:
160
+ fmt = _build_format(args)
161
+ result = forward(
162
+ dir=dir,
163
+ revision=args.positional,
164
+ fmt=fmt,
165
+ branch_override=args.branch,
166
+ )
167
+ except ExitError as e:
168
+ return f"gitcalver: {e.message}", e.code
169
+
170
+ return result, 0
171
+
172
+
173
+ def main(argv: list[str] | None = None) -> NoReturn:
174
+ if argv is None:
175
+ argv = sys.argv[1:]
176
+ output, code = run(argv)
177
+ print(output, file=sys.stderr if code != 0 else sys.stdout)
178
+ sys.exit(code)
gitcalver/py.typed ADDED
File without changes
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitcalver
3
+ Version: 20260418.5
4
+ Summary: Deterministic calendar versioning from git history
5
+ Project-URL: Homepage, https://gitcalver.org
6
+ Project-URL: Source, https://github.com/gitcalver/python
7
+ Project-URL: Issues, https://github.com/gitcalver/python/issues
8
+ Author-email: Michael Shields <shields@gitcalver.org>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: calendar-versioning,calver,git,versioning
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Topic :: Software Development :: Build Tools
16
+ Classifier: Topic :: Software Development :: Version Control :: Git
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+
20
+ # gitcalver
21
+
22
+ A Python implementation of [GitCalVer](https://gitcalver.org), which derives
23
+ calendar-based version numbers from git history.
24
+
25
+ Each commit on the default branch gets a unique, strictly increasing version of
26
+ the form `YYYYMMDD.N`, where `N` is the number of commits on that UTC date.
27
+
28
+ See the [GitCalVer specification](https://gitcalver.org) for full details.
29
+
30
+ ## Installation
31
+
32
+ ```sh
33
+ uv add gitcalver
34
+ # or
35
+ pip install gitcalver
36
+ ```
37
+
38
+ See [Requirements](#requirements) below.
39
+
40
+ ## CLI usage
41
+
42
+ ```
43
+ gitcalver [OPTIONS] [REVISION | VERSION]
44
+ ```
45
+
46
+ With no arguments, prints the version for `HEAD`:
47
+
48
+ ```sh
49
+ $ gitcalver
50
+ 20260411.3
51
+ ```
52
+
53
+ Pass a revision to compute its version:
54
+
55
+ ```sh
56
+ $ gitcalver HEAD~1
57
+ 20260411.2
58
+ ```
59
+
60
+ ### Version prefix
61
+
62
+ Use `--prefix` to prepend a literal string:
63
+
64
+ | Use case | Command | Example output |
65
+ |----------|------------------------------|------------------|
66
+ | Default | `gitcalver` | `20260411.3` |
67
+ | SemVer | `gitcalver --prefix "0."` | `0.20260411.3` |
68
+ | Go | `gitcalver --prefix "v0."` | `v0.20260411.3` |
69
+
70
+ ### Dirty workspace
71
+
72
+ By default, `gitcalver` exits with status 2 if the workspace has uncommitted
73
+ changes. Use `--dirty STRING` to produce a version instead; the output will
74
+ include the given string and a short commit hash
75
+ (e.g. `--dirty "-dirty"` produces `20260411.3-dirty.abc1234`).
76
+
77
+ Use `--no-dirty-hash` with `--dirty` to suppress the hash suffix.
78
+ Use `--no-dirty` to explicitly refuse dirty versions (overrides `--dirty`).
79
+
80
+ Dirty versions are a convenience and are not necessarily unique.
81
+
82
+ ### Reverse lookup
83
+
84
+ Pass a version number to get the corresponding commit hash:
85
+
86
+ ```sh
87
+ $ gitcalver 20260411.3
88
+ a1b2c3d4e5f6...
89
+
90
+ $ gitcalver --short --prefix "0." 0.20260411.3
91
+ a1b2c3d
92
+ ```
93
+
94
+ If the version was generated with `--prefix`, pass the same `--prefix` for
95
+ reverse lookup. Dirty versions cannot be reversed.
96
+
97
+ ### Options
98
+
99
+ | Option | Description |
100
+ |---------------------|------------------------------------------------|
101
+ | `--prefix PREFIX` | Literal string prepended to version |
102
+ | `--dirty STRING` | Enable dirty versions; append `STRING.HASH` |
103
+ | `--no-dirty` | Refuse dirty versions (overrides `--dirty`) |
104
+ | `--no-dirty-hash` | Suppress `.HASH` suffix (requires `--dirty`) |
105
+ | `--branch BRANCH` | Base branch name; overrides auto-detection. This is the branch versions are minted on, not the branch you are working on. |
106
+ | `--short` | Output short commit hash (reverse lookup mode) |
107
+ | `--help` | Show help |
108
+
109
+ ### Exit codes
110
+
111
+ | Code | Meaning |
112
+ |------|----------------------------------------|
113
+ | 0 | Success |
114
+ | 1 | Error (not a git repo, no commits, non-monotonic dates, shallow clone) |
115
+ | 2 | Dirty workspace or off default branch (without `--dirty`) |
116
+ | 3 | Cannot trace to default branch |
117
+
118
+ ## Python API
119
+
120
+ ```python
121
+ import gitcalver
122
+
123
+ # Forward: compute a version for HEAD (or a specific revision).
124
+ version = gitcalver.get_version(repo="/path/to/repo")
125
+ # e.g. "20260411.3"
126
+
127
+ version = gitcalver.get_version(
128
+ repo="/path/to/repo",
129
+ revision="HEAD~1",
130
+ prefix="v0.",
131
+ dirty="-dirty",
132
+ )
133
+
134
+ # Reverse: resolve a version back to a commit hash.
135
+ commit = gitcalver.find_commit("20260411.3", repo="/path/to/repo")
136
+
137
+ # If the version was generated with --prefix, pass the same prefix:
138
+ commit = gitcalver.find_commit(
139
+ "v0.20260411.3", prefix="v0.", repo="/path/to/repo"
140
+ )
141
+ ```
142
+
143
+ Errors are raised as `gitcalver.ExitError`, which carries a `code` attribute
144
+ matching the CLI exit codes above.
145
+
146
+ ## Hatch plugin
147
+
148
+ `gitcalver` ships a [Hatch](https://hatch.pypa.io/) version source plugin. To
149
+ use it in `pyproject.toml`:
150
+
151
+ ```toml
152
+ [build-system]
153
+ requires = ["hatchling", "gitcalver"]
154
+ build-backend = "hatchling.build"
155
+
156
+ [tool.hatch.version]
157
+ source = "gitcalver"
158
+ # Optional:
159
+ # prefix = "0."
160
+ # dirty = "-dirty"
161
+ # no-dirty-hash = true
162
+ # branch = "main"
163
+ ```
164
+
165
+ ## Requirements
166
+
167
+ - Python 3.10+
168
+ - `git` on `$PATH`
169
+ - Full commit history (shallow clones made with `--depth` are rejected; partial
170
+ clones made with `--filter=blob:none` are fine)
171
+
172
+ ## License
173
+
174
+ MIT
@@ -0,0 +1,16 @@
1
+ gitcalver/__init__.py,sha256=7896Q4BsaA7d2DdXCSvjrAfRd7LfuWvJfPNzhcZm7cY,1096
2
+ gitcalver/__main__.py,sha256=8sjB1kMwKos_iow-KbnxPrfBUbZvZ7vH-EE4Zo6ct_k,138
3
+ gitcalver/_branch.py,sha256=7GY1RFwF9nX9WpnduIILkTwIr2UjL2fyxRg4ENqo_74,1836
4
+ gitcalver/_errors.py,sha256=6kB1YEdObCWsEtWR1EYtUG8r4wbNqZFHPbDPdh8ihEs,310
5
+ gitcalver/_format.py,sha256=yJyq99VbM3dzCXXTXHWsNBQvv3ACiys9Fusc2uRhx4g,654
6
+ gitcalver/_git.py,sha256=iZJhSDpZ9nX4p-3SOnBmos7XpFdznACQUaTzKrqxNF4,3395
7
+ gitcalver/_hatch_hooks.py,sha256=Iz8jhG7a_NMYCujrFfor4pfRxv3uFDfkwgRTwLY9Up4,441
8
+ gitcalver/_hatch_source.py,sha256=oclwg-P4aVEHL-7VA-2D_7mbV9SungeJ7O-Xs1adsAs,999
9
+ gitcalver/_version.py,sha256=S9cY8XG5jGlzIBYBjWES-9a7QullpNiM7ezNEBSgXvE,5735
10
+ gitcalver/cli.py,sha256=tpNNauoo6HlaILl_dejaFEZkEsNcZnfEE1daeBxBPOQ,5687
11
+ gitcalver/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ gitcalver-20260418.5.dist-info/METADATA,sha256=Qdg9lhDf0bVY2ZH-kQ_9dqekbZnJNO9ZXZkUi2DGlwI,5041
13
+ gitcalver-20260418.5.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
14
+ gitcalver-20260418.5.dist-info/entry_points.txt,sha256=IXcVpCaN0XiVcET1HWKbV_KGUsjFW9ecD77_YgFpju8,93
15
+ gitcalver-20260418.5.dist-info/licenses/LICENSE,sha256=yMt4bLjW824Sn1DDZSbUz0K4TeJsl8jX4mVQfENnRe0,1058
16
+ gitcalver-20260418.5.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ gitcalver = gitcalver.cli:main
3
+
4
+ [hatch]
5
+ gitcalver = gitcalver._hatch_hooks
@@ -0,0 +1,19 @@
1
+ Copyright © 2026 Michael Shields
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.