If this content overflows, use the left and right arrow keys to scroll horizontally.

On this page

Specification

GitCalVer specification—version format, branch model, deterministic interfaces, and publication contract

Version: 0.3

GitCalVer derives version numbers deterministically from git history. Each commit on one selected branch maps to a unique, strictly increasing version built from the commit’s UTC committer date and a count over the commits that share that date. Calculation is local and offline: any clone with the same history computes the same version. A separate publication step claims an immutable tag, resolving the case where two independent clones calculate the same candidate before either publishes it.

Version format

Base format

YYYYMMDD.N

Where:

  • YYYYMMDD is the Gregorian UTC date of the commit’s committer timestamp, using a four-digit year, two-digit month, and two-digit day.
  • N is a positive integer without leading zeroes: the number of commits, counting the commit itself, that the commit can reach through any parent—not only the first—and whose UTC committer date equals its own. This set of commits is the commit’s date cohort.

For example:

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

Prefix

A caller MAY prepend a single-line literal prefix. The default prefix is empty.

Use casePrefixExample
Base formempty20260412.3
Initial or unstable SemVer0.0.20260412.3
Initial or unstable Go tagv0.v0.20260412.3

The prefix is not part of the base version and implementations MUST NOT infer one. A nonempty prefix MUST NOT contain a newline.

Projects maintaining incompatible release lines MUST use distinct caller-managed major prefixes or distinct package namespaces. The 0. prefix denotes an initial or unstable line; it is not a universal compatibility boundary.

How a version is calculated

The selected chain

Versions are defined against one branch, the selected branch—normally the repository’s main branch (Selected branch below defines how implementations choose it). Following the branch tip backward through first parents only gives the selected chain: the commits that receive versions.

  • A merge commit is itself on the chain.
  • Commits reachable only through a merge commit’s second or later parents are not.
  • A squash merge contributes the resulting single commit.
  • A fast-forward contributes each forwarded commit.

The commit being calculated is the target; it is HEAD when the caller supplies no explicit revision. A target is a clean chain member only if it appears exactly in the first-parent traversal from the selected tip; ordinary reachability is not enough. First-parent traversal decides only which commits are on the chain—it does not limit N, which counts through every parent.

Committer date and UTC

GitCalVer MUST use the committer timestamp, not the author timestamp. Git stores a timestamp as Unix epoch seconds plus a timezone offset; calculation MUST interpret the instant in UTC and ignore the stored offset when choosing the date. The committer timestamp records when a commit was applied to the branch, and changes under operations such as amend, rebase, and cherry-pick.

Calculation algorithm

For a selected-chain target:

  1. Convert the target’s committer timestamp to YYYYMMDD in UTC—this is the target’s own date; no traversal is required.
  2. Traverse backward from the target through all parents, visiting each reachable commit at most once.
  3. For each visited commit: if its UTC committer date equals the target’s, count it (including the target itself) and continue traversing its parents. If its date is strictly older, do not count it and do not traverse past it. If its date is strictly newer, reject the history.
  4. Stop each path once pruned by an older date or once a true root is proved.
  5. Let N be the total count from step 3.
  6. Return YYYYMMDD.N.

An implementation MUST NOT return a result when local history ends before every path’s older boundary or a true root can be proved; see Incomplete and partial histories.

Computing a target’s date cohort: count, prune, or rejectThe walk starts by counting target T, then follows child-to-parent arrows from right to left. Same-date A1 and A2 are counted and continued through. Older B1 is not counted and prunes only its path. Later C1 rejects the entire calculation.A2A1TB1C1walk: child -> parent (right to left)same datecount + continuetarget datecount Tolder dateno count + prunereject calculationlater date
Figure 1. Computing target T’s date cohort. The walk follows every parent. A same-date ancestor (A1, A2) is counted and explored further. An older-dated ancestor (B1) is a safe boundary—not counted, and nothing behind it needs to be visited. A later-dated ancestor (C1) is not a boundary GitCalVer can trust: it means committer dates decreased somewhere in reachable history, so the whole calculation is rejected rather than silently under- or over-counting.

Why the count uses every parent

Git stores a commit’s parents in order: a merge records the commit that was checked out as its first parent and the merged tips as later parents. Git’s own tooling privileges that first slot—git log --first-parent, HEAD^ as shorthand for HEAD^1—and in a project whose merges are always made from the main branch, the main branch’s first-parent chain is exactly its own history. That is why the selected chain, which decides which commits receive versions at all, follows first parents.

But parent order records where a merge was made, not which line is the mainline. A branch name is only a movable pointer, and a fast-forward moves it onto an existing commit created somewhere else, adopting that commit’s parent order unchanged. Merge the selected branch into a feature branch, then fast-forward the selected branch onto the resulting merge: the merge’s first parent is the feature tip, and the selected branch’s own earlier commits now sit behind its second parent. No commit was rewritten, no committer date changed, and every step was an ordinary git operation—yet the selected branch’s first-parent chain now runs through different commits than before.

A count taken along first parents can therefore decrease as the branch advances (Figure 2). A count over commits reachable through every parent cannot: advancing by new commits and fast-forwards only ever moves the ref to a descendant of its old tip, and a descendant reaches everything its ancestors reach. So GitCalVer splits the two roles. The first-parent chain—well defined at any instant—decides which commits get versions. The date cohort—reachability through every parent—decides N, and only ever grows.

First-parent counting is fragile; all-parent counting is notAn older common ancestor O forks into main commits C1 through C4 and unmerged feature commits F1 and F2; main points to C4 with a count of 4. After main is merged into feature and main fast-forwards to merge M, M's first parent is F2 and its second parent is C4. A first-parent position count reaches only M, F2, and F1, so it decreases from 4 to 3. The all-parent date cohort holds all seven same-date commits reachable from M, so N increases from 4 to 7.OC1F1C2F2C3C4older common ancestor · outside cohortSELECTED BRANCHFEATURE BRANCHSELECTED TIP · N=4FEATURE TIPBEFOREBRANCHES NOT MERGEDNEXT: MERGE MAIN INTO FEATURE, THEN FAST-FORWARD MAIN TO MOC1F1C2F2C3C4Molder common ancestor · outside cohort1st parent2nd parentold tipnew tipAFTERFIRST-PARENT POSITION43DECREASESCOUNTS F1 F2 MIGNORES C1–C4OC1F1C2F2C3C4Molder common ancestor · outside cohort1st parent2nd parentold tipnew tipAFTERALL-PARENT DATE COHORT47INCREASESCOUNTS ALL 7 REACHABLE COMMITS
Figure 2. The selected and feature branches fork from an older common ancestor outside the date cohort; the selected tip is C4 and its count is 4. Merging the selected branch into the feature branch, then fast-forwarding the selected branch onto M, moves the selected branch’s own commits behind M’s second parent. Scoring that same unchanged graph both ways: a first-parent position count decreases from 4 to 3, while the date cohort grows from 4 to 7, because every commit that counted before remains reachable through M’s second parent.

Sparse sequences

Because N counts a whole date cohort instead of a position on the first-parent chain, a single merge can grow N by more than one at once:

CommitCommitter date (UTC)Date cohort sizeVersion
m42026-04-10420260410.4
merge2026-04-10 (merges 16 same-day feature commits)2120260410.21

A merge that brings in k same-date commits increases the date cohort, and therefore N, by k plus one for the merge commit itself. Here 20260410.5 through 20260410.20 were never assigned to any commit. Reverse lookup for a value in such a gap MUST report that the version was not found. It MUST NOT return the nearest lower or higher commit.

A merge grows the date cohort from 4 to 21All commits shown share one UTC date. The selected chain advances from m4, whose date cohort has four commits, to merge M. M keeps m4 as its first parent and reaches sixteen additional feature commits through its second parent. Including M itself gives N of 21. Values 5 through 20 are unassigned, so reverse lookup for them returns not found.m1m2m3m416 commitsMALL COMMITS: SAME UTC DATEparent links point leftSELECTED CHAINFEATURE BRANCH1st parent2nd parentN=4N=21WHY THE NEXT N IS 214already reachable+16merged commits+1merge commit=21N(M)ASSIGNED N VALUES1 2 3 45–20 · UNASSIGNED21reverse lookup in this gap: NOT FOUND
Figure 3. A merge that brings in several same-date commits jumps N by more than one—here, 16 feature-branch commits plus the merge itself push N from 4 to 21 in a single selected-chain step. YYYYMMDD.5 through YYYYMMDD.20 were never assigned to any commit; reverse lookup for a value in that range MUST report that the version was not found, never the nearest commit on either side.

Guarantees and prerequisites

Within one fixed, complete selected chain, where no commit reachable from any chain commit has a UTC committer date later than a commit that can reach it:

  1. Every commit maps to exactly one clean base version.
  2. Every clean base version maps back to at most one commit. The mapping need not be onto: a syntactically valid YYYYMMDD.N MAY correspond to no commit (see Sparse sequences).
  3. Versions strictly increase from parent to child.
  4. The selected branch tip’s version never decreases as the branch advances by any combination of new commits and fast-forwards, regardless of which parent a merge records as first.

These follow from two facts about the commit graph. First, anything a parent reaches, its child also reaches, and the graph has no cycles—so a same-date parent’s date cohort is strictly contained in its child’s, N grows by at least one with each same-date chain step, and no two chain commits share a (YYYYMMDD, N) pair; across a date boundary, the date segment increases and carries the whole version with it. Second, a branch that advances by new commits and fast-forwards only ever moves to a descendant of its old tip—so when the date is unchanged, the old tip’s cohort stays inside the new tip’s, whichever parent slots connect them (see Why the count uses every parent). Under the same prerequisite, the containment extends to any ancestor, not only former tips: if X is reachable from Y and X’s date is no later than Y’s, then version(Y) > version(X). Canonical publication’s continuity check relies on exactly this.

The prerequisite is not decorative: committer dates must be non-decreasing along every parent path reachable from a chain commit, not only along the chain itself. A reachable commit whose date is later than a commit that can reach it is a decreasing-history error (exit code 1), detected while computing a date cohort. History rewrites that change the selected chain, or that make any reachable commit’s date decrease along a path that reaches it, invalidate these guarantees. Dirty versions are outside the uniqueness and reversibility guarantees.

Selected branch

Detection

Unless the caller explicitly selects a branch, implementations MUST use this precedence:

  1. The symbolic cached remote default at refs/remotes/REMOTE/HEAD.
  2. Cached REMOTE/main, then REMOTE/master.
  3. Local main, then local master.
  4. Error if no branch can be selected.

REMOTE defaults to origin and MAY be selected explicitly. Remote selection chooses cached refs only; it MUST NOT fetch.

After selecting the branch name, implementations MUST prefer its local branch tip and otherwise use its cached remote-tracking tip. This keeps clean, unpushed commits on the selected local branch calculable. The resolved tip MUST be cached for the invocation so concurrent ref movement cannot combine two views of the chain.

Target relationship

Implementations MUST classify the target against the selected chain:

  • On-chain: The target is an exact selected-chain member. Calculate it directly.
  • Off-chain but traceable: The target can reach at least one selected-chain commit through any parent path. Use the newest such selected-chain commit as the calculation anchor and mark the result dirty.
  • Not traceable: Complete local history proves that no common ancestry exists. Return the not-traceable result.
  • Unprovable: Missing local history prevents membership, anchor, or nonrelationship proof. Return the incomplete-history result.

A commit reachable only through a merge’s second or later parent is off-chain, not clean. An off-chain target that has merged a newer selected-chain commit anchors at that newest reachable chain member.

Dirty versions

With no supplied target, implementations MUST calculate HEAD and inspect the current workspace when one exists. Staged changes, unstaged changes, and untracked nonignored files make the result dirty; ignored files do not.

An explicit revision—including explicit HEAD—describes only that commit, so implementations MUST ignore workspace state for explicit revisions. The revision’s chain relationship still applies: an explicit off-chain revision remains dirty. An explicitly supplied empty target is invalid; it is not equivalent to omission. Bare repositories MUST support explicit revisions, reverse lookup, and omitted-target calculation without attempting a workspace check.

By default, implementations MUST refuse a dirty result. A caller MAY opt in with a nonempty dirty string. With a hash:

PREFIXYYYYMMDD.NDIRTY.HASH

Without a hash:

PREFIXYYYYMMDD.NDIRTY

HASH MUST be the first seven lowercase characters of the original target commit’s full object ID. It MUST NOT use git’s repository-dependent abbreviation machinery. The separator before HASH is exactly one literal period; there is no g prefix.

A no-hash option is valid only when dirty output is enabled. An explicit no-dirty option overrides dirty output configuration.

Dirty output is not uniquely reversible: different workspaces and different off-chain histories can share the same dirty string.

Deterministic interfaces

Input precedence

A single positional target MAY be either a version or a revision. After removing the exact configured prefix, an input with the complete shape YYYYMMDD.N MUST enter reverse mode before revision parsing, even if a git ref has the same name or the date is invalid. Trailing text and multiline values do not match.

A reverse input MUST include the exact configured prefix when that prefix is nonempty. Implementations MUST validate the date as a Gregorian date and reject counts with leading zeroes.

Forward output

Forward calculation outputs the configured prefix, the base version, and any permitted dirty suffix. An explicit revision ignores workspace state, as defined in Dirty versions.

Reverse output

Reverse lookup walks the selected chain to the requested date’s date block—the maximal consecutive run of selected-chain commits sharing that UTC committer date—and MUST prove the block complete by reaching a chain commit with a strictly older date, or a true root, past it. Because assigned N values are sparse, it MUST then compute each block member’s date cohort size to find the one member (if any) whose N matches the requested count—N strictly increases across the block, so this scan can stop as soon as it passes the requested count. If no member matches, the implementation MUST report that the version was not found. It MUST NOT return the nearest lower or higher commit.

Exit codes

CLI implementations MUST use these exit codes:

CodeMeaning
0Success
1Invalid input, repository state, version date, or decreasing history
2Dirty workspace or off-chain target refused without dirty output enabled
3Complete history proves the target is unrelated to the selected chain
4Local history cannot prove membership, anchor, or a complete date block

Library implementations SHOULD expose distinguishable errors with equivalent semantics.

Incomplete and partial histories

Core calculation and reverse lookup MUST NOT access the network, including lazy fetches from partial-clone promisor remotes.

Shallow and partial histories MAY succeed when their local commit objects prove:

  1. the target’s selected-chain relationship or reachable anchor; and
  2. for N, that along every parent path from the target, the walk reaches either a true repository root or a commit whose UTC committer date is strictly older than the target’s, before it reaches any shallow boundary.

A shallow-boundary commit whose committer date is not strictly older than the target’s leaves open the possibility that history beyond it shares the target’s date. Implementations MUST then return the incomplete-history result (exit code 4), not a guessed version. A blanket shallow-repository rejection is not conforming.

Whether a shallow clone can prove a target’s cohortTwo matched shallow-history cases show target T, same-date commit A1, boundary B, and unavailable parent history beyond a shallow cut. Top: B is older than T, so the walk prunes at B before unavailable history matters and exit code 0 is permitted. Bottom: B shares T's date and counts, but its parents are unavailable; hidden same-date commits may exist, so exit code 4 is required.BA1TPROVABLEexit 0 permitted??SHALLOW CUThidden historyolder than Tsame date as TtargetOlder B is pruned before hidden parents are needed.Every parent path is proven.BA1TINCOMPLETEexit 4 required??SHALLOW CUThidden historysame date as Tsame date as TtargetB counts, but its parents are unavailable.Hidden same-date commits may exist.
Figure 4. Whether a shallow or partial clone can prove a target’s count. Top: every path’s shallow boundary has a date strictly older than the target’s—no hidden commit beyond it can share the target’s date, so the count is provable from local history alone. Bottom: a shallow boundary shares the target’s date—history beyond it might still contribute to the cohort, so the implementation MUST refuse with the incomplete-history result (exit code 4) rather than guess.

Because N depends on every reachable parent, a safe shallow clone must include every commit committed on the target’s UTC date that is reachable from the target across all merged branches, not merely the selected chain. --shallow-since=<day-before-target-date> is insufficient when a same-day branch was merged from deeper history. A partial clone with --filter=blob:none and full commit history is sufficient, since only commit objects are required.

Implementations MUST ignore replacement refs while calculating. They MUST reject a repository with a legacy info/grafts file as incomplete because grafts silently replace stored ancestry.

Conformance requires SHA-1 repositories. Implementations MAY support SHA-256 repositories and SHOULD report that capability separately.

Canonical publication

Core calculation does not coordinate independent clones: two offline clones can temporarily calculate the same candidate when their local histories diverge. Canonical publication resolves that collision by claiming an immutable, protected tag without force after CI succeeds. A canonical tag is a caller-defined tag prefix followed by a clean GitCalVer version; the tags with that prefix and shape are the namespace, and the matching tags, that the steps below operate on.

Publication is a separate, explicit operation and MAY access the network. For one caller-defined canonical tag namespace, it MUST:

  1. Refresh the selected remote branch and require the publishing commit to be its current tip.
  2. Enumerate all globally published matching tags, including tags whose commits are otherwise unreachable from the selected tip. A registry release remains part of publication history even if it is later yanked.
  3. Select the numerically greatest matching GitCalVer version.
  4. Validate that tag’s date segment against its target commit.
  5. Require its target to remain reachable from the publishing commit through any parent, with a committer date no later than the publishing commit’s own.
  6. Require the new candidate to be greater, unless the candidate tag already points to the publishing commit.
  7. Create the candidate tag without force.

Step 5 is the continuity check, and it deliberately tests reachability through any parent rather than first-parent ancestry. Whenever the Guarantees prerequisite holds, a reachable, non-later-dated previous target already implies that the new candidate’s version is greater. Requiring first-parent ancestry instead would permanently block publication once an ordinary merge and fast-forward move the previous tag’s target behind a second parent (see Why the count uses every parent), even though that target remains reachable with an unchanged date. The check still rejects what it should: a previous target made unreachable by a rewrite, rebase, or replacement fails it, and so does a reachable target whose committer date is later than the publishing commit’s. Step 6’s numeric comparison is the guard that holds with no prerequisite at all, and steps 1 and 3 keep an unrelated or rogue line from publishing—the publishing commit must be the selected branch’s current remote tip, and a genuinely newer tag anywhere in the namespace still blocks publication as a newer-published-tag failure.

Publication continuity checks reachability and dateAn older common ancestor O forks into the main and feature histories. The publishing commit M reaches the previous canonical tag target C4 through its second parent, and C4 has the same date as M. Both step 5 conditions pass, so publication may continue to later checks. In the contrast, M reaches D1 but D1 is later-dated than M, so the date condition fails and step 5 blocks publication.OC1F1C2F2C3C4Molder common ancestor1st parent2nd parentPARENT LINKS POINT LEFTPREVIOUS TAG TARGETPUBLISHING TIPSTEP 5 ASKS TWO QUESTIONSC4 · PREVIOUS TAG TARGETPASSC4Mvia 2nd parentreachable from MYESdate no later than MYES · SAMESTEP 5 PASSES · CONTINUED1 · CONTRASTFAILD1Mreachablereachable from MYESdate no later than MNO · LATERSTEP 5 FAILS · BLOCK
Figure 5. Canonical publication’s continuity check under the same reparenting topology as Figure 2. The previous canonical tag’s target (C4) is no longer a first-parent ancestor of the publishing commit M, but it is still reachable through M’s second parent with an unchanged date—so step 5 accepts it. A reachable target with a later date than the publishing commit (D1) still fails the check: reachability alone was never sufficient.

A retry is idempotent only when the matching canonical tag points to the same commit and that commit remains the selected remote tip. A matching tag at a different commit, a newer published tag, a continuity failure, or a lost race MUST block publication.

Tag namespaces MUST be configured as immutable before automated publication. Publication workflows SHOULD use constant concurrency so only one claimant runs at a time; nonforce tag creation remains the final collision guard.

Compatibility

The base algorithm is ecosystem-independent. Prefixes, local dirty forms, dependency ranges, and constrained platform fields are nonnormative integration choices documented on the Compatibility page.