diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a9204..ad1d9ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ ## 0.1.1 +``` +## v0.1.1 + +✨ Added +- Added git hooks management CLI +- Added emoji option to CLI and config to control emoji prefixes + +📝 Documentation +- Recommended installation using pipx and uv +``` + ## v0.1.1 ✨ Added diff --git a/README.md b/README.md index debc848..7f430f4 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,17 @@ Alternatively, pass `--pre-commit-template` and/or `--pre-push-template` to inst Note: If a hook already exists, DevTools creates a timestamped backup in `.git/hooks/` with the `.bak` suffix before overwriting, e.g., `pre-commit.20250101123000.bak`. +Auto-fix behavior (pre-commit): + +- If initial checks fail, DevTools attempts to auto-fix issues by running `scripts/format.py` and `scripts/lintfix.py` if those files exist in your repo. +- Any changes made are automatically staged (`git add -A`), and checks are re-run. The commit will fail only if the re-run still fails. +- You can run these scripts manually as well: + +```bash +python3 scripts/format.py +python3 scripts/lintfix.py +``` + --- ## Features diff --git a/devtools/commitgen/git.py b/devtools/commitgen/git.py index 8eae9c0..2ec7d7c 100644 --- a/devtools/commitgen/git.py +++ b/devtools/commitgen/git.py @@ -38,8 +38,10 @@ class CommitGenGitService(GitService): def commit_changes(self, message: str, sign: bool = False) -> None: """Commit changes with the given message.""" - if not self.commit(message, sign=sign): - raise Exception("Failed to commit changes") + ok, stdout, stderr, code = self.commit_verbose(message, sign=sign) + if not ok: + details = stderr or stdout or f"exit code {code}" + raise Exception(f"Failed to commit changes: {details}") def push_changes(self) -> None: """Push changes to the current branch.""" diff --git a/devtools/hooks/cli.py b/devtools/hooks/cli.py index ab54863..e2be0bf 100644 --- a/devtools/hooks/cli.py +++ b/devtools/hooks/cli.py @@ -15,7 +15,7 @@ console = Console() HOOK_TEMPLATES = { "pre-commit": """#!/usr/bin/env bash -set -euo pipefail +set -u echo "Running pre-commit checks..." @@ -50,11 +50,11 @@ run_lint() { # Python if [ -f "pyproject.toml" ] || [ -f "requirements.txt" ]; then if command -v ruff >/dev/null 2>&1; then - ruff . + ruff . || return 1 return 0 elif command -v black >/dev/null 2>&1; then echo "ruff not found; running black --check instead" - black --check . + black --check . || return 1 return 0 fi fi @@ -68,7 +68,7 @@ run_lint() { # Go if [ -f "go.mod" ]; then if command -v golangci-lint >/dev/null 2>&1; then - golangci-lint run + golangci-lint run || return 1 return 0 fi if command -v go >/dev/null 2>&1; then @@ -76,9 +76,9 @@ run_lint() { if [ -n "$fmt_out" ]; then echo "Files not formatted (gofmt):" echo "$fmt_out" - exit 1 + return 1 fi - go vet ./... + go vet ./... || return 1 return 0 fi fi @@ -102,15 +102,15 @@ run_tests() { # Node.js/TypeScript if [ -f "package.json" ]; then if command -v npm >/dev/null 2>&1; then - npm test -s --if-present || true + npm test -s --if-present || return 1 return 0 fi if command -v pnpm >/dev/null 2>&1; then - pnpm -s test || true + pnpm -s test || return 1 return 0 fi if command -v yarn >/dev/null 2>&1; then - yarn -s test || true + yarn -s test || return 1 return 0 fi fi @@ -118,31 +118,31 @@ run_tests() { # Python if [ -f "pyproject.toml" ] || [ -f "requirements.txt" ]; then if command -v pytest >/dev/null 2>&1; then - pytest -q + pytest -q || return 1 return 0 fi fi # Rust if [ -f "Cargo.toml" ] && command -v cargo >/dev/null 2>&1; then - cargo test -q + cargo test -q || return 1 return 0 fi # Go if [ -f "go.mod" ] && command -v go >/dev/null 2>&1; then - go test ./... + go test ./... || return 1 return 0 fi # Java if [ -f "pom.xml" ] || [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then if [ -x "./gradlew" ]; then - ./gradlew -q test || true + ./gradlew -q test || return 1 return 0 fi if command -v mvn >/dev/null 2>&1; then - mvn -q test || true + mvn -q test || return 1 return 0 fi fi @@ -150,8 +150,29 @@ run_tests() { echo "No recognized test runner found; skipping tests" } -run_lint -run_tests +run_checks() { + lint_failed=0 + test_failed=0 + run_lint || lint_failed=1 + run_tests || test_failed=1 + if [ $lint_failed -ne 0 ] || [ $test_failed -ne 0 ]; then + return 1 + fi + return 0 +} + +# Initial check; if it fails, try to auto-fix using local scripts if present +if ! run_checks; then + echo "Initial checks failed; attempting auto-fix via scripts/format.py and scripts/lintfix.py if present" + if [ -f "scripts/format.py" ]; then + if command -v python3 >/dev/null 2>&1; then python3 scripts/format.py || true; else python scripts/format.py || true; fi + fi + if [ -f "scripts/lintfix.py" ]; then + if command -v python3 >/dev/null 2>&1; then python3 scripts/lintfix.py || true; else python scripts/lintfix.py || true; fi + fi + if command -v git >/dev/null 2>&1; then git add -A; fi + run_checks +fi """, "pre-push": """#!/usr/bin/env bash diff --git a/devtools/shared/git.py b/devtools/shared/git.py index 4410d36..5128f13 100644 --- a/devtools/shared/git.py +++ b/devtools/shared/git.py @@ -76,8 +76,23 @@ class GitService: if sign: args.append("-S") args.extend(["-m", message]) - stdout, stderr, _ = self._run_git_command(args) - return not stderr + stdout, stderr, returncode = self._run_git_command(args) + return returncode == 0 + + def commit_verbose( + self, message: str, sign: bool = False + ) -> Tuple[bool, str, str, int]: + """Create a commit and return success and raw outputs. + + Returns: + (ok, stdout, stderr, returncode) + """ + args = ["commit"] + if sign: + args.append("-S") + args.extend(["-m", message]) + stdout, stderr, returncode = self._run_git_command(args) + return returncode == 0, stdout, stderr, returncode def get_repo_name(self) -> str: """Get the repository name.""" diff --git a/scripts/format.py b/scripts/format.py new file mode 100644 index 0000000..7793bc0 --- /dev/null +++ b/scripts/format.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +Multi-ecosystem formatter runner. + +Best-effort: formats sources if tools exist. Safe to run repeatedly. +""" +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +def has_file(*names: str) -> bool: + return any((Path.cwd() / name).exists() for name in names) + + +def run(cmd: list[str]) -> int: + try: + return subprocess.run(cmd, check=False).returncode + except Exception: + return 0 + + +def main() -> int: + # Node/TS: prettier write + if has_file("package.json"): + if shutil.which("npm"): + run(["npx", "--yes", "prettier", "--write", "."]) # type: ignore + elif shutil.which("pnpm"): + run(["pnpm", "dlx", "prettier", "--write", "."]) # type: ignore + elif shutil.which("yarn"): + run(["npx", "--yes", "prettier", "--write", "."]) # type: ignore + + # Python: ruff format or black + if has_file("pyproject.toml", "requirements.txt"): + if shutil.which("ruff"): + run(["ruff", "format", "."]) # type: ignore + elif shutil.which("black"): + run(["black", "."]) # type: ignore + + # Rust: cargo fmt + if has_file("Cargo.toml") and shutil.which("cargo"): + run(["cargo", "fmt", "--all"]) # type: ignore + + # Go: gofmt -w + if has_file("go.mod") and shutil.which("go"): + # Format in-place; gofmt returns 0 even if files changed + run(["gofmt", "-w", "."]) # type: ignore + + # Java: Gradle/Maven format plugins are not standard; skip + return 0 + + +if __name__ == "__main__": + import shutil + + raise SystemExit(main()) diff --git a/scripts/lintfix.py b/scripts/lintfix.py new file mode 100644 index 0000000..87e4699 --- /dev/null +++ b/scripts/lintfix.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Multi-ecosystem lint fixer. + +Best-effort: applies automatic fixes if tools exist. Safe to run repeatedly. +""" +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def has_file(*names: str) -> bool: + return any((Path.cwd() / name).exists() for name in names) + + +def run(cmd: list[str]) -> int: + try: + return subprocess.run(cmd, check=False).returncode + except Exception: + return 0 + + +def main() -> int: + import shutil + + # Node/TS: eslint --fix and prettier --write + if has_file("package.json"): + if shutil.which("npx"): + run(["npx", "--yes", "eslint", "--fix", "."]) # type: ignore + run(["npx", "--yes", "prettier", "--write", "."]) # type: ignore + elif shutil.which("pnpm"): + run(["pnpm", "dlx", "eslint", "--fix", "."]) # type: ignore + run(["pnpm", "dlx", "prettier", "--write", "."]) # type: ignore + + # Python: ruff --fix or autoflake + isort + black + if has_file("pyproject.toml", "requirements.txt"): + if shutil.which("ruff"): + run(["ruff", "check", "--fix", "."]) # type: ignore + else: + if shutil.which("autoflake"): + run(["autoflake", "-r", "--in-place", "--remove-all-unused-imports", "."]) # type: ignore + if shutil.which("isort"): + run(["isort", "."]) # type: ignore + if shutil.which("black"): + run(["black", "."]) # type: ignore + + # Go: go fmt and go fix + if has_file("go.mod") and shutil.which("go"): + run(["go", "fmt", "./..."]) # type: ignore + run(["go", "fix", "./..."]) # type: ignore + + # Rust: cargo clippy --fix (requires nightly for some fixes; allow failure) + if has_file("Cargo.toml") and shutil.which("cargo"): + run(["cargo", "clippy", "--fix", "-Z", "unstable-options", "--allow-dirty", "--allow-staged"]) # type: ignore + + # Java: no standard auto-fix; skip + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())