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(homebrew): bump td to ${{ steps.version.outputs.version }}"
git push
4 changes: 3 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(cli): describe changes (td-a1b2)

Details here

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

Commit subjects should follow `type(scope?): summary`, with an optional ` (td-<id>)` suffix when there is a matching task. Running `make install-hooks` installs the repo-managed `commit-msg` normalizer alongside `pre-commit`.

## Architecture

- `cmd/` - Cobra commands
Expand Down
18 changes: 13 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help fmt test install tag release check-clean check-version install-hooks
.PHONY: help fmt test install tag release check-clean check-version install-hooks test-commit-msg

SHELL := /bin/sh

Expand All @@ -13,7 +13,8 @@ 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-commit-msg # run commit-msg hook smoke tests" \
" 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 +53,13 @@ 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"
@repo_root=$$(git rev-parse --show-toplevel); \
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 "Installed pre-commit -> $$hooks_dir/pre-commit"; \
echo "Installed commit-msg -> $$hooks_dir/commit-msg"

test-commit-msg:
@./scripts/test_commit_msg.sh
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,16 +189,23 @@ make install-dev
# Format code
make fmt

# Install git pre-commit hook (gofmt, go vet, go build on staged files)
# Install repo-managed git hooks:
# pre-commit -> gofmt, go vet, go build on staged files
# commit-msg -> normalize commit subjects to type(scope?): summary
make install-hooks
```

Commit subjects should use `type(scope?): summary`, with an optional ` (td-<id>)` suffix when the change maps to a tracked task. Examples: `fix: reduce noisy review auto-handoff warning (td-db1f2a)` and `docs(release): update changelog for v0.43.0`.

## Tests & Quality Checks

```bash
# Run all tests (114 tests across cmd/, internal/db/, internal/models/, etc.)
make test

# Run the commit-msg hook smoke tests
make test-commit-msg

# Expected output: ok for each package, ~2s total runtime
# Example:
# ok github.com/marcus/td/cmd 1.994s
Expand Down
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(release): 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(release): 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
- [ ] Release-related commits use `type(scope?): summary`
- [ ] Version number follows semver
- [ ] Commits pushed to main
- [ ] Tag created with `-a` (annotated)
Expand Down
149 changes: 149 additions & 0 deletions scripts/commit-msg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# commit-msg hook for td
# Install: make install-hooks
set -euo pipefail

msg_file=${1:-}
canonical_subject_re='^[a-z][a-z0-9-]*(\([[:alnum:]_.-]+\))?:[[:space:]].+([[:space:]]\(td-[a-z0-9]+\))?$'
bracketed_ticket_re='^\[([Tt][Dd]-[A-Za-z0-9]+)\][[:space:]]*(.+)$'
trailing_ticket_re='^(.+)[[:space:]]+\((td-[A-Za-z0-9]+)\)$'
type_with_colon_re='^([A-Za-z][A-Za-z0-9-]*)(\([^)]+\))?:[[:space:]]+(.+)$'
type_with_dash_re='^([A-Za-z][A-Za-z0-9-]*)(\([^)]+\))?[[:space:]]*-[[:space:]]+(.+)$'
type_with_space_re='^([A-Za-z][A-Za-z0-9-]*)(\([^)]+\))?[[:space:]]+(.+)$'

if [[ -z "$msg_file" || ! -f "$msg_file" ]]; then
exit 0
fi

lower() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}

trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}

canonical_type() {
local raw
raw="$(lower "$1")"
case "$raw" in
feat|feature)
printf 'feat'
;;
fix|bugfix|hotfix)
printf 'fix'
;;
docs|doc)
printf 'docs'
;;
chore|refactor|perf|test|build|ci|style)
printf '%s' "$raw"
;;
*)
return 1
;;
esac
}

is_bypass_subject() {
local subject="$1"
[[ -z "$subject" ]] && return 0
[[ "$subject" =~ ^Merge([[:space:]]|$) ]] && return 0
[[ "$subject" =~ ^Revert([[:space:]]|$) ]] && return 0
[[ "$subject" =~ ^(fixup!|squash!|amend!|reword!) ]] && return 0
return 1
}

is_canonical_subject() {
local subject="$1"
[[ "$subject" =~ $canonical_subject_re ]]
}

normalize_subject() {
local subject="$1"
local ticket_suffix=""
local type_part=""
local scope_part=""
local summary=""
local mapped_type=""

if is_bypass_subject "$subject"; then
printf '%s' "$subject"
return 0
fi

if [[ "$subject" =~ $bracketed_ticket_re ]]; then
ticket_suffix="$(lower "${BASH_REMATCH[1]}")"
subject="${BASH_REMATCH[2]}"
fi

if [[ "$subject" =~ $trailing_ticket_re ]]; then
subject="${BASH_REMATCH[1]}"
if [[ -z "$ticket_suffix" ]]; then
ticket_suffix="$(lower "${BASH_REMATCH[2]}")"
fi
fi

subject="$(trim "$subject")"

if is_canonical_subject "$subject"; then
summary="$subject"
elif [[ "$subject" =~ $type_with_colon_re ]]; then
type_part="${BASH_REMATCH[1]}"
scope_part="${BASH_REMATCH[2]}"
summary="${BASH_REMATCH[3]}"
if mapped_type="$(canonical_type "$type_part" 2>/dev/null)"; then
summary="${mapped_type}${scope_part}: ${summary}"
else
summary="chore: ${subject}"
fi
elif [[ "$subject" =~ $type_with_dash_re ]]; then
type_part="${BASH_REMATCH[1]}"
scope_part="${BASH_REMATCH[2]}"
summary="${BASH_REMATCH[3]}"
if mapped_type="$(canonical_type "$type_part" 2>/dev/null)"; then
summary="${mapped_type}${scope_part}: ${summary}"
else
summary="chore: ${subject}"
fi
elif [[ "$subject" =~ $type_with_space_re ]]; then
type_part="${BASH_REMATCH[1]}"
scope_part="${BASH_REMATCH[2]}"
summary="${BASH_REMATCH[3]}"
if mapped_type="$(canonical_type "$type_part" 2>/dev/null)"; then
summary="${mapped_type}${scope_part}: ${summary}"
else
summary="chore: ${subject}"
fi
else
summary="chore: ${subject}"
fi

if [[ -n "$ticket_suffix" && ! "$summary" =~ \(td-[a-z0-9]+\)$ ]]; then
summary="${summary} (${ticket_suffix})"
fi

printf '%s' "$summary"
}

first_line=""
IFS= read -r first_line < "$msg_file" || true

normalized_subject="$(normalize_subject "$first_line")"

if [[ "$normalized_subject" == "$first_line" ]]; 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"
5 changes: 3 additions & 2 deletions scripts/loop-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Batch review loops:

```bash
git add <specific files>
git commit -m "feat: <summary> (td-<id>)"
git commit -m "feat(cli): <summary> (td-<id>)"
td review <id>
```

Expand All @@ -174,4 +174,5 @@ 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 reference td.** Format: `type(scope?): <summary> (td-<id>)`
- `make install-hooks` installs a local `commit-msg` normalizer that keeps common legacy subjects aligned with that format.
68 changes: 68 additions & 0 deletions scripts/test_commit_msg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
hook="$repo_root/scripts/commit-msg.sh"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

pass_count=0

assert_file_equals() {
local name="$1"
local input="$2"
local expected="$3"
local file="$tmpdir/$name.txt"

printf '%s' "$input" > "$file"
"$hook" "$file"

if ! diff -u <(printf '%s' "$expected") "$file"; then
echo "FAILED: $name"
exit 1
fi

pass_count=$((pass_count + 1))
}

assert_file_equals \
"pass-through" \
$'feat(cli): add commit normalizer (td-a1b2)\n\nBody line\nNightshift-Task: example\n' \
$'feat(cli): add commit normalizer (td-a1b2)\n\nBody line\nNightshift-Task: example\n'

assert_file_equals \
"capitalized-prefix" \
$'Docs: Update changelog for v0.43.0\n' \
$'docs: Update changelog for v0.43.0\n'

assert_file_equals \
"legacy-bracketed-prefix" \
$'[td-527bd4] Clean up Dispatch worktrees page search, filters, and row density\n' \
$'chore: Clean up Dispatch worktrees page search, filters, and row density (td-527bd4)\n'

assert_file_equals \
"preserve-ticket-suffix" \
$'Feat(api): add release endpoint (td-9FA24F)\n' \
$'feat(api): add release endpoint (td-9fa24f)\n'

assert_file_equals \
"preserve-body-and-trailers" \
$'Update README links\n\nExpanded body stays here.\nNightshift-Task: commit-normalize\nNightshift-Ref: https://github.com/marcus/nightshift\n' \
$'chore: Update README links\n\nExpanded body stays here.\nNightshift-Task: commit-normalize\nNightshift-Ref: https://github.com/marcus/nightshift\n'

assert_file_equals \
"bypass-merge" \
$'Merge pull request #91 from marcus/dispatch/td-527bd4-0006\n\nMerge body\n' \
$'Merge pull request #91 from marcus/dispatch/td-527bd4-0006\n\nMerge body\n'

assert_file_equals \
"bypass-revert" \
$'Revert "fix: add release endpoint"\n\nThis reverts commit abcdef.\n' \
$'Revert "fix: add release endpoint"\n\nThis reverts commit abcdef.\n'

assert_file_equals \
"bypass-fixup" \
$'fixup! feat(cli): add commit normalizer\n' \
$'fixup! feat(cli): add commit normalizer\n'

echo "commit-msg tests passed ($pass_count cases)"