diff --git a/Makefile b/Makefile index a8b8339..20b4596 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,13 @@ BINARY_NAME=late VERSION?=2.0.0-rc.1 -# Go compiler flags -LDFLAGS=-ldflags "-X late/internal/common.Version=${VERSION}" +# Go compiler flags. Commit/build-number/build-date stamping: when git is +# unavailable (tarball checkout, no repo) the commit and build number +# degrade to "unknown" without failing the build. +GIT_COMMIT:=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +BUILD_NUMBER:=$(shell git rev-list --count HEAD 2>/dev/null || echo unknown) +BUILD_DATE:=$(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS=-ldflags "-X late/internal/common.Version=${VERSION} -X late/internal/common.BuildNumber=${BUILD_NUMBER} -X late/internal/common.Commit=${GIT_COMMIT} -X 'late/internal/common.BuildDate=${BUILD_DATE}'" help: ## Show this help @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) diff --git a/cmd/late/main.go b/cmd/late/main.go index bb16d27..6d4273e 100644 --- a/cmd/late/main.go +++ b/cmd/late/main.go @@ -128,7 +128,9 @@ func main() { tool.SetSqzEnabled(*enableSqzReq) if *versionReq { - fmt.Printf("late %s\n", common.Version) + // Full one-line build identity: version + stamped commit/build + // date, degrading to the bare dev banner for a plain `go build`. + fmt.Println(common.VersionDisplay()) return } diff --git a/internal/common/version.go b/internal/common/version.go index f6c5d47..2203498 100644 --- a/internal/common/version.go +++ b/internal/common/version.go @@ -1,3 +1,93 @@ package common +import "strings" + +// Version is the release identifier (e.g. "2.0.0-rc.1"), set via +// -ldflags -X at build time; it stays "dev" for a plain `go build`. var Version = "dev" + +// BuildNumber is the deterministic build ordinal — the commit count of the +// current HEAD (`git rev-list --count HEAD`), set via -ldflags -X at build +// time (Makefile: BUILD_NUMBER). It is stable for the same commit, grows +// monotonically as the repo grows, and needs no network; it stays "unknown" +// when not stamped (plain `go build`) or when git is unavailable (tarball +// without .git). +var BuildNumber = "unknown" + +// Commit is the short git commit the binary was built from, set via +// -ldflags -X at build time (Makefile: `git rev-parse --short HEAD`); +// it stays "unknown" when not stamped (plain `go build`, tarball +// without .git). +var Commit = "unknown" + +// BuildDate is the UTC build timestamp (RFC3339), set via -ldflags -X +// at build time (Makefile: `date -u +%Y-%m-%dT%H:%M:%SZ`); it stays +// "unknown" when not stamped. +var BuildDate = "unknown" + +// versionDisplay renders the one-line build identity from injected +// values, so tests can exercise every degradation without mutating the +// package vars: +// +// late 2.0.0-rc.1 (build 1239, commit 2fe0e83, built 2026-09-25T13:12:11Z) +// +// Degradations (the output stays on one line): +// - unknown build number ("unknown" or empty) → the build number is omitted; +// - unknown commit ("unknown" or empty) → the commit is omitted; +// - unknown date ("unknown" or empty) → the date is omitted; +// - dev version with nothing stamped (plain `go build`) → bare "late dev". +func versionDisplay(version, buildNumber, commit, buildDate string) string { + buildKnown := buildNumber != "" && buildNumber != "unknown" + commitKnown := commit != "" && commit != "unknown" + dateKnown := buildDate != "" && buildDate != "unknown" + if version == "dev" && !buildKnown && !commitKnown && !dateKnown { + return "late dev" + } + display := "late " + version + var meta []string + if buildKnown { + meta = append(meta, "build "+buildNumber) + } + if commitKnown { + meta = append(meta, "commit "+commit) + } + if dateKnown { + meta = append(meta, "built "+buildDate) + } + if len(meta) > 0 { + display += " (" + strings.Join(meta, ", ") + ")" + } + return display +} + +// VersionDisplay renders the full one-line build identity for -version +// from the stamped package vars. See versionDisplay for the format and +// its graceful degradations. +func VersionDisplay() string { + return versionDisplay(Version, BuildNumber, Commit, BuildDate) +} + +// versionDisplayShort renders the compact identity used where only one +// row must fit (the TUI info bar): "[ · b][ · +// ]" — the build number comes first, then the short commit. Each +// unknown piece degrades silently to just the version, and the build date +// is deliberately left out. +func versionDisplayShort(version, buildNumber, commit string) string { + parts := make([]string, 0, 3) + if version != "" { + parts = append(parts, version) + } + if buildNumber != "" && buildNumber != "unknown" { + parts = append(parts, "b"+buildNumber) + } + if commit != "" && commit != "unknown" { + parts = append(parts, commit) + } + return strings.Join(parts, " · ") +} + +// VersionDisplayShort renders the short build identity from the stamped +// package vars. See versionDisplayShort for the format. +func VersionDisplayShort() string { + return versionDisplayShort(Version, BuildNumber, Commit) +} diff --git a/internal/common/version_test.go b/internal/common/version_test.go new file mode 100644 index 0000000..66c8e51 --- /dev/null +++ b/internal/common/version_test.go @@ -0,0 +1,211 @@ +package common + +import ( + "strings" + "testing" +) + +func TestVersionDisplay(t *testing.T) { + tests := []struct { + name string + version string + buildNum string + commit string + buildDate string + want string + }{ + { + name: "all four pieces stamped, order is build, commit, built", + version: "2.0.0-rc.1", + buildNum: "1239", + commit: "2fe0e83", + buildDate: "2026-09-25T13:12:11Z", + want: "late 2.0.0-rc.1 (build 1239, commit 2fe0e83, built 2026-09-25T13:12:11Z)", + }, + { + name: "build number only", + version: "2.0.0-rc.1", + buildNum: "1239", + want: "late 2.0.0-rc.1 (build 1239)", + }, + { + name: "build number and build date omit the commit", + version: "2.0.0-rc.1", + buildNum: "1239", + buildDate: "2026-09-25T13:12:11Z", + want: "late 2.0.0-rc.1 (build 1239, built 2026-09-25T13:12:11Z)", + }, + { + name: "commit and build date stamped without build number", + version: "2.0.0-rc.1", + commit: "2fe0e83", + buildDate: "2026-09-25T10:57:00+02:00", + want: "late 2.0.0-rc.1 (commit 2fe0e83, built 2026-09-25T10:57:00+02:00)", + }, + { + name: "commit only", + version: "2.0.0-rc.1", + commit: "2fe0e83", + want: "late 2.0.0-rc.1 (commit 2fe0e83)", + }, + { + name: "build date only", + version: "2.0.0-rc.1", + buildDate: "2026-09-25T08:57:00Z", + want: "late 2.0.0-rc.1 (built 2026-09-25T08:57:00Z)", + }, + { + name: "nothing stamped", + version: "2.0.0-rc.1", + want: "late 2.0.0-rc.1", + }, + { + name: "dev with nothing stamped is the bare dev banner", + version: "dev", + want: "late dev", + }, + { + name: "unknown sentinels are treated as unstamped", + version: "2.0.0-rc.1", + buildNum: "unknown", + commit: "unknown", + buildDate: "unknown", + want: "late 2.0.0-rc.1", + }, + { + name: "empty build number is treated as unstamped", + version: "2.0.0-rc.1", + buildNum: "", + commit: "2fe0e83", + want: "late 2.0.0-rc.1 (commit 2fe0e83)", + }, + { + name: "dev with only the build number stamped keeps it", + version: "dev", + buildNum: "1239", + want: "late dev (build 1239)", + }, + { + name: "dev with only the build date stamped keeps the date", + version: "dev", + buildDate: "2026-09-25T08:57:00Z", + want: "late dev (built 2026-09-25T08:57:00Z)", + }, + { + name: "dev with only the commit stamped keeps the commit", + version: "dev", + commit: "2fe0e83", + want: "late dev (commit 2fe0e83)", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := versionDisplay(tt.version, tt.buildNum, tt.commit, tt.buildDate) + if got != tt.want { + t.Errorf("versionDisplay(%q, %q, %q, %q) = %q, want %q", tt.version, tt.buildNum, tt.commit, tt.buildDate, got, tt.want) + } + // The output must stay on one line: it feeds -version stdout + // and single-row parsers. + if strings.ContainsAny(got, "\n\r") { + t.Errorf("versionDisplay(...) = %q, must not contain line breaks", got) + } + }) + } +} + +func TestVersionDisplayShort(t *testing.T) { + tests := []struct { + name string + version string + buildNum string + commit string + want string + }{ + { + name: "build number precedes the commit", + version: "2.0.0-rc.1", + buildNum: "1239", + commit: "2fe0e83", + want: "2.0.0-rc.1 · b1239 · 2fe0e83", + }, + { + name: "build number only", + version: "2.0.0-rc.1", + buildNum: "1239", + want: "2.0.0-rc.1 · b1239", + }, + { + name: "commit only", + version: "2.0.0-rc.1", + commit: "2fe0e83", + want: "2.0.0-rc.1 · 2fe0e83", + }, + { + name: "unknown build number degrades to version and commit", + version: "2.0.0-rc.1", + buildNum: "unknown", + commit: "2fe0e83", + want: "2.0.0-rc.1 · 2fe0e83", + }, + { + name: "empty build number degrades to version and commit", + version: "2.0.0-rc.1", + buildNum: "", + commit: "2fe0e83", + want: "2.0.0-rc.1 · 2fe0e83", + }, + { + name: "unknown commit degrades to the bare version", + version: "2.0.0-rc.1", + commit: "unknown", + want: "2.0.0-rc.1", + }, + { + name: "empty commit degrades to the bare version", + version: "2.0.0-rc.1", + want: "2.0.0-rc.1", + }, + { + name: "dev with unknown commit", + version: "dev", + want: "dev", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := versionDisplayShort(tt.version, tt.buildNum, tt.commit) + if got != tt.want { + t.Errorf("versionDisplayShort(%q, %q, %q) = %q, want %q", tt.version, tt.buildNum, tt.commit, got, tt.want) + } + if strings.ContainsAny(got, "\n\r") { + t.Errorf("versionDisplayShort(...) = %q, must not contain line breaks", got) + } + }) + } +} + +// TestVersionDisplayWrappersUseVars pins that the thin var-backed wrappers +// plumb the package vars through (the build-tag-free injection point used +// by the TUI tests). +func TestVersionDisplayWrappersUseVars(t *testing.T) { + origVersion, origBuildNum, origCommit, origBuildDate := Version, BuildNumber, Commit, BuildDate + defer func() { Version, BuildNumber, Commit, BuildDate = origVersion, origBuildNum, origCommit, origBuildDate }() + + Version, BuildNumber, Commit, BuildDate = "2.0.0-rc.1", "1239", "2fe0e83", "2026-09-25T10:57:00+02:00" + if got, want := VersionDisplay(), "late 2.0.0-rc.1 (build 1239, commit 2fe0e83, built 2026-09-25T10:57:00+02:00)"; got != want { + t.Errorf("VersionDisplay() = %q, want %q", got, want) + } + if got, want := VersionDisplayShort(), "2.0.0-rc.1 · b1239 · 2fe0e83"; got != want { + t.Errorf("VersionDisplayShort() = %q, want %q", got, want) + } + + // A plain `go build` (all vars at their source defaults) is the bare + // dev banner. + Version, BuildNumber, Commit, BuildDate = "dev", "unknown", "unknown", "unknown" + if got := VersionDisplay(); got != "late dev" { + t.Errorf("VersionDisplay() = %q, want %q", got, "late dev") + } + if got := VersionDisplayShort(); got != "dev" { + t.Errorf("VersionDisplayShort() = %q, want %q", got, "dev") + } +}