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
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,20 @@ Use `td usage -q` after first read.
```bash
go build -o td . # Build locally
go test ./... # Test all
make install-hooks # Install pre-commit + commit-msg hooks
```

## Version & Release

```bash
# Commit changes with proper message
git add .
git commit -m "feat: description of changes

Details here
# Install the local hooks once per clone
make install-hooks

🤖 Generated with Claude Code

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>"
# Commit changes with the normalized td subject
git add .
git commit \
-m "$(td commit-message 'describe changes')" \
-m "Details here"

# Create version tag (bump from current version, e.g., v0.2.0 → v0.3.0)
git tag -a v0.3.0 -m "Release v0.3.0: description"
Expand Down
12 changes: 8 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ help:
@printf "%s\n" \
"Targets:" \
" make fmt # gofmt -w ." \
" make install-hooks # install git pre-commit hook" \
" make install-hooks # install git pre-commit + commit-msg hooks" \
" make test # go test ./..." \
" make install # build and install with version from git" \
" make tag VERSION=vX.Y.Z # create annotated git tag (requires clean tree)" \
Expand Down Expand Up @@ -52,6 +52,10 @@ release: tag
git push origin "$(VERSION)"

install-hooks:
@echo "Installing git pre-commit hook..."
@ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit
@echo "Done. Hook installed at .git/hooks/pre-commit"
@common_dir=$$(git rev-parse --git-common-dir); \
repo_root=$$(cd "$$common_dir/.." && pwd -P); \
hooks_dir=$$(git rev-parse --git-path hooks); \
echo "Installing git hooks into $$hooks_dir..."; \
ln -sf "$$repo_root/scripts/pre-commit.sh" "$$hooks_dir/pre-commit"; \
ln -sf "$$repo_root/scripts/commit-msg.sh" "$$hooks_dir/commit-msg"; \
echo "Done. Hooks installed at $$hooks_dir/pre-commit and $$hooks_dir/commit-msg"
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,32 @@ make install-dev
# Format code
make fmt

# Install git pre-commit hook (gofmt, go vet, go build on staged files)
# Install git hooks (pre-commit checks + commit subject normalization)
make install-hooks
```

## Commit Messages

Install the hooks once per clone:

```bash
make install-hooks
```

Generate a canonical subject for the focused issue, or pass `--issue td-abc123`
explicitly:

```bash
git commit \
-m "$(td commit-message 'normalize commit message workflow')" \
-m "Optional body text"
```

The `commit-msg` hook rewrites only the first line, preserves commit bodies and
trailers, and leaves Git-generated merge/revert/autosquash subjects untouched.
If no issue is focused, only typed `docs`, `test`, `chore`, and `ci` subjects
such as `docs: Update changelog for v0.43.0` stay no-issue commits.

## Tests & Quality Checks

```bash
Expand Down Expand Up @@ -422,6 +444,7 @@ Analytics are stored locally and help identify workflow patterns. Disable with `
| Reject | `td reject <id> --reason "..."` |
| Link files | `td link <id> <files...>` |
| Check file changes | `td files <id>` |
| Normalize commit subject | `td commit-message "summary"` |
| Draft release notes | `td release-notes --version v0.2.0` |
| Undo last action | `td undo` |
| New named session | `td session --new "feature-work"` |
Expand Down
188 changes: 188 additions & 0 deletions cmd/commit_message.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package cmd

import (
"fmt"
"os"
"strings"

"github.com/marcus/td/internal/config"
"github.com/marcus/td/internal/db"
"github.com/marcus/td/internal/git"
"github.com/marcus/td/internal/models"
"github.com/marcus/td/internal/output"
"github.com/spf13/cobra"
)

var commitMessageCmd = &cobra.Command{
Use: "commit-message [summary]",
Aliases: []string{"commit-msg"},
Short: "Normalize a commit subject for the current td issue",
Long: `Normalize a commit subject to td's conventional format:
<type>: <summary> (td-<id>)

The issue ID comes from --issue, a trailing (td-<id>) suffix already present in
the subject, or the focused issue. When no issue is available, only typed
docs/test/chore/ci subjects can stay no-issue commits. When
--file is set, td rewrites only the first line of the commit message file in
place and preserves the body/trailers. Git-generated merge, revert, and
autosquash subjects are left unchanged.`,
Example: ` td commit-message "normalize commit hook docs"
td commit-message --issue td-a1b2 "normalize commit hook docs"
td commit-message --type docs "Update changelog for v0.43.0"
td commit-message --file .git/COMMIT_EDITMSG`,
GroupID: "system",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
baseDir := getBaseDir()

filePath, _ := cmd.Flags().GetString("file")
if filePath != "" && len(args) > 0 {
return fmt.Errorf("summary argument cannot be used with --file")
}
if filePath == "" && len(args) == 0 {
return fmt.Errorf(`summary required. Use: td commit-message [--issue <id>] "summary"`)
}

subject, err := commitMessageSubject(args, filePath)
if err != nil {
output.Error("%v", err)
return err
}

if git.ShouldSkipCommitMessageNormalization(subject) {
if filePath == "" {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), strings.TrimSpace(subject))
}
return nil
}

issueID, issueType, err := resolveCommitMessageContext(baseDir, cmd, subject)
if err != nil {
output.Error("%v", err)
return err
}

explicitType, _ := cmd.Flags().GetString("type")
opts := git.CommitMessageOptions{
IssueID: issueID,
IssueType: issueType,
Type: git.CommitType(explicitType),
}

if filePath != "" {
if err := git.RewriteCommitMessageFile(filePath, opts); err != nil {
output.Error("%v", err)
return err
}
return nil
}

normalized, err := git.NormalizeCommitSubject(subject, opts)
if err != nil {
output.Error("%v", err)
return err
}

_, _ = fmt.Fprintln(cmd.OutOrStdout(), normalized)
return nil
},
}

func commitMessageSubject(args []string, filePath string) (string, error) {
if filePath == "" {
return args[0], nil
}

data, err := os.ReadFile(filePath)
if err != nil {
return "", err
}

message := string(data)
if idx := strings.Index(message, "\n"); idx >= 0 {
return strings.TrimSuffix(message[:idx], "\r"), nil
}

return message, nil
}

func resolveCommitMessageContext(baseDir string, cmd *cobra.Command, subject string) (string, models.Type, error) {
issueFlag, _ := cmd.Flags().GetString("issue")
issueID, err := normalizeCommitMessageIssueRef(baseDir, issueFlag)
if err != nil {
return "", "", err
}

if issueID == "" {
issueID, err = git.ExtractCommitIssueID(subject)
if err != nil {
return "", "", err
}
}

explicitType, _ := cmd.Flags().GetString("type")
if issueID == "" && strings.TrimSpace(explicitType) != "" {
commitType, err := git.NormalizeCommitType(explicitType)
if err != nil {
return "", "", err
}
if git.CommitTypeAllowsNoIssue(commitType) {
return "", "", nil
}
}

if issueID == "" {
focusedID, err := config.GetFocus(baseDir)
if err != nil {
return "", "", err
}
issueID, err = git.NormalizeCommitIssueID(focusedID)
if err != nil {
return "", "", err
}
}

if issueID == "" {
return "", "", nil
}

database, err := db.Open(baseDir)
if err != nil {
return "", "", err
}
defer database.Close()

issue, err := database.GetIssue(issueID)
if err != nil {
return "", "", err
}

return issueID, issue.Type, nil
}

func normalizeCommitMessageIssueRef(baseDir, raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil
}
if trimmed == "." {
focusedID, err := config.GetFocus(baseDir)
if err != nil {
return "", err
}
if strings.TrimSpace(focusedID) == "" {
return "", fmt.Errorf("no focused issue available for --issue .")
}
trimmed = focusedID
}

return git.NormalizeCommitIssueID(trimmed)
}

func init() {
rootCmd.AddCommand(commitMessageCmd)

commitMessageCmd.Flags().StringP("issue", "i", "", "Issue ID (default: subject suffix or focused issue)")
commitMessageCmd.Flags().StringP("type", "t", "", "Commit type override (feat, fix, docs, test, chore, ci)")
commitMessageCmd.Flags().StringP("file", "f", "", "Rewrite a commit message file in place")
}
Loading
Loading