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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,5 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/td.rb
git diff --cached --quiet && echo "No changes" && exit 0
git commit -m "td: bump to ${{ steps.version.outputs.version }}"
git commit -m "chore: bump Homebrew formula to ${{ steps.version.outputs.version }}"
git push
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ go test ./... # Test all
```bash
# Commit changes with proper message
git add .
git commit -m "feat: description of changes
git commit -m "feat: normalize commit messages (td-a1b2)

Details here

Expand All @@ -56,6 +56,9 @@ go install -ldflags "-X main.Version=v0.3.0" ./...
td version
```

Task-linked commits should use `type: summary (td-<id>)`.
Automation-only commits should use `type: summary`, for example `chore: bump Homebrew formula to v0.3.0`.

## Architecture

- `cmd/` - Cobra commands
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 and 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"
@hooks_dir=$$(git rev-parse --git-path hooks); \
repo_root=$$(git rev-parse --show-toplevel); \
mkdir -p "$$hooks_dir"; \
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"
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,15 @@ 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 runs gofmt, go vet, and go build
# - commit-msg normalizes to type: summary or type: summary (td-<id>)
make install-hooks
```

For task-linked development commits, use `type: summary (td-<id>)` such as `feat: normalize commit messages (td-a1b2)`.
For automation or release commits without a td task, use `type: summary` such as `chore: bump Homebrew formula to v0.3.0`.

## Tests & Quality Checks

```bash
Expand Down Expand Up @@ -543,8 +548,9 @@ Contributions welcome! Process:
1. **Fork and branch**: Work on feature branches
2. **Tests required**: Add tests for new features/fixes (see `cmd/*_test.go` for patterns)
3. **Run `make test` and `make fmt`** before submitting
4. **PR review**: One reviewer approval required
5. **Session isolation respected**: PRs should follow td's own handoff patterns where applicable
4. **Commit format**: Use `type: summary (td-<id>)` for task-linked work, or `type: summary` when no td task applies
5. **PR review**: One reviewer approval required
6. **Session isolation respected**: PRs should follow td's own handoff patterns where applicable

## Support

Expand Down
173 changes: 173 additions & 0 deletions commit_msg_hook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package main

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func TestCommitMsgHookNormalizesAndRejects(t *testing.T) {
t.Parallel()

repoRoot, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}

tests := []struct {
name string
input string
want string
wantErr bool
wantStderr string
}{
{
name: "accepts task linked subject",
input: "feat: normalize commit messages (td-a1b2)\n",
want: "feat: normalize commit messages (td-a1b2)\n",
},
{
name: "accepts automation subject without task id",
input: "chore: bump Homebrew formula to v1.2.3\n",
want: "chore: bump Homebrew formula to v1.2.3\n",
},
{
name: "normalizes casing and spacing",
input: " Feat normalize commit messages (td-a1b2) \n\nBody line\nNightshift-Task: commit-normalize\n",
want: "feat: normalize commit messages (td-a1b2)\n\nBody line\nNightshift-Task: commit-normalize\n",
},
{
name: "rejects invalid trailing parenthetical suffix",
input: "feat: normalize commit messages (jira-123)\n",
want: "feat: normalize commit messages (jira-123)\n",
wantErr: true,
wantStderr: "only allowed trailing parenthetical suffix",
},
{
name: "rejects invalid suffix after normalizing prefix",
input: "Feat normalize commit messages (foo)\n",
want: "Feat normalize commit messages (foo)\n",
wantErr: true,
wantStderr: "only allowed trailing parenthetical suffix",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

tempDir := t.TempDir()
messageFile := filepath.Join(tempDir, "COMMIT_EDITMSG")
if err := os.WriteFile(messageFile, []byte(tt.input), 0o644); err != nil {
t.Fatalf("write message file: %v", err)
}

cmd := exec.Command("bash", filepath.Join(repoRoot, "scripts/commit-msg.sh"), messageFile)
cmd.Dir = repoRoot
output, err := cmd.CombinedOutput()
got := string(output)

if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got success with output: %s", got)
}
if !strings.Contains(got, tt.wantStderr) {
t.Fatalf("expected output to contain %q, got %q", tt.wantStderr, got)
}
} else if err != nil {
t.Fatalf("hook failed: %v\n%s", err, got)
}

contents, readErr := os.ReadFile(messageFile)
if readErr != nil {
t.Fatalf("read message file: %v", readErr)
}
if string(contents) != tt.want {
t.Fatalf("unexpected message file contents:\nwant: %q\ngot: %q", tt.want, string(contents))
}
})
}
}

func TestInstallHooksWorksInGitWorktree(t *testing.T) {
t.Parallel()

repoRoot, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}

tempDir := t.TempDir()
sourceRepo := filepath.Join(tempDir, "source")
worktreeDir := filepath.Join(tempDir, "worktree")

if err := os.MkdirAll(filepath.Join(sourceRepo, "scripts"), 0o755); err != nil {
t.Fatalf("mkdir scripts: %v", err)
}

copyFile := func(src, dst string, mode os.FileMode) {
t.Helper()
data, readErr := os.ReadFile(src)
if readErr != nil {
t.Fatalf("read %s: %v", src, readErr)
}
if writeErr := os.WriteFile(dst, data, mode); writeErr != nil {
t.Fatalf("write %s: %v", dst, writeErr)
}
}

copyFile(filepath.Join(repoRoot, "Makefile"), filepath.Join(sourceRepo, "Makefile"), 0o644)
copyFile(filepath.Join(repoRoot, "scripts/pre-commit.sh"), filepath.Join(sourceRepo, "scripts/pre-commit.sh"), 0o755)
copyFile(filepath.Join(repoRoot, "scripts/commit-msg.sh"), filepath.Join(sourceRepo, "scripts/commit-msg.sh"), 0o755)

run := func(dir string, args ...string) string {
t.Helper()
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = dir
output, runErr := cmd.CombinedOutput()
if runErr != nil {
t.Fatalf("%s failed: %v\n%s", strings.Join(args, " "), runErr, output)
}
return string(output)
}

run(sourceRepo, "git", "init", "-b", "main")
run(sourceRepo, "git", "config", "user.name", "Test User")
run(sourceRepo, "git", "config", "user.email", "test@example.com")
run(sourceRepo, "git", "add", "Makefile", "scripts/pre-commit.sh", "scripts/commit-msg.sh")
run(sourceRepo, "git", "commit", "-m", "chore: seed worktree fixture")
run(sourceRepo, "git", "worktree", "add", "-b", "feature/test-hooks", worktreeDir)

run(worktreeDir, "make", "install-hooks")

hooksDir := strings.TrimSpace(run(worktreeDir, "git", "rev-parse", "--git-path", "hooks"))
preCommitTarget := strings.TrimSpace(run(worktreeDir, "readlink", filepath.Join(hooksDir, "pre-commit")))
commitMsgTarget := strings.TrimSpace(run(worktreeDir, "readlink", filepath.Join(hooksDir, "commit-msg")))

wantPreCommit, err := filepath.EvalSymlinks(filepath.Join(worktreeDir, "scripts/pre-commit.sh"))
if err != nil {
t.Fatalf("eval symlink pre-commit target: %v", err)
}
wantCommitMsg, err := filepath.EvalSymlinks(filepath.Join(worktreeDir, "scripts/commit-msg.sh"))
if err != nil {
t.Fatalf("eval symlink commit-msg target: %v", err)
}
preCommitTarget, err = filepath.EvalSymlinks(preCommitTarget)
if err != nil {
t.Fatalf("eval installed pre-commit target: %v", err)
}
commitMsgTarget, err = filepath.EvalSymlinks(commitMsgTarget)
if err != nil {
t.Fatalf("eval installed commit-msg target: %v", err)
}

if preCommitTarget != wantPreCommit {
t.Fatalf("unexpected pre-commit target: want %q, got %q", wantPreCommit, preCommitTarget)
}
if commitMsgTarget != wantCommitMsg {
t.Fatalf("unexpected commit-msg target: want %q, got %q", wantCommitMsg, commitMsgTarget)
}
}
5 changes: 3 additions & 2 deletions docs/guides/releasing-new-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Add entry at the top of `CHANGELOG.md`:
Commit the changelog:
```bash
git add CHANGELOG.md
git commit -m "docs: Update changelog for vX.Y.Z"
git commit -m "docs: update changelog for vX.Y.Z"
```

### 3. Verify Tests Pass
Expand Down Expand Up @@ -137,7 +137,7 @@ go test ./...
# Update changelog
# (Edit CHANGELOG.md, add entry at top)
git add CHANGELOG.md
git commit -m "docs: Update changelog for vX.Y.Z"
git commit -m "docs: update changelog for vX.Y.Z"

# Push commits, then tag (tag push triggers automated release)
git push origin main
Expand All @@ -156,6 +156,7 @@ brew upgrade td && td version
- [ ] Working tree clean
- [ ] CHANGELOG.md updated with new version entry
- [ ] Changelog committed to git
- [ ] Commit subjects use `type: summary` or `type: summary (td-<id>)`
- [ ] Version number follows semver
- [ ] Commits pushed to main
- [ ] Tag created with `-a` (annotated)
Expand Down
Loading