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
4 changes: 3 additions & 1 deletion 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-hooks:
@ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit
@ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg
@echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)"
@echo "✓ commit-msg hook installed (.git/hooks/commit-msg → scripts/commit-msg.sh)"
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ Each task has a default cooldown interval to prevent the same task from running

### Pre-commit hooks

Install the git pre-commit hook to catch formatting and vet issues before pushing:
Install the git pre-commit and commit-msg hooks to catch formatting, vet, and commit message issues:

```bash
make install-hooks
Expand All @@ -273,6 +273,8 @@ This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook run

To bypass in a pinch: `git commit --no-verify`

See [docs/COMMIT_CONVENTION.md](docs/COMMIT_CONVENTION.md) for the commit message format, trailer rules, and the `commit-msg` hook.

## Uninstalling

```bash
Expand Down
90 changes: 90 additions & 0 deletions cmd/nightshift/commands/commitmsg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package commands

import (
"fmt"
"os"
"path/filepath"

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

var commitMsgCheck bool

var commitMsgCmd = &cobra.Command{
Use: "commit-msg [--check] <file>",
Short: "Normalize a Git commit message file",
Args: cobra.ExactArgs(1),
RunE: runCommitMsg,
}

func init() {
commitMsgCmd.Flags().BoolVar(&commitMsgCheck, "check", false, "validate without changing the file")
rootCmd.AddCommand(commitMsgCmd)
}

func runCommitMsg(cmd *cobra.Command, args []string) error {
path := args[0]
input, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read commit message %s: %w", path, err)
}
if commitMsgCheck {
if err := commitmsg.Validate(string(input)); err != nil {
return fmt.Errorf("validate commit message: %w", err)
}
return nil
}

normalized, err := commitmsg.Normalize(string(input))
if err != nil {
return fmt.Errorf("normalize commit message: %w", err)
}
if normalized == string(input) {
return nil
}
if err := writeCommitMessageAtomically(path, []byte(normalized)); err != nil {
return fmt.Errorf("write commit message %s: %w", path, err)
}
return nil
}

func writeCommitMessageAtomically(path string, content []byte) (err error) {
info, err := os.Stat(path)
if err != nil {
return err
}
temp, err := os.CreateTemp(filepath.Dir(path), ".commit-msg-*")
if err != nil {
return err
}
tempName := temp.Name()
closed := false
defer func() {
if !closed {
if closeErr := temp.Close(); err == nil && closeErr != nil {
err = closeErr
}
}
if err != nil {
_ = os.Remove(tempName)
}
}()
if err := temp.Chmod(info.Mode().Perm()); err != nil {
return err
}
if _, err := temp.Write(content); err != nil {
return err
}
if err := temp.Sync(); err != nil {
return err
}
if err := temp.Close(); err != nil {
return err
}
closed = true
if err := os.Rename(tempName, path); err != nil {
return err
}
return nil
}
60 changes: 60 additions & 0 deletions cmd/nightshift/commands/commitmsg_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package commands

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

"github.com/marcus/nightshift/internal/commitmsg"
)

func TestRunCommitMsgNormalizesFileAtomically(t *testing.T) {
messagePath := filepath.Join(t.TempDir(), "COMMIT_EDITMSG")
if err := os.WriteFile(messagePath, []byte("Improve setup\n"), 0o600); err != nil {
t.Fatal(err)
}

commitMsgCheck = false
t.Cleanup(func() { commitMsgCheck = false })
if err := runCommitMsg(nil, []string{messagePath}); err != nil {
t.Fatalf("runCommitMsg() error = %v", err)
}

content, err := os.ReadFile(messagePath)
if err != nil {
t.Fatal(err)
}
if got, want := string(content), "chore: improve setup\n"; got != want {
t.Errorf("normalized content = %q, want %q", got, want)
}
info, err := os.Stat(messagePath)
if err != nil {
t.Fatal(err)
}
if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want {
t.Errorf("file mode = %o, want %o", got, want)
}
}

func TestRunCommitMsgCheckDoesNotChangeFile(t *testing.T) {
messagePath := filepath.Join(t.TempDir(), "COMMIT_EDITMSG")
original := []byte("Improve setup\n")
if err := os.WriteFile(messagePath, original, 0o600); err != nil {
t.Fatal(err)
}

commitMsgCheck = true
t.Cleanup(func() { commitMsgCheck = false })
err := runCommitMsg(nil, []string{messagePath})
if !errors.Is(err, commitmsg.ErrInvalidMessage) {
t.Fatalf("runCommitMsg() error = %v, want ErrInvalidMessage", err)
}
content, err := os.ReadFile(messagePath)
if err != nil {
t.Fatal(err)
}
if string(content) != string(original) {
t.Errorf("check mode changed content to %q", content)
}
}
56 changes: 56 additions & 0 deletions docs/COMMIT_CONVENTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Commit Convention

Nightshift uses a normalized Conventional Commits format.

## Format

```text
type(scope)!: lowercase description

wrapped body text

Trailer: value
```

The type is required. The scope is optional. The `!` marker is optional and marks a breaking change. The description is lowercase, has no terminal punctuation, and the complete subject is at most 72 characters.

Allowed types are `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, and `test`.

The normalizer infers `feat` from `add`, `implement`, `introduce`, or `support` subjects. It infers `fix` from `fix`, `bug`, `repair`, `resolve`, or `handle` subjects. It maps common first words to the other allowed types and uses `chore` by default.

The body starts after one blank line. Body paragraphs wrap at 72 characters. Trailers start after one blank line and remain at the end. The normalizer preserves body content that it can repair without data loss.

## Trailers

Trailers remain at the end of the message. The normalizer removes later exact duplicates for each case-insensitive token and preserves distinct values. It preserves trailer values, including `Nightshift-Task` and `Nightshift-Ref`.

Breaking changes can use either `!` in the subject or a `BREAKING CHANGE` trailer. The normalizer adds `!` when a breaking trailer exists.

## Examples

```text
feat(cli): add commit message normalization

Normalize Git commit messages before they enter project history.

Nightshift-Task: commit-normalize
Nightshift-Ref: https://github.com/marcus/nightshift
```

```text
fix!: remove the legacy config format

BREAKING CHANGE: migrate existing config files before upgrading.
```

## Installation and bypass

Install both the `pre-commit` and `commit-msg` hooks with:

```bash
make install-hooks
```

The `commit-msg` hook normalizes the message file atomically. Use `nightshift commit-msg --check <file>` to validate a message without changing it.

Use `git commit --no-verify` to bypass both hooks for an exceptional commit.
Loading