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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,35 @@ jobs:
uses: golangci/golangci-lint-action@v6
with:
version: latest

commit-msg:
name: Commit messages
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'

- name: Validate commit messages (Conventional Commits)
run: |
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
RANGE="${{ github.event.pull_request.base.sha }}..HEAD"
else
RANGE="HEAD^..HEAD"
fi
status=0
# Merge commits (e.g. "Merge pull request #N") are excluded: they are
# generated by GitHub and never follow Conventional Commits.
for sha in $(git rev-list --no-merges "$RANGE"); do
if ! git log -1 --format=%B "$sha" | go run ./cmd/nightshift commit normalize --check; then
echo "::error title=Invalid commit message::$(git log -1 --format='%h %s' "$sha") does not follow Conventional Commits (docs/commit-messages.md)"
status=1
fi
done
exit $status
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 hooks (pre-commit, commit-msg)"
@echo " help - Show this help"

# Install git pre-commit hook
# Install git hooks (pre-commit + commit-msg)
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)"
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,9 @@ Each task has a default cooldown interval to prevent the same task from running

## Development

### Pre-commit hooks
### Git hooks

Install the git pre-commit hook to catch formatting and vet issues before pushing:
Install the git hooks to catch formatting/vet issues and enforce commit message style before pushing:

```bash
make install-hooks
Expand All @@ -271,6 +271,10 @@ This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook run
- **go vet** — catches common correctness issues
- **go build** — ensures the project compiles

It also symlinks `scripts/commit-msg.sh` into `.git/hooks/commit-msg`, which enforces
[Conventional Commits](docs/commit-messages.md) (`<type>(<scope>): <subject>`) and
normalizes the message before the commit is created.

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

## Uninstalling
Expand Down
89 changes: 89 additions & 0 deletions cmd/nightshift/commands/commit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package commands

import (
"fmt"
"io"
"os"

"github.com/spf13/cobra"

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

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

With --check the message is only validated: nothing is printed on success
and the exit code is non-zero when the message does not conform. Without
--check the normalized message is printed to stdout.`,
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 fmt.Errorf("invalid commit message: %w", err)
}

if check {
return nil
}
fmt.Fprintln(cmd.OutOrStdout(), normalized)
return nil
},
}

func init() {
commitNormalizeCmd.Flags().BoolP("check", "c", false, "Only validate; do not print the normalized message")
commitNormalizeCmd.Flags().StringP("file", "f", "", "Read the message from this file (used 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
}
70 changes: 70 additions & 0 deletions docs/commit-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 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>

<trailers>
```

- **type** — one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`,
`test`, `build`, `ci`, `chore`, `revert`.
- **scope** — optional, e.g. `fix(api): ...`; balanced parentheses and no
whitespace inside.
- **subject** — lowercase, imperative mood, no trailing period, max 72 chars.
- **body** — optional, wrapped at 72 columns, separated from the subject by a
blank line.
- **trailers** — optional `Token: value` lines (e.g. `Signed-off-by:`) as the
final block of the message; kept verbatim, one per line. A `Token: value`-
shaped line elsewhere in the body is treated as prose.

Examples:

```
feat(api): add retry with exponential backoff
fix: apply --max-projects limit after processed-today filter
docs: document the commit-msg hook
```

## 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
```

The command fixes the trivially fixable (whitespace, type casing, trailing
period, body wrapping) and exits non-zero when a message cannot be normalized
(missing/unknown type, malformed scope, capitalized or overlong subject). The
72-character subject limit is applied after the trailing period is trimmed, so
a 73-character subject ending in `.` is accepted. Add `--check` to validate
only without printing the normalized message.

## commit-msg hook

To enforce the rules locally, install the hook:

```sh
make install-hooks
```

This also symlinks `scripts/commit-msg.sh` into `.git/hooks/commit-msg` (and
`scripts/pre-commit.sh` into `.git/hooks/pre-commit`). The commit-msg 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`.

CI runs the same validation over every non-merge commit in a pull request, so
a non-conforming message fails the build even without the local hook. Merge
commits are skipped: they are generated by GitHub and do not follow
Conventional Commits.
Loading