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
8 changes: 4 additions & 4 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
- "^chore:"
- "^docs(\\([^)]+\\))?:"
- "^test(\\([^)]+\\))?:"
- "^ci(\\([^)]+\\))?:"
- "^chore(\\([^)]+\\))?:"

# Note: Homebrew formula is manually maintained in marcus/homebrew-tap
# to build from source (avoids Gatekeeper warnings on macOS)
17 changes: 12 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 test-commit-msg install tag release check-clean check-version install-hooks

SHELL := /bin/sh

Expand All @@ -13,8 +13,9 @@ 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 test-commit-msg # run commit-msg hook regression tests" \
" make install # build and install with version from git" \
" make tag VERSION=vX.Y.Z # create annotated git tag (requires clean tree)" \
" make release VERSION=vX.Y.Z # tag + push (triggers GoReleaser via GitHub Actions)"
Expand All @@ -25,6 +26,9 @@ fmt:
test:
go test ./...

test-commit-msg:
./scripts/test-commit-msg.sh

install:
@V="$(GIT_DESCRIBE)"; V=$${V:-dev}; \
echo "Installing td $$V"; \
Expand Down Expand Up @@ -52,6 +56,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"
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,16 +189,37 @@ 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-msg normalizer)
make install-hooks
```

## Commit Messages

New commits should use `type(scope)?: summary`.

- Supported types: `feat`, `fix`, `docs`, `test`, `chore`, `ci`, `perf`, `refactor`, `style`, `release`
- Add `(td-<id>)` for task work when you have one; it is recommended, not required for every commit
- `(#123)` PR references are also fine at the end of the subject
- Merge, revert, fixup, squash, and release/version flows are exempt from the local normalizer

Accepted examples:

```text
feat(cli): add commit message normalizer (td-a1b2c3)
fix(review): preserve Nightshift trailers (#91)
docs: document install-hooks behavior
release: v0.44.0
```

## Tests & Quality Checks

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

# Verify the commit-msg normalizer
make test-commit-msg

# Expected output: ok for each package, ~2s total runtime
# Example:
# ok github.com/marcus/td/cmd 1.994s
Expand Down
199 changes: 199 additions & 0 deletions scripts/commit-msg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#!/usr/bin/env bash
# commit-msg hook for td
# Install: make install-hooks
set -euo pipefail

MSG_FILE="${1:-}"
ALLOWED_TYPES_DISPLAY='feat, fix, docs, test, chore, ci, perf, refactor, style, release'

if [[ -z "$MSG_FILE" || ! -f "$MSG_FILE" ]]; then
echo "Usage: $0 <commit-message-file>" >&2
exit 1
fi

trim_and_collapse() {
printf '%s' "$1" | tr '\t' ' ' | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//'
}

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

is_allowed_type() {
case "$1" in
feat|fix|docs|test|chore|ci|perf|refactor|style|release)
return 0
;;
esac

return 1
}

parse_subject() {
local subject="$1"
local pattern="$2"

printf '%s\n' "$subject" | sed -En "s/$pattern/\\1|\\2|\\3/p" | head -n 1
}

is_exempt_subject() {
local subject="$1"

case "$subject" in
Merge*|Merged*|merge*)
return 0
;;
Revert*|revert*)
return 0
;;
fixup!\ *|squash!\ *)
return 0
;;
release:*|release\(*\):*|Release:*|Release\(*\):*)
return 0
;;
Version*|Bump\ version*)
return 0
;;
esac

printf '%s\n' "$subject" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+)?$' && return 0

return 1
}

normalize_subject() {
local subject="$1"
local cleaned type scope summary
local with_colon_regex='^([A-Za-z]+)(\([^)]+\))?[[:space:]]*:[[:space:]]*(.+)$'
local scoped_missing_colon_regex='^([A-Za-z]+)(\([^)]+\))[[:space:]]+(.+)$'
local unscoped_missing_colon_regex='^([A-Za-z]+)[[:space:]]+(.+)$'
local malformed_scope_summary_regex='^\([^)]+\)(:|[[:space:]])'

cleaned="$(trim_and_collapse "$subject")"

if [[ "$cleaned" =~ $with_colon_regex ]]; then
type="$(lowercase "${BASH_REMATCH[1]}")"
scope="${BASH_REMATCH[2]}"
summary="${BASH_REMATCH[3]}"
if is_allowed_type "$type"; then
printf '%s' "$(trim_and_collapse "$type$scope: $summary")"
return 0
fi
fi

if [[ "$cleaned" =~ $scoped_missing_colon_regex ]]; then
type="$(lowercase "${BASH_REMATCH[1]}")"
scope="${BASH_REMATCH[2]}"
summary="${BASH_REMATCH[3]}"
if is_allowed_type "$type"; then
printf '%s' "$(trim_and_collapse "$type$scope: $summary")"
return 0
fi
fi

if [[ "$cleaned" =~ $unscoped_missing_colon_regex ]]; then
type="$(lowercase "${BASH_REMATCH[1]}")"
summary="$(trim_and_collapse "${BASH_REMATCH[2]}")"
if is_allowed_type "$type"; then
if [[ "$summary" =~ $malformed_scope_summary_regex ]]; then
printf '%s' "$cleaned"
return 0
fi
printf '%s' "$(trim_and_collapse "$type: $summary")"
return 0
fi
fi

printf '%s' "$cleaned"
}

strip_trailing_refs() {
local subject="$1"
local stripped
local trailing_ref_regex='^(.+)[[:space:]]+\((td-[[:alnum:]]+|#[0-9]+)\)$'
local ref_only_regex='^\((td-[[:alnum:]]+|#[0-9]+)\)$'

stripped="$(trim_and_collapse "$subject")"

while [[ "$stripped" =~ $trailing_ref_regex ]]; do
stripped="$(trim_and_collapse "${BASH_REMATCH[1]}")"
done

if [[ "$stripped" =~ $ref_only_regex ]]; then
stripped=""
fi

printf '%s' "$stripped"
}

is_valid_subject() {
local subject="$1"
local parsed type scope summary

[[ -n "$subject" ]] || return 1

parsed="$(parse_subject "$subject" '^([a-z]+)(\([^)]+\))?:[[:space:]]+(.+)$')"
if [[ -n "$parsed" ]]; then
IFS='|' read -r type scope summary <<EOF
$parsed
EOF
is_allowed_type "$type" || return 1
summary="$(strip_trailing_refs "$summary")"
[[ -n "$summary" ]] || return 1
return 0
fi

return 1
}

rewrite_subject_line() {
local new_subject="$1"
local tmp_file

tmp_file="$(mktemp "${TMPDIR:-/tmp}/td-commit-msg-XXXXXX")"
{
printf '%s\n' "$new_subject"
sed '1d' "$MSG_FILE"
} >"$tmp_file"
mv "$tmp_file" "$MSG_FILE"
}

subject="$(sed -n '1p' "$MSG_FILE")"
trimmed_subject="$(trim_and_collapse "$subject")"

if is_exempt_subject "$trimmed_subject"; then
exit 0
fi

normalized_subject="$(normalize_subject "$subject")"

if is_valid_subject "$normalized_subject"; then
if [[ "$normalized_subject" != "$subject" ]]; then
rewrite_subject_line "$normalized_subject"
echo "Normalized commit subject: $normalized_subject"
fi
exit 0
fi

cat >&2 <<EOF
Commit subject must match: type(scope)?: summary
Allowed types: $ALLOWED_TYPES_DISPLAY
Optional trailing refs: (td-<id>) and/or (#123)

Examples:
feat(cli): add commit message normalizer (td-a1b2c3)
fix(review): preserve Nightshift trailers (#91)
docs: document install-hooks behavior

Exempt flows:
Merge..., Revert..., fixup!, squash!, release/version commits

Scopes must be attached directly to the type:
fix(cli): preserve trailers
not: fix (cli): preserve trailers

To fix the last commit message:
git commit --amend
EOF
exit 1
10 changes: 8 additions & 2 deletions scripts/loop-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,18 @@ 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>
```

Use `td review`, not `td close` — self-closing is blocked.

Commit subjects should use `type(scope)?: summary`.

- Include `(td-<id>)` for task work when you have it; it is recommended, not required for every commit.
- `(#123)` references are also accepted at the end of the subject.
- Examples: `feat(cli): add JSON output (td-a1b2c3)`, `fix(review): preserve trailers (#91)`, `docs: clarify install-hooks behavior`

## Rules

- **ONE task per iteration.** Complete it, verify it, commit it, mark it done, then exit.
Expand All @@ -174,4 +180,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 follow `type(scope)?: summary`.** `(td-<id>)` is recommended for task work, not required for every commit.
2 changes: 1 addition & 1 deletion scripts/pre-commit.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/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
set -euo pipefail

PASS=0
Expand Down
Loading