Stop choosing a single scope. Commit like you mean it.
- The Problem
- The Solution
- Format Specification
- Real-World Examples
- Version Bumping Rules
- Installation & Setup
- Parser Libraries
- Why This Matters
- Migration Guide
- FAQ
- Contributing
- License
Traditional Conventional Commits forces you to choose exactly one scope per commit:
feat(asd): add some meaningless stuff
But in real-world development:
- A single shared utility file (
itul/fah.huh) can affect 3 different modules simultaneously - A bug fix might span multiple files across different parts of the codebase
- A feature might touch both the API layer and the UI layer
This leads to one of three bad outcomes:
- Lying about the scope: Using a single scope that doesn't fully describe the change
- Splitting commits artificially: Breaking a logical change into multiple commits just to use different scopes :)
- Using vague scopes: Resorting to
asd,asd, andasdbecause nothing fits
This makes commit history less useful for:
- Generating accurate changelogs
- Understanding the impact of changes
- Reviewing pull requests
- Cherry-picking commits
SCOPE-X extends Conventional Commits with two simple delimiters that clarify whether changes affect a single file or multiple files, and which module is primarily affected. (Bruh, no one care)
| Delimiter | Meaning | Use Case |
|---|---|---|
& (Ampersand) |
Same file, multiple modules | A shared utility file that auth, cart, and api all depend on |
, (Comma) |
Multiple files, different modules | You touched auth/login.ts, cart/checkout.ts, and api/routes.ts in one commit |
- Maximum 3 scopes: Keep it focused. If you need more than 3, your commit is too large.
- Primary scope first: The first scope listed is the one most affected by the change.
- Order matters: Scopes should be ordered from most affected to least affected.
- Delimiter choice is meaningful:
&means the same file,,means different files. - Never mix delimiters: A commit must use either
&or,, never both.
<type>(<scope1> & <scope2>): <subject> # Single file, multiple modules
<type>(<scope1>, <scope2>): <subject> # Multiple files, different modules
<type>(<scope1> & <scope2> & <scope3>): <subject> # Max 3 scopes
<type>(<scope1>, <scope2>, <scope3>): <subject> # Max 3 scopes
Add ! before the parentheses to indicate a breaking change:
<type>!(<scope1> & <scope2>): <subject> # Breaking change, same file
<type>!(<scope1>, <scope2>): <subject> # Breaking change, multiple files
| Part | Description | Rules |
|---|---|---|
| type | Category of change | feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert |
| ! | Breaking change indicator | Optional, only included for breaking changes |
| scopes | Modules affected | 1-3 scopes, lowercase alphanumeric, no spaces |
| delimiter | Separator between scopes | & for same file, , for different files |
| subject | Brief description | Present tense, imperative mood, no period |
feat(auth & monitoring): add structured JSON logging
A single file (utils/logger.ts) is updated to add structured logging. This affects both the authentication module (which uses the logger) and the monitoring module (which consumes logs).
fix(auth & admin & api): tighten email validation regex
The common/validators.ts file is updated. This file is used by authentication (user registration), admin (user management), and API (input validation).
fix(auth, api): handle expired token gracefully
Two files were updated: auth/token.ts and api/middleware.ts. Both are part of fixing how expired tokens are handled.
feat(core, payment, notification): implement new event bus
Three separate files across three modules were created/updated to implement a new event bus system.
feat!(core, ui): migrate to new state management library
A breaking change that updates the core state management library and requires UI components to be updated. Changes span multiple files across two modules.
refactor!(api & database): change response format for all endpoints
A breaking refactor that changes the API response format. The change affects both the API layer and the database layer, but all changes are within a single file that handles both.
fix(api & feat): add request timeout configuration
The primary change is a fix to the API module (Scope 1), but it also adds a minor feature to the same file. Since the primary scope is fix, this is treated as a patch.
docs(readme, contributing): update onboarding guides
Two separate documentation files were updated. This is a multiple-file change with docs type.
These are examples of what SCOPE-X helps you avoid:
# Vague - doesn't tell the full story
feat(misc): update helpers
# Dishonest - doesn't mention all affected modules
feat(auth): update shared utilities # Actually affects 5 modules!
# Unnecessarily split - one logical change broken into 3 commits
feat(auth): update logger
fix(core): update logger
chore(monitoring): update logger # All for the same change!
SCOPE-X extends Semantic Versioning rules based on the primary scope (the first scope listed).
| Primary Type | Secondary Types | Version Bump | Example |
|---|---|---|---|
Any type with ! |
Any | MAJOR | feat!(api, db): breaking change |
feat in Scope 1 |
Any | MINOR | feat(api & ui): add feature |
feat in Scope 2 or 3 |
fix in Scope 1 |
PATCH | fix(api, feat): patch with feature |
feat in Scope 2 or 3 |
docs in Scope 1 |
PATCH | docs(readme, feat): doc with feature |
perf in Scope 1 |
Any | MINOR | perf(api): improve performance |
fix in Scope 1 |
Any except feat |
PATCH | fix(auth): fix bug |
docs, style, refactor |
Any | PATCH | refactor(core): internal change |
chore, ci, build |
Any | PATCH | chore(deps): update dependencies |
function determineVersionBump(commit) {
// 1. Breaking change always wins
if (commit.breaking) return 'major';
// 2. Check primary scope
const primaryType = commit.type;
// 3. Features in primary scope = MINOR
if (primaryType === 'feat') return 'minor';
// 4. Performance improvements = MINOR
if (primaryType === 'perf') return 'minor';
// 5. Everything else = PATCH
return 'patch';
}feat(api & ui): add export feature # MINOR (API is primary)
fix(api & feat): improve performance # PATCH (fix is primary)
feat(core, helper): new util function # MINOR (core is primary, feat wins)
feat!(api, database): change schema # MAJOR (breaking change)
feat(core & ui & api): add dark mode # MINOR (core is primary)
perf(database & api): optimize queries # MINOR (performance improvement)
docs(readme, feat): update docs # PATCH (docs is primary)
- More accurate versioning: The primary scope determines the actual impact
- Better changelog generation: Features in secondary scopes are listed as minor improvements
- Clear communication: Team members understand which module is most affected
Save this as .git/hooks/commit-msg:
#!/bin/bash
# SCOPE-X Commit Message Validator
# Install: chmod +x .git/hooks/commit-msg
COMMIT_MSG=$(cat "$1")
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Skip merge and revert commits
if [[ "$COMMIT_MSG" =~ ^(Merge|Revert) ]]; then
exit 0
fi
# Check format: type(!)(scope1 [&|,] scope2 [&|,] scope3): subject
# Valid types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert
if [[ ! "$COMMIT_MSG" =~ ^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)!?\(([a-z0-9]+([&,][a-z0-9]+){0,2})\):\ .+$ ]]; then
echo -e "${RED}ERROR: Invalid SCOPE-X commit message format${NC}"
echo ""
echo "Expected formats:"
echo " Single file, multiple modules: <type>(<scope1> & <scope2>): <subject>"
echo " Multiple files, different modules: <type>(<scope1>, <scope2>): <subject>"
echo " Breaking changes: <type>!(<scope1> & <scope2>): <subject>"
echo ""
echo "Rules:"
echo " • Maximum 3 scopes"
echo " • Cannot mix '&' and ',' delimiters"
echo " • Scopes must be lowercase letters and numbers"
echo " • Valid types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert"
echo ""
echo "Examples:"
echo " ${GREEN}feat(auth & monitoring): add structured logging${NC}"
echo " ${GREEN}fix(auth, api): handle expired token${NC}"
echo " ${GREEN}feat!(core, ui): migrate to new state management${NC}"
exit 1
fi
# Additional validation: check for mixed delimiters
SCOPES=$(echo "$COMMIT_MSG" | sed -n 's/^[^(]*(\([^)]*\)).*$/\1/p')
if [[ "$SCOPES" == *"&"* ]] && [[ "$SCOPES" == *","* ]]; then
echo -e "${RED}ERROR: Cannot mix '&' and ',' delimiters${NC}"
echo "Use either '&' (same file) or ',' (different files), not both"
exit 1
fi
# Count scopes
if [[ "$SCOPES" == *"&"* ]]; then
COUNT=$(echo "$SCOPES" | tr '&' '\n' | wc -l | tr -d ' ')
else
COUNT=$(echo "$SCOPES" | tr ',' '\n' | wc -l | tr -d ' ')
fi
if [ "$COUNT" -gt 3 ]; then
echo -e "${RED}ERROR: Too many scopes ($COUNT). Maximum is 3.${NC}"
exit 1
fi
echo -e "${GREEN}✅ SCOPE-X commit format valid!${NC}"
exit 0Make it executable:
chmod +x .git/hooks/commit-msgCreate .github/workflows/validate-commit.yml:
name: Validate SCOPE-X Commits
on:
push:
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate commit messages
run: |
# Get all commits in this PR/push
if [ "${{ github.event_name }}" == "pull_request" ]; then
COMMITS=$(git log --format=%s origin/${{ github.base_ref }}..HEAD 2>/dev/null || echo "")
else
COMMITS=$(git log --format=%s HEAD~10..HEAD 2>/dev/null || echo "")
fi
if [ -z "$COMMITS" ]; then
echo "No commits to validate"
exit 0
fi
VALID=0
echo "$COMMITS" | while IFS= read -r commit; do
[ -z "$commit" ] && continue
# Skip merge commits
[[ "$commit" =~ ^Merge ]] && continue
[[ "$commit" =~ ^Revert ]] && continue
# Validate format
if [[ ! "$commit" =~ ^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)!?\(([a-z0-9]+([&,][a-z0-9]+){0,2})\):\ .+$ ]]; then
echo "❌ Invalid commit: $commit"
VALID=1
else
echo "✅ Valid commit: $commit"
fi
done
if [ "$VALID" -eq 1 ]; then
exit 1
fi
echo "✅ All commits valid!"Create .pre-commit-config.yaml for pre-commit framework:
repos:
- repo: local
hooks:
- id: scope-x-validate
name: Validate SCOPE-X commit messages
entry: .git/hooks/commit-msg
language: script
stages: [commit-msg]npm install -D @scope-x/validatorpip install scope-x-validatorgo get github.com/yourusername/scope-x-validator/**
* Parse a SCOPE-X commit message
* @param {string} msg - The commit message
* @returns {Object|null} Parsed commit or null if invalid
*/
const parseScopeX = (msg) => {
const match = msg.match(/^(\w+)(!?)\(([^)]+)\):\s(.+)$/);
if (!match) return null;
const [, type, breaking, scopesStr, subject] = match;
// Determine delimiter and parse scopes
const hasAmpersand = scopesStr.includes('&');
const hasComma = scopesStr.includes(',');
// Validate delimiter mixing
if (hasAmpersand && hasComma) return null;
const isSingleFile = hasAmpersand;
const delimiter = hasAmpersand ? / & / : /, /;
const scopes = scopesStr.split(delimiter).map(s => s.trim());
// Validate max 3 scopes
if (scopes.length === 0 || scopes.length > 3) return null;
// Validate scope characters
if (!scopes.every(s => /^[a-z0-9]+$/.test(s))) return null;
return {
type,
breaking: !!breaking,
scopes,
isSingleFile,
subject,
primaryScope: scopes[0],
secondaryScopes: scopes.slice(1)
};
};
// Usage examples
const examples = [
'feat(auth & monitoring): add structured logging',
'fix(auth, api): handle expired token gracefully',
'feat!(core, payment, notification): implement new event bus'
];
examples.forEach(msg => {
console.log(parseScopeX(msg));
});
// Output:
// {
// type: 'feat',
// breaking: false,
// scopes: ['auth', 'monitoring'],
// isSingleFile: true,
// subject: 'add structured logging',
// primaryScope: 'auth',
// secondaryScopes: ['monitoring']
// }import re
def parse_scope_x(msg):
"""
Parse a SCOPE-X commit message
Args:
msg (str): The commit message
Returns:
dict: Parsed commit data or None if invalid
"""
pattern = r'^(\w+)(!?)\(([^)]+)\):\s(.+)$'
match = re.match(pattern, msg)
if not match:
return None
type_, breaking, scopes_str, subject = match.groups()
# Check for mixed delimiters
has_ampersand = '&' in scopes_str
has_comma = ',' in scopes_str
if has_ampersand and has_comma:
return None
is_single_file = has_ampersand
# Split by & or , depending on delimiter
if is_single_file:
scopes = [s.strip() for s in scopes_str.split(' & ')]
else:
scopes = [s.strip() for s in scopes_str.split(',')]
# Enforce max 3 scopes
if len(scopes) == 0 or len(scopes) > 3:
return None
# Validate scope characters
for scope in scopes:
if not re.match(r'^[a-z0-9]+$', scope):
return None
return {
'type': type_,
'breaking': bool(breaking),
'scopes': scopes,
'is_single_file': is_single_file,
'subject': subject,
'primary_scope': scopes[0],
'secondary_scopes': scopes[1:]
}
# Usage examples
examples = [
'feat(auth & monitoring): add structured logging',
'fix(auth, api): handle expired token gracefully',
'feat!(core, payment, notification): implement new event bus'
]
for msg in examples:
print(parse_scope_x(msg))package main
import (
"fmt"
"regexp"
"strings"
)
type Commit struct {
Type string
Breaking bool
Scopes []string
IsSingleFile bool
Subject string
PrimaryScope string
SecondaryScopes []string
}
func ParseScopeX(msg string) *Commit {
// Regex: type(!)(scope(s)): subject
re := regexp.MustCompile(`^(\w+)(!?)\(([^)]+)\):\s(.+)$`)
matches := re.FindStringSubmatch(msg)
if len(matches) != 5 {
return nil
}
type_ := matches[1]
breaking := matches[2] == "!"
scopesStr := matches[3]
subject := matches[4]
// Check for mixed delimiters
hasAmpersand := strings.Contains(scopesStr, "&")
hasComma := strings.Contains(scopesStr, ",")
if hasAmpersand && hasComma {
return nil
}
isSingleFile := hasAmpersand
var scopes []string
if isSingleFile {
scopes = strings.Split(scopesStr, " & ")
} else {
scopes = strings.Split(scopesStr, ",")
}
// Trim spaces
for i := range scopes {
scopes[i] = strings.TrimSpace(scopes[i])
}
// Enforce max 3 scopes
if len(scopes) == 0 || len(scopes) > 3 {
return nil
}
// Validate scope characters
scopeRe := regexp.MustCompile(`^[a-z0-9]+$`)
for _, scope := range scopes {
if !scopeRe.MatchString(scope) {
return nil
}
}
secondary := []string{}
if len(scopes) > 1 {
secondary = scopes[1:]
}
return &Commit{
Type: type_,
Breaking: breaking,
Scopes: scopes,
IsSingleFile: isSingleFile,
Subject: subject,
PrimaryScope: scopes[0],
SecondaryScopes: secondary,
}
}
func main() {
examples := []string{
"feat(auth & monitoring): add structured logging",
"fix(auth, api): handle expired token gracefully",
"feat!(core, payment, notification): implement new event bus",
}
for _, msg := range examples {
commit := ParseScopeX(msg)
fmt.Printf("%+v\n", commit)
}
}use regex::Regex;
use std::collections::HashMap;
#[derive(Debug)]
struct Commit {
type_: String,
breaking: bool,
scopes: Vec<String>,
is_single_file: bool,
subject: String,
primary_scope: String,
secondary_scopes: Vec<String>,
}
fn parse_scope_x(msg: &str) -> Option<Commit> {
let re = Regex::new(r"^(\w+)(!?)\(([^)]+)\):\s(.+)$").unwrap();
let caps = re.captures(msg)?;
let type_ = caps[1].to_string();
let breaking = caps[2] == "!";
let scopes_str = caps[3].to_string();
let subject = caps[4].to_string();
let has_ampersand = scopes_str.contains('&');
let has_comma = scopes_str.contains(',');
if has_ampersand && has_comma {
return None;
}
let is_single_file = has_ampersand;
let scopes: Vec<String> = if is_single_file {
scopes_str.split(" & ").map(|s| s.trim().to_string()).collect()
} else {
scopes_str.split(',').map(|s| s.trim().to_string()).collect()
};
if scopes.is_empty() || scopes.len() > 3 {
return None;
}
// Validate scope characters
let scope_re = Regex::new(r"^[a-z0-9]+$").unwrap();
for scope in &scopes {
if !scope_re.is_match(scope) {
return None;
}
}
let secondary_scopes = if scopes.len() > 1 {
scopes[1..].to_vec()
} else {
vec![]
};
Some(Commit {
type_,
breaking,
scopes: scopes.clone(),
is_single_file,
subject,
primary_scope: scopes[0].clone(),
secondary_scopes,
})
}
fn main() {
let examples = vec![
"feat(auth & monitoring): add structured logging",
"fix(auth, api): handle expired token gracefully",
"feat!(core, payment, notification): implement new event bus",
];
for example in examples {
println!("{:?}", parse_scope_x(example));
}
}- Better changelogs: Automatically group changes by primary scope
- Smart reviewers: Immediately know which modules to review based on delimiter
- Accurate labels:
&commits need deep integration review;,commits need cross-module review - Clear impact analysis: Know which modules are most affected
- One commit, one logical change: Even if it touches multiple areas
- Honest commit history: No more vague scopes
- Easier cherry-picking: Know exactly what's affected
- Better PR descriptions: The commit message tells the whole story
- Automated version bumps: Based on primary scope
- Smart build triggers: Only rebuild affected modules
- Targeted testing: Run tests only for affected scopes
- Better release notes: Group changes by primary scope
If you're currently using Conventional Commits, migrating is straightforward:
- Single scope commits: Keep them as-is
- Multiple scopes: Replace
(scope1, scope2)with(scope1 & scope2)for same-file changes - Add primary scope: Reorder scopes so the most affected one is first
| Conventional Commits | SCOPE-X |
|---|---|
feat(auth): add login |
feat(auth): add login (unchanged) |
feat(auth, ui): add dark mode |
feat(auth & ui): add dark mode (same file) |
fix(api, models, utils): update validation |
fix(api, models, utils): update validation (multiple files) |
feat(core, ui, api): add notifications |
feat(core, ui, api): add notifications (multiple files) |
#!/bin/bash
# migrate-to-scope-x.sh
# Converts Conventional Commits to SCOPE-X format
# Usage: ./migrate-to-scope-x.sh [--dry-run]
DRY_RUN=false
if [[ "$1" == "--dry-run" ]]; then
DRY_RUN=true
fi
# Backup existing commits
git branch backup-before-migration
git log --format=%H %s | while read commit; do
msg=$(git log -1 --format=%s $commit)
# Check if it's a conventional commit with multiple scopes
if [[ $msg =~ ^([a-z]+)(!?)\(([^,]+),([^)]+)\):\ (.+)$ ]]; then
type=${BASH_REMATCH[1]}
breaking=${BASH_REMATCH[2]}
scope1=${BASH_REMATCH[3]}
scope2=${BASH_REMATCH[4]}
subject=${BASH_REMATCH[5]}
new_msg="${type}${breaking}(${scope1} & ${scope2}): ${subject}"
echo "Converting: $msg"
echo "To: $new_msg"
if [[ "$DRY_RUN" == false ]]; then
git commit --amend -m "$new_msg" -C $commit
fi
fi
done
if [[ "$DRY_RUN" == false ]]; then
echo "✅ Migration complete! Changes have been amended."
else
echo "✅ Dry run complete. No changes were made."
echo "Run without --dry-run to apply changes."
fi- Update your commit validation hook
- Update your CI/CD pipeline
- Update your changelog generator
- Update your contributing guidelines
- Inform your team about the change
- Set up a transition period where both formats are allowed
- Gradually migrate existing commits (optional)
Three scopes is the practical limit for a single commit. If your change affects more than 3 modules, it's probably too large and should be split into multiple commits.
No. A commit must use either & (same file) or , (multiple files). Mixing them would create confusion about what the commit actually does.
Use &. Example: feat(auth & monitoring): update logger where logger.ts is used by both modules.
You can use a single scope: feat(auth): update login flow (even if it touches multiple files in the auth module).
You can customize semantic-release with a custom parser that understands SCOPE-X format.
Scopes should be lowercase and without spaces. Use hyphens if needed: user-auth, api-gateway.
Yes! Single-scope commits are identical to Conventional Commits. Multiple-scope commits are the extension.
SCOPE-X is designed as an extension to Conventional Commits. It works with most tools that support Conventional Commits.
You can use the provided parsers to extract the primary scope and treat it as a standard Conventional Commit.
&(ampersand): Changes are in a single file that affects multiple modules,(comma): Changes are in multiple files across different modules
Spaces around delimiters are optional but recommended for readability:
feat(auth&monitoring): ...(valid)feat(auth & monitoring): ...(recommended)feat(auth, api): ...(recommended)
This is our idea and we're building the ecosystem around it!
- Report bugs in the hook scripts
- Translate documentation to your language
- Add parsers in other languages (Rust, Kotlin, Ruby, etc.)
- Write blog posts about your experience
- Improve this documentation
- Create tooling (IDE plugins, CI actions, etc.)
git clone https://github.com/lilypadteam/SCOPE-X
cd SCOPE-X- Fork the repository
- Create a feature branch
- Make your changes
- Run tests
- Submit a pull request
- Wait for review
- Conventional Commits - The foundation
- semantic-release - Automated version management
- commitlint - Lint commit messages
- standard-version - Version management
- Based on the original idea by LilyPadTeam
- Inspired by Conventional Commits
- Built with ❤️ for developers who hate lying to their commit history
Star this repo if you believe in honest commit messages!