Specification

GitCalVer specification—version format, algorithm, output formats, and edge cases

Version: 0.1 (draft)

Erratum (nonnormative). Version 0.1’s “HEAD relationship” test uses ordinary ancestry to decide whether a target is on the default branch. That test also admits a commit reachable only through a merge commit’s second parent, even though calculation and reverse lookup traverse only the first-parent chain. Such a target can be reported as clean while being absent from the chain used for reverse lookup, so the 1:1 and reversibility guarantees in this document do not hold for it. Implementations should require exact first-parent membership for a clean version. This notice records the defect; it does not change the normative requirements of version 0.1.

Abstract

GitCalVer is a versioning scheme that derives version numbers deterministically from git history. Each first-parent commit on the default branch maps to a unique, strictly increasing version number based on the commit’s UTC date and its position within that day’s commits.

Version format

Base format

YYYYMMDD.N

Where:

Examples

A repository with 5 first-parent commits on main:

CommitCommitter date (UTC)Version
e12026-04-10 09:00:0020260410.1
e22026-04-10 14:30:0020260410.2
e32026-04-10 17:00:0020260410.3
e42026-04-11 10:00:0020260411.1
e52026-04-11 11:00:0020260411.2

Commits e1e3 share the date 2026-04-10. The latest (e3) is the 3rd consecutive commit with that date, so its version is 20260410.3.

When e4 is added on a new day, the count resets: 20260411.1.

Guarantees

1:1 mapping

Every first-parent commit on the default branch maps to exactly one version number. Every version number identifies exactly one commit.

Strictly increasing

Successive first-parent commits always produce strictly greater version numbers, under the prerequisite that committer dates on the first-parent chain are non-decreasing.

Proof. Within the same UTC date, each additional commit increments N, so versions are strictly increasing. Across a date boundary, YYYYMMDD increases. Since the first segment is compared first, YYYYMMDD₂.1 > YYYYMMDD₁.K for any K, so the version still strictly increases.

Prerequisite. Committer dates on the first-parent chain must be non-decreasing. This holds naturally when commits flow forward in time. History rewrites (rebase, filter-repo, force push) that produce decreasing committer dates void this guarantee. Implementations SHOULD validate this at the date boundary and report an error if violated.

Algorithm

To compute the version for HEAD:

  1. Let DATE = the UTC committer date of HEAD, formatted as YYYYMMDD
  2. Walk the first-parent chain starting from HEAD
  3. Count consecutive commits whose UTC committer date equals DATE
  4. Stop at the first commit with a different date
  5. Let N = the count from step 3
  6. The version is DATE.N

Committer date

GitCalVer uses the committer date, not the author date. The committer date reflects when a commit was applied to the branch (updated by rebase, amend, cherry-pick). The author date reflects original authorship and is preserved across history rewrites.

The committer date is chosen because it better represents the commit’s position in the branch’s timeline.

First-parent traversal

Only the first-parent chain is traversed. In a merge commit, the first parent is the branch being merged into (typically main). Commits from merged branches are not counted.

This means:

UTC

All dates are interpreted in UTC. A commit made at 23:00 local time in UTC+2 has a UTC time of 21:00 and uses that UTC date.

Git stores timestamps as Unix epoch seconds (1-second granularity) plus a timezone offset. The epoch seconds are inherently UTC-relative. The timezone offset is metadata and does not affect the UTC interpretation.

Dirty state

A version is dirty when either condition holds:

  1. Dirty workspace: git status --porcelain produces any output (staged changes, unstaged changes, or untracked non-ignored files). Gitignored files do not make the workspace dirty.
  2. Off default branch: HEAD is not on the default branch but is traceable to it (see Default branch). The version is computed from the merge-base commit.

Behavior

Default branch

GitCalVer versions are derived from the default branch’s first-parent history.

Detection precedence

  1. Explicit --branch BRANCH flag
  2. git symbolic-ref refs/remotes/origin/HEAD (remote default)
  3. Existence of origin/main, then origin/master
  4. If no remote: existence of local main, then master
  5. Error if no default branch can be determined

HEAD relationship

Implementations MUST check HEAD’s relationship to the default branch:

Committer date validation

Implementations SHOULD validate that committer dates on the first-parent chain are non-decreasing. At minimum, when the counting walk (step 4 of the algorithm) encounters the first commit with a different date, that date SHOULD be checked:

This is a cheap check (O(1) at the boundary already visited). It does not validate the entire history but catches the most common violation.

In practice, standard git workflows (direct commits, merges, rebases) maintain the non-decreasing property. It can be violated by clock skew, explicit GIT_COMMITTER_DATE manipulation, or git rebase --committer-date-is-author-date with out-of-order author dates.

Output format

The version string is composed from a fixed base version plus optional prefix and dirty suffix, controlled by flags rather than named formats.

Base version

The base version is always YYYYMMDD.N. This is the invariant core of GitCalVer and cannot be changed.

Prefix

A --prefix PREFIX flag prepends a literal string to the version. The default prefix is empty.

Clean version: {prefix}YYYYMMDD.N

Common prefixes:

PrefixUse case
(empty)Default; Python, Debian, RPM, Ruby, R, Perl, Docker, Homebrew, Conda, Clojure, Conan, vcpkg, Alpine, Arch Linux, Nix, Snap, Flatpak, winget, Firefox
0.SemVer ecosystems: npm, Rust, .NET, Swift, CocoaPods, Dart, Helm, Terraform, PHP, Elixir, Haskell, Julia, Chocolatey, PowerShell, VS Code
v0.Go modules

Dirty flags

By default, implementations MUST refuse to produce a version for a dirty workspace (exit with a non-zero status). The --dirty flag opts in to dirty versions.

FlagEffect
--dirty STRINGEnable dirty versions. Append STRING.HASH to the base version. STRING must not be empty.
--no-dirtyRefuse dirty versions. Overrides --dirty set by configuration or environment.
--no-dirty-hashSuppress the .HASH suffix, appending only STRING. Requires --dirty.

Where HASH is the short commit hash (git rev-parse --short HEAD). The . before HASH is implicit and included automatically when the hash is present.

Dirty version (with hash): {prefix}YYYYMMDD.N{dirty}.HASH Dirty version (no hash): {prefix}YYYYMMDD.N{dirty}

Validation:

Non-uniqueness

Dirty versions are not uniquely reversible. The 1:1 mapping guarantee applies only to clean versions. Multiple distinct states can produce the same dirty version string—for example, two dirty builds from the same commit with different uncommitted changes.

Dirty version sort order

In most ecosystems, the dirty version sorts before (lower than) the clean version. This is correct: a dirty build is not yet the release.

Exceptions:

Ecosystem mapping

Ecosystem--prefix--dirty--no-dirty-hashCleanDirtyVersion spec
Generic / scripts-dirty20260412.320260412.3-dirty.abc1234
Python (PyPI)+dirty20260412.320260412.3+dirty.abc1234PEP 440
npm0.-dirty0.20260412.30.20260412.3-dirty.abc1234SemVer 2.0.0, node-semver
Go modulesv0.-dirtyv0.20260412.3v0.20260412.3-dirty.abc1234Go Modules Reference
Rust (Cargo)0.-dirty0.20260412.30.20260412.3-dirty.abc1234Cargo dependencies
.NET (NuGet)0.-dirty0.20260412.30.20260412.3-dirty.abc1234NuGet versioning
Debian/Ubuntu+dirty20260412.320260412.3+dirty.abc1234Debian Policy §5.6.12
RPM (Fedora/RHEL)~dirtyyes20260412.320260412.3~dirtyRPM spec format
R (CRAN)20260412.3(not expressible)Writing R Extensions
Perl (CPAN)-dirty20260412.320260412.3-dirty.abc1234Perl version
Ruby (RubyGems).pre.dirty20260412.320260412.3.pre.dirty.abc1234RubyGems specification
Java (Maven/Gradle)-SNAPSHOTyes20260412.320260412.3-SNAPSHOTMaven version order
Docker/OCI-dirty20260412.320260412.3-dirty.abc1234OCI Distribution Spec
Homebrew-dirty20260412.320260412.3-dirty.abc1234
Swift Package Manager0.-dirty0.20260412.30.20260412.3-dirty.abc1234SPM documentation
CocoaPods0.-dirty0.20260412.30.20260412.3-dirty.abc1234Podspec syntax
iOS/macOS (CFBundleVersion)20260412.3(not expressible)Apple bundle versioning
Android (versionName)-dirty20260412.320260412.3-dirty.abc1234Android app versioning
Flutter/Dart (pub.dev)0.-dirty0.20260412.30.20260412.3-dirty.abc1234Dart package versioning
Helm charts0.-dirty0.20260412.30.20260412.3-dirty.abc1234Helm chart best practices
Terraform providers0.-dirty0.20260412.30.20260412.3-dirty.abc1234Terraform provider versioning
PHP Composer0.-dirty0.20260412.30.20260412.3-dirty.abc1234Composer versions
Conda+dirty20260412.320260412.3+dirty.abc1234Conda version spec
HomeKit (FirmwareRevision)20260412.3(not expressible)HAP Specification §9.40
Elixir (Hex)0.-dirty0.20260412.30.20260412.3-dirty.abc1234Elixir Version
Haskell (Hackage)0.0.20260412.3(not expressible)PVP
Julia (Pkg)0.-dirty0.20260412.30.20260412.3-dirty.abc1234Pkg.jl versioning
Clojure (Clojars)-SNAPSHOTyes20260412.320260412.3-SNAPSHOTClojars
Conan (C/C++)-dirty20260412.320260412.3-dirty.abc1234Conan versioning
vcpkg (C/C++)-dirty20260412.320260412.3-dirty.abc1234vcpkg versioning
Alpine (apk)_preyes20260412.320260412.3_preAlpine packaging
Arch Linux (pacman)~dirtyyes20260412.320260412.3~dirtyArch packaging
Nixpreyes20260412.320260412.3preNix compareVersions
Snap-dirty20260412.320260412.3-dirty.abc1234Snap format
Flatpak-dirty20260412.320260412.3-dirty.abc1234Flatpak conventions
Chocolatey0.-dirty0.20260412.30.20260412.3-dirty.abc1234Chocolatey versioning
winget-dirty20260412.320260412.3-dirty.abc1234winget manifest
PowerShell Gallery0.-dirty0.20260412.30.20260412.3-dirty.abc1234PowerShell modules
VS Code extensions0.-dirty0.20260412.30.20260412.3-dirty.abc1234VS Code extension manifest
Chrome extensions(not supported)(not supported)Chrome manifest version
Firefox add-ons20260412.3(not expressible)WebExtensions manifest

R/CRAN limitation: R version strings are sequences of at least two non-negative integers separated by . or - (Writing R Extensions). No alphabetic characters are permitted. YYYYMMDD.N is valid for clean builds. Dirty versions are not expressible in R-compatible version strings.

Haskell/Hackage limitation: PVP version strings are sequences of non-negative integers separated by . (PVP). No alphabetic characters are permitted. 0.YYYYMMDD.N is valid for clean builds. Dirty versions are not expressible in PVP-compatible version strings.

CFBundleVersion limitation: CFBundleVersion segments are numeric only. Dirty versions are not expressible.

HomeKit limitation: FirmwareRevision segments are numeric only (uint32). Dirty versions are not expressible.

Chrome extension limitation: Chrome extension versions (version) are 1–4 dot-separated integers, each between 0 and 65,535 (Chrome manifest version). Since YYYYMMDD values (e.g., 20260412) exceed 65,535, GitCalVer versions cannot be used as the version. However, Chrome’s version_name is a freeform string displayed to users instead of version when present. GitCalVer can be used as the version_name.

Firefox add-on limitation: Firefox add-on versions are 1–4 dot-separated integers, each up to 2,147,483,647 (WebExtensions manifest). YYYYMMDD.N is valid for clean builds. Dirty versions are not expressible since no alphabetic characters are permitted.

Alpine dirty-version note: Alpine uses pre-release suffixes (_alpha, _beta, _pre, _rc) that sort before the base version. --dirty _pre produces YYYYMMDD.N_pre, which apk correctly sorts below the clean version.

Nix dirty-version note: Nix’s builtins.compareVersions treats pre as a special component that sorts before an empty component. --dirty pre produces YYYYMMDD.Npre, which Nix correctly sorts below the clean version.

Mobile platform details

iOS/macOS (Apple):

Android:

HomeKit:

Numeric limits

The YYYYMMDD segment (e.g., 20260412) must fit within each ecosystem’s numeric constraints:

EcosystemSegment typeMaximum20260412 fits?
NuGet (.NET)Int322,147,483,647Yes
npm (node-semver)JS integer9,007,199,254,740,991Yes
R (CRAN)32-bit int2,147,483,647Yes
Cargo (Rust)u6418,446,744,073,709,551,615Yes
Go modulesstring-comparedNo limitYes
Python (PEP 440)arbitrary intNo limitYes
Debianstring-comparedNo limitYes
RPMstring-comparedNo limitYes
MavenBigIntegerNo limitYes
Apple (CFBundleVersion)uint324,294,967,295Yes
Android (versionCode)Int322,147,483,647Yes (as YYYYMMDD×100+N)
HomeKit (FirmwareRevision)uint324,294,967,295Yes
Elixir (Hex)arbitrary intNo limitYes
Haskell (Hackage)arbitrary intNo limitYes
Julia (Pkg)arbitrary intNo limitYes
Clojure (Clojars)BigIntegerNo limitYes
ConanstringNo limitYes
vcpkgstringNo limitYes
Alpine (apk)numeric stringNo limitYes
Arch Linux (pacman)string-comparedNo limitYes
NixintegerNo limitYes
Snapstring (max 32 chars)No limitYes
FlatpakstringNo limitYes
ChocolateyInt32 (NuGet)2,147,483,647Yes
wingetstringNo limitYes
PowerShell GalleryInt32 (.NET)2,147,483,647Yes
VS Code extensionsarbitrary intNo limitYes
Chrome extensionsuint1665,535No
Firefox add-onsInt322,147,483,647Yes

Shallow clones

GitCalVer works with shallow clones as long as the clone includes all first-parent commits for the current UTC date. If the clone is too shallow (the oldest commit in the walked history shares HEAD’s date), the count may be incomplete. Implementations SHOULD detect this and warn.

A safe shallow clone depth for GitCalVer is any depth that includes at least one commit from the previous UTC date, for example:

git clone --shallow-since=yesterday

References