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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable changes to nightshift are documented in this file.

## [Unreleased]

### Features
- **Commit message normalizer** — new `internal/commitmsg` package plus a
`nightshift commit-msg` subcommand (`--check`, `--fix`, `--print-spec`,
`--quiet`) that validates and rewrites commit messages against one
Conventional Commits format. `make install-hooks` now also installs a
`commit-msg` git hook, and the orchestrator's plan and implement prompts
quote the same canonical spec so autonomous agents write messages the hook
accepts. Messages git generates itself — merges, reverts and
`fixup!`/`squash!`/`amend!` commits — are exempt from the format.

## [v0.3.3] - 2026-02-19

### Features
Expand Down
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 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)"
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,38 @@ This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook run

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

### Commit message format

Commits follow [Conventional Commits](https://www.conventionalcommits.org/):

```
type(scope)!: subject

Body, wrapped at 72 columns.

Trailer-Key: value
```

- `type` is lowercase and one of `feat`, `fix`, `docs`, `style`, `refactor`,
`perf`, `test`, `build`, `ci`, `chore`, `revert`
- the whole header line is at most 72 characters, imperative mood, no
trailing period
- trailers form one block at the very end
- messages git writes itself are exempt — headers starting with `Merge `,
`Revert "`, `fixup! `, `squash! ` or `amend! ` always pass, so `git merge`
and `git rebase --autosquash` are unaffected

`make install-hooks` also installs a `commit-msg` hook that enforces this via
`nightshift commit-msg --check`. You can run the checker yourself:

```bash
nightshift commit-msg --print-spec # Show the full format
nightshift commit-msg --check .git/COMMIT_EDITMSG
nightshift commit-msg --fix .git/COMMIT_EDITMSG # Rewrite it for you
```

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

## Uninstalling

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

import (
"fmt"
"io"
"os"

"github.com/spf13/cobra"

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

// commitMsgOptions holds the parsed flags for `nightshift commit-msg`.
type commitMsgOptions struct {
check bool
fix bool
printSpec bool
quiet bool
}

func newCommitMsgCmd() *cobra.Command {
opts := &commitMsgOptions{}

cmd := &cobra.Command{
Use: "commit-msg [file|-]",
Short: "Check or normalize a commit message",
Long: `Check a commit message against the repository's commit format, or
rewrite it into that format.

Reads the message from FILE, or from stdin when FILE is "-" or omitted.
This is what the commit-msg git hook runs; install it with:

make install-hooks

Examples:
nightshift commit-msg --print-spec
nightshift commit-msg --check .git/COMMIT_EDITMSG
nightshift commit-msg --fix .git/COMMIT_EDITMSG
printf 'Fixed the thing.' | nightshift commit-msg --fix -`,
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
path := "-"
if len(args) == 1 {
path = args[0]
}
return runCommitMsg(path, opts, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr())
},
}

cmd.Flags().BoolVar(&opts.check, "check", false, "Validate only; exit non-zero when the message has errors")
cmd.Flags().BoolVar(&opts.fix, "fix", false, "Rewrite the message into the canonical format")
cmd.Flags().BoolVar(&opts.printSpec, "print-spec", false, "Print the commit message format and exit")
cmd.Flags().BoolVar(&opts.quiet, "quiet", false, "Suppress issue output; rely on the exit code")

return cmd
}

func init() {
rootCmd.AddCommand(newCommitMsgCmd())
}

func runCommitMsg(path string, opts *commitMsgOptions, stdin io.Reader, stdout, stderr io.Writer) error {
if opts.printSpec {
fmt.Fprint(stdout, commitmsg.Spec())
return nil
}
if opts.check && opts.fix {
return fmt.Errorf("--check and --fix are mutually exclusive")
}

raw, err := readCommitMsg(path, stdin)
if err != nil {
return err
}

label := path
if label == "-" {
label = "<stdin>"
}

if opts.fix {
fixed, issues, err := commitmsg.Normalize(raw, commitMsgValidationOptions())
if err != nil {
return fmt.Errorf("%s: %w\n\n%s", label, err, commitmsg.Spec())
}
if path == "-" {
fmt.Fprint(stdout, fixed)
} else if fixed != raw {
if err := os.WriteFile(path, []byte(fixed), 0o644); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
}
reportCommitMsgIssues(stderr, label, issues, opts.quiet)
if commitmsg.HasErrors(issues) {
return fmt.Errorf("%s: commit message still has errors after --fix", label)
}
return nil
}

// Default behaviour, with or without an explicit --check, is to validate.
msg, err := commitmsg.Parse(raw)
if err != nil {
return fmt.Errorf("%s: %w", label, err)
}
issues := commitmsg.ValidateWithOptions(msg, commitMsgValidationOptions())
reportCommitMsgIssues(stderr, label, issues, opts.quiet)
if commitmsg.HasErrors(issues) {
if !opts.quiet {
fmt.Fprintf(stderr, "\n%s", commitmsg.Spec())
}
return fmt.Errorf("%s: commit message does not match the required format", label)
}
return nil
}

// commitMsgValidationOptions returns the validation options the CLI enforces. Nightshift
// trailers are not required of humans, so the defaults are used as-is.
func commitMsgValidationOptions() commitmsg.Options {
return commitmsg.DefaultOptions()
}

func readCommitMsg(path string, stdin io.Reader) (string, error) {
if path == "-" {
data, err := io.ReadAll(stdin)
if err != nil {
return "", fmt.Errorf("reading stdin: %w", err)
}
return string(data), nil
}
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("reading %s: %w", path, err)
}
return string(data), nil
}

// reportCommitMsgIssues prints issues as "file:line: severity: message (rule)".
func reportCommitMsgIssues(w io.Writer, label string, issues []commitmsg.Issue, quiet bool) {
if quiet {
return
}
for _, i := range issues {
line := "-"
if i.Line > 0 {
line = fmt.Sprintf("%d", i.Line)
}
fmt.Fprintf(w, "%s:%s: %s: %s (%s)\n", label, line, i.Severity, i.Message, i.Rule)
}
}
Loading