Detailed changes
@@ -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 <amolith@secluded.site>
---
-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:
-
-<git_formatted-commit_flags>
--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_flags>
-<git_formatted-commit_example>
-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"
-</git_formatted-commit_example>
-
-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.
@@ -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`.
@@ -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 <email>
+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
@@ -0,0 +1,234 @@
+#!/usr/bin/perl
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# 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 = <STDIN>;
+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);
+}