From 9ff524d83ef9fe45cdbf4c620c232b192b534d36 Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 17 Oct 2026 02:50:40 +0000 Subject: [PATCH] Install from @latest, retract v0.0.1, print a go-installed binary's version (closes #116) README "Install" now leads with go install ...@latest: until 1.0 each release is a v0.0.N tag on main's tip, so @latest gives that tip once it has passed the build. It also says what @main and v0.0.1 give and how to check an installed binary. go.mod retracts v0.0.1, the early build without a database. sfdupes --version printed "dev" for every go-installed binary, since only the linker set Version. When the linker sets none, it now prints the main module's version from runtime/debug.ReadBuildInfo; "dev" is left only when neither is known. Version is now empty by default. The retraction takes effect only once a release carrying it is the newest. Model: opus-5-5 --- README.md | 41 +++++++++++++++++++++++++++++++++-------- TODO.md | 10 ++++++++++ go.mod | 2 ++ main.go | 32 +++++++++++++++++++++++++++++--- main_test.go | 39 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 112 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1e1dc06..acac419 100644 --- a/README.md +++ b/README.md @@ -49,15 +49,40 @@ results are as fresh as the last completed scan. ### Install -With Go installed, this builds and installs the current `main` branch: +With Go installed, this builds and installs the newest release: + +```sh +go install sneak.berlin/go/sfdupes@latest +``` + +Until 1.0, releases are `v0.0.N` tags on the tip of the `main` branch: each time +`main` moves and its tip passes `script/cibuild`, that tip is tagged with the +next `v0.0.N`. So `@latest` installs `main`'s tip once it has passed the build. +`@main` installs the tip before it is tagged: ```sh go install sneak.berlin/go/sfdupes@main ``` -The binary goes to `$(go env GOPATH)/bin`, or to `$GOBIN` when that is set. A -binary installed this way reports its version as `dev`; one built from a clone -or into the Docker image carries the git tag or commit it was built from. +`v0.0.1` is an early build without a database. It does not work as this README +describes, and `go.mod` retracts it. + +The binary goes to `$(go env GOPATH)/bin`, or to `$GOBIN` when that is set. To +see which version an installed binary is: + +```sh +sfdupes --version +go version -m "$(command -v sfdupes)" +``` + +`sfdupes --version` prints `sfdupes VERSION`. For a binary from `go install`, +`VERSION` is the version Go recorded in it: the release tag, such as `v0.0.3`, +or for `@main` a pseudo-version ending in the first 12 characters of the +commit's hash. A binary built from a clone or into the Docker image carries the +git tag or commit it was built from instead, and `VERSION` is `dev` only when +the binary carries no version at all. `go version -m` prints what Go recorded in +the binary; its `mod` line names the module's version. Binaries from `v0.0.2` +and earlier print `dev`, so check those with `go version -m`. From a clone, `make build` writes the binary to `./sfdupes`: @@ -262,10 +287,10 @@ sfdupes [command] --help CPUs), `-x` (`--one-file-system`) keeps the walk of each operand on that operand's filesystem, and `--no-default-excludes` lets the walk enter the default excludes, a list of paths; all three are described under "`scan` mode". -`sfdupes --version` (or `-v`) prints one line, `sfdupes VERSION`, to stdout and -exits 0, writing nothing to stderr. `-h` or `--help`, alone or after a -subcommand, prints the help text to stderr and exits 0, writing nothing to -stdout. +`sfdupes --version` (or `-v`) prints one line, `sfdupes VERSION` (see +"Install"), to stdout and exits 0, writing nothing to stderr. `-h` or `--help`, +alone or after a subcommand, prints the help text to stderr and exits 0, writing +nothing to stdout. ### Database diff --git a/TODO.md b/TODO.md index 04bb908..9096660 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,16 @@ # Completed Steps +- releases until 1.0 are `v0.0.N` tags on `main`'s tip, so + `go install sneak.berlin/go/sfdupes@latest` installs `main`'s tip once it has + passed the build; README "Install" says so, says what `@main` and `v0.0.1` + give, and how to check an installed binary. `go.mod` retracts `v0.0.1`, the + early build without a database. When the linker set no version, + `sfdupes --version` prints the main module's version that Go recorded in the + binary, the tag or the pseudo-version of a `go install` build, and `dev` only + when neither is known (2026-10-17, + https://git.eeqj.de/sneak/sfdupes/issues/116) + - `scan` creates the database's directory where the database path leads: the path with its last element cut off as text, never cleaned, so a `..` after a symlinked directory is followed through the symlink, as when the file is diff --git a/go.mod b/go.mod index cf8c37c..5d7e17c 100644 --- a/go.mod +++ b/go.mod @@ -24,3 +24,5 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) + +retract v0.0.1 // early build without a database; does not work as the README describes diff --git a/main.go b/main.go index 1c3843c..2178408 100644 --- a/main.go +++ b/main.go @@ -28,6 +28,7 @@ import ( "io" "os" "runtime" + "runtime/debug" "github.com/spf13/cobra" ) @@ -58,10 +59,32 @@ var errNoSubcommand = errors.New("no subcommand") var errWorkersBelowOne = errors.New("--workers must be at least 1") // Version is the build version, injected at link time via -ldflags -// (see the Makefile); "dev" for a plain go build. +// (see the Makefile); empty when the linker set none, as for a binary +// built by go install. buildVersion turns it into what --version prints. // //nolint:gochecknoglobals // written only by the linker -var Version = "dev" +var Version string + +// buildVersion returns the version that sfdupes --version prints +// (README "Install"): the version the linker set, when it set one; +// otherwise the main module's version that Go recorded in the binary, +// a tag such as v0.0.2 or the pseudo-version of an @main build; and +// "dev" when neither is known. info is what debug.ReadBuildInfo +// returns, nil when the binary carries none. Go records "(devel)" for +// a build that has no version, such as one inside a copy of the source +// without git. +func buildVersion(linkerVersion string, info *debug.BuildInfo) string { + if linkerVersion != "" { + return linkerVersion + } + + if info == nil || info.Main.Version == "" || + info.Main.Version == "(devel)" { + return "dev" + } + + return info.Main.Version +} func main() { // Once the reader of a stdout pipe has gone, as in "sfdupes report | @@ -116,7 +139,10 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command { var showVersion bool printVersion := runE(func(context.Context, []string) error { - _, err := fmt.Fprintf(stdout, "sfdupes %s\n", Version) + info, _ := debug.ReadBuildInfo() + + _, err := fmt.Fprintf(stdout, "sfdupes %s\n", + buildVersion(Version, info)) if err != nil { return fmt.Errorf("write stdout: %w", err) } diff --git a/main_test.go b/main_test.go index 8cd0b6f..8e39eab 100644 --- a/main_test.go +++ b/main_test.go @@ -9,6 +9,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime/debug" "slices" "strconv" "strings" @@ -714,6 +715,8 @@ func TestRunVersion(t *testing.T) { // README §Subcommands: the version is one line on stdout, with // nothing on stderr, and exits 0. + info, _ := debug.ReadBuildInfo() + for _, arg := range []string{"--version", "-v"} { var stdout, stderr bytes.Buffer @@ -722,7 +725,7 @@ func TestRunVersion(t *testing.T) { t.Errorf("run(%s) = %d, want %d", arg, code, exitOK) } - want := "sfdupes " + Version + "\n" + want := "sfdupes " + buildVersion(Version, info) + "\n" if got := stdout.String(); got != want { t.Errorf("run(%s) stdout = %q, want %q", arg, got, want) } @@ -733,6 +736,40 @@ func TestRunVersion(t *testing.T) { } } +func TestBuildVersion(t *testing.T) { + t.Parallel() + + // README "Install": the linker's version wins; otherwise the main + // module's version that Go recorded; "dev" when neither is known. + tagged := &debug.BuildInfo{Main: debug.Module{Version: "v0.0.2"}} + pseudo := &debug.BuildInfo{Main: debug.Module{ + Version: "v1.0.0-rc2.0.20261016120000-4e4a94d0c2a1", + }} + devel := &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}} + + cases := []struct { + name string + linker string + info *debug.BuildInfo + want string + }{ + {"linker set", "v0.0.2-3-g4e4a94d", tagged, "v0.0.2-3-g4e4a94d"}, + {"linker set, no build info", "4e4a94d", nil, "4e4a94d"}, + {"build info with a tag", "", tagged, "v0.0.2"}, + {"build info with a pseudo-version", "", pseudo, + "v1.0.0-rc2.0.20261016120000-4e4a94d0c2a1"}, + {"(devel)", "", devel, "dev"}, + {"no build info", "", nil, "dev"}, + } + + for _, c := range cases { + if got := buildVersion(c.linker, c.info); got != c.want { + t.Errorf("%s: buildVersion(%q, ...) = %q, want %q", + c.name, c.linker, got, c.want) + } + } +} + func TestRunVersionWriteFailureIsFatal(t *testing.T) { t.Parallel()