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: update td formula for ${{ steps.version.outputs.version }}"
git push
9 changes: 3 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,10 @@ go test ./... # Test all
```bash
# Commit changes with proper message
git add .
git commit -m "feat: description of changes
git commit -m "feat: describe changes (td-<id>)"

Details here

🤖 Generated with Claude Code

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>"
# Release or automation-only commits omit the td suffix
git commit -m "docs: update changelog for v0.3.0"

# 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
11 changes: 7 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 hooks (pre-commit, commit-msg)" \
" 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,9 @@ 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)"; \
mkdir -p "$$HOOKS_DIR"; \
echo "Installing git hooks into $$HOOKS_DIR..."; \
install -m 0755 scripts/pre-commit.sh "$$HOOKS_DIR/pre-commit"; \
install -m 0755 scripts/commit-msg.sh "$$HOOKS_DIR/commit-msg"; \
echo "Done. Hooks installed at $$HOOKS_DIR/pre-commit and $$HOOKS_DIR/commit-msg"
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,12 @@ 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 subjects should use `type: summary (td-<id>)` for task-linked development work and `type: summary` for release or automation commits.

## Tests & Quality Checks

```bash
Expand Down Expand Up @@ -543,8 +545,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 commits; automation/release commits may omit the td suffix
5. **PR review**: One reviewer approval required
6. **Session isolation respected**: PRs should follow td's own handoff patterns where applicable

## Support

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

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

func runCommitMsgHook(t *testing.T, initial string) (string, string, error) {
t.Helper()

tmpDir := t.TempDir()
msgFile := filepath.Join(tmpDir, "COMMIT_EDITMSG")
if err := os.WriteFile(msgFile, []byte(initial), 0o644); err != nil {
t.Fatalf("write commit message: %v", err)
}

cmd := exec.Command("bash", "scripts/commit-msg.sh", msgFile)
cmd.Dir = "."
output, err := cmd.CombinedOutput()

finalContent, readErr := os.ReadFile(msgFile)
if readErr != nil {
t.Fatalf("read commit message: %v", readErr)
}

return string(finalContent), string(output), err
}

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

tests := []struct {
name string
initial string
expected string
}{
{
name: "task linked subject",
initial: "Feat : normalize commit messages (td-2b41b2)\n\nNightshift-Task: commit-normalize\n" +
"Nightshift-Ref: https://github.com/marcus/nightshift\n",
expected: "feat: normalize commit messages (td-2b41b2)\n\nNightshift-Task: commit-normalize\n" +
"Nightshift-Ref: https://github.com/marcus/nightshift\n",
},
{
name: "automation subject without td suffix",
initial: "Docs:update changelog for v0.40.0\n",
expected: "docs: update changelog for v0.40.0\n",
},
{
name: "internal parentheses remain valid",
initial: "Fix: handle foo (bar) safely (td-2b41b2)\n",
expected: "fix: handle foo (bar) safely (td-2b41b2)\n",
},
{
name: "git workflow subjects bypass normalization",
initial: "fixup! feat: normalize commit messages (td-2b41b2)\n",
expected: "fixup! feat: normalize commit messages (td-2b41b2)\n",
},
}

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

finalContent, output, err := runCommitMsgHook(t, tt.initial)
if err != nil {
t.Fatalf("hook failed: %v\noutput:\n%s", err, output)
}
if finalContent != tt.expected {
t.Fatalf("unexpected commit message\nwant:\n%s\ngot:\n%s", tt.expected, finalContent)
}
})
}
}

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

tests := []struct {
name string
initial string
}{
{
name: "missing colon is rejected instead of inferred",
initial: "feat normalize commit messages (td-2b41b2)\n",
},
{
name: "non td suffix is rejected",
initial: "feat: normalize commit messages (jira-123)\n",
},
{
name: "extra trailing parenthetical is rejected",
initial: "feat: normalize commit messages (td-2b41b2) (extra)\n",
},
{
name: "empty summary is rejected",
initial: "feat:\n",
},
}

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

finalContent, output, err := runCommitMsgHook(t, tt.initial)
if err == nil {
t.Fatalf("expected hook to fail, output:\n%s", output)
}
if finalContent != tt.initial {
t.Fatalf("hook should leave rejected message unchanged\nwant:\n%s\ngot:\n%s", tt.initial, finalContent)
}
if !strings.Contains(output, "type: summary") {
t.Fatalf("expected remediation output, got:\n%s", output)
}
})
}
}
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 subject uses `type: summary`
- [ ] Version number follows semver
- [ ] Commits pushed to main
- [ ] Tag created with `-a` (annotated)
Expand Down
94 changes: 94 additions & 0 deletions scripts/commit-msg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# commit-msg hook for td
# Install: make install-hooks
# or: install -m 0755 scripts/commit-msg.sh "$(git rev-parse --git-path hooks)/commit-msg"
set -euo pipefail

msg_file=${1:?usage: commit-msg.sh <commit-message-file>}

trim() {
sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//'
}

fail() {
cat >&2 <<'EOF'
Commit subject must use one of these canonical formats:
type: summary
type: summary (td-<id>)

The hook only normalizes safe prefix inconsistencies such as type casing or
spacing around the colon. Any other trailing parenthetical suffix is rejected.

Examples:
feat: normalize commit messages (td-2b41b2)
docs: update changelog for v0.40.0
EOF
exit 1
}

if [[ ! -f "$msg_file" ]]; then
echo "commit-msg hook could not read $msg_file" >&2
exit 1
fi

if IFS= read -r subject <"$msg_file"; then
:
else
subject=""
fi

trimmed_subject=$(printf '%s' "$subject" | trim)

if [[ -z "$trimmed_subject" ]]; then
fail
fi

# Preserve Git workflow subjects that rely on special prefixes.
if [[ "$trimmed_subject" =~ ^(fixup\!\ |squash\!\ |Merge |Revert ) ]]; then
exit 0
fi

if [[ ! "$trimmed_subject" =~ ^([[:alpha:]][[:alnum:]-]*)[[:space:]]*:[[:space:]]*(.+)$ ]]; then
fail
fi

type_part=${BASH_REMATCH[1]}
summary_part=${BASH_REMATCH[2]}

normalized_type=$(printf '%s' "$type_part" | tr '[:upper:]' '[:lower:]')
normalized_summary=$(printf '%s' "$summary_part" | trim)

if [[ -z "$normalized_summary" ]]; then
fail
fi

if [[ "$normalized_summary" =~ ^(.*)[[:space:]]+\(([^()]*)\)$ ]]; then
summary_without_suffix=$(printf '%s' "${BASH_REMATCH[1]}" | trim)
suffix=${BASH_REMATCH[2]}

if [[ ! "$suffix" =~ ^td-[a-f0-9]+$ ]]; then
fail
fi
if [[ -z "$summary_without_suffix" ]]; then
fail
fi

normalized_summary="${summary_without_suffix} (${suffix})"
fi

normalized_subject="${normalized_type}: ${normalized_summary}"

if [[ "$normalized_subject" == "$subject" ]]; then
exit 0
fi

tmp_file=$(mktemp)
trap 'rm -f "$tmp_file"' EXIT

printf '%s\n' "$normalized_subject" >"$tmp_file"
if [[ $(wc -l <"$msg_file") -gt 1 ]]; then
tail -n +2 "$msg_file" >>"$tmp_file"
fi

mv "$tmp_file" "$msg_file"
trap - EXIT
3 changes: 2 additions & 1 deletion scripts/loop-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ td review <id>
```

Use `td review`, not `td close` — self-closing is blocked.
Release and automation-only commits should use `type: <summary>` without the td suffix.

## Rules

Expand All @@ -174,4 +175,4 @@ Use `td review`, not `td close` — self-closing is blocked.
- **Don't break sync.** Deterministic IDs, proper event logging, no hard deletes.
- **Session isolation is sacred.** Don't bypass review guards.
- **If stuck, log and skip.** `td log <id> "Blocked: <reason>"` then `td block <id>`.
- **Commit messages reference td.** Format: `feat|fix|chore: <summary> (td-<id>)`
- **Commit messages use the canonical format.** Task-linked commits: `type: <summary> (td-<id>)`; automation/release commits: `type: <summary>`
3 changes: 2 additions & 1 deletion scripts/pre-commit.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# pre-commit hook for td
# Install: make install-hooks (or: ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit)
# Install: make install-hooks
# or: install -m 0755 scripts/pre-commit.sh "$(git rev-parse --git-path hooks)/pre-commit"
set -euo pipefail

PASS=0
Expand Down