mirror of
https://github.com/zensical/zensical.git
synced 2026-05-03 17:40:31 +00:00
workspace:chore - initial commit
This commit is contained in:
+243
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Copyright (c) Zensical LLC <https://zensical.org>
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Third-party contributions licensed under CLA
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
import os, re, sys, tomllib # noqa: E401
|
||||
|
||||
from dataclasses import dataclass
|
||||
from glob import glob
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ScopeError(ValueError):
|
||||
"""Invalid commit scope error."""
|
||||
|
||||
|
||||
class TypeError(ValueError):
|
||||
"""Invalid commit type error."""
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
"""
|
||||
Commit message.
|
||||
|
||||
This class represents a commit message with a scope, type, and description.
|
||||
It provides methods to parse and validate commit messages according to our
|
||||
format, which is slightly different from the Conventional Commits standard,
|
||||
improving readability and consistency.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def parse(cls, message: str) -> "Message":
|
||||
"""
|
||||
Parse a commit message string into an object.
|
||||
"""
|
||||
match = re.match(r"^([^:]+):([^\s]+) - (.+)$", message)
|
||||
if not match:
|
||||
raise ValueError("Required format: <scope>:<type> - <description>")
|
||||
|
||||
# Extract components and return commit message
|
||||
scope, type, description = match.groups()
|
||||
return cls(scope=scope, type=type, description=description)
|
||||
|
||||
def validate(self, scopes: dict[str, str]) -> None:
|
||||
"""
|
||||
Validate the commit message against the given scopes and types.
|
||||
"""
|
||||
if self.scope not in scopes:
|
||||
raise ScopeError(f"Invalid scope: {self.scope}")
|
||||
|
||||
# Validate type
|
||||
if self.type not in TYPES:
|
||||
raise TypeError(f"Invalid type: {self.type}")
|
||||
|
||||
# Validate description
|
||||
if self.description != self.description.lower():
|
||||
raise ValueError("Commit message must be lowercased.")
|
||||
|
||||
# Retrieve staged files
|
||||
with os.popen("git diff --cached --name-only") as p:
|
||||
output = p.read()
|
||||
|
||||
# Validate if files are within scope
|
||||
for file in output.strip().split("\n"):
|
||||
if not f"./{file}".startswith(scopes[self.scope]):
|
||||
raise ValueError(
|
||||
f"Invalid scope for file: "
|
||||
f"{file} not in {scopes[self.scope]}"
|
||||
)
|
||||
|
||||
scope: str
|
||||
"""
|
||||
Commit scope.
|
||||
"""
|
||||
|
||||
type: str
|
||||
"""
|
||||
Commit type.
|
||||
"""
|
||||
|
||||
description: str
|
||||
"""
|
||||
Commit description.
|
||||
"""
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve(directory: str) -> dict[str, str] | None:
|
||||
"""
|
||||
Return commit scopes for a cargo project.
|
||||
|
||||
This function checks, if the given directory contains a `Cargo.toml` file,
|
||||
and if so, parses it to extract the workspace members. It then resolves the
|
||||
valid scopes, which are the names of the crates defined in the respective
|
||||
`Cargo.toml` files.
|
||||
"""
|
||||
path = os.path.join(directory, "Cargo.toml")
|
||||
if not os.path.isfile(path):
|
||||
return
|
||||
|
||||
# Open and parse the Cargo.toml file
|
||||
with open(path, "rb") as f:
|
||||
content = tomllib.load(f)
|
||||
|
||||
# Return workspace members
|
||||
if "workspace" in content:
|
||||
scopes: dict[str, str] = {}
|
||||
|
||||
# Get the list of member crates
|
||||
for member in content["workspace"].get("members", []):
|
||||
path = os.path.join(directory, member)
|
||||
for match in glob(path):
|
||||
nested = resolve(match)
|
||||
if nested:
|
||||
scopes.update(nested)
|
||||
|
||||
# Return commit scopes
|
||||
return scopes
|
||||
|
||||
# Return crate
|
||||
package = content.get("package")
|
||||
if package and "name" in package:
|
||||
return {package["name"]: directory}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
TYPES = {
|
||||
"feature",
|
||||
"fix",
|
||||
"refactor",
|
||||
"docs",
|
||||
"perf",
|
||||
"test",
|
||||
"build",
|
||||
"style",
|
||||
"chore",
|
||||
"release",
|
||||
}
|
||||
"""
|
||||
Commit types.
|
||||
"""
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
BG_RED = "\033[41m"
|
||||
"""
|
||||
ANSI escape code for red background.
|
||||
"""
|
||||
|
||||
FG_RED = "\033[31m"
|
||||
"""
|
||||
ANSI escape code for red foreground.
|
||||
"""
|
||||
|
||||
RESET = "\033[0m"
|
||||
"""
|
||||
ANSI escape code to reset formatting.
|
||||
"""
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Program
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Commit message linter.
|
||||
"""
|
||||
if len(sys.argv) < 2:
|
||||
print("No commit message provided.")
|
||||
sys.exit(1)
|
||||
|
||||
# Commit message might be passed as string, or in a file
|
||||
commit = sys.argv[1]
|
||||
if os.path.isfile(commit):
|
||||
with open(sys.argv[1], "r") as f:
|
||||
message = f.read().strip()
|
||||
else:
|
||||
message = commit.strip()
|
||||
|
||||
# Skip merge commits
|
||||
if message.startswith("Merge branch"):
|
||||
return sys.exit(0)
|
||||
|
||||
# Resolve cargo workspace members and parse commit message
|
||||
scopes = resolve(os.path.curdir)
|
||||
scopes["workspace"] = "."
|
||||
try:
|
||||
msg = Message.parse(message)
|
||||
msg.validate(scopes)
|
||||
|
||||
# If an error happened, print it
|
||||
except ValueError as e:
|
||||
print(f"{FG_RED}✘{RESET} {BG_RED} Error {RESET} {e}")
|
||||
print("")
|
||||
print(" Commit rejected.")
|
||||
print("")
|
||||
|
||||
# Exit with error
|
||||
return sys.exit(1)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user