diff --git a/cmd/azform/buildinfo.go b/cmd/azform/buildinfo.go new file mode 100644 index 0000000..910df40 --- /dev/null +++ b/cmd/azform/buildinfo.go @@ -0,0 +1,77 @@ +package main + +import ( + "regexp" + "runtime/debug" + "strings" +) + +// pseudoVersionRe matches the timestamp-hash tail of a Go pseudo-version +// ("v0.0.0-20260926165719-25ff1eef784e"), optionally followed by build +// metadata such as "+dirty". +var pseudoVersionRe = regexp.MustCompile(`\d{14}-[0-9a-f]{12}(\+.*)?$`) + +// fillBuildInfo backfills version, commit and date from the Go build info +// when the release ldflags did not set them. Goreleaser sets all three; +// `go install ...@vX.Y.Z` records only the module version, and `go build` +// in a git checkout (make build) records only the VCS stamp. Without this +// both of those report a bare "dev" with no way to tell which code it is. +func fillBuildInfo() { + info, ok := debug.ReadBuildInfo() + if !ok { + return + } + version, commit, date = resolveBuildInfo(version, commit, date, info) +} + +func resolveBuildInfo(v, c, d string, info *debug.BuildInfo) (string, string, string) { + // Only a tagged module version replaces "dev". A local `go build` + // reports "(devel)" or, since Go 1.24, a pseudo-version like + // "v0.0.0-20260926165719-25ff1eef784e+dirty"; both keep "dev", which + // the update check never nags (see update.compareSemver) and which + // does not invalidate the metadata cache on every commit. The commit + // below still says which code it is. + if v == "dev" { + mv := info.Main.Version + if mv != "" && mv != "(devel)" && !strings.Contains(mv, "+") && !pseudoVersionRe.MatchString(mv) { + v = strings.TrimPrefix(mv, "v") + } + } + if c != "" { + return v, c, d + } + var modified bool + for _, s := range info.Settings { + switch s.Key { + case "vcs.revision": + c = s.Value + if len(c) > 7 { + c = c[:7] + } + case "vcs.time": + if d == "" { + d = s.Value + } + case "vcs.modified": + modified = s.Value == "true" + } + } + if c != "" && modified { + c += "-dirty" + } + return v, c, d +} + +// versionString is the one-line version shown by --version, -h and the +// help overlay: "azform 0.4.1 (abc1234, 2026-09-01T10:00:00Z)". +func versionString() string { + s := "azform " + version + if commit != "" { + s += " (" + commit + if date != "" { + s += ", " + date + } + s += ")" + } + return s +} diff --git a/cmd/azform/buildinfo_test.go b/cmd/azform/buildinfo_test.go new file mode 100644 index 0000000..f5dba0e --- /dev/null +++ b/cmd/azform/buildinfo_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "runtime/debug" + "testing" +) + +func TestResolveBuildInfo(t *testing.T) { + vcs := []debug.BuildSetting{ + {Key: "vcs.revision", Value: "0123456789abcdef"}, + {Key: "vcs.time", Value: "2026-09-01T10:00:00Z"}, + {Key: "vcs.modified", Value: "false"}, + } + cases := []struct { + name string + v, c, d string + mainVersion string + settings []debug.BuildSetting + wantV, wantC string + wantD string + }{ + { + name: "ldflags win", + v: "0.4.1", c: "abc1234", d: "2026-08-01T00:00:00Z", + mainVersion: "v9.9.9", settings: vcs, + wantV: "0.4.1", wantC: "abc1234", wantD: "2026-08-01T00:00:00Z", + }, + { + name: "go install at a tag", + v: "dev", mainVersion: "v0.4.1", + wantV: "0.4.1", + }, + { + name: "local go build keeps dev, gains commit", + v: "dev", mainVersion: "(devel)", settings: vcs, + wantV: "dev", wantC: "0123456", wantD: "2026-09-01T10:00:00Z", + }, + { + name: "local go build pseudo-version keeps dev", + v: "dev", mainVersion: "v0.0.0-20260926165719-25ff1eef784e+dirty", + wantV: "dev", + }, + { + name: "go install at a commit keeps dev", + v: "dev", mainVersion: "v0.4.2-0.20260926165719-25ff1eef784e", + wantV: "dev", + }, + { + name: "dirty tree", + v: "dev", mainVersion: "(devel)", + settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "0123456789abcdef"}, + {Key: "vcs.modified", Value: "true"}, + }, + wantV: "dev", wantC: "0123456-dirty", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + info := &debug.BuildInfo{Main: debug.Module{Version: tc.mainVersion}, Settings: tc.settings} + v, c, d := resolveBuildInfo(tc.v, tc.c, tc.d, info) + if v != tc.wantV || c != tc.wantC || d != tc.wantD { + t.Errorf("got (%q, %q, %q), want (%q, %q, %q)", v, c, d, tc.wantV, tc.wantC, tc.wantD) + } + }) + } +} diff --git a/cmd/azform/main.go b/cmd/azform/main.go index 8b95987..8421a4b 100644 --- a/cmd/azform/main.go +++ b/cmd/azform/main.go @@ -36,6 +36,8 @@ func main() { } func run(args []string) int { + fillBuildInfo() + // Subcommands are dispatched before flag parsing: `shell-init` is a // bare positional, which the flag path would otherwise mistake for // an az command path. @@ -80,6 +82,7 @@ func run(args []string) int { fs.BoolVar(&doctorFlag, "doctor", false, "print environment summary and exit (spec §15.3)") fs.Usage = func() { + fmt.Fprintf(fs.Output(), "%s\n\n", versionString()) fmt.Fprintf(fs.Output(), "Usage: azform --line --out [--vars ] [--cwd ]\n") fmt.Fprintf(fs.Output(), " azform shell-init print the shell widget to stdout\n\n") fmt.Fprintf(fs.Output(), "Flags:\n") @@ -272,15 +275,7 @@ func cursorByte(line string, cursor int, prefix string) int { } func printVersion() { - fmt.Printf("azform %s", version) - if commit != "" { - fmt.Printf(" (%s", commit) - if date != "" { - fmt.Printf(", %s", date) - } - fmt.Printf(")") - } - fmt.Println() + fmt.Println(versionString()) } func effectiveCacheDir(override string) string { diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index f289805..e9c0c34 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -1806,6 +1806,9 @@ func TestHelpOverlayToggles(t *testing.T) { if !strings.Contains(view, "keyboard shortcuts") { t.Errorf("help view should announce itself; got:\n%s", view) } + if !strings.Contains(view, "azform test") { + t.Errorf("help view should show the azform version; got:\n%s", view) + } m, _ = form.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) form = m.(ui.Form) diff --git a/internal/ui/view.go b/internal/ui/view.go index eef2a60..df70719 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -417,7 +417,19 @@ func (m Form) renderHelp() string { } var sb strings.Builder - writeLine(&sb, headerStyle.Render("azform — keyboard shortcuts")) + // The version sits right-aligned on the title line: the help overlay + // is the one place inside the form a user looks for "what is this", + // and a bug report needs the version without leaving the form. + title := headerStyle.Render("azform — keyboard shortcuts") + if m.version != "" { + ver := hintStyle.Render("azform " + m.version) + if pad := w - ansi.StringWidth(title) - ansi.StringWidth(ver); pad >= 2 { + title += strings.Repeat(" ", pad) + ver + } else { + title += " " + ver + } + } + writeLine(&sb, title) writeLine(&sb, sep) sections := []struct {