Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
version: "2"

linters:
# standard = errcheck, govet, ineffassign, staticcheck, unused
default: standard
enable:
- revive
- gocritic
- misspell
- unconvert
- whitespace
- unparam
exclusions:
rules:
# Both functions are deliberate stubs whose signatures (including error
# returns) will be exercised by planned implementations; unparam cannot
# see that future use, so suppress only these two findings.
- linters: [unparam]
text: 'parseRunReportMarkdown - result 1'
- linters: [unparam]
text: '\(\*Orchestrator\)\.commit - result 0'
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,12 @@ help:
@echo " check - Run tests and lint"
@echo " install - Build and install to Go bin directory"
@echo " calibrate-providers - Compare local Claude/Codex session usage for calibration"
@echo " install-hooks - Install git pre-commit hook"
@echo " install-hooks - Install git pre-commit and commit-msg hooks"
@echo " help - Show this help"

# Install git pre-commit hook
# Install git pre-commit and commit-msg hooks
install-hooks:
@ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit
@echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)"
@ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg
@echo "✓ commit-msg hook installed (.git/hooks/commit-msg → scripts/commit-msg.sh)"
10 changes: 5 additions & 5 deletions cmd/nightshift/commands/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var budgetCmd = &cobra.Command{
Long: `Display current budget status and usage.

Shows spending across all providers or a specific provider.`,
RunE: func(cmd *cobra.Command, args []string) error {
RunE: func(cmd *cobra.Command, _ []string) error {
provider, _ := cmd.Flags().GetString("provider")
return runBudget(provider)
},
Expand Down Expand Up @@ -114,14 +114,14 @@ func runBudget(filterProvider string) error {
return nil
}

func printProviderBudget(mgr *budget.Manager, cfg *config.Config, provName string, source budget.BudgetSource, snapCollector *snapshots.Collector, codex *providers.Codex) error {
func printProviderBudget(mgr *budget.Manager, cfg *config.Config, provName string, source budget.Source, snapCollector *snapshots.Collector, codex *providers.Codex) error {
result, err := mgr.CalculateAllowance(provName)
if err != nil {
return err
}
claudeApprox := provName == "claude" && result.UsedPercentSource == "jsonl-fallback"

estimate := budget.BudgetEstimate{
estimate := budget.Estimate{
WeeklyTokens: int64(cfg.GetProviderBudget(provName)),
Source: "config",
}
Expand Down Expand Up @@ -297,7 +297,7 @@ func printProviderBudget(mgr *budget.Manager, cfg *config.Config, provName strin
}

// printTokenAccountingNote adds a brief note about how tokens are counted.
func printTokenAccountingNote(provider string, estimate budget.BudgetEstimate) {
func printTokenAccountingNote(provider string, estimate budget.Estimate) {
if estimate.Source != "calibrated" && estimate.Source != "scraped" {
return
}
Expand All @@ -319,7 +319,7 @@ func formatTokens64(tokens int64) string {
return fmt.Sprintf("%d", tokens)
}

func formatBudgetMeta(estimate budget.BudgetEstimate) string {
func formatBudgetMeta(estimate budget.Estimate) string {
if estimate.Source == "" {
return ""
}
Expand Down
101 changes: 101 additions & 0 deletions cmd/nightshift/commands/commit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package commands

import (
"errors"
"fmt"
"io"
"os"
"strings"

"github.com/marcus/nightshift/internal/commits"
"github.com/spf13/cobra"
)

var commitCmd = &cobra.Command{
Use: "commit",
Short: "Conventional Commits helpers",
Long: `Tools for working with Conventional Commits messages.

Use "commit normalize" to validate and reformat a commit message so it
follows the project's rules (type prefix, lowercase type, subject length,
and wrapped body).`,
}

var commitNormalizeCmd = &cobra.Command{
Use: "normalize [MESSAGE]",
Short: "Normalize a commit message to Conventional Commits format",
Long: `Validate and rewrite a commit message into canonical Conventional
Commits form.

The message is read from a positional argument, from a file passed via
--file (typically .git/COMMIT_EDITMSG by a commit-msg hook), or from stdin
when no argument and no --file are given.

nightshift commit normalize "feat: add login"
nightshift commit normalize --file .git/COMMIT_EDITMSG
git log -1 --pretty=%B | nightshift commit normalize

Use --check to only validate without rewriting: nothing is printed and the
exit code is non-zero when the message is invalid or not already in canonical
form.`,
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
check, _ := cmd.Flags().GetBool("check")
file, _ := cmd.Flags().GetString("file")

raw, err := readCommitMessage(args, file)
if err != nil {
return err
}

normalized, err := commits.Normalize(raw)
if err != nil {
return err
}

if check {
if !isCanonical(raw, normalized) {
return errors.New("message is not in canonical Conventional Commits form (run without --check to print the normalized message)")
}
return nil
}
if _, err := fmt.Fprintln(os.Stdout, normalized); err != nil {
return err
}
return nil
},
}

func init() {
commitNormalizeCmd.Flags().BoolP("check", "c", false, "Only validate; do not rewrite")
commitNormalizeCmd.Flags().StringP("file", "f", "", "Read the message from this file (use by the commit-msg hook)")
commitCmd.AddCommand(commitNormalizeCmd)
rootCmd.AddCommand(commitCmd)
}

// readCommitMessage resolves the message source in order: positional arg,
// --file, then stdin.
func readCommitMessage(args []string, file string) (string, error) {
if len(args) == 1 {
return args[0], nil
}
if file != "" {
b, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("read %s: %w", file, err)
}
return string(b), nil
}
b, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("read stdin: %w", err)
}
return string(b), nil
}

// isCanonical reports whether raw is already in normalized form, ignoring a
// single trailing newline.
func isCanonical(raw, normalized string) bool {
return strings.TrimSuffix(raw, "\n") == normalized
}
61 changes: 61 additions & 0 deletions cmd/nightshift/commands/commit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package commands

import (
"os"
"path/filepath"
"testing"
)

func TestReadCommitMessage(t *testing.T) {
t.Run("positional argument wins", func(t *testing.T) {
got, err := readCommitMessage([]string{"feat: add thing"}, "ignored-file")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "feat: add thing" {
t.Errorf("got %q, want %q", got, "feat: add thing")
}
})

t.Run("reads from file", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "msg")
if err := os.WriteFile(path, []byte("fix: correct thing\n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := readCommitMessage(nil, path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "fix: correct thing\n" {
t.Errorf("got %q, want %q", got, "fix: correct thing\n")
}
})

t.Run("missing file errors", func(t *testing.T) {
if _, err := readCommitMessage(nil, filepath.Join(t.TempDir(), "nope")); err == nil {
t.Error("expected an error for a missing file")
}
})
}

func TestIsCanonical(t *testing.T) {
tests := []struct {
name string
raw string
normalized string
want bool
}{
{"exact match", "feat: add login", "feat: add login", true},
{"trailing newline ignored", "feat: add login\n", "feat: add login", true},
{"needs rewriting", "FEAT: add login", "feat: add login", false},
{"trailing blank lines", "feat: add login\n\n", "feat: add login", false},
{"extra whitespace", " feat: add login ", "feat: add login", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := isCanonical(tc.raw, tc.normalized); got != tc.want {
t.Errorf("isCanonical(%q, %q) = %v, want %v", tc.raw, tc.normalized, got, tc.want)
}
})
}
}
10 changes: 5 additions & 5 deletions cmd/nightshift/commands/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var configCmd = &cobra.Command{

Shows current configuration merged from global and project configs.
Use subcommands to get/set specific values or validate the config.`,
RunE: func(cmd *cobra.Command, args []string) error {
RunE: func(_ *cobra.Command, _ []string) error {
return runConfigShow()
},
}
Expand All @@ -36,7 +36,7 @@ Examples:
nightshift config get providers.claude.enabled
nightshift config get logging.level`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
RunE: func(_ *cobra.Command, args []string) error {
return runConfigGet(args[0])
},
}
Expand Down Expand Up @@ -66,7 +66,7 @@ var configValidateCmd = &cobra.Command{
Long: `Validate the current configuration.

Checks both global and project configs for errors.`,
RunE: func(cmd *cobra.Command, args []string) error {
RunE: func(_ *cobra.Command, _ []string) error {
return runConfigValidate()
},
}
Expand Down Expand Up @@ -370,7 +370,7 @@ func printStruct(v reflect.Value, indent int) {
case reflect.Struct:
fmt.Printf("%s%s:\n", prefix, tag)
printStruct(value, indent+1)
case reflect.Ptr:
case reflect.Pointer:
if !value.IsNil() {
if value.Elem().Kind() == reflect.Struct {
fmt.Printf("%s%s:\n", prefix, tag)
Expand Down Expand Up @@ -408,7 +408,7 @@ func printStruct(v reflect.Value, indent int) {

func isZero(v reflect.Value) bool {
switch v.Kind() {
case reflect.Ptr, reflect.Interface:
case reflect.Pointer, reflect.Interface:
return v.IsNil()
case reflect.Slice, reflect.Map:
return v.Len() == 0
Expand Down
22 changes: 12 additions & 10 deletions cmd/nightshift/commands/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ func isDaemonRunning() (bool, int) {
return isProcessRunning(pid), pid
}

func runDaemonStart(cmd *cobra.Command, args []string) error {
func runDaemonStart(_ *cobra.Command, _ []string) error {
// Check if already running
if running, pid := isDaemonRunning(); running {
return fmt.Errorf("daemon already running (pid %d)", pid)
Expand Down Expand Up @@ -564,28 +564,30 @@ func takeSnapshot(ctx context.Context, cfg *config.Config, database *db.DB, log

if cfg.Providers.Claude.Enabled {
snapshot, err := collector.TakeSnapshot(ctx, "claude")
if err != nil {
switch {
case err != nil:
log.Warnf("snapshot claude: %v", err)
} else if snapshot.ScrapedPct != nil {
case snapshot.ScrapedPct != nil:
log.Infof("snapshot claude: %.1f%%", *snapshot.ScrapedPct)
} else {
default:
log.Info("snapshot claude: local-only")
}
}

if cfg.Providers.Codex.Enabled {
snapshot, err := collector.TakeSnapshot(ctx, "codex")
if err != nil {
switch {
case err != nil:
log.Warnf("snapshot codex: %v", err)
} else if snapshot.ScrapedPct != nil {
case snapshot.ScrapedPct != nil:
log.Infof("snapshot codex: %.1f%%", *snapshot.ScrapedPct)
} else {
default:
log.Info("snapshot codex: local-only")
}
}
}

func pruneSnapshots(ctx context.Context, cfg *config.Config, database *db.DB, log *logging.Logger) {
func pruneSnapshots(_ context.Context, cfg *config.Config, database *db.DB, log *logging.Logger) {
collector := snapshots.NewCollector(database, nil, nil, nil, nil, weekStartDayFromConfig(cfg))
deleted, err := collector.Prune(cfg.Budget.SnapshotRetentionDays)
if err != nil {
Expand All @@ -609,7 +611,7 @@ func weekStartDayFromConfig(cfg *config.Config) time.Weekday {
}
}

func runDaemonStop(cmd *cobra.Command, args []string) error {
func runDaemonStop(_ *cobra.Command, _ []string) error {
running, pid := isDaemonRunning()
if !running {
// Check if PID file exists but process is dead
Expand Down Expand Up @@ -657,7 +659,7 @@ func runDaemonStop(cmd *cobra.Command, args []string) error {
}
}

func runDaemonStatus(cmd *cobra.Command, args []string) error {
func runDaemonStatus(_ *cobra.Command, _ []string) error {
running, pid := isDaemonRunning()

if !running {
Expand Down
2 changes: 1 addition & 1 deletion cmd/nightshift/commands/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func init() {
rootCmd.AddCommand(doctorCmd)
}

func runDoctor(cmd *cobra.Command, args []string) error {
func runDoctor(_ *cobra.Command, _ []string) error {
// Augment PATH the same way 'run' does so CLI checks are accurate.
ensurePATH()

Expand Down
2 changes: 1 addition & 1 deletion cmd/nightshift/commands/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func init() {
rootCmd.AddCommand(initCmd)
}

func runInit(cmd *cobra.Command, args []string) error {
func runInit(cmd *cobra.Command, _ []string) error {
global, _ := cmd.Flags().GetBool("global")
force, _ := cmd.Flags().GetBool("force")

Expand Down
4 changes: 2 additions & 2 deletions cmd/nightshift/commands/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func init() {
}

// runInstall implements the install command
func runInstall(cmd *cobra.Command, args []string) error {
func runInstall(_ *cobra.Command, args []string) error {
// Determine service type
serviceType := ""
if len(args) > 0 {
Expand Down Expand Up @@ -378,7 +378,7 @@ func installCron(binaryPath string, cfg *config.Config) error {
}

// runUninstall implements the uninstall command
func runUninstall(cmd *cobra.Command, args []string) error {
func runUninstall(_ *cobra.Command, _ []string) error {
var errors []string
removed := false

Expand Down
2 changes: 1 addition & 1 deletion cmd/nightshift/commands/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ var logsCmd = &cobra.Command{
Long: `View nightshift logs.

Displays recent log entries. Use --follow to stream logs in real-time.`,
RunE: func(cmd *cobra.Command, args []string) error {
RunE: func(cmd *cobra.Command, _ []string) error {
tail, _ := cmd.Flags().GetInt("tail")
follow, _ := cmd.Flags().GetBool("follow")
export, _ := cmd.Flags().GetString("export")
Expand Down
Loading