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
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)"
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)
}
})
}
}
4 changes: 2 additions & 2 deletions cmd/nightshift/commands/config.go
Original file line number Diff line number Diff line change
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
51 changes: 51 additions & 0 deletions docs/commit-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Commit Messages

Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/)
for all commit messages. This keeps the history readable and lets tooling
derive changelogs automatically.

## Format

```
<type>(<scope>): <subject>

<body>
```

- **type** — one of `feat`, `fix`, `docs`, `style`, `refactor`, `test`,
`chore`, `perf`, `build`, `ci`.
- **scope** — optional, e.g. `fix(api): ...`.
- **breaking change** — append `!` after the type or scope, e.g. `feat(api)!:` —
a breaking change must also be called out in the body.
- **subject** — lowercase, imperative mood, no trailing period, max 72 chars.
- **body** — optional, wrapped at 72 columns, separated from the subject by a
blank line. Paragraph breaks are preserved and bullet-list items (`- ` /
`* `) stay on their own lines.

## The `commit normalize` command

Validate and reformat a message:

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

Add `--check` to validate only: nothing is printed, and the command exits
non-zero when a message cannot be normalized (missing/unknown type, capitalized
or overlong subject) or is not already in canonical form.

## commit-msg hook

To enforce the rules locally, install the hook:

```sh
make install-hooks
# or manually:
ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg
```

The hook normalizes your message file in place before the commit is created and
rejects messages that cannot be fixed automatically. Bypass it with
`git commit --no-verify`.
Loading