From 8fdbfb7296a0d3b31655ed655fff812d1e3eca7a Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Sun, 28 Sep 2025 13:55:05 -0400 Subject: [PATCH 1/6] refactor for new architecture --- cmd/root.go | 112 +++++++++++++++++++++++++++++----------------------- main.go | 2 +- 2 files changed, 63 insertions(+), 51 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 50819e5..5b86255 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,9 @@ +// Package cmd implements the command-line interface using Cobra and Viper. package cmd import ( + "errors" + "fmt" "os" "github.com/charmbracelet/log" @@ -9,49 +12,70 @@ import ( "github.com/spf13/viper" ) -var ( - configFile string - Debug bool - Logger *log.Logger - EnvVar string -) +// ErrRootCmd indicates a failure in root command execution. +var ErrRootCmd = errors.New("failed to run example command") + +// NewRootCmd creates the root command with configured flags and pre-run hooks. +func NewRootCmd(logger *log.Logger) *cobra.Command { + cmd := &cobra.Command{ + Use: "example", + Short: "An example application.", + Long: "An example application, it doesn't do anything.", + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := viper.BindPFlag("debug", cmd.PersistentFlags().Lookup("debug")); err != nil { + return fmt.Errorf("%w: %s", ErrRootCmd, err) + } + + return nil + }, + } -// RootCmd is the base command when called without any subcommands. -var RootCmd = &cobra.Command{ - Use: "example", - Short: "An example application.", - Long: "An example application, it doesn't do anything.", -} + var configFile string + var debug bool -// Execute adds initialization. -func Execute() { - err := RootCmd.Execute() - if err != nil { - Logger.Error("error running command") - os.Exit(1) - } + cmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is $HOME/.config.toml)") + cmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug mode") + + // cmd.AddCommand(NewChildCommand()) + + return cmd } -// init sets and binds flags. -func init() { - cobra.OnInitialize(initConfig) +// Execute initializes the logger, configuration, and runs the root command. +// It returns the command instance regardless of execution success. +func Execute() *cobra.Command { + logger := log.NewWithOptions(os.Stderr, log.Options{ + ReportCaller: true, + ReportTimestamp: true, + Level: log.WarnLevel, + }) - RootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is $HOME/.config.toml)") - RootCmd.PersistentFlags().BoolVar(&Debug, "debug", false, "enable debug mode") + cobra.OnInitialize(func() { + initConfig(logger) + }) - _ = viper.BindPFlag("debug", RootCmd.PersistentFlags().Lookup("debug")) -} + cmd := NewRootCmd(logger) + + if err := cmd.Execute(); err != nil { + logger.Error(ErrRootCmd.Error(), "error", err) + + return cmd + } -// initConfig loads env variables and the config file. -func initConfig() { - initLogger() + return cmd +} +// initConfig loads configuration from environment variables, config files, and flags. +// Configuration precedence: flags > env > config file > defaults. +func initConfig(logger *log.Logger) { if err := godotenv.Load(); err != nil { - Logger.Debug(".env file not found, using environment variables") + logger.Debug(".env file not found, using environment variables") } else { - Logger.Debug(".env file loaded successfully") + logger.Debug(".env file loaded successfully") } + configFile := viper.GetString("config") + if configFile != "" { viper.SetConfigFile(configFile) } else { @@ -65,33 +89,21 @@ func initConfig() { } viper.AutomaticEnv() - _ = viper.BindEnv("envVar", "ENV_VAR") + if err := viper.BindEnv("debug", "DEBUG"); err != nil { + logger.Error(ErrRootCmd.Error(), "error", err) + } if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); ok { - Logger.Debug("config file not found") + logger.Debug("config file not found") } else { - Logger.Error("error loading config file", "error", err) + logger.Error("error loading config file", "error", err) } } else { - Logger.Debug("using config file", "file", viper.ConfigFileUsed()) + logger.Debug("using config file", "file", viper.ConfigFileUsed()) } if viper.GetBool("debug") { - Debug = true - initLogger() - } -} - -// initLogger initializes the logger. -func initLogger() { - Logger = log.New(os.Stderr) - Logger.SetReportCaller(true) - Logger.SetReportTimestamp(true) - - if Debug { - Logger.SetLevel(log.DebugLevel) - } else { - Logger.SetLevel(log.WarnLevel) + logger.SetLevel(log.DebugLevel) } } diff --git a/main.go b/main.go index b76bbb4..1ed4b40 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - if err := fang.Execute(context.Background(), cmd.RootCmd); err != nil { + if err := fang.Execute(context.Background(), cmd.Execute()); err != nil { os.Exit(1) } } From 08418d5cbb0162dc6ec8789209fa3bfae43953a9 Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Sun, 28 Sep 2025 14:22:17 -0400 Subject: [PATCH 2/6] add precommit hooks --- .github/workflows/claude-code-review.yml | 9 +++---- .github/workflows/claude.yml | 3 +-- .pre-commit-config.yaml | 33 ++++++++++++++++++++++++ CLAUDE.md | 17 +++++++----- README.md | 30 ++++++++++++++++++++- 5 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 31c04fd..1602a78 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -17,14 +17,14 @@ jobs: # github.event.pull_request.user.login == 'external-contributor' || # github.event.pull_request.user.login == 'new-developer' || # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - + runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write - + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -46,12 +46,11 @@ jobs: - Performance considerations - Security concerns - Test coverage - + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index b1a3201..ee44a7f 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -35,7 +35,7 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - + # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read @@ -47,4 +47,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..82813ad --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,33 @@ +repos: + # Go hooks + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-mod-tidy + - id: go-unit-tests + - id: golangci-lint + + # Markdown hooks + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.43.0 + hooks: + - id: markdownlint + args: ['--fix'] + + # Spellcheck + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + name: Check spelling + + # General hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict diff --git a/CLAUDE.md b/CLAUDE.md index bac6277..9a2cf69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,14 +52,18 @@ goreleaser release --snapshot --clean goreleaser check ``` -### Linting +### Code Quality ```bash -# Markdown linting (runs in GitHub Actions, installed via brew) -markdownlint-cli2 "**/*.md" +# Run pre-commit hooks on all files +pre-commit run --all-files -# Go linting (if golangci-lint is installed) -golangci-lint run +# Run pre-commit hooks on staged files +pre-commit run + +# Manually run specific linters +golangci-lint run # Go linting +markdownlint-cli2 "**/*.md" # Markdown linting ``` ## Architecture @@ -99,6 +103,7 @@ The project follows a standard Go CLI application structure: - The root command is named "example" and should be renamed for your application - Debug logging is available via the `--debug` flag - Configuration precedence: flags > env vars > config file > defaults -- Logger instance is globally available as `cmd.Logger` +- Logger instance is passed via dependency injection to `NewRootCmd()` - Releases are automated via GoReleaser when tags are pushed to GitHub - Binary builds have CGO disabled for maximum portability +- Pre-commit hooks automatically run on git commit to ensure code quality diff --git a/README.md b/README.md index bf0c76e..1169043 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,35 @@ go test -cover ./... go test -v ./... ``` -### Linting +### Code Quality + +#### Pre-commit Hooks + +This project uses [pre-commit](https://pre-commit.com/) to ensure code quality +and consistency. The hooks automatically run before each commit. + +```bash +# Install pre-commit (macOS) +brew install pre-commit + +# Install the git hooks +pre-commit install + +# Run hooks manually on all files +pre-commit run --all-files + +# Run hooks on staged files only +pre-commit run +``` + +The following hooks are configured: + +- **Go**: `go fmt`, `go mod tidy`, unit tests, and `golangci-lint` +- **Markdown**: `markdownlint` with auto-fix +- **General**: trailing whitespace, end-of-file fixer, YAML validation, + large file detection, merge conflict detection + +#### Manual Linting ```bash # Install Go linter From 1e64957c648e28b036d1760de112fda51cd9dbe3 Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Sun, 28 Sep 2025 14:51:05 -0400 Subject: [PATCH 3/6] revert: restore Claude workflow files to match main branch Reverts whitespace changes to avoid workflow validation errors. These files need to remain identical to main branch versions. --- .github/workflows/claude-code-review.yml | 9 +++++---- .github/workflows/claude.yml | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 1602a78..31c04fd 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -17,14 +17,14 @@ jobs: # github.event.pull_request.user.login == 'external-contributor' || # github.event.pull_request.user.login == 'new-developer' || # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - + runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write - + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -46,11 +46,12 @@ jobs: - Performance considerations - Security concerns - Test coverage - + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index ee44a7f..b1a3201 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -35,7 +35,7 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - + # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read @@ -47,3 +47,4 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' + From c1fea1b76d858e0519486afe9828d8d60f6b324b Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Sun, 28 Sep 2025 15:06:35 -0400 Subject: [PATCH 4/6] add precommit hook tools to documentation --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1169043..977fa8c 100644 --- a/README.md +++ b/README.md @@ -191,10 +191,60 @@ go test -v ./... This project uses [pre-commit](https://pre-commit.com/) to ensure code quality and consistency. The hooks automatically run before each commit. +##### Installing Pre-commit and Required Tools + +macOS (Homebrew) + ```bash -# Install pre-commit (macOS) -brew install pre-commit +# Install all tools via Homebrew +brew install pre-commit golangci-lint markdownlint-cli codespell +``` + +Linux (Ubuntu/Debian) + +```bash +# Install available packages via apt +sudo apt update +sudo apt install pre-commit golang-golangci-lint + +# Install Node.js and markdownlint-cli +sudo apt install nodejs npm +sudo npm install -g markdownlint-cli + +# Install codespell via pip +sudo apt install python3-pip +pip3 install codespell +``` + +Windows + +```bash +# Using winget for golangci-lint +winget install GolangCI.golangci-lint + +# Using pip for Python tools (requires Python installed) +pip install pre-commit codespell +# Using npm for markdownlint-cli (requires Node.js installed) +npm install -g markdownlint-cli +``` + +Alternative: Using pip and npm directly (all platforms) + +```bash +# Install Go linter +go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +# Install Python tools +pip install pre-commit codespell + +# Install Node.js tools +npm install -g markdownlint-cli +``` + +##### Setting up pre-commit in your repository + +```bash # Install the git hooks pre-commit install @@ -209,6 +259,7 @@ The following hooks are configured: - **Go**: `go fmt`, `go mod tidy`, unit tests, and `golangci-lint` - **Markdown**: `markdownlint` with auto-fix +- **Spelling**: `codespell` for checking spelling errors - **General**: trailing whitespace, end-of-file fixer, YAML validation, large file detection, merge conflict detection From 8ea8946c22cf374e2bf0a73a7a829ac4a47f4052 Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Sun, 28 Sep 2025 15:21:50 -0400 Subject: [PATCH 5/6] add test for root command --- .claude/settings.local.json | 9 +++++ cmd/root_test.go | 73 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 cmd/root_test.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2ee2528 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:github.com)" + ], + "deny": [], + "ask": [] + } +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..67d23c6 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/charmbracelet/log" +) + +func TestNewRootCmd(t *testing.T) { + t.Parallel() + + logger := log.NewWithOptions(bytes.NewBuffer(nil), log.Options{ + Level: log.ErrorLevel, + }) + + t.Run("creates command with correct properties", func(t *testing.T) { + t.Parallel() + + cmd := NewRootCmd(logger) + + if cmd == nil { + t.Fatal("expected command to be created") + } + + if cmd.Use != "example" { + t.Errorf("expected Use to be 'example', got %q", cmd.Use) + } + + if cmd.Short == "" { + t.Error("expected Short description to be non-empty") + } + + if cmd.Long == "" { + t.Error("expected Long description to be non-empty") + } + }) + + t.Run("has PreRunE function", func(t *testing.T) { + t.Parallel() + + cmd := NewRootCmd(logger) + + if cmd.PreRunE == nil { + t.Error("expected PreRunE to be set") + } + }) +} + +func TestInitConfig(t *testing.T) { + t.Run("completes without error", func(t *testing.T) { + t.Parallel() + + logger := log.NewWithOptions(bytes.NewBuffer(nil), log.Options{ + Level: log.ErrorLevel, + }) + + initConfig(logger) + }) + + t.Run("sets logger level when debug enabled", func(t *testing.T) { + t.Parallel() + + var logOutput bytes.Buffer + logger := log.NewWithOptions(&logOutput, log.Options{ + Level: log.WarnLevel, + }) + + if logger.GetLevel() != log.WarnLevel { + t.Errorf("expected initial logger level to be WarnLevel, got %v", logger.GetLevel()) + } + }) +} From af46e5970331bcc355e73a8da35d9c1ee722c09e Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Sun, 28 Sep 2025 15:41:09 -0400 Subject: [PATCH 6/6] add test for root command --- cmd/root.go | 2 +- cmd/root_test.go | 115 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 86 insertions(+), 31 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 5b86255..f5c1e90 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -59,7 +59,7 @@ func Execute() *cobra.Command { if err := cmd.Execute(); err != nil { logger.Error(ErrRootCmd.Error(), "error", err) - return cmd + return nil } return cmd diff --git a/cmd/root_test.go b/cmd/root_test.go index 67d23c6..0cb4c52 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,73 +1,128 @@ package cmd import ( - "bytes" + "io" + "os" + "path/filepath" "testing" "github.com/charmbracelet/log" + "github.com/spf13/viper" ) func TestNewRootCmd(t *testing.T) { - t.Parallel() - - logger := log.NewWithOptions(bytes.NewBuffer(nil), log.Options{ - Level: log.ErrorLevel, - }) - - t.Run("creates command with correct properties", func(t *testing.T) { + t.Run("creates root command with correct configuration", func(t *testing.T) { t.Parallel() + logger := log.New(io.Discard) cmd := NewRootCmd(logger) if cmd == nil { - t.Fatal("expected command to be created") + t.Fatal("expected command to be created, got nil") } if cmd.Use != "example" { t.Errorf("expected Use to be 'example', got %q", cmd.Use) } - if cmd.Short == "" { - t.Error("expected Short description to be non-empty") + if cmd.Short != "An example application." { + t.Errorf("expected Short to be 'An example application.', got %q", cmd.Short) } - if cmd.Long == "" { - t.Error("expected Long description to be non-empty") + if cmd.Long != "An example application, it doesn't do anything." { + t.Errorf("expected Long to be 'An example application, it doesn't do anything.', got %q", cmd.Long) } - }) - t.Run("has PreRunE function", func(t *testing.T) { - t.Parallel() + configFlag := cmd.PersistentFlags().Lookup("config") + if configFlag == nil { + t.Error("expected config flag to be set") + } - cmd := NewRootCmd(logger) + debugFlag := cmd.PersistentFlags().Lookup("debug") + if debugFlag == nil { + t.Error("expected debug flag to be set") + } if cmd.PreRunE == nil { t.Error("expected PreRunE to be set") } + + // Check for subcommands. + // found := false + // for _, subCmd := range cmd.Commands() { + // if subCmd.Use == "export" { + // found = true + // break + // } + // } + // if !found { + // t.Error("expected export subcommand to be added") + // } }) } func TestInitConfig(t *testing.T) { - t.Run("completes without error", func(t *testing.T) { - t.Parallel() - - logger := log.NewWithOptions(bytes.NewBuffer(nil), log.Options{ - Level: log.ErrorLevel, + t.Run("updates logger level when debug is enabled", func(t *testing.T) { + logger := log.NewWithOptions(io.Discard, log.Options{ + ReportCaller: false, + ReportTimestamp: false, + Level: log.WarnLevel, }) + viper.Reset() + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, ".granola.toml") + configContent := `debug = true` + + if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { + t.Fatalf("failed to write to test configFile: %v", err) + } + + viper.Set("config", configFile) + initConfig(logger) + + if logger.GetLevel() != log.DebugLevel { + t.Errorf("expected logger level to be DebugLevel, got %v", logger.GetLevel()) + } + + if !viper.GetBool("debug") { + t.Error("expected debug mode to be enabled in viper") + } }) - t.Run("sets logger level when debug enabled", func(t *testing.T) { - t.Parallel() + t.Run("loads environment variables from .env file", func(t *testing.T) { + logger := log.New(io.Discard) - var logOutput bytes.Buffer - logger := log.NewWithOptions(&logOutput, log.Options{ - Level: log.WarnLevel, - }) + viper.Reset() - if logger.GetLevel() != log.WarnLevel { - t.Errorf("expected initial logger level to be WarnLevel, got %v", logger.GetLevel()) + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".env") + envContent := `DEBUG=true` + + if err := os.WriteFile(envFile, []byte(envContent), 0644); err != nil { + t.Fatalf("failed to write to test .env file: %v", err) + } + + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get the current working directory: %v", err) + } + + defer func() { + if err := os.Chdir(oldWd); err != nil { + t.Fatalf("failed to change to old working directory: %v", err) + } + }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change to temp directory: %v", err) + } + + initConfig(logger) + if !viper.GetBool("debug") { + t.Error("expected DEBUG_MODE from .env to be loaded") } }) }