Author SHA1 Message Date
clawbot 29922dba51 Move the command line from cmd/bsdaily into internal/cli (closes #8)
check / check (push) Successful in 3m55s
cmd/bsdaily/main.go now only passes Version to cli.Main and exits with
the status it returns, as REPO_POLICIES.md requires of cmd/. The cobra
command, the flag rules and the date parsing moved unchanged into
internal/cli, so flags, help text, error messages, exit status and the
first log line stay as they were. The fallback to dev for an empty
version moved with them into cli.Main.

parseTargetDates is exported as ParseTargetDates so that a table test
outside the package can reach it; the test never touches the filesystem
or runs the extraction. README describes the new layout and TODO.md
records the step.

Model: opus-5-5
2026-10-06 19:42:41 +00:00
clawbot f5c7614768 Stamp the git tag or short commit into the binary (closes #4)
check / check (push) Successful in 3m35s
A plain `docker build .` now stamps bsdaily's version: the VERSION
build argument when one is given, otherwise `git describe --tags
--always` on the .git in the build context, as the canonical
Dockerfile does. The build fails if .git is there and the version
still comes out empty, dev or unknown. A host `make` build stamps the
same `git describe`, or dev. bsdaily logs the version on the first
line of every run and prints it with --version; a build that stamps
nothing, or an empty value, reports dev.

Judgement call: the build line also takes the canonical -trimpath and
-s -w.
One //nolint (gochecknoglobals): -X can only set a package-level
variable.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-06 18:41:51 +02:00
clawbot c16f177575 Adopt the shared .golangci.yml and fix the code to it (closes #6)
check / check (push) Successful in 5m14s
Vendor .golangci.yml byte-identical from sneak/prompts at cc440118 and
move the Dockerfile lint phase to golangci-lint v2.14.0 by the digest
REPO_POLICIES.md names. Fix the code to that config with flags, help
text, output files, SQL and the order of steps unchanged; long
functions are split into named steps.

Judgement call: the extraction transaction is now rolled back on every
early return; the old deferred rollback missed most failures and could
dereference a nil transaction.
Thirteen //nolint directives (gosec, unconvert, mnd, unqueryvet), each
with its reason.

Model: opus-5-5
2026-10-06 14:24:48 +02:00
8 changed files with 332 additions and 121 deletions
+19 -3
View File
@@ -47,7 +47,9 @@ FROM golang:1.26.4-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af01
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
ARG VERSION=dev
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
@@ -58,8 +60,22 @@ RUN go mod download
# Copy source code
COPY . .
# Build (pure Go, no CGO required since we use modernc.org/sqlite)
RUN CGO_ENABLED=0 go build -o /bsdaily ./cmd/bsdaily
# Build (pure Go, no CGO required since we use modernc.org/sqlite).
# The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With
# .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout.
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /bsdaily ./cmd/bsdaily
# Runtime stage
# alpine:3.21, 2026-06-28
+5 -3
View File
@@ -1,7 +1,9 @@
.PHONY: all bootstrap setup check test lint fmt fmt-check build clean deps test-coverage test-integration install release release-snapshot docker hooks
# Version number
VERSION := 0.1.0-dev
# Stamped into the binary: the same `git describe` a plain `docker build .`
# runs, or dev when it prints nothing (outside a git checkout, or where git is
# missing). ?= so that a VERSION already in the environment takes precedence.
VERSION ?= $(or $(shell git describe --tags --always 2>/dev/null),dev)
# Default target
all: bsdaily
@@ -36,7 +38,7 @@ lint:
# Build binary (pure Go; no CGO required since we use modernc.org/sqlite).
bsdaily: internal/*/*.go cmd/bsdaily/*.go
CGO_ENABLED=0 go build -o $@ ./cmd/bsdaily
CGO_ENABLED=0 go build -ldflags "-X main.Version=$(VERSION)" -o $@ ./cmd/bsdaily
# Clean build artifacts.
clean:
+22 -1
View File
@@ -156,6 +156,11 @@ included to do exactly this and to rebuild the aggregate statistics
# How It Works
`cmd/bsdaily/main.go` only passes the version to `internal/cli` and exits with
the status it returns. `internal/cli` holds the command line: the flags, the
rules for combining them and the parsing of the dates they name.
`internal/bsdaily` does the extraction.
A single run proceeds as follows:
1. **Find the snapshot.** Scan `SnapshotBase` for directories matching
@@ -189,6 +194,7 @@ A single run proceeds as follows:
bsdaily # extract the snapshot date minus one day
bsdaily --date 2026-06-27 # extract a single specific day
bsdaily --from 2026-06-01 --to 2026-06-27 # extract an inclusive range
bsdaily --version # print the version and exit
```
Flags:
@@ -197,9 +203,24 @@ Flags:
`--from`/`--to`.
- `--from YYYY-MM-DD` — start of an inclusive range (requires `--to`).
- `--to YYYY-MM-DD` — end of an inclusive range (requires `--from`).
- `-v`, `--version` — print the version and exit.
With no flags, the tool extracts the day before the latest snapshot. All
progress is logged as structured `slog` text to stderr.
progress is logged as structured `slog` text to stderr; the first line of every
run carries the version.
The version is set at link time and depends on how the binary was built:
- `docker build .` takes it from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always` on the `.git` in the build
context. The build fails if `.git` is there and the version still comes out
empty, `dev` or `unknown`. With neither `.git` nor `VERSION`, the binary
reports `dev`.
- `script/docker`, `script/cibuild` and `make docker` pass the host's
`git describe --tags --always --dirty` as `VERSION`, so on a modified tree the
version ends in `-dirty`. When that prints nothing, they pass `unknown`.
- `make` stamps the host's `git describe --tags --always`, without `-dirty`, or
`dev` when that prints nothing.
## Merging dumps back into a database
+10 -2
View File
@@ -14,11 +14,19 @@ pre-1.0
# Next Step
Expand tests beyond the compilation smoke test: unit tests for the extraction,
verification, and atomic-publish paths.
Expand the `internal/bsdaily` tests beyond the compilation smoke test: unit
tests for the extraction, verification, and atomic-publish paths.
# Completed Steps
- 2026-10-06: Moved the command line (flags, the rules for combining them, date
parsing) from `cmd/bsdaily` into `internal/cli`, with unit tests for the flag
rules and the dates; `cmd/bsdaily/main.go` is now a single call into it
(https://git.eeqj.de/sneak/bsdaily/issues/8).
- 2026-10-06: A plain `docker build .` and a host `make` build stamp the git tag
or short commit into the binary, which `bsdaily` logs on the first line of
every run and prints with `--version`
(https://git.eeqj.de/sneak/bsdaily/issues/4).
- 2026-10-06: Added the canonical `.golangci.yml`, moved the lint phase to
golangci-lint v2.14.0, and fixed the code to pass it
(https://git.eeqj.de/sneak/bsdaily/issues/6).
+8 -112
View File
@@ -3,122 +3,18 @@
package main
import (
"errors"
"fmt"
"log/slog"
"os"
"time"
"git.eeqj.de/sneak/bsdaily/internal/bsdaily"
"github.com/spf13/cobra"
"git.eeqj.de/sneak/bsdaily/internal/cli"
)
var (
errDateExclusive = errors.New("--date and --from/--to are mutually exclusive")
errFromRequiresTo = errors.New("--from requires --to")
errToRequiresFrom = errors.New("--to requires --from")
errFromAfterTo = errors.New("is after --to")
)
// Version is the git tag or short commit, set at link time with
// -X main.Version=... by the Dockerfile and the Makefile. A build that sets
// nothing, or sets it empty, reports dev.
//
//nolint:gochecknoglobals // -X can only set a package-level variable
var Version string
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
var dateFlag, fromFlag, toFlag string
rootCmd := &cobra.Command{
Use: "bsdaily",
Short: "Extract a single day's data from the latest daily snapshot",
SilenceUsage: true,
RunE: func(_ *cobra.Command, _ []string) error {
targetDates, err := parseTargetDates(dateFlag, fromFlag, toFlag)
if err != nil {
return err
}
err = bsdaily.Run(targetDates)
if err != nil {
return err
}
slog.Info("completed successfully")
return nil
},
}
rootCmd.Flags().StringVarP(&dateFlag, "date", "d", "",
"target date to extract (YYYY-MM-DD); "+
"defaults to snapshot date minus one day")
rootCmd.Flags().StringVar(&fromFlag, "from", "",
"start of date range to extract (YYYY-MM-DD, inclusive); use with --to")
rootCmd.Flags().StringVar(&toFlag, "to", "",
"end of date range to extract (YYYY-MM-DD, inclusive); use with --from")
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
// parseTargetDates turns the --date, --from and --to flags into the days
// to extract. It returns nil when none of them is set, which Run takes
// to mean the snapshot date minus one day.
func parseTargetDates(dateFlag, fromFlag, toFlag string) ([]time.Time, error) {
hasDate := dateFlag != ""
hasFrom := fromFlag != ""
hasTo := toFlag != ""
// Validate mutual exclusivity
if hasDate && (hasFrom || hasTo) {
return nil, errDateExclusive
}
if hasFrom != hasTo {
if hasFrom {
return nil, errFromRequiresTo
}
return nil, errToRequiresFrom
}
if hasDate {
t, err := time.Parse("2006-01-02", dateFlag)
if err != nil {
return nil, fmt.Errorf(
"invalid --date %q (expected YYYY-MM-DD): %w", dateFlag, err)
}
return []time.Time{t}, nil
}
if !hasFrom {
return nil, nil
}
from, err := time.Parse("2006-01-02", fromFlag)
if err != nil {
return nil, fmt.Errorf(
"invalid --from %q (expected YYYY-MM-DD): %w", fromFlag, err)
}
to, err := time.Parse("2006-01-02", toFlag)
if err != nil {
return nil, fmt.Errorf(
"invalid --to %q (expected YYYY-MM-DD): %w", toFlag, err)
}
if from.After(to) {
return nil, fmt.Errorf("--from %s %w %s", fromFlag, errFromAfterTo, toFlag)
}
var targetDates []time.Time
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
targetDates = append(targetDates, d)
}
return targetDates, nil
os.Exit(cli.Main(Version))
}
+67
View File
@@ -0,0 +1,67 @@
// Package cli is the bsdaily command line: the command, its flags, the
// rules for combining them and the dates they name. The extraction itself
// is in package bsdaily.
package cli
import (
"log/slog"
"os"
"git.eeqj.de/sneak/bsdaily/internal/bsdaily"
"github.com/spf13/cobra"
)
// Main runs the bsdaily command on the program's command-line arguments
// and returns the status for the process to exit with. version is the
// build's version; an empty one is reported as dev.
func Main(version string) int {
if version == "" {
version = "dev"
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
var dateFlag, fromFlag, toFlag string
rootCmd := &cobra.Command{
Use: "bsdaily",
Short: "Extract a single day's data from the latest daily snapshot",
Version: version,
SilenceUsage: true,
RunE: func(_ *cobra.Command, _ []string) error {
slog.Info("starting", "version", version)
targetDates, err := ParseTargetDates(dateFlag, fromFlag, toFlag)
if err != nil {
return err
}
err = bsdaily.Run(targetDates)
if err != nil {
return err
}
slog.Info("completed successfully")
return nil
},
}
rootCmd.Flags().StringVarP(&dateFlag, "date", "d", "",
"target date to extract (YYYY-MM-DD); "+
"defaults to snapshot date minus one day")
rootCmd.Flags().StringVar(&fromFlag, "from", "",
"start of date range to extract (YYYY-MM-DD, inclusive); use with --to")
rootCmd.Flags().StringVar(&toFlag, "to", "",
"end of date range to extract (YYYY-MM-DD, inclusive); use with --from")
err := rootCmd.Execute()
if err != nil {
return 1
}
return 0
}
+74
View File
@@ -0,0 +1,74 @@
package cli
import (
"errors"
"fmt"
"time"
)
var (
errDateExclusive = errors.New("--date and --from/--to are mutually exclusive")
errFromRequiresTo = errors.New("--from requires --to")
errToRequiresFrom = errors.New("--to requires --from")
errFromAfterTo = errors.New("is after --to")
)
// ParseTargetDates turns the --date, --from and --to flags into the days
// to extract. It returns nil when none of them is set, which bsdaily.Run
// takes to mean the snapshot date minus one day.
func ParseTargetDates(dateFlag, fromFlag, toFlag string) ([]time.Time, error) {
hasDate := dateFlag != ""
hasFrom := fromFlag != ""
hasTo := toFlag != ""
// Validate mutual exclusivity
if hasDate && (hasFrom || hasTo) {
return nil, errDateExclusive
}
if hasFrom != hasTo {
if hasFrom {
return nil, errFromRequiresTo
}
return nil, errToRequiresFrom
}
if hasDate {
t, err := time.Parse("2006-01-02", dateFlag)
if err != nil {
return nil, fmt.Errorf(
"invalid --date %q (expected YYYY-MM-DD): %w", dateFlag, err)
}
return []time.Time{t}, nil
}
if !hasFrom {
return nil, nil
}
from, err := time.Parse("2006-01-02", fromFlag)
if err != nil {
return nil, fmt.Errorf(
"invalid --from %q (expected YYYY-MM-DD): %w", fromFlag, err)
}
to, err := time.Parse("2006-01-02", toFlag)
if err != nil {
return nil, fmt.Errorf(
"invalid --to %q (expected YYYY-MM-DD): %w", toFlag, err)
}
if from.After(to) {
return nil, fmt.Errorf("--from %s %w %s", fromFlag, errFromAfterTo, toFlag)
}
var targetDates []time.Time
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
targetDates = append(targetDates, d)
}
return targetDates, nil
}
+127
View File
@@ -0,0 +1,127 @@
package cli_test
import (
"slices"
"strings"
"testing"
"git.eeqj.de/sneak/bsdaily/internal/cli"
)
func TestParseTargetDates(t *testing.T) {
t.Parallel()
const oneDay = "2026-05-14"
// want lists the expected days as YYYY-MM-DD.
tests := []struct {
name string
date string
from string
to string
want []string
}{
{name: "no flags"},
{name: "single date", date: "2026-06-27", want: []string{"2026-06-27"}},
{
name: "range across a month end", from: "2026-06-29", to: "2026-07-02",
want: []string{"2026-06-29", "2026-06-30", "2026-07-01", "2026-07-02"},
},
{name: "range of one day", from: oneDay, to: oneDay, want: []string{oneDay}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := cli.ParseTargetDates(tt.date, tt.from, tt.to)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
days := make([]string, 0, len(got))
for _, day := range got {
days = append(days, day.Format("2006-01-02"))
}
if !slices.Equal(days, tt.want) {
t.Errorf("days = %v, want %v", days, tt.want)
}
})
}
}
func TestParseTargetDatesErrors(t *testing.T) {
t.Parallel()
const dateExclusive = "--date and --from/--to are mutually exclusive"
// wantErr is the whole error message. With startsWith set it is only how
// the message starts: for a malformed date, the date parser's own
// explanation follows it.
tests := []struct {
name string
date string
from string
to string
wantErr string
startsWith bool
}{
{
name: "from after to", from: "2026-03-02", to: "2026-03-01",
wantErr: "--from 2026-03-02 is after --to 2026-03-01",
},
{name: "from without to", from: "2026-04-01", wantErr: "--from requires --to"},
{name: "to without from", to: "2026-04-02", wantErr: "--to requires --from"},
{
name: "date with from", date: "2026-01-05", from: "2026-01-06",
wantErr: dateExclusive,
},
{
name: "date with to", date: "2026-01-13", to: "2026-01-14",
wantErr: dateExclusive,
},
{
name: "date with from and to", date: "2026-01-07",
from: "2026-01-08", to: "2026-01-09",
wantErr: dateExclusive,
},
{
name: "malformed date", date: "27.06.2026",
wantErr: `invalid --date "27.06.2026" (expected YYYY-MM-DD): `,
startsWith: true,
},
{
name: "date that does not exist", date: "2026-02-30",
wantErr: `invalid --date "2026-02-30" (expected YYYY-MM-DD): `,
startsWith: true,
},
{
name: "malformed from", from: "2026-1-10", to: "2026-01-11",
wantErr: `invalid --from "2026-1-10" (expected YYYY-MM-DD): `,
startsWith: true,
},
{
name: "malformed to", from: "2026-01-12", to: "tomorrow",
wantErr: `invalid --to "tomorrow" (expected YYYY-MM-DD): `,
startsWith: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := cli.ParseTargetDates(tt.date, tt.from, tt.to)
switch {
case err == nil:
t.Errorf("no error, want %q", tt.wantErr)
case tt.startsWith && !strings.HasPrefix(err.Error(), tt.wantErr):
t.Errorf("error = %q, want one starting %q", err, tt.wantErr)
case !tt.startsWith && err.Error() != tt.wantErr:
t.Errorf("error = %q, want %q", err, tt.wantErr)
}
})
}
}