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.NWhere:
YYYYMMDDis the Gregorian UTC date of the commit’s committer timestamp, using a four-digit year, two-digit month, and two-digit day.Nis 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:
| Commit | Committer date (UTC) | Version |
|---|---|---|
e1 | 2026-04-10 09:00:00 | 20260410.1 |
e2 | 2026-04-10 14:30:00 | 20260410.2 |
e3 | 2026-04-10 17:00:00 | 20260410.3 |
e4 | 2026-04-11 10:00:00 | 20260411.1 |
e5 | 2026-04-11 11:00:00 | 20260411.2 |
Prefix
A caller MAY prepend a single-line literal prefix. The default prefix is empty.
| Use case | Prefix | Example |
|---|---|---|
| Base form | empty | 20260412.3 |
| Initial or unstable SemVer | 0. | 0.20260412.3 |
| Initial or unstable Go tag | v0. | 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:
- Convert the target’s committer timestamp to
YYYYMMDDin UTC—this is the target’s own date; no traversal is required. - Traverse backward from the target through all parents, visiting each reachable commit at most once.
- 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.
- Stop each path once pruned by an older date or once a true root is proved.
- Let
Nbe the total count from step 3. - 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.
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.
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:
| Commit | Committer date (UTC) | Date cohort size | Version |
|---|---|---|---|
m4 | 2026-04-10 | 4 | 20260410.4 |
merge | 2026-04-10 (merges 16 same-day feature commits) | 21 | 20260410.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.
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:
- Every commit maps to exactly one clean base version.
- Every clean base version maps back to at most one commit. The mapping need
not be onto: a syntactically valid
YYYYMMDD.NMAY correspond to no commit (see Sparse sequences). - Versions strictly increase from parent to child.
- 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:
- The symbolic cached remote default at
refs/remotes/REMOTE/HEAD. - Cached
REMOTE/main, thenREMOTE/master. - Local
main, then localmaster. - 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.HASHWithout a hash:
PREFIXYYYYMMDD.NDIRTYHASH 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:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Invalid input, repository state, version date, or decreasing history |
| 2 | Dirty workspace or off-chain target refused without dirty output enabled |
| 3 | Complete history proves the target is unrelated to the selected chain |
| 4 | Local 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:
- the target’s selected-chain relationship or reachable anchor; and
- 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.
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:
- Refresh the selected remote branch and require the publishing commit to be its current tip.
- 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.
- Select the numerically greatest matching GitCalVer version.
- Validate that tag’s date segment against its target commit.
- 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.
- Require the new candidate to be greater, unless the candidate tag already points to the publishing commit.
- 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.
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.