From bf6196a9b2b1158b4a3f8040765f8405a3122a5c Mon Sep 17 00:00:00 2001 From: Mikhail Milovidov Date: Wed, 10 Jun 2026 15:18:54 +0300 Subject: [PATCH 1/2] Initial commit: git-version-utils v0.1.0 --- .gitignore | 14 +++ README.md | 104 +++++++++++++++++++- pyproject.toml | 25 +++++ src/git_version/__init__.py | 3 + src/git_version/cli.py | 50 ++++++++++ src/git_version/core.py | 146 ++++++++++++++++++++++++++++ tests/test_git_version.py | 185 ++++++++++++++++++++++++++++++++++++ 7 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 pyproject.toml create mode 100644 src/git_version/__init__.py create mode 100644 src/git_version/cli.py create mode 100644 src/git_version/core.py create mode 100644 tests/test_git_version.py diff --git a/.gitignore b/.gitignore index 83972fa..835bac8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +<<<<<<< HEAD # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] @@ -216,3 +217,16 @@ __marimo__/ # Streamlit .streamlit/secrets.toml +======= +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.pytest_cache/ + +# IDE +.vscode/ +.idea/ +>>>>>>> 35b63c9 (Initial commit: git-version-utils v0.1.0) diff --git a/README.md b/README.md index fe5535d..1ae7c45 100644 --- a/README.md +++ b/README.md @@ -1 +1,103 @@ -# git_version_utils \ No newline at end of file +# git-version-utils + +Extract version information from a Git repository and output as environment variables. + +## Installation + +```bash +pip install git-version-utils +``` + +## Usage + +### CLI + +```bash +# Print all version info +git-version + +# Output as environment variables (useful with `source` or `eval`) +git-version --property env + +# Custom prefix for environment variables +git-version --prefix MYAPP --property env + +# Custom tag pattern +git-version --tag-pattern "release-*" --property env + +# Get a single property +git-version --property version +git-version --property version_major +git-version --property commit + +# Specify repository path +git-version --repo /path/to/repo --property env +``` + +### Python API + +```python +from git_version import GitVersion + +gv = GitVersion(repo_path="/path/to/repo", tag_pattern="v[0-9]*") + +print(gv.version) # "1.2.3.42" +print(gv.version_major) # "1" +print(gv.version_minor) # "2" +print(gv.version_patch) # "3" +print(gv.build) # "42" +print(gv.tag) # "v1.2.3" +print(gv.branch) # "main" +print(gv.short) # "a1b2c3" +print(gv.full) # "1.2.3.42-a1b2c3" +print(gv.commit) # "a1b2c3d4e5f6..." + +# Get all as environment variables +env_vars = gv.env(prefix="BUILD_VERSION") +for key, value in env_vars.items(): + print(f"{key}={value}") +``` + +## Environment Variables Output + +With default prefix `BUILD_VERSION`: + +| Variable | Example | Description | +|---|---|---| +| `BUILD_VERSION` | `1.2.3.42` | Full version: `.` | +| `BUILD_VERSION_MAJOR` | `1` | Major version component | +| `BUILD_VERSION_MINOR` | `2` | Minor version component | +| `BUILD_VERSION_PATCH` | `3` | Patch version component | +| `BUILD_VERSION_BUILD` | `42` | Commits since last tag | +| `BUILD_VERSION_TAG` | `v1.2.3` | Latest matching git tag | +| `BUILD_VERSION_FULL` | `1.2.3.42-a1b2c3` | Version with commit hash | +| `BUILD_VERSION_EXTENDED` | `1.2.3.42-a1b2c3` | Full if build>0, else version | +| `BUILD_VERSION_SHORT` | `a1b2c3` | Short 6-char commit hash | +| `BUILD_VERSION_COMMIT` | `a1b2c3d4...` | Full 40-char commit hash | +| `BUILD_VERSION_BRANCH` | `main` | Current branch name | +| `BUILD_VERSION_DEFAULT_BRANCH` | `master` | Default branch from git config | + +## CI/CD Integration + +### Shell (source) + +```bash +source <(git-version --property env) +echo "$BUILD_VERSION" +``` + +### CMake + +```cmake +execute_process( + COMMAND git-version --property env + OUTPUT_VARIABLE GIT_VERSION_ENV + OUTPUT_STRIP_TRAILING_WHITESPACE +) +``` + +### Docker + +```dockerfile +RUN pip install git-version-utils +RUN source <(git-version --property env) && echo "Building $BUILD_VERSION" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2d6e83f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "git-version-utils" +version = "0.1.0" +description = "Extract version information from a Git repository and output as environment variables" +readme = "README.md" +authors = [ + { name = "Mikhail Milovidov", email = "synacker@yandex.ru" }, +] +license = "MIT" +keywords = ["git", "version", "build", "ci", "cd"] +requires-python = ">=3.10" + +[project.urls] +Homepage = "https://github.com/synacker/git_version_utils" +Repository = "https://github.com/synacker/git_version_utils" + +[project.scripts] +git-version = "git_version.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] \ No newline at end of file diff --git a/src/git_version/__init__.py b/src/git_version/__init__.py new file mode 100644 index 0000000..cf2dd6d --- /dev/null +++ b/src/git_version/__init__.py @@ -0,0 +1,3 @@ +from .core import GitVersion + +__all__ = ["GitVersion"] \ No newline at end of file diff --git a/src/git_version/cli.py b/src/git_version/cli.py new file mode 100644 index 0000000..772b385 --- /dev/null +++ b/src/git_version/cli.py @@ -0,0 +1,50 @@ +import argparse +import sys + +from .core import GitVersion + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Extract version information from a Git repository.", + ) + parser.add_argument( + "--prefix", "-p", + default="BUILD_VERSION", + help="Prefix for environment variable names (default: BUILD_VERSION)", + ) + parser.add_argument( + "--repo", "-r", + default=None, + help="Path to the Git repository (default: current working directory)", + ) + parser.add_argument( + "--tag-pattern", "-t", + default="v[0-9]*", + help="Glob pattern to match version tags (default: v[0-9]*)", + ) + parser.add_argument( + "--property", "-P", + choices=[ + "tag", "version", "version_major", "version_minor", "version_patch", + "build", "branch", "short", "full", "extended", "commit", + "default_branch", "env", "all", + ], + default="all", + help="Which property to output (default: all)", + ) + args = parser.parse_args() + + gv = GitVersion(repo_path=args.repo, tag_pattern=args.tag_pattern) + + if args.property == "env": + for key, value in gv.env(prefix=args.prefix).items(): + print(f"{key}={value}") + elif args.property == "all": + print(gv) + else: + print(getattr(gv, args.property)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/git_version/core.py b/src/git_version/core.py new file mode 100644 index 0000000..0745deb --- /dev/null +++ b/src/git_version/core.py @@ -0,0 +1,146 @@ +import os +import re +import subprocess +from functools import cached_property + + +class GitVersion: + """Extract version information from a Git repository. + + Args: + repo_path: Path to the Git repository (default: current working directory). + tag_pattern: Glob pattern to match version tags (default: "v[0-9]*"). + """ + + def __init__( + self, + repo_path: str | None = None, + tag_pattern: str = "v[0-9]*", + ): + self.repo_path = os.path.abspath(repo_path or os.getcwd()) + self.tag_pattern = tag_pattern + + def _git(self, *args: str) -> str: + """Execute a git command safely and return stripped stdout.""" + try: + result = subprocess.run( + ["git", "-C", self.repo_path, *args], + capture_output=True, + text=True, + check=True, + timeout=10, + ) + return result.stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError, TimeoutError): + return "" + + @cached_property + def tag(self) -> str: + """Latest version tag matching the tag pattern (e.g. 'v1.2.3').""" + return self._git( + "describe", "--match", self.tag_pattern, "--abbrev=0", "--tags" + ) + + @cached_property + def version(self) -> str: + """Full version string: '.' (e.g. '1.2.3.42').""" + tag = self.tag + if not tag: + return "0.0.0.0" + stripped = re.sub(r"^[^\d]+", "", tag) + return f"{stripped}.{self.build}" + + @cached_property + def version_major(self) -> str: + """Major version component.""" + parts = self.version.split(".") + return parts[0] if len(parts) > 0 else "0" + + @cached_property + def version_minor(self) -> str: + """Minor version component.""" + parts = self.version.split(".") + return parts[1] if len(parts) > 1 else "0" + + @cached_property + def version_patch(self) -> str: + """Patch version component.""" + parts = self.version.split(".") + return parts[2] if len(parts) > 2 else "0" + + @cached_property + def default_branch(self) -> str: + """Default branch name from git config (falls back to 'master').""" + result = self._git("config", "--get", "init.defaultBranch") + return result or "master" + + @cached_property + def build(self) -> str: + """Number of commits since the last version tag.""" + tag = self.tag + if not tag: + return "0" + return self._git("rev-list", f"{tag}..", "--count") + + @cached_property + def branch(self) -> str: + """Current branch name.""" + return self._git("branch", "--show-current") + + @cached_property + def short(self) -> str: + """Short 6-character commit hash.""" + return self._git("rev-parse", "--short=6", "HEAD") + + @cached_property + def full(self) -> str: + """Full version string with commit hash: '-'.""" + return f"{self.version}-{self.short}" + + @cached_property + def extended(self) -> str: + """Extended version: same as 'version' if build==0, else 'full'.""" + if self.build == "0": + return self.version + return f"{self.version}-{self.short}" + + @cached_property + def commit(self) -> str: + """Full 40-character commit hash.""" + return self._git("rev-parse", "HEAD") + + def env(self, prefix: str = "BUILD_VERSION") -> dict[str, str]: + """Return all version info as a dictionary of environment variables. + + Args: + prefix: Prefix for all variable names (default: "BUILD_VERSION"). + + Returns: + Dict mapping variable names to values, e.g.: + {"BUILD_VERSION": "1.2.3.42", "BUILD_VERSION_MAJOR": "1", ...} + """ + return { + f"{prefix}": self.version, + f"{prefix}_MAJOR": self.version_major, + f"{prefix}_MINOR": self.version_minor, + f"{prefix}_PATCH": self.version_patch, + f"{prefix}_BUILD": self.build, + f"{prefix}_TAG": self.tag, + f"{prefix}_FULL": self.full, + f"{prefix}_EXTENDED": self.extended, + f"{prefix}_SHORT": self.short, + f"{prefix}_COMMIT": self.commit, + f"{prefix}_BRANCH": self.branch, + f"{prefix}_DEFAULT_BRANCH": self.default_branch, + } + + def __str__(self) -> str: + return f""" + Tag: {self.tag} + Version: {self.version} + Full: {self.full} + Branch: {self.branch} + Build: {self.build} + Extended: {self.extended} + Commit: {self.commit} + """ \ No newline at end of file diff --git a/tests/test_git_version.py b/tests/test_git_version.py new file mode 100644 index 0000000..cf7b9c9 --- /dev/null +++ b/tests/test_git_version.py @@ -0,0 +1,185 @@ +import os +import tempfile +import subprocess +from pathlib import Path + +import pytest + +from git_version import GitVersion + + +@pytest.fixture +def git_repo(): + """Create a temporary git repository with a tag for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + repo = Path(tmpdir) + subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=repo, capture_output=True, check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=repo, capture_output=True, check=True, + ) + + # Create initial commit + (repo / "file.txt").write_text("hello") + subprocess.run(["git", "add", "."], cwd=repo, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=repo, capture_output=True, check=True, + ) + + # Create a tag + subprocess.run( + ["git", "tag", "v1.0.0"], + cwd=repo, capture_output=True, check=True, + ) + + # Create a second commit + (repo / "file.txt").write_text("hello world") + subprocess.run(["git", "add", "."], cwd=repo, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", "second"], + cwd=repo, capture_output=True, check=True, + ) + + yield repo + + +class TestGitVersion: + def test_tag(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + assert gv.tag == "v1.0.0" + + def test_version(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + assert gv.version == "1.0.0.1" # 1 commit after tag + + def test_version_major(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + assert gv.version_major == "1" + + def test_version_minor(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + assert gv.version_minor == "0" + + def test_version_patch(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + assert gv.version_patch == "0" + + def test_build(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + assert gv.build == "1" + + def test_branch(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + assert gv.branch == "master" + + def test_short(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + assert len(gv.short) == 6 + + def test_full(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + assert gv.full == f"1.0.0.1-{gv.short}" + + def test_extended_with_build(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + assert gv.extended == gv.full + + def test_extended_without_build(self, git_repo): + """When build == 0, extended should equal version.""" + with tempfile.TemporaryDirectory() as tmpdir: + repo = Path(tmpdir) + subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=repo, capture_output=True, check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=repo, capture_output=True, check=True, + ) + (repo / "file.txt").write_text("hello") + subprocess.run(["git", "add", "."], cwd=repo, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=repo, capture_output=True, check=True, + ) + subprocess.run( + ["git", "tag", "v2.0.0"], + cwd=repo, capture_output=True, check=True, + ) + + gv = GitVersion(repo_path=str(repo)) + assert gv.build == "0" + assert gv.extended == gv.version + + def test_commit(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + assert len(gv.commit) == 40 + + def test_default_branch(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + assert gv.default_branch == "master" + + def test_env(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + env = gv.env(prefix="TEST") + assert env["TEST"] == "1.0.0.1" + assert env["TEST_MAJOR"] == "1" + assert env["TEST_MINOR"] == "0" + assert env["TEST_PATCH"] == "0" + assert env["TEST_BUILD"] == "1" + assert env["TEST_TAG"] == "v1.0.0" + assert env["TEST_BRANCH"] == "master" + assert len(env["TEST_COMMIT"]) == 40 + assert len(env["TEST_SHORT"]) == 6 + + def test_custom_tag_pattern(self, git_repo): + gv = GitVersion(repo_path=str(git_repo), tag_pattern="release-*") + assert gv.tag == "" # No matching tag + + def test_no_tag(self): + """Repository without any tags should return defaults.""" + with tempfile.TemporaryDirectory() as tmpdir: + repo = Path(tmpdir) + subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=repo, capture_output=True, check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=repo, capture_output=True, check=True, + ) + (repo / "file.txt").write_text("hello") + subprocess.run(["git", "add", "."], cwd=repo, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=repo, capture_output=True, check=True, + ) + + gv = GitVersion(repo_path=str(repo)) + assert gv.tag == "" + assert gv.version == "0.0.0.0" + assert gv.build == "0" + + def test_not_a_git_repo(self): + """Non-git directory should return defaults gracefully.""" + with tempfile.TemporaryDirectory() as tmpdir: + gv = GitVersion(repo_path=tmpdir) + assert gv.tag == "" + assert gv.version == "0.0.0.0" + assert gv.build == "0" + assert gv.branch == "" + assert gv.short == "" + assert gv.commit == "" + + def test_str(self, git_repo): + gv = GitVersion(repo_path=str(git_repo)) + output = str(gv) + assert "v1.0.0" in output + assert "1.0.0.1" in output \ No newline at end of file From 823fe72edb0306c69ce35754a6d60cf2c1d121e5 Mon Sep 17 00:00:00 2001 From: Mikhail Milovidov Date: Wed, 10 Jun 2026 15:39:06 +0300 Subject: [PATCH 2/2] Added github ci --- .github/workflows/ci.yml | 139 ++++++++++++++++++++ .github/workflows/publish.yml | 40 ++++++ .gitignore | 237 +--------------------------------- MANIFEST.in | 1 + pyproject.toml | 2 +- setup.py | 28 ++++ src/git_version/__init__.py | 8 +- src/git_version/cli.py | 3 +- src/git_version/core.py | 42 +++++- tests/test_git_version.py | 89 ++++++++----- tools/write_version.py | 71 ++++++++++ 11 files changed, 389 insertions(+), 271 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 MANIFEST.in create mode 100644 setup.py create mode 100644 tools/write_version.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fd190d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,139 @@ +name: CI + +on: + push: + branches: [main, "release/*"] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + outputs: + version: ${{ steps.git_version.outputs.BUILD_VERSION }} + version_major: ${{ steps.git_version.outputs.BUILD_VERSION_MAJOR }} + version_minor: ${{ steps.git_version.outputs.BUILD_VERSION_MINOR }} + version_patch: ${{ steps.git_version.outputs.BUILD_VERSION_PATCH }} + version_build: ${{ steps.git_version.outputs.BUILD_VERSION_BUILD }} + tag: ${{ steps.git_version.outputs.BUILD_VERSION_TAG }} + branch: ${{ steps.git_version.outputs.BUILD_VERSION_BRANCH }} + commit: ${{ steps.git_version.outputs.BUILD_VERSION_COMMIT }} + short: ${{ steps.git_version.outputs.BUILD_VERSION_SHORT }} + full: ${{ steps.git_version.outputs.BUILD_VERSION_FULL }} + extended: ${{ steps.git_version.outputs.BUILD_VERSION_EXTENDED }} + default_branch: ${{ steps.git_version.outputs.BUILD_VERSION_DEFAULT_BRANCH }} + release_branches: ${{ steps.git_version.outputs.BUILD_VERSION_RELEASE_BRANCHES }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install build tools + run: | + python -m pip install --upgrade pip + pip install build + + - name: Generate _version.py from git tags + run: python tools/write_version.py + + - name: Build package + run: python -m build + + - name: Install built package + run: pip install dist/*.whl + + - name: Extract version info from built package + id: git_version + run: | + while IFS='=' read -r key value; do + echo "$key=$value" >> "$GITHUB_OUTPUT" + done < <(git-version --property env) + + - name: Show version info + run: | + echo "═══════════════════════════════════════" + echo " GIT VERSION INFORMATION" + echo "═══════════════════════════════════════" + echo " Version: ${{ steps.git_version.outputs.BUILD_VERSION }}" + echo " Major: ${{ steps.git_version.outputs.BUILD_VERSION_MAJOR }}" + echo " Minor: ${{ steps.git_version.outputs.BUILD_VERSION_MINOR }}" + echo " Patch: ${{ steps.git_version.outputs.BUILD_VERSION_PATCH }}" + echo " Build: ${{ steps.git_version.outputs.BUILD_VERSION_BUILD }}" + echo " Tag: ${{ steps.git_version.outputs.BUILD_VERSION_TAG }}" + echo " Branch: ${{ steps.git_version.outputs.BUILD_VERSION_BRANCH }}" + echo " Commit: ${{ steps.git_version.outputs.BUILD_VERSION_COMMIT }}" + echo " Short: ${{ steps.git_version.outputs.BUILD_VERSION_SHORT }}" + echo " Full: ${{ steps.git_version.outputs.BUILD_VERSION_FULL }}" + echo " Extended: ${{ steps.git_version.outputs.BUILD_VERSION_EXTENDED }}" + echo " DefaultBranch: ${{ steps.git_version.outputs.BUILD_VERSION_DEFAULT_BRANCH }}" + echo " ReleaseBranches: ${{ steps.git_version.outputs.BUILD_VERSION_RELEASE_BRANCHES }}" + echo "═══════════════════════════════════════" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + test: + runs-on: ubuntu-latest + needs: build + + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install build tools + run: | + python -m pip install --upgrade pip + pip install build + + - name: Generate _version.py from git tags + run: python tools/write_version.py + + - name: Build package + run: python -m build + + - name: Install built package + run: pip install dist/*.whl + + - name: Install test dependencies + run: pip install pytest + + - name: Run tests from repo + run: python -m pytest tests/ -v + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install lint tools + run: | + python -m pip install --upgrade pip + pip install ruff + + - name: Lint with ruff + run: ruff check src/ tests/ \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ecd67cc --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,40 @@ +name: Publish to PyPI + +on: + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g., 0.1.0)" + required: true + type: string + +jobs: + publish: + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install build tools + run: | + python -m pip install --upgrade pip + pip install build + + - name: Generate _version.py from git tags + run: python tools/write_version.py + + - name: Build package + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 835bac8..b55cec0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,232 +1,5 @@ -<<<<<<< HEAD -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -# Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -# poetry.lock -# poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -# pdm.lock -# pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -# pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# Redis -*.rdb -*.aof -*.pid - -# RabbitMQ -mnesia/ -rabbitmq/ -rabbitmq-data/ - -# ActiveMQ -activemq-data/ - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ -# Temporary file for partial code execution -tempCodeRunnerFile.py - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ - -# Streamlit -.streamlit/secrets.toml -======= -# Python -__pycache__/ -*.py[cod] -*.egg-info/ -dist/ -build/ -.pytest_cache/ - -# IDE -.vscode/ -.idea/ ->>>>>>> 35b63c9 (Initial commit: git-version-utils v0.1.0) +build +dist +__pycache__ +src/git_version/_version.py +src/git_version_utils.egg-info/* diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..b01ede1 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include tools/write_version.py \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2d6e83f..f7769d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "git-version-utils" -version = "0.1.0" +version = "0.0.0" description = "Extract version information from a Git repository and output as environment variables" readme = "README.md" authors = [ diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2d15470 --- /dev/null +++ b/setup.py @@ -0,0 +1,28 @@ +"""Setup script for git-version-utils. + +The package version is determined dynamically from git tags. +Run `python tools/write_version.py` before building to generate +src/git_version/_version.py with the correct version. + +The version in pyproject.toml is a fallback placeholder (0.0.0). +""" + +import os +import re + +from setuptools import setup + + +def get_version() -> str: + """Get the package version from pyproject.toml.""" + pyproject = os.path.join(os.path.dirname(__file__), "pyproject.toml") + if not os.path.exists(pyproject): + return "0.0.0" + with open(pyproject, "r") as f: + content = f.read() + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else "0.0.0" + + +if __name__ == "__main__": + setup(version=get_version()) \ No newline at end of file diff --git a/src/git_version/__init__.py b/src/git_version/__init__.py index cf2dd6d..0a965a6 100644 --- a/src/git_version/__init__.py +++ b/src/git_version/__init__.py @@ -1,3 +1,9 @@ +try: + from ._version import __version__, __version_tuple__ +except ImportError: + __version__ = "0.0.0" + __version_tuple__ = (0, 0, 0) + from .core import GitVersion -__all__ = ["GitVersion"] \ No newline at end of file +__all__ = ["GitVersion", "__version__", "__version_tuple__"] \ No newline at end of file diff --git a/src/git_version/cli.py b/src/git_version/cli.py index 772b385..2059437 100644 --- a/src/git_version/cli.py +++ b/src/git_version/cli.py @@ -1,5 +1,4 @@ import argparse -import sys from .core import GitVersion @@ -28,7 +27,7 @@ def main() -> None: choices=[ "tag", "version", "version_major", "version_minor", "version_patch", "build", "branch", "short", "full", "extended", "commit", - "default_branch", "env", "all", + "default_branch", "release_branches", "env", "all", ], default="all", help="Which property to output (default: all)", diff --git a/src/git_version/core.py b/src/git_version/core.py index 0745deb..adac78c 100644 --- a/src/git_version/core.py +++ b/src/git_version/core.py @@ -1,6 +1,7 @@ import os import re import subprocess +import fnmatch from functools import cached_property @@ -10,15 +11,19 @@ class GitVersion: Args: repo_path: Path to the Git repository (default: current working directory). tag_pattern: Glob pattern to match version tags (default: "v[0-9]*"). + release_branches: List of branch patterns considered as release branches. + Defaults to [default_branch, "release/*"]. """ def __init__( self, repo_path: str | None = None, tag_pattern: str = "v[0-9]*", + release_branches: list[str] | None = None, ): self.repo_path = os.path.abspath(repo_path or os.getcwd()) self.tag_pattern = tag_pattern + self._release_branches = release_branches def _git(self, *args: str) -> str: """Execute a git command safely and return stripped stdout.""" @@ -74,6 +79,16 @@ def default_branch(self) -> str: result = self._git("config", "--get", "init.defaultBranch") return result or "master" + @cached_property + def release_branches(self) -> list[str]: + """List of release branch patterns. + + Defaults to [default_branch, "release/*"] if not explicitly set. + """ + if self._release_branches is not None: + return self._release_branches + return [self.default_branch, "release/*"] + @cached_property def build(self) -> str: """Number of commits since the last version tag.""" @@ -94,16 +109,31 @@ def short(self) -> str: @cached_property def full(self) -> str: - """Full version string with commit hash: '-'.""" - return f"{self.version}-{self.short}" + """Full version string. + + If the current branch matches a release branch pattern, returns just the + version (e.g. '1.0.0.1'). Otherwise returns the extended version with + commit hash (e.g. '1.0.0.1-a1b2c3'). + """ + if self._is_release_branch(): + return self.version + return self.extended @cached_property def extended(self) -> str: - """Extended version: same as 'version' if build==0, else 'full'.""" - if self.build == "0": - return self.version + """Extended version: '-' (e.g. '1.0.0.1-a1b2c3').""" return f"{self.version}-{self.short}" + def _is_release_branch(self) -> bool: + """Check if the current branch matches any release branch pattern.""" + current = self.branch + if not current: + return False + for pattern in self.release_branches: + if fnmatch.fnmatch(current, pattern): + return True + return False + @cached_property def commit(self) -> str: """Full 40-character commit hash.""" @@ -132,6 +162,7 @@ def env(self, prefix: str = "BUILD_VERSION") -> dict[str, str]: f"{prefix}_COMMIT": self.commit, f"{prefix}_BRANCH": self.branch, f"{prefix}_DEFAULT_BRANCH": self.default_branch, + f"{prefix}_RELEASE_BRANCHES": " ".join(self.release_branches), } def __str__(self) -> str: @@ -143,4 +174,5 @@ def __str__(self) -> str: Build: {self.build} Extended: {self.extended} Commit: {self.commit} + Release branches: {", ".join(self.release_branches)} """ \ No newline at end of file diff --git a/tests/test_git_version.py b/tests/test_git_version.py index cf7b9c9..469a40d 100644 --- a/tests/test_git_version.py +++ b/tests/test_git_version.py @@ -1,4 +1,3 @@ -import os import tempfile import subprocess from pathlib import Path @@ -81,41 +80,30 @@ def test_short(self, git_repo): gv = GitVersion(repo_path=str(git_repo)) assert len(gv.short) == 6 - def test_full(self, git_repo): + def test_full_on_release_branch(self, git_repo): + """On a release branch (master), full should equal version.""" gv = GitVersion(repo_path=str(git_repo)) + assert gv.full == gv.version + assert gv.full == "1.0.0.1" + + def test_full_on_non_release_branch(self, git_repo): + """On a non-release branch, full should equal extended (version-short).""" + gv = GitVersion( + repo_path=str(git_repo), + release_branches=["main", "release/*"], + ) + assert gv.full == gv.extended assert gv.full == f"1.0.0.1-{gv.short}" def test_extended_with_build(self, git_repo): + """Extended always includes commit hash.""" gv = GitVersion(repo_path=str(git_repo)) - assert gv.extended == gv.full - - def test_extended_without_build(self, git_repo): - """When build == 0, extended should equal version.""" - with tempfile.TemporaryDirectory() as tmpdir: - repo = Path(tmpdir) - subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True) - subprocess.run( - ["git", "config", "user.email", "test@test.com"], - cwd=repo, capture_output=True, check=True, - ) - subprocess.run( - ["git", "config", "user.name", "Test"], - cwd=repo, capture_output=True, check=True, - ) - (repo / "file.txt").write_text("hello") - subprocess.run(["git", "add", "."], cwd=repo, capture_output=True, check=True) - subprocess.run( - ["git", "commit", "-m", "initial"], - cwd=repo, capture_output=True, check=True, - ) - subprocess.run( - ["git", "tag", "v2.0.0"], - cwd=repo, capture_output=True, check=True, - ) + assert gv.extended == f"{gv.version}-{gv.short}" - gv = GitVersion(repo_path=str(repo)) - assert gv.build == "0" - assert gv.extended == gv.version + def test_extended_always_contains_commit(self, git_repo): + """Extended always includes version and short commit hash.""" + gv = GitVersion(repo_path=str(git_repo)) + assert gv.extended == f"{gv.version}-{gv.short}" def test_commit(self, git_repo): gv = GitVersion(repo_path=str(git_repo)) @@ -135,6 +123,7 @@ def test_env(self, git_repo): assert env["TEST_BUILD"] == "1" assert env["TEST_TAG"] == "v1.0.0" assert env["TEST_BRANCH"] == "master" + assert env["TEST_FULL"] == env["TEST"] # On release branch, full == version assert len(env["TEST_COMMIT"]) == 40 assert len(env["TEST_SHORT"]) == 6 @@ -178,6 +167,46 @@ def test_not_a_git_repo(self): assert gv.short == "" assert gv.commit == "" + def test_release_branches_default(self, git_repo): + """Default release_branches should include default branch and release/*.""" + gv = GitVersion(repo_path=str(git_repo)) + branches = gv.release_branches + assert "master" in branches + assert "release/*" in branches + assert len(branches) == 2 + + def test_release_branches_custom(self, git_repo): + """Custom release_branches should be returned as-is.""" + gv = GitVersion( + repo_path=str(git_repo), + release_branches=["main", "release/*", "hotfix/*"], + ) + assert gv.release_branches == ["main", "release/*", "hotfix/*"] + + def test_release_branches_single(self, git_repo): + """Single branch in release_branches list.""" + gv = GitVersion( + repo_path=str(git_repo), + release_branches=["main"], + ) + assert gv.release_branches == ["main"] + + def test_release_branches_in_env(self, git_repo): + """release_branches should appear in env output.""" + gv = GitVersion(repo_path=str(git_repo)) + env = gv.env(prefix="TEST") + assert "TEST_RELEASE_BRANCHES" in env + assert "master" in env["TEST_RELEASE_BRANCHES"] + assert "release/*" in env["TEST_RELEASE_BRANCHES"] + + def test_release_branches_in_str(self, git_repo): + """release_branches should appear in string representation.""" + gv = GitVersion(repo_path=str(git_repo)) + output = str(gv) + assert "Release branches" in output + assert "master" in output + assert "release/*" in output + def test_str(self, git_repo): gv = GitVersion(repo_path=str(git_repo)) output = str(gv) diff --git a/tools/write_version.py b/tools/write_version.py new file mode 100644 index 0000000..a7b1e0a --- /dev/null +++ b/tools/write_version.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Pre-build script: generate src/git_version/_version.py from git tags. + +This script uses git-version-utils itself (from source) to determine +the package version, then writes it to _version.py so the built package +has the correct __version__ at runtime. + +Usage: + python tools/write_version.py +""" + +import os +import re +import subprocess +import sys +from pathlib import Path + + +def get_version_from_git() -> str | None: + """Try to determine version using git-version-utils itself.""" + src_path = str(Path(__file__).parent.parent / "src") + if src_path not in sys.path: + sys.path.insert(0, src_path) + + try: + from git_version.core import GitVersion # type: ignore[import-untyped] + + gv = GitVersion() + if gv.tag: + return gv.version + except ImportError: + pass + + # Fallback: call git describe directly + try: + result = subprocess.run( + ["git", "describe", "--match", "v[0-9]*", "--tags"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0: + tag = result.stdout.strip() + count_result = subprocess.run( + ["git", "rev-list", f"{tag}..", "--count"], + capture_output=True, text=True, timeout=10, + ) + count = count_result.stdout.strip() if count_result.returncode == 0 else "0" + stripped = re.sub(r"^[^\d]+", "", tag) + return f"{stripped}.{count}" + except Exception: + pass + + return None + + +def main() -> None: + env_version = os.environ.get("GIT_VERSION_UTILS_VERSION") + version = env_version or get_version_from_git() or "0.0.0" + + version_path = Path(__file__).parent.parent / "src" / "git_version" / "_version.py" + version_path.parent.mkdir(parents=True, exist_ok=True) + version_path.write_text( + f'# file generated by tools/write_version.py\n' + f"# don't change, don't track in version control\n" + f'__version__ = "{version}"\n' + f'__version_tuple__ = {tuple(int(x) if x.isdigit() else x for x in version.split("."))}\n' + ) + print(f"Version: {version}") + + +if __name__ == "__main__": + main() \ No newline at end of file