smart-commit-tool 2.0.3__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {smart_commit_tool-2.0.3 → smart_commit_tool-3.0.0}/.github/workflows/smart_commit_ci.yml +5 -6
  2. {smart_commit_tool-2.0.3 → smart_commit_tool-3.0.0}/PKG-INFO +1 -1
  3. smart_commit_tool-3.0.0/assets/demo.gif +0 -0
  4. {smart_commit_tool-2.0.3 → smart_commit_tool-3.0.0}/pyproject.toml +1 -1
  5. smart_commit_tool-3.0.0/src/__init__.py +0 -0
  6. smart_commit_tool-3.0.0/src/cli.py +70 -0
  7. smart_commit_tool-3.0.0/src/services/config.py +31 -0
  8. smart_commit_tool-3.0.0/src/services/git.py +53 -0
  9. smart_commit_tool-3.0.0/src/services/logger.py +26 -0
  10. smart_commit_tool-3.0.0/src/services/runner.py +20 -0
  11. smart_commit_tool-3.0.0/src/services/security.py +59 -0
  12. smart_commit_tool-2.0.3/assets/demonstration.gif +0 -0
  13. smart_commit_tool-2.0.3/src/smart_commit/cli.py +0 -105
  14. smart_commit_tool-2.0.3/src/smart_commit/config.py +0 -41
  15. smart_commit_tool-2.0.3/src/smart_commit/constants.py +0 -115
  16. smart_commit_tool-2.0.3/src/smart_commit/exceptions.py +0 -14
  17. smart_commit_tool-2.0.3/src/smart_commit/logger.py +0 -32
  18. smart_commit_tool-2.0.3/src/smart_commit/services/ci.py +0 -66
  19. smart_commit_tool-2.0.3/src/smart_commit/services/git.py +0 -129
  20. smart_commit_tool-2.0.3/src/smart_commit/services/security.py +0 -68
  21. smart_commit_tool-2.0.3/src/smart_commit/services/validator.py +0 -28
  22. {smart_commit_tool-2.0.3 → smart_commit_tool-3.0.0}/.gitignore +0 -0
  23. {smart_commit_tool-2.0.3 → smart_commit_tool-3.0.0}/LICENSE +0 -0
  24. {smart_commit_tool-2.0.3 → smart_commit_tool-3.0.0}/README.md +0 -0
  25. {smart_commit_tool-2.0.3 → smart_commit_tool-3.0.0}/README.ru.md +0 -0
@@ -12,7 +12,6 @@ jobs:
12
12
  strategy:
13
13
  fail-fast: false
14
14
  matrix:
15
- # Runs the pipeline on multiple Python versions automatically
16
15
  python-version: ["3.10", "3.11", "3.12"]
17
16
 
18
17
  steps:
@@ -23,18 +22,20 @@ jobs:
23
22
  uses: actions/setup-python@v5
24
23
  with:
25
24
  python-version: ${{ matrix.python-version }}
26
- cache: 'pip' # Speeds up consecutive runs
25
+ cache: 'pip'
27
26
 
28
27
  - name: Install Dependencies (Adaptive Smart Installer)
29
28
  run: |
30
29
  python -m pip install --upgrade pip
31
30
 
31
+ # Ensure local bin is in PATH for all subsequent steps
32
+ echo "$HOME/.local/bin" >> $GITHUB_PATH
33
+
32
34
  echo "🔍 Detecting package manager..."
33
35
 
34
36
  if [ -f "poetry.lock" ] || [ -f "pyproject.toml" ] && grep -q "tool.poetry" "pyproject.toml"; then
35
37
  echo "🚀 Poetry detected!"
36
38
  pip install poetry
37
- # Force poetry to install globally so custom commands work without 'poetry run'
38
39
  poetry config virtualenvs.create false
39
40
  poetry install --no-interaction --no-ansi
40
41
 
@@ -52,12 +53,10 @@ jobs:
52
53
  elif [ -f "requirements.txt" ]; then
53
54
  echo "🚀 Standard requirements.txt detected!"
54
55
  pip install -r requirements.txt
55
- # Install package itself if setup.py exists
56
56
  if [ -f "setup.py" ]; then pip install -e .; fi
57
57
 
58
58
  elif [ -f "pyproject.toml" ]; then
59
- echo "🚀 Standard PEP 621 pyproject.toml detected (Hatch, Flit, Setuptools)!"
60
- # Attempt to install with dev dependencies if available, otherwise regular install
59
+ echo "🚀 Standard PEP 621 pyproject.toml detected!"
61
60
  pip install -e .[dev] || pip install -e .
62
61
 
63
62
  else
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smart-commit-tool
3
- Version: 2.0.3
3
+ Version: 3.0.0
4
4
  Summary: Automated pre-push workflow manager with built-in code quality enforcement and smart branch protection.
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
Binary file
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "smart-commit-tool"
7
- version = "2.0.3"
7
+ version = "3.0.0"
8
8
  description = "Automated pre-push workflow manager with built-in code quality enforcement and smart branch protection."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
@@ -0,0 +1,70 @@
1
+ import argparse
2
+ import sys
3
+ from .services import (
4
+ config as config_service,
5
+ git as git_service,
6
+ security as security_service,
7
+ runner as runner_service,
8
+ )
9
+ from .services.logger import logger
10
+
11
+
12
+ def interactive_input(prompt: str, default: str = "") -> str:
13
+ """Запрашивает ввод у пользователя, если флаг не передан."""
14
+ suffix = f" [{default}]" if default else ""
15
+ value = input(f"{prompt}{suffix}: ").strip()
16
+ return value if value else default
17
+
18
+
19
+ def main():
20
+ parser = argparse.ArgumentParser(description="Smart Git Commit Tool")
21
+ parser.add_argument("-b", "--branch", help="Имя ветки")
22
+ parser.add_argument("-m", "--message", help="Сообщение коммита")
23
+ parser.add_argument("-r", "--remote", help="Remote (например, origin)")
24
+
25
+ args = parser.parse_args()
26
+
27
+ logger.info("Инициализация Smart Commit...")
28
+
29
+ # 1. Сбор параметров (Fallback на интерактивный ввод)
30
+ branch = args.branch or interactive_input("Введите имя ветки")
31
+ message = args.message or interactive_input("Введите сообщение коммита")
32
+ remote = args.remote or interactive_input("Введите remote", default="origin")
33
+
34
+ if not branch or not message:
35
+ logger.error("Ветка и сообщение коммита обязательны!")
36
+ sys.exit(1)
37
+
38
+ # 2. Загрузка конфига
39
+ config = config_service.load_config()
40
+
41
+ # 3. Валидация ветки
42
+ git_service.check_protected(branch, config.get("protected_branches", []))
43
+ git_service.ensure_branch(branch)
44
+
45
+ # 4. Добавление файлов в индекс (чтобы сканер секретов увидел их)
46
+ git_service.add_all()
47
+
48
+ # 5. Секьюрити чек
49
+ staged_files = git_service.get_staged_files()
50
+ if not staged_files:
51
+ logger.warning("Нет изменений для коммита.")
52
+ sys.exit(0)
53
+
54
+ if security_service.check_secrets(staged_files):
55
+ # Удаляем из индекса файлы, которые только что попали в gitignore
56
+ git_service.run_cmd(["git", "rm", "-r", "--cached", "."])
57
+ git_service.add_all()
58
+
59
+ # 6. Запуск кастомных команд (ruff, тесты и т.д.)
60
+ runner_service.run_pre_commands(config.get("commands", []))
61
+
62
+ # 7. Финализация (Commit & Push)
63
+ git_service.commit(message)
64
+ git_service.push(remote, branch)
65
+
66
+ logger.success("Smart Commit завершил работу!")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -0,0 +1,31 @@
1
+ import os
2
+ from pathlib import Path
3
+ from .logger import logger
4
+
5
+ try:
6
+ import tomllib
7
+ except ModuleNotFoundError:
8
+ import tomli as tomllib
9
+
10
+
11
+ def load_config() -> dict:
12
+ """Загружает секцию [tool.smart_commit] из pyproject.toml."""
13
+ config_path = Path(os.getcwd()) / "pyproject.toml"
14
+
15
+ default_config = {
16
+ "repository_url": "",
17
+ "commands": [],
18
+ "protected_branches": ["main", "master"],
19
+ }
20
+
21
+ if not config_path.exists():
22
+ logger.warning("pyproject.toml не найден. Используются настройки по умолчанию.")
23
+ return default_config
24
+
25
+ try:
26
+ with open(config_path, "rb") as f:
27
+ data = tomllib.load(f)
28
+ return data.get("tool", {}).get("smart_commit", default_config)
29
+ except Exception as e:
30
+ logger.error(f"Ошибка чтения конфига: {e}")
31
+ return default_config
@@ -0,0 +1,53 @@
1
+ import subprocess
2
+ import sys
3
+ from .logger import logger
4
+
5
+
6
+ def run_cmd(cmd: list[str], check=True) -> str:
7
+ """Выполняет bash-команду и возвращает вывод."""
8
+ result = subprocess.run(cmd, capture_output=True, text=True)
9
+ if check and result.returncode != 0:
10
+ logger.error(f"Ошибка Git: {result.stderr.strip()}")
11
+ sys.exit(1)
12
+ return result.stdout.strip()
13
+
14
+
15
+ def check_protected(branch: str, protected_branches: list[str]):
16
+ """Блокирует пуш в защищенные ветки."""
17
+ if branch in protected_branches:
18
+ logger.error(f"Прямой коммит в защищенную ветку '{branch}' запрещен!")
19
+ sys.exit(1)
20
+
21
+
22
+ def ensure_branch(branch: str):
23
+ """Проверяет наличие ветки. Если нет - создает."""
24
+ result = subprocess.run(["git", "checkout", branch], capture_output=True, text=True)
25
+ if result.returncode != 0:
26
+ logger.info(f"Ветка '{branch}' не найдена. Создаю новую...")
27
+ run_cmd(["git", "checkout", "-b", branch])
28
+ else:
29
+ logger.info(f"Переключено на ветку '{branch}'.")
30
+
31
+
32
+ def add_all():
33
+ run_cmd(["git", "add", "."])
34
+ logger.info("Файлы добавлены в индекс (git add).")
35
+
36
+
37
+ def get_staged_files() -> list[str]:
38
+ """Возвращает список файлов, готовых к коммиту."""
39
+ output = run_cmd(["git", "diff", "--cached", "--name-only"])
40
+ return [f for f in output.split("\n") if f]
41
+
42
+
43
+ def commit(message: str):
44
+ run_cmd(
45
+ ["git", "commit", "-m", message], check=False
46
+ ) # check=False, чтобы обработать "nothing to commit"
47
+ logger.info(f"Коммит создан: '{message}'")
48
+
49
+
50
+ def push(remote: str, branch: str):
51
+ logger.info(f"Отправка изменений в {remote}/{branch}...")
52
+ run_cmd(["git", "push", "-u", remote, branch])
53
+ logger.success("Push успешно завершен!")
@@ -0,0 +1,26 @@
1
+ import sys
2
+ from colorama import Fore, Style, init
3
+
4
+
5
+ init(autoreset=True)
6
+
7
+
8
+ class Logger:
9
+ @staticmethod
10
+ def info(msg: str):
11
+ print(f"{Fore.CYAN}[INFO]{Style.RESET_ALL} {msg}")
12
+
13
+ @staticmethod
14
+ def success(msg: str):
15
+ print(f"{Fore.GREEN}[SUCCESS]{Style.RESET_ALL} {msg}")
16
+
17
+ @staticmethod
18
+ def warning(msg: str):
19
+ print(f"{Fore.YELLOW}[WARNING]{Style.RESET_ALL} {msg}")
20
+
21
+ @staticmethod
22
+ def error(msg: str):
23
+ print(f"{Fore.RED}[ERROR]{Style.RESET_ALL} {msg}", file=sys.stderr)
24
+
25
+
26
+ logger = Logger()
@@ -0,0 +1,20 @@
1
+ import subprocess
2
+ import sys
3
+ from .logger import logger
4
+
5
+
6
+ def run_pre_commands(commands: list[str]):
7
+ """Запускает команды из конфига перед коммитом."""
8
+ if not commands:
9
+ return
10
+
11
+ logger.info("Запуск pre-commit команд...")
12
+ for cmd in commands:
13
+ logger.info(f"Выполнение: {cmd}")
14
+ result = subprocess.run(cmd, shell=True)
15
+
16
+ if result.returncode != 0:
17
+ logger.error(f"Команда '{cmd}' завершилась с ошибкой!")
18
+ sys.exit(1)
19
+
20
+ logger.success("Все pre-commit команды выполнены успешно.")
@@ -0,0 +1,59 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ from .logger import logger
5
+
6
+ SECRET_PATTERNS = [
7
+ re.compile(
8
+ r'(?i)(api[_-]?key|secret|password|token)\s*[:=]\s*["\'][a-zA-Z0-9\-_]{10,}["\']'
9
+ ),
10
+ re.compile(r"-----BEGIN (RSA|OPENSSH|PRIVATE) KEY-----"),
11
+ ]
12
+
13
+ SUSPICIOUS_FILES = [".env", ".env.local", "secrets.json"]
14
+
15
+
16
+ def check_secrets(staged_files: list[str]) -> bool:
17
+ """
18
+ Проверяет файлы на наличие секретов.
19
+ Возвращает True, если был изменен .gitignore (требуется повторный git add).
20
+ """
21
+ secrets_found = []
22
+
23
+ for file_path in staged_files:
24
+ if not os.path.exists(file_path):
25
+ continue
26
+
27
+ file_name = os.path.basename(file_path)
28
+
29
+ if file_name in SUSPICIOUS_FILES:
30
+ secrets_found.append(file_path)
31
+ continue
32
+
33
+ try:
34
+ with open(file_path, "r", encoding="utf-8") as f:
35
+ content = f.read()
36
+ if any(pattern.search(content) for pattern in SECRET_PATTERNS):
37
+ secrets_found.append(file_path)
38
+ except UnicodeDecodeError:
39
+ pass
40
+
41
+ if not secrets_found:
42
+ return False
43
+
44
+ logger.warning("ОБНАРУЖЕНЫ ВОЗМОЖНЫЕ УТЕЧКИ СЕКРЕТОВ!")
45
+ for s in secrets_found:
46
+ logger.warning(f" -> {s}")
47
+
48
+ answer = input("Добавить эти файлы в .gitignore? [y/N]: ").strip().lower()
49
+ if answer == "y":
50
+ with open(".gitignore", "a", encoding="utf-8") as f:
51
+ f.write("\n# Auto-added by Smart Commit\n")
52
+ for s in secrets_found:
53
+ f.write(f"{s}\n")
54
+
55
+ logger.success("Файлы добавлены в .gitignore.")
56
+ return True
57
+
58
+ logger.error("Коммит прерван из-за угрозы безопасности. Очистите секреты вручную.")
59
+ sys.exit(1)
@@ -1,105 +0,0 @@
1
- import argparse
2
- import sys
3
- from .exceptions import SmartCommitError
4
- from .config import ConfigService
5
- from .services.security import SecurityService
6
- from .services.git import GitService
7
- from .services.validator import ValidatorService
8
- from .services.ci import CIService
9
- from .logger import Console, logger
10
- from .constants import TOOL_NAME, DEFAULT_PROTECTED_BRANCHES
11
-
12
-
13
- def run_ci_generation_flow():
14
- """Dedicated flow for 'smart-commit generate-action' command."""
15
- logger.info("=== CI Generation Flow Started ===")
16
- config = ConfigService.load_or_create()
17
- commands = config.get("commands", [])
18
- protected_branches = config.get("protected_branches", DEFAULT_PROTECTED_BRANCHES)
19
-
20
- GitService.ensure_repo()
21
-
22
- CIService.generate_github_action(commands, protected_branches)
23
- CIService.prompt_and_push()
24
-
25
- sys.exit(0)
26
-
27
-
28
- def main():
29
- if len(sys.argv) > 1 and sys.argv[1] == "generate-action":
30
- try:
31
- run_ci_generation_flow()
32
- except SmartCommitError as e:
33
- Console.error(str(e))
34
- sys.exit(1)
35
- except KeyboardInterrupt:
36
- Console.warning("\nProcess interrupted by user.")
37
- sys.exit(0)
38
-
39
- parser = argparse.ArgumentParser(description=TOOL_NAME)
40
- parser.add_argument("-b", "--branch", help="Specify target branch")
41
- parser.add_argument("-m", "--message", help="Commit message")
42
- args = parser.parse_args()
43
-
44
- try:
45
- logger.info("=== Smart Commit Process Started ===")
46
-
47
- config = ConfigService.load_or_create()
48
- protected_branches = config.get(
49
- "protected_branches", DEFAULT_PROTECTED_BRANCHES
50
- )
51
- commands = config.get("commands", [])
52
-
53
- GitService.ensure_repo()
54
- SecurityService.ensure_gitignore()
55
- SecurityService.check_env_leaks()
56
-
57
- current_branch = GitService.get_current_branch()
58
- branch = args.branch or current_branch
59
- GitService.switch_branch(branch)
60
-
61
- if branch in protected_branches:
62
- Console.warning(
63
- f"You are pushing directly to a protected branch: '{branch}'."
64
- )
65
- ans = input("Are you sure you want to continue? [y/N]: ").strip().lower()
66
- if ans != "y":
67
- raise SmartCommitError("Operation cancelled by user.")
68
-
69
- has_changes = GitService.has_any_changes()
70
- message = args.message
71
-
72
- if has_changes:
73
- if not message:
74
- message = input("📝 Enter commit message: ").strip()
75
- if not message:
76
- raise SmartCommitError("Commit message cannot be empty.")
77
-
78
- ValidatorService.check_conventional_commit(message)
79
-
80
- if commands:
81
- Console.info("--- 🛠 RUNNING PIPELINE (from pyproject.toml) ---")
82
- ValidatorService.run_commands(commands)
83
-
84
- Console.info("--- 🚀 FINALIZING WORKFLOW ---")
85
-
86
- if has_changes:
87
- GitService.smart_stage()
88
- GitService.commit(message)
89
- else:
90
- Console.info(
91
- "Working directory is clean. Skipping commit phase and proceeding to push."
92
- )
93
-
94
- GitService.push_with_retry(branch)
95
-
96
- except SmartCommitError as e:
97
- Console.error(str(e))
98
- sys.exit(1)
99
- except KeyboardInterrupt:
100
- Console.warning("\nProcess interrupted by user.")
101
- sys.exit(0)
102
-
103
-
104
- if __name__ == "__main__":
105
- main()
@@ -1,41 +0,0 @@
1
- import sys
2
- from .exceptions import SmartCommitError
3
- from .logger import Console, logger
4
- from .constants import PYPROJECT_FILE, DEFAULT_TOML_CONFIG
5
-
6
- if sys.version_info >= (3, 11):
7
- import tomllib
8
- else:
9
- import tomli as tomllib
10
-
11
-
12
- class ConfigService:
13
- @classmethod
14
- def load_or_create(cls) -> dict:
15
- """Reads configuration. If the section is missing, appends default settings."""
16
- if not PYPROJECT_FILE.exists():
17
- Console.warning(f"{PYPROJECT_FILE.name} not found. Creating a base file...")
18
- PYPROJECT_FILE.touch()
19
-
20
- try:
21
- with open(PYPROJECT_FILE, "rb") as f:
22
- data = tomllib.load(f)
23
- except Exception as e:
24
- raise SmartCommitError(f"Failed to parse {PYPROJECT_FILE.name}: {e}")
25
-
26
- config = data.get("tool", {}).get("smart_commit")
27
-
28
- if config is None:
29
- Console.info(
30
- "Section [tool.smart_commit] not found. Generating default pipeline..."
31
- )
32
- logger.info(f"Appending default tool.smart_commit to {PYPROJECT_FILE.name}")
33
-
34
- with open(PYPROJECT_FILE, "a", encoding="utf-8") as f:
35
- f.write(DEFAULT_TOML_CONFIG)
36
-
37
- with open(PYPROJECT_FILE, "rb") as f:
38
- data = tomllib.load(f)
39
- config = data.get("tool", {}).get("smart_commit", {})
40
-
41
- return config
@@ -1,115 +0,0 @@
1
- from pathlib import Path
2
-
3
- TOOL_NAME = "Smart Commit & Push Tool"
4
- PYPROJECT_FILE = Path("pyproject.toml")
5
- LOG_FILE = ".smart_commit.log"
6
-
7
- DEFAULT_PROTECTED_BRANCHES = ["main", "master", "prod", "release"]
8
-
9
- DEFAULT_TOML_CONFIG = """
10
- # Auto-generated by Smart Commit
11
- [tool.smart_commit]
12
- repository_url = ""
13
- commands = ["echo 'Running pre-commit pipeline...'"]
14
- protected_branches = ["main", "master", "prod", "release"]
15
- """
16
-
17
- GITIGNORE_FILE = Path(".gitignore")
18
- EXCLUDED_SCAN_DIRS = ["venv", ".venv", "env", "node_modules", ".git", "__pycache__"]
19
-
20
- BASE_GITIGNORE = """
21
- # Universal .gitignore generated by Smart Commit
22
- __pycache__/
23
- *.py[cod]
24
- *$py.class
25
- .venv/
26
- venv/
27
- env/
28
- .env*
29
- *.env
30
- !.env.example
31
- .vscode/
32
- .idea/
33
- dist/
34
- build/
35
- *.egg-info/
36
- node_modules/
37
- .DS_Store
38
- .smart_commit.log
39
- """
40
-
41
- WORKFLOW_DIR = Path(".github/workflows")
42
- WORKFLOW_FILE = WORKFLOW_DIR / "smart_commit_ci.yml"
43
- CI_COMMIT_MESSAGE = "ci: auto-generate GitHub Actions workflow via Smart Commit"
44
-
45
- GITHUB_ACTION_TEMPLATE = """name: Smart Commit CI Pipeline
46
-
47
- on:
48
- push:
49
- branches: [ __BRANCHES__ ]
50
- pull_request:
51
- branches: [ __BRANCHES__ ]
52
-
53
- jobs:
54
- validate:
55
- runs-on: ubuntu-latest
56
- strategy:
57
- fail-fast: false
58
- matrix:
59
- python-version: ["3.10", "3.11", "3.12"]
60
-
61
- steps:
62
- - name: Checkout Repository
63
- uses: actions/checkout@v4
64
-
65
- - name: Set up Python ${{ matrix.python-version }}
66
- uses: actions/setup-python@v5
67
- with:
68
- python-version: ${{ matrix.python-version }}
69
- cache: 'pip'
70
-
71
- - name: Install Dependencies (Adaptive Smart Installer)
72
- run: |
73
- python -m pip install --upgrade pip
74
-
75
- # Ensure local bin is in PATH for all subsequent steps
76
- echo "$HOME/.local/bin" >> $GITHUB_PATH
77
-
78
- echo "🔍 Detecting package manager..."
79
-
80
- if [ -f "poetry.lock" ] || [ -f "pyproject.toml" ] && grep -q "tool.poetry" "pyproject.toml"; then
81
- echo "🚀 Poetry detected!"
82
- pip install poetry
83
- poetry config virtualenvs.create false
84
- poetry install --no-interaction --no-ansi
85
-
86
- elif [ -f "Pipfile.lock" ] || [ -f "Pipfile" ]; then
87
- echo "🚀 Pipenv detected!"
88
- pip install pipenv
89
- pipenv install --system --dev
90
-
91
- elif [ -f "pdm.lock" ] || [ -f "pyproject.toml" ] && grep -q "tool.pdm" "pyproject.toml"; then
92
- echo "🚀 PDM detected!"
93
- pip install pdm
94
- pdm config python.use_venv false
95
- pdm install
96
-
97
- elif [ -f "requirements.txt" ]; then
98
- echo "🚀 Standard requirements.txt detected!"
99
- pip install -r requirements.txt
100
- if [ -f "setup.py" ]; then pip install -e .; fi
101
-
102
- elif [ -f "pyproject.toml" ]; then
103
- echo "🚀 Standard PEP 621 pyproject.toml detected!"
104
- pip install -e .[dev] || pip install -e .
105
-
106
- else
107
- echo "⚠️ No known package manager found. Skipping dependency installation."
108
- fi
109
-
110
- __COMMANDS__
111
- """
112
-
113
- CONVENTIONAL_COMMIT_PATTERN = (
114
- r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?:\s.+"
115
- )
@@ -1,14 +0,0 @@
1
- class SmartCommitError(Exception):
2
- detail = "Unknown error"
3
-
4
- def __init__(self, *args):
5
- message = args[0] if args else self.detail
6
- super().__init__(message)
7
-
8
-
9
- class GitOperationError(SmartCommitError):
10
- detail = "Git execution failed"
11
-
12
-
13
- class ValidationError(SmartCommitError):
14
- detail = "Validation check failed"
@@ -1,32 +0,0 @@
1
- import logging
2
- from colorama import init, Fore, Style
3
-
4
- init(autoreset=True)
5
-
6
- logging.basicConfig(
7
- level=logging.DEBUG,
8
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
9
- )
10
- logger = logging.getLogger("SmartCommit")
11
-
12
-
13
- class Console:
14
- @staticmethod
15
- def info(msg: str):
16
- print(f"{Fore.CYAN}ℹ️ {msg}")
17
- logger.info(msg)
18
-
19
- @staticmethod
20
- def success(msg: str):
21
- print(f"{Fore.GREEN}{Style.BRIGHT}✅ {msg}")
22
- logger.info(f"SUCCESS: {msg}")
23
-
24
- @staticmethod
25
- def warning(msg: str):
26
- print(f"{Fore.YELLOW}⚠️ {msg}")
27
- logger.warning(msg)
28
-
29
- @staticmethod
30
- def error(msg: str):
31
- print(f"{Fore.RED}{Style.BRIGHT}❌ {msg}")
32
- logger.error(msg)
@@ -1,66 +0,0 @@
1
- from ..logger import Console, logger
2
- from ..exceptions import SmartCommitError
3
- from .git import GitService
4
- from ..constants import (
5
- WORKFLOW_DIR,
6
- WORKFLOW_FILE,
7
- GITHUB_ACTION_TEMPLATE,
8
- CI_COMMIT_MESSAGE,
9
- )
10
-
11
-
12
- class CIService:
13
- @classmethod
14
- def generate_github_action(cls, commands: list[str], protected_branches: list[str]):
15
- """Generates a GitHub Actions workflow file dynamically based on pyproject.toml commands."""
16
- Console.info("⚙️ Generating GitHub Actions workflow...")
17
-
18
- branches_yaml = ", ".join(f'"{b}"' for b in protected_branches)
19
-
20
- if not commands:
21
- commands_yaml = (
22
- ' - run: echo "No commands defined in pyproject.toml"'
23
- )
24
- else:
25
- commands_yaml = "\n".join(
26
- f" - name: Run '{cmd}'\n run: {cmd}" for cmd in commands
27
- )
28
-
29
- workflow_content = GITHUB_ACTION_TEMPLATE.replace("__BRANCHES__", branches_yaml)
30
- workflow_content = workflow_content.replace("__COMMANDS__", commands_yaml)
31
-
32
- try:
33
- WORKFLOW_DIR.mkdir(parents=True, exist_ok=True)
34
- with open(WORKFLOW_FILE, "w", encoding="utf-8") as f:
35
- f.write(workflow_content)
36
-
37
- Console.success(f"GitHub Action successfully created at: {WORKFLOW_FILE}")
38
- logger.info("Generated GitHub Action workflow file.")
39
-
40
- except Exception as e:
41
- raise SmartCommitError(f"Failed to generate CI workflow: {e}")
42
-
43
- @classmethod
44
- def prompt_and_push(cls):
45
- """Asks the user if they want to instantly commit and push the new Action."""
46
- ans = (
47
- input("🚀 Do you want to commit and push this GitHub Action now? [y/N]: ")
48
- .strip()
49
- .lower()
50
- )
51
- if ans != "y":
52
- Console.info("Action saved locally. You can commit it manually later.")
53
- return
54
-
55
- try:
56
- branch = GitService.get_current_branch()
57
-
58
- GitService._run(["git", "add", str(WORKFLOW_FILE)])
59
-
60
- GitService.commit(CI_COMMIT_MESSAGE)
61
- Console.info(f"Committed: '{CI_COMMIT_MESSAGE}'")
62
-
63
- GitService.push_with_retry(branch)
64
-
65
- except Exception as e:
66
- raise SmartCommitError(f"Failed to push CI configuration: {e}")
@@ -1,129 +0,0 @@
1
- import subprocess
2
- from pathlib import Path
3
- from ..exceptions import GitOperationError
4
- from ..logger import Console, logger
5
-
6
-
7
- class GitService:
8
- @classmethod
9
- def _run(cls, cmd: list[str], silent: bool = True) -> subprocess.CompletedProcess:
10
- """Internal helper for executing Git commands with combined output."""
11
- logger.debug(f"Git CMD: {' '.join(cmd)}")
12
- return subprocess.run(cmd, capture_output=True, text=True)
13
-
14
- @classmethod
15
- def ensure_repo(cls):
16
- """Checks for repository initialization, Detached HEAD, and ongoing merges/rebases."""
17
- git_dir = Path(".git")
18
- if not git_dir.exists():
19
- Console.warning("Git not initialized. Initializing now...")
20
- cls._run(["git", "init"])
21
- is_rebase = (git_dir / "rebase-apply").exists() or (
22
- git_dir / "rebase-merge"
23
- ).exists()
24
- is_merge = (git_dir / "MERGE_HEAD").exists()
25
-
26
- if is_rebase or is_merge:
27
- raise GitOperationError(
28
- "🛑 Git is currently in the middle of a REBASE or MERGE.\n"
29
- "Please resolve conflicts manually or run 'git rebase --abort', then try again."
30
- )
31
-
32
- res = cls._run(["git", "branch", "--show-current"])
33
- if not res.stdout.strip():
34
- raise GitOperationError(
35
- "You are in a DETACHED HEAD state. Please switch to a branch (e.g., 'git checkout main')."
36
- )
37
-
38
- @classmethod
39
- def get_current_branch(cls) -> str:
40
- return cls._run(["git", "branch", "--show-current"]).stdout.strip()
41
-
42
- @classmethod
43
- def switch_branch(cls, branch: str):
44
- """Safely switches to the target branch or creates it."""
45
- current = cls.get_current_branch()
46
- if current != branch:
47
- Console.info(f"🌿 Switching branch: {current} ➔ {branch}")
48
- has_commits = cls._run(["git", "rev-parse", "HEAD"]).returncode == 0
49
-
50
- if not has_commits:
51
- res = cls._run(["git", "branch", "-M", branch])
52
- else:
53
- res = cls._run(["git", "checkout", "-B", branch])
54
-
55
- if res.returncode != 0:
56
- error_msg = res.stderr.strip() or res.stdout.strip()
57
- raise GitOperationError(f"Failed to switch branch: {error_msg}")
58
-
59
- @classmethod
60
- def has_any_changes(cls) -> bool:
61
- """Checks if there are any staged, unstaged, or untracked changes."""
62
- res = cls._run(["git", "status", "--porcelain"])
63
- return bool(res.stdout.strip())
64
-
65
- @classmethod
66
- def has_staged_changes(cls) -> bool:
67
- """Checks if there are files already in the staging area."""
68
- res = cls._run(["git", "diff", "--cached", "--quiet"])
69
- return res.returncode != 0
70
-
71
- @classmethod
72
- def smart_stage(cls):
73
- """Intelligent file staging."""
74
- if cls.has_staged_changes():
75
- Console.info("Staged files detected. Committing current index only.")
76
- else:
77
- Console.warning("No files in the staging area.")
78
- ans = (
79
- input("Would you like to stage all changes (git add .)? [y/N]: ")
80
- .strip()
81
- .lower()
82
- )
83
- if ans == "y":
84
- cls._run(["git", "add", "."])
85
- else:
86
- raise GitOperationError("Nothing to commit. Operation cancelled.")
87
-
88
- @classmethod
89
- def commit(cls, message: str):
90
- """Commits changes. Ignores 'nothing to commit' errors to allow push-only flows."""
91
- res = cls._run(["git", "commit", "-m", message])
92
-
93
- if res.returncode != 0:
94
- output = (res.stdout + res.stderr).lower()
95
- if "nothing to commit" in output or "working tree clean" in output:
96
- return
97
-
98
- raise GitOperationError(
99
- f"Commit failed. Git says:\n{res.stderr or res.stdout}"
100
- )
101
-
102
- @classmethod
103
- def push_with_retry(cls, branch: str):
104
- """Pushes to remote, attempting a pull --rebase if rejected or diverged."""
105
- Console.info(f"Pushing to branch: {branch}...")
106
- res = cls._run(["git", "push", "-u", "origin", branch])
107
-
108
- if res.returncode == 0:
109
- Console.success(f"Code successfully pushed to origin/{branch}!")
110
- return
111
- Console.warning("Push rejected or branches diverged. Remote changes detected.")
112
- Console.info("Synchronizing via pull --rebase...")
113
-
114
- pull_res = cls._run(["git", "pull", "origin", branch, "--rebase"])
115
-
116
- if pull_res.returncode != 0:
117
- error_msg = pull_res.stderr.strip() or pull_res.stdout.strip()
118
- cls._run(["git", "rebase", "--abort"])
119
- raise GitOperationError(
120
- f"🛑 Rebase conflict! Conflicts were detected with remote files.\n"
121
- f"Sync aborted. Please resolve manually. Git says:\n{error_msg}"
122
- )
123
-
124
- Console.success("Sync successful. Retrying final push...")
125
- push_retry = cls._run(["git", "push", "-u", "origin", branch])
126
-
127
- if push_retry.returncode != 0:
128
- error_msg = push_retry.stderr.strip() or push_retry.stdout.strip()
129
- raise GitOperationError(f"Final push failed after rebase:\n{error_msg}")
@@ -1,68 +0,0 @@
1
- import subprocess
2
- from pathlib import Path
3
- from ..logger import Console, logger
4
- from ..constants import GITIGNORE_FILE, BASE_GITIGNORE, EXCLUDED_SCAN_DIRS
5
-
6
-
7
- class SecurityService:
8
- @classmethod
9
- def ensure_gitignore(cls):
10
- """Ensures .gitignore exists or creates a secure default."""
11
- if not GITIGNORE_FILE.exists():
12
- Console.warning(
13
- f"{GITIGNORE_FILE.name} not found. Creating a secure default configuration..."
14
- )
15
- with open(GITIGNORE_FILE, "w", encoding="utf-8") as f:
16
- f.write(BASE_GITIGNORE.lstrip())
17
- logger.info("Created default .gitignore")
18
-
19
- @classmethod
20
- def check_env_leaks(cls):
21
- """
22
- Scans for .env files (e.g. .env.local, .test.env, prod.env)
23
- and uses Git to check if they are ignored.
24
- Prevents accidental leaks of secrets.
25
- """
26
- Console.info("🛡️ Scanning for potential secret leaks (.env files)...")
27
-
28
- raw_files = set(Path(".").rglob(".env*")) | set(Path(".").rglob("*.env"))
29
-
30
- env_files = [
31
- f
32
- for f in raw_files
33
- if f.is_file() and not any(part in EXCLUDED_SCAN_DIRS for part in f.parts)
34
- ]
35
-
36
- leaked_files = []
37
-
38
- for env_file in env_files:
39
- res = subprocess.run(
40
- ["git", "check-ignore", "-q", str(env_file)], capture_output=True
41
- )
42
- if res.returncode == 1:
43
- leaked_files.append(str(env_file))
44
-
45
- if leaked_files:
46
- Console.error(
47
- f"CRITICAL SECURITY RISK: .env files found that are NOT in {GITIGNORE_FILE.name}!"
48
- )
49
- for lf in leaked_files:
50
- print(f" 👉 {lf}")
51
-
52
- ans = (
53
- input(
54
- f"🛑 Add these files to {GITIGNORE_FILE.name} automatically? [Y/n]: "
55
- )
56
- .strip()
57
- .lower()
58
- )
59
- if ans != "n":
60
- with open(GITIGNORE_FILE, "a", encoding="utf-8") as f:
61
- f.write("\n# Auto-added by Smart Commit to prevent leaks\n")
62
- for lf in leaked_files:
63
- f.write(f"{lf}\n")
64
- Console.success(f"Files successfully added to {GITIGNORE_FILE.name}.")
65
- else:
66
- Console.warning(
67
- "Action declined. Please be careful: secrets might be pushed!"
68
- )
@@ -1,28 +0,0 @@
1
- import subprocess
2
- import re
3
- from ..exceptions import ValidationError
4
- from ..logger import Console, logger
5
- from ..constants import CONVENTIONAL_COMMIT_PATTERN
6
-
7
-
8
- class ValidatorService:
9
- @classmethod
10
- def check_conventional_commit(cls, message: str):
11
- """Validates message against Conventional Commits standard."""
12
- if not re.match(CONVENTIONAL_COMMIT_PATTERN, message):
13
- logger.warning(f"Non-conventional commit message: {message}")
14
- Console.warning(
15
- "Commit message does not follow Conventional Commits (e.g., 'feat: add auth')."
16
- )
17
-
18
- @classmethod
19
- def run_commands(cls, commands: list[str]):
20
- """Runs pre-commit pipeline commands (linters, tests)."""
21
- for cmd in commands:
22
- Console.info(f"Executing: {cmd}")
23
- logger.info(f"Running validation command: {cmd}")
24
- res = subprocess.run(cmd, shell=True)
25
- if res.returncode != 0:
26
- raise ValidationError(
27
- f"Pipeline failed at: '{cmd}'. Please fix the issues!"
28
- )