diff --git a/skills/formatting-commits/SKILL.md b/skills/formatting-commits/SKILL.md index 4c6b81c231bd5ab71a461b496ab38e4d19bfb9d3..3044833343baae7e350e4eed611af3af2cc52e1f 100644 --- a/skills/formatting-commits/SKILL.md +++ b/skills/formatting-commits/SKILL.md @@ -1,52 +1,50 @@ --- name: formatting-commits -description: Creates commits strictly following Conventional Commits format. ALWAYS read BEFORE committing changes, amending commits, or when the user mentions git commits, conventional commits, or commit messages. -compatibility: Requires `git` and `git-format` CLI tools (invoked as `git formatted-commit`) +description: >- + Formats commit messages by detecting the project's style from recent history. + ALWAYS read BEFORE committing changes, amending commits, or when the user + mentions commits, commit messages, conventional commits, or commit formatting, + regardless of VCS. Also use when writing tag annotations or change + descriptions. license: GPL-3.0-or-later metadata: author: Amolith --- -Create commits using `git formatted-commit`. For amends, choose the appropriate approach: - -- **Message stays accurate** → `git commit --amend --no-edit` (or `-a --amend --no-edit` to stage all) -- **Message needs updating** → `git formatted-commit --amend` to reconstruct with new type/scope/body - -`git formatted-commit` has no sub-commands and the following options: - - --t --type Commit type (required) --s --scope Commit scope (optional) --B --breaking Mark as breaking change (optional) --m --message Commit message (required) --b --body Commit body (optional) --T --trailer Trailer in 'Sentence-case-key: value' format (optional, repeatable) --a --add Stage all modified files before committing (optional) ---amend Amend the previous commit (optional) --h --help - - -git formatted-commit -t feat -s "web/git-bug" -m "do a fancy new thing" -b "$(cat <<'EOF' -Multi-line - -- Body -- Here - -EOF -)" -T "Fixes: https://todo.sr.ht/~user/tracker/#123" - - -Most source code repositories require both an appropriate prefix _and_ scope. Necessity of scope increases with repository size; the smaller the repo, the less necessary the scope. Valid trailers for ticket tracking integration depend on the platform in use. - -- GitHub - - Closes: - - Fixes: - - Resolves: - - References: -- SourceHut - - Closes: - - Fixes: - - Implements: - - References: - -Refer to [installing-git-format.md](references/installing-git-format.md) if it's unavailable. +Formats commit and change messages. Detects the project's style, then follows +the appropriate conventions for subject, body, and trailers. + +## Detecting the style + +Look at ~20 recent commit or change subjects. Two patterns to check: + +- **Conventional Commits**: subjects match `type:` or `type(scope):` prefixes + (e.g., `feat:`, `fix(auth):`, `chore:`, `refactor(api):`) +- **Kernel-style**: subjects are plain imperative statements with no type + prefix (e.g., "Add user authentication", "net: Fix buffer overflow") + +If the majority of recent subjects follow one pattern, use that style. If the +history is mixed or the repository has no commits, ask the user. + +## Style paths + +- **Conventional Commits** → read [conventional-commits.md](references/conventional-commits.md) +- **Kernel-style** → read [kernel-style.md](references/kernel-style.md) + +## Trailers + +Both styles use trailers. They go after the body, separated by a blank line. +Format: `Key: value`, one per line. + +Valid trailer keys for ticket tracking depend on the platform: + +- GitHub: Closes, Fixes, Resolves, References +- SourceHut: Closes, Fixes, Implements, References + +Other common trailers: Signed-off-by, Co-authored-by, Reviewed-by, Acked-by, +Assisted-by. + +On the Conventional Commits path, pass trailers as `-T` flags to +`git formatted-commit`. On the kernel-style path, include trailers at the end +of the message before piping through `scripts/format message` — the script +detects and preserves them. diff --git a/skills/formatting-commits/references/conventional-commits.md b/skills/formatting-commits/references/conventional-commits.md new file mode 100644 index 0000000000000000000000000000000000000000..5509348d7e3509a6e641efda82e92fb7e949f037 --- /dev/null +++ b/skills/formatting-commits/references/conventional-commits.md @@ -0,0 +1,44 @@ +# Conventional Commits + +Create commits using `git formatted-commit`. For amends, choose the appropriate +approach: + +- **Message stays accurate** → `git commit --amend --no-edit` (or `-a --amend --no-edit` to stage all) +- **Message needs updating** → `git formatted-commit --amend` to reconstruct with new type/scope/body + +`git formatted-commit` has no sub-commands and the following options: + +``` +-t --type Commit type (required) +-s --scope Commit scope (optional) +-B --breaking Mark as breaking change (optional) +-m --message Commit message (required) +-b --body Commit body (optional) +-T --trailer Trailer in 'Sentence-case-key: value' format (optional, repeatable) +-a --add Stage all modified files before committing (optional) +--amend Amend the previous commit (optional) +-h --help +``` + +Example: + +```bash +git formatted-commit -t feat -s "web/git-bug" -m "do a fancy new thing" -b "$(cat <<'EOF' +Multi-line + +- Body +- Here + +EOF +)" -T "Fixes: https://todo.sr.ht/~user/tracker/#123" +``` + +Most source code repositories require both an appropriate prefix _and_ scope. +Necessity of scope increases with repository size; the smaller the repo, the +less necessary the scope. + +Refer to [installing-git-format.md](installing-git-format.md) for setup. If +`git formatted-commit` is unavailable and cannot be installed, fall back to the +format script: assemble the subject as `type(scope): message` (or +`type(scope)!: message` for breaking changes), write the full message with body +and trailers, and pipe through `scripts/format message`. diff --git a/skills/formatting-commits/references/kernel-style.md b/skills/formatting-commits/references/kernel-style.md new file mode 100644 index 0000000000000000000000000000000000000000..358e25f750138b5df88991bc294718a286ade63a --- /dev/null +++ b/skills/formatting-commits/references/kernel-style.md @@ -0,0 +1,71 @@ +# Kernel-style commits + +Imperative, clearly structured commit messages. The subject says what the +change does; the body explains what and why. + +## Subject + +- Imperative mood: "Add feature", not "Added feature" or "Adds feature" +- Capitalize the first word +- No trailing period +- Maximum 50 characters + +Some projects use a subsystem prefix (e.g., `net/tcp: Fix buffer overflow`, +`auth: Add OIDC provider`). Check recent commit subjects to see whether the +project follows this convention, and use the same prefix style if so. The +prefix and colon count toward the 50-character limit. + +Validate the subject with the format script before composing the full message. +You'll often need to iterate on the subject a few times to get it under 50 +characters — this is a cheap round trip compared to recomposing the whole +message: + +``` +scripts/format subject "Your subject line here" +``` + +Exit 0 means the subject is valid. Exit 1 means it exceeds 50 characters — +rephrase rather than truncate. + +## Body + +Explain _what_ changed and _why_, not _how_ — the diff shows how. + +The format script handles body reflow, so you don't need to worry about +wrapping. Freely write standard, Markdown-ish commits and it'll handle the rest. + +You tend to wrap your own lines incorrectly because you see in tokens, not +characters. Always pipe the message through the format script rather than +trusting your own line breaks. + +## Composing and formatting + +1. Draft the subject and iterate with `format subject` until it fits +2. Write the full message (subject, blank line, body, blank line, trailers) +3. Pipe the whole thing through `format message`, which validates the subject, + reflows the body, and passes trailers through unchanged + +```bash +scripts/format message <<'EOF' | git commit -F - +Add OIDC discovery support + +This commit refactors the authentication module to support +multiple identity providers. + +- Replace the monolithic auth handler with a provider interface +- Add support for OIDC discovery + +Signed-off-by: Name +Fixes: https://todo.sr.ht/~user/tracker/#42 +EOF +``` + +The format script detects trailers at the end of the message (lines matching +`Key: value` format) and preserves them as-is. Everything between the subject +and the trailer block is reflowed as body text. + +## Amending + +- **Message stays accurate**: amend without changing the message +- **Message needs updating**: compose and format a new message, then amend + with the formatted output diff --git a/skills/formatting-commits/scripts/format b/skills/formatting-commits/scripts/format new file mode 100755 index 0000000000000000000000000000000000000000..22a884c20d056910e32729dac98e21306592331f --- /dev/null +++ b/skills/formatting-commits/scripts/format @@ -0,0 +1,234 @@ +#!/usr/bin/perl +# SPDX-FileCopyrightText: Amolith +# +# SPDX-License-Identifier: GPL-3.0-or-later + +use strict; +use warnings; + +my $WIDTH = 72; + +my $mode = shift @ARGV; +unless (defined $mode && ($mode eq 'subject' || $mode eq 'message')) { + print STDERR "Usage: format subject \"text\" | format message < file\n"; + exit 1; +} + +if ($mode eq 'subject') { + my $subject = shift @ARGV; + unless (defined $subject) { + print STDERR "subject mode requires a subject argument\n"; + exit 1; + } + if (length($subject) > 50) { + print STDERR "Subject exceeds 50 characters (" . length($subject) . ")\n"; + exit 1; + } + print $subject; + exit 0; +} + +# Message mode: read full message from stdin, format it, emit to stdout. +my @all_lines = ; +chomp @all_lines; + +unless (@all_lines) { + print STDERR "Empty message\n"; + exit 1; +} + +# First line is the subject. +my $subject = $all_lines[0]; +if (length($subject) > 50) { + print STDERR "Subject exceeds 50 characters (" . length($subject) . ")\n"; + exit 1; +} + +# Subject only — no body or trailers. +if (@all_lines == 1) { + print "$subject\n"; + exit 0; +} + +# Skip the blank line after the subject. +my $start = 1; +$start++ if $start < @all_lines && $all_lines[$start] =~ /^\s*$/; + +# Nothing after the subject (just trailing blanks). +if ($start >= @all_lines) { + print "$subject\n"; + exit 0; +} + +my @rest = @all_lines[$start .. $#all_lines]; + +# Detect trailer block by scanning backward. Trailers are Key: value lines at +# the end of the message (after skipping trailing blank lines). The key must +# start with a letter and contain only letters, digits, and hyphens. +my $scan = $#rest; + +# Skip trailing blank lines. +$scan-- while $scan >= 0 && $rest[$scan] =~ /^\s*$/; + +my $trailer_end = $scan; # last non-blank line + +# Collect trailer lines. +while ($scan >= 0 && $rest[$scan] =~ /^[A-Za-z][A-Za-z0-9-]*: ./) { + $scan--; +} + +my $trailer_start = $scan + 1; +my @trailers; +if ($trailer_start <= $trailer_end) { + @trailers = @rest[$trailer_start .. $trailer_end]; +} + +# Body is everything before the trailer block, with trailing blanks trimmed. +my @body_lines; +if ($trailer_start > 0) { + my $body_end = $trailer_start - 1; + # Trim trailing blank lines from the body. + $body_end-- while $body_end >= 0 && $rest[$body_end] =~ /^\s*$/; + @body_lines = @rest[0 .. $body_end] if $body_end >= 0; +} + +# Reflow the body. +my $formatted_body = reflow_body(@body_lines); + +# Assemble output. +print "$subject\n"; +if ($formatted_body ne '') { + print "\n$formatted_body\n"; +} +if (@trailers) { + print "\n", join("\n", @trailers), "\n"; +} + +exit 0; + +# --- Subroutines --- + +sub reflow_body { + my @lines = @_; + return '' unless @lines; + + my @result; + my @plain_buffer; + + my $flush_plain = sub { + if (@plain_buffer) { + my $joined = join(' ', @plain_buffer); + push @result, word_wrap($joined, $WIDTH); + @plain_buffer = (); + } + }; + + for my $line (@lines) { + # Code blocks: 4+ leading spaces or tab — preserve as-is. + if ($line =~ /^(?: |\t)/) { + $flush_plain->(); + push @result, $line; + next; + } + + (my $trimmed = $line) =~ s/^\s+|\s+$//g; + + # Blank lines. + if ($trimmed eq '') { + $flush_plain->(); + push @result, ''; + next; + } + + # Bullet lists (- or *). + if ($trimmed =~ /^([*-] )(.*)/) { + $flush_plain->(); + my ($marker, $content) = ($1, $2); + push @result, wrap_hanging($marker, ' ', $content, $WIDTH); + next; + } + + # Numbered lists (1. , 2. , 10. , etc.). + if ($trimmed =~ /^(\d+\.\s)(.*)/) { + $flush_plain->(); + my $marker = $1; + my $content = defined $2 ? $2 : ''; + my $indent = ' ' x length($marker); + push @result, wrap_hanging($marker, $indent, $content, $WIDTH); + next; + } + + # Plain text: buffer for paragraph reflow. + push @plain_buffer, $trimmed; + } + + $flush_plain->(); + return join("\n", @result); +} + +sub word_wrap { + my ($text, $width) = @_; + my @words = split /\s+/, $text; + return '' unless @words; + + my @lines; + my $current = ''; + my $current_width = 0; + + for my $word (@words) { + my $wlen = length($word); + if ($current eq '') { + $current = $word; + $current_width = $wlen; + } elsif ($current_width + 1 + $wlen <= $width) { + $current .= " $word"; + $current_width += 1 + $wlen; + } else { + push @lines, $current; + $current = $word; + $current_width = $wlen; + } + } + push @lines, $current if $current ne ''; + return join("\n", @lines); +} + +sub wrap_hanging { + my ($first_prefix, $cont_prefix, $text, $width) = @_; + my $first_width = $width - length($first_prefix); + my $cont_width = $width - length($cont_prefix); + + my @words = split /\s+/, $text; + return $first_prefix unless @words; + + my @lines; + my $current = ''; + my $current_width = 0; + my $is_first = 1; + + for my $word (@words) { + my $wlen = length($word); + my $max = $is_first ? $first_width : $cont_width; + + if ($current eq '') { + $current = $word; + $current_width = $wlen; + } elsif ($current_width + 1 + $wlen <= $max) { + $current .= " $word"; + $current_width += 1 + $wlen; + } else { + my $prefix = $is_first ? $first_prefix : $cont_prefix; + push @lines, $prefix . $current; + $is_first = 0; + $current = $word; + $current_width = $wlen; + } + } + + if ($current ne '') { + my $prefix = $is_first ? $first_prefix : $cont_prefix; + push @lines, $prefix . $current; + } + + return join("\n", @lines); +}