Commit 18ed1dc

bryfry <bryon@fryer.io>
2025-07-22 10:04:56
refactor: Simplify Makefile to 5 essential targets
- Reduce from 10+ targets to exactly 5: build, lint, fmt, update, test - Integrate vendor pack/unpack tools from tools/vendor/ directory - Build and test targets automatically unpack vendor dependencies - Update target runs go mod tidy, go mod vendor, then packs vendor.tar.gz - Lint target combines go vet with format checking - Remove unnecessary targets (run, clean, tidy, check, deps, help) - Update CLAUDE.md documentation to reflect simplified commands
1 parent 18c1cde
docs/CLAUDE.md
@@ -37,18 +37,13 @@ This is a Go web application for "buylater.email" - an email-based delayed grati
 
 ## Development Commands
 
-**Makefile Available**: Use `make help` to see all available targets.
-
-### Quick Commands (via Makefile)
+### Makefile Commands (Recommended)
 ```bash
-make run       # Start development server
-make build     # Build application binary
-make test      # Run tests
+make build     # Build application binary (with vendor unpack)
+make test      # Run tests (with vendor unpack)
 make fmt       # Format code
-make vet       # Vet code
-make check     # Run fmt, vet, and test
-make clean     # Remove binaries
-make tidy      # Clean up dependencies
+make lint      # Lint code (vet + format check)
+make update    # Update dependencies and vendor pack
 ```
 
 ### Direct Go Commands
tools/vendor/pack.go
@@ -0,0 +1,96 @@
+//go:build ignore
+
+// Command tools/vendor/pack.go  creates `vendor.tar.gz` from the current
+// project `vendor` directory.  Intended for use as a build support tool.
+package main
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"fmt"
+	"io"
+	"io/fs"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"runtime"
+)
+
+// vendor.tar.gz pack
+func main() {
+	const (
+		archiveName   = "vendor.tar.gz"
+		vendorDir     = "vendor"
+		vendorDirMode = 0o755
+	)
+
+	// discover this file's location
+	_, callerFilepath, _, callerFound := runtime.Caller(0)
+	if !callerFound {
+		slog.Error("failed to find caller location")
+		return
+	}
+	archivePath := filepath.Join(filepath.Dir(callerFilepath), archiveName)
+
+	archiveFile, err := os.Create(archivePath)
+	if err != nil {
+		slog.Error("failed to create output tarball",
+			slog.String("err", err.Error()),
+			slog.String("path", archivePath))
+		return
+	}
+	defer func() { _ = archiveFile.Close() }()
+
+	gzw := gzip.NewWriter(archiveFile)
+	defer func() { _ = gzw.Close() }()
+
+	tw := tar.NewWriter(gzw)
+	defer func() { _ = tw.Close() }()
+
+	err = filepath.WalkDir(
+		vendorDir,
+		func(path string, d fs.DirEntry, err error) error {
+			if err != nil {
+				return fmt.Errorf("failed to walk vendor dir path=%q: %w", path, err)
+			}
+			relPath, err := filepath.Rel(vendorDir, path)
+			if err != nil {
+				return fmt.Errorf("failed to create relative path path=%q: %w", path, err)
+			}
+			if relPath == "." {
+				return nil
+			}
+			info, err := d.Info()
+			if err != nil {
+				return fmt.Errorf("failed to get file info path=%q: %w", path, err)
+			}
+			if info.Mode()&os.ModeSymlink != 0 {
+				return nil
+			}
+			header, err := tar.FileInfoHeader(info, "")
+			if err != nil {
+				return fmt.Errorf("failed to create tar header path=%q: %w", path, err)
+			}
+			header.Name = relPath
+			err = tw.WriteHeader(header)
+			if err != nil {
+				return fmt.Errorf("failed to write tar header path=%q: %w", path, err)
+			}
+			if info.IsDir() {
+				return nil
+			}
+			vendorFile, err := os.Open(path)
+			if err != nil {
+				return fmt.Errorf("failed to open vendor file path=%q: %w", path, err)
+			}
+			_, err = io.Copy(tw, vendorFile)
+			if err != nil {
+				return fmt.Errorf("failed to write vendor file to tar path=%q: %w", path, err)
+			}
+			return nil
+		})
+	if err != nil {
+		slog.Error("failed to package vendor directory",
+			slog.String("err", err.Error()))
+	}
+}
tools/vendor/README.md
@@ -0,0 +1,24 @@
+# vendor packing tools
+
+This directory contains two helper scripts to manage a compressed `vendor.tar.gz` archive of the project's `vendor` directory.  This keeps the project commits clean from vendor changes, while preserving repeatable builds.
+
+## Files
+
+- `pack.go` – Creates `vendor.tar.gz` from the current `vendor` directory.
+- `unpack.go` – Extracts `vendor.tar.gz` into the `vendor` directory.
+
+## Usage
+
+Run from the project root:
+
+```bash
+go run tools/vendor/pack.go   # Archive the vendor directory
+go run tools/vendor/unpack.go # Extract the vendor archive
+```
+
+## Design Decisions
+
+- Designed to avoid committing all of the `vendor` changes directly within the repo
+- Enables reproducible builds with vendored, `git-lfs` stored `vendor.tar.gz` 
+- No external tools: Pure Go. No make, make-alternatives, no shell dependencies.
+- Portable & clean: `go build` still works if you don't use this tool
tools/vendor/unpack.go
@@ -0,0 +1,106 @@
+//go:build ignore
+
+// Command tools/vendor/unpack.go  extracts `vendor.tar.gz` into the current
+// project `vendor` directory.  Intended for use as a build support tool.
+package main
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"errors"
+	"io"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"runtime"
+)
+
+// vendor.tar.gz unpack
+func main() {
+	const (
+		archiveName   = "vendor.tar.gz"
+		vendorDir     = "vendor"
+		vendorDirMode = 0o755
+	)
+
+	// discover this file's location
+	_, callerFilepath, _, callerFound := runtime.Caller(0)
+	if !callerFound {
+		slog.Error("failed to find caller location")
+		return
+	}
+	archivePath := filepath.Join(filepath.Dir(callerFilepath), archiveName)
+
+	archiveFile, err := os.Open(archivePath)
+	if err != nil {
+		slog.Error("failed to open vendor tarball",
+			slog.String("err", err.Error()),
+			slog.String("path", archivePath))
+		return
+	}
+	defer func() { _ = archiveFile.Close() }()
+
+	gzr, err := gzip.NewReader(archiveFile)
+	if err != nil {
+		slog.Error("failed to open gzip reader",
+			slog.String("err", err.Error()),
+			slog.String("path", archivePath))
+		return
+	}
+	defer func() { _ = gzr.Close() }()
+
+	tr := tar.NewReader(gzr)
+	for {
+		header, err := tr.Next()
+		if err != nil {
+			if errors.Is(err, io.EOF) {
+				break
+			}
+			slog.Error("failed to read next tar entry",
+				slog.String("err", err.Error()))
+		}
+		info := header.FileInfo()
+
+		if !info.IsDir() {
+			target := filepath.Join(vendorDir, header.Name)
+			dir := filepath.Dir(target)
+
+			err = os.MkdirAll(dir, vendorDirMode)
+			if err != nil {
+				slog.Error("failed to create vendor dir",
+					slog.String("err", err.Error()),
+					slog.String("dir", dir))
+				return
+			}
+
+			vendorFile, err := os.OpenFile(
+				target,
+				os.O_CREATE|os.O_WRONLY|os.O_TRUNC,
+				info.Mode())
+			if err != nil {
+				slog.Error("failed to open vendor file",
+					slog.String("err", err.Error()),
+					slog.String("path", target))
+				_ = vendorFile.Close()
+				return
+			}
+
+			_, err = io.Copy(vendorFile, tr)
+			if err != nil {
+				slog.Error("failed to copy vendor file from tar",
+					slog.String("err", err.Error()),
+					slog.String("path", target))
+				_ = vendorFile.Close()
+				return
+			}
+
+			err = vendorFile.Close()
+			if err != nil {
+				slog.Error("failed to close vendor file",
+					slog.String("err", err.Error()),
+					slog.String("path", target))
+				return
+			}
+		}
+	}
+}
tools/vendor/vendor.tar.gz
Binary file
Makefile
@@ -1,72 +1,43 @@
 # Makefile for buylater.email project
-.PHONY: build run test fmt vet clean help
+.PHONY: build lint fmt update test
 
 # Variables
 BINARY_NAME=buylater
 BINARY_PATH=./cmd/web
 BUILD_DIR=./bin
 
-# Default target
-all: build
-
 # Build the application
 build:
+	@echo "Unpacking vendor dependencies..."
+	@go run tools/vendor/unpack.go
 	@echo "Building $(BINARY_NAME)..."
 	@mkdir -p $(BUILD_DIR)
 	@go build -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
 	@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
 
-# Run the application for development
-run:
-	@echo "Starting development server..."
-	@go run $(BINARY_PATH)/*.go
-
-# Run tests
-test:
-	@echo "Running tests..."
-	@go test ./...
+# Lint code (vet + format check)
+lint:
+	@echo "Linting code..."
+	@go vet ./...
+	@test -z "$$(gofmt -l .)" || (echo "Code not formatted, run 'make fmt'" && exit 1)
 
 # Format code
 fmt:
 	@echo "Formatting code..."
 	@go fmt ./...
 
-# Vet code for potential issues
-vet:
-	@echo "Vetting code..."
-	@go vet ./...
-
-# Clean up binaries
-clean:
-	@echo "Cleaning up..."
-	@rm -rf $(BUILD_DIR)
-	@rm -f $(BINARY_NAME)
-	@echo "Clean complete"
-
-# Tidy up dependencies
-tidy:
-	@echo "Tidying dependencies..."
+# Update dependencies and vendor
+update:
+	@echo "Updating dependencies..."
 	@go mod tidy
+	@go mod vendor
+	@echo "Packing vendor dependencies..."
+	@go run tools/vendor/pack.go
+	@echo "Dependencies updated and vendored"
 
-# Run all quality checks
-check: fmt vet test
-	@echo "All checks passed!"
-
-# Install development dependencies
-deps:
-	@echo "Downloading dependencies..."
-	@go mod download
-
-# Show help
-help:
-	@echo "Available targets:"
-	@echo "  build    - Build the application binary"
-	@echo "  run      - Run the application for development"
-	@echo "  test     - Run all tests"
-	@echo "  fmt      - Format all Go code"
-	@echo "  vet      - Run go vet on all code"
-	@echo "  clean    - Remove built binaries"
-	@echo "  tidy     - Clean up go.mod dependencies"
-	@echo "  check    - Run fmt, vet, and test"
-	@echo "  deps     - Download dependencies"
-	@echo "  help     - Show this help message"
\ No newline at end of file
+# Run tests
+test:
+	@echo "Unpacking vendor dependencies..."
+	@go run tools/vendor/unpack.go
+	@echo "Running tests..."
+	@go test ./...
\ No newline at end of file