smart-commit-tool 1.1.0__tar.gz → 2.0.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smart-commit-tool
3
- Version: 1.1.0
3
+ Version: 2.0.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "smart-commit-tool"
7
- version = "1.1.0"
7
+ version = "2.0.1"
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"
@@ -0,0 +1,105 @@
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()
@@ -0,0 +1,41 @@
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
@@ -0,0 +1,116 @@
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
+ # Runs the pipeline on multiple Python versions automatically
60
+ python-version: ["3.10", "3.11", "3.12"]
61
+
62
+ steps:
63
+ - name: Checkout Repository
64
+ uses: actions/checkout@v4
65
+
66
+ - name: Set up Python ${{ matrix.python-version }}
67
+ uses: actions/setup-python@v5
68
+ with:
69
+ python-version: ${{ matrix.python-version }}
70
+ cache: 'pip' # Speeds up consecutive runs
71
+
72
+ - name: Install Dependencies (Adaptive Smart Installer)
73
+ run: |
74
+ python -m pip install --upgrade pip
75
+
76
+ echo "🔍 Detecting package manager..."
77
+
78
+ if [ -f "poetry.lock" ] || [ -f "pyproject.toml" ] && grep -q "tool.poetry" "pyproject.toml"; then
79
+ echo "🚀 Poetry detected!"
80
+ pip install poetry
81
+ # Force poetry to install globally so custom commands work without 'poetry run'
82
+ poetry config virtualenvs.create false
83
+ poetry install --no-interaction --no-ansi
84
+
85
+ elif [ -f "Pipfile.lock" ] || [ -f "Pipfile" ]; then
86
+ echo "🚀 Pipenv detected!"
87
+ pip install pipenv
88
+ pipenv install --system --dev
89
+
90
+ elif [ -f "pdm.lock" ] || [ -f "pyproject.toml" ] && grep -q "tool.pdm" "pyproject.toml"; then
91
+ echo "🚀 PDM detected!"
92
+ pip install pdm
93
+ pdm config python.use_venv false
94
+ pdm install
95
+
96
+ elif [ -f "requirements.txt" ]; then
97
+ echo "🚀 Standard requirements.txt detected!"
98
+ pip install -r requirements.txt
99
+ # Install package itself if setup.py exists
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 (Hatch, Flit, Setuptools)!"
104
+ # Attempt to install with dev dependencies if available, otherwise regular install
105
+ pip install -e .[dev] || pip install -e .
106
+
107
+ else
108
+ echo "⚠️ No known package manager found. Skipping dependency installation."
109
+ fi
110
+
111
+ __COMMANDS__
112
+ """
113
+
114
+ CONVENTIONAL_COMMIT_PATTERN = (
115
+ r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?:\s.+"
116
+ )
@@ -0,0 +1,66 @@
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,40 +1,19 @@
1
1
  import subprocess
2
2
  from pathlib import Path
3
3
  from ..logger import Console, logger
4
+ from ..constants import GITIGNORE_FILE, BASE_GITIGNORE, EXCLUDED_SCAN_DIRS
4
5
 
5
6
 
6
7
  class SecurityService:
7
- BASE_GITIGNORE = """
8
- # Universal .gitignore generated by Smart Commit
9
- __pycache__/
10
- *.py[cod]
11
- *$py.class
12
- .venv/
13
- venv/
14
- env/
15
- .env*
16
- *.env
17
- !.env.example
18
- .vscode/
19
- .idea/
20
- dist/
21
- build/
22
- *.egg-info/
23
- node_modules/
24
- .DS_Store
25
- .smart_commit.log
26
- """
27
-
28
8
  @classmethod
29
9
  def ensure_gitignore(cls):
30
10
  """Ensures .gitignore exists or creates a secure default."""
31
- path = Path(".gitignore")
32
- if not path.exists():
11
+ if not GITIGNORE_FILE.exists():
33
12
  Console.warning(
34
- ".gitignore not found. Creating a secure default configuration..."
13
+ f"{GITIGNORE_FILE.name} not found. Creating a secure default configuration..."
35
14
  )
36
- with open(path, "w", encoding="utf-8") as f:
37
- f.write(cls.BASE_GITIGNORE.lstrip())
15
+ with open(GITIGNORE_FILE, "w", encoding="utf-8") as f:
16
+ f.write(BASE_GITIGNORE.lstrip())
38
17
  logger.info("Created default .gitignore")
39
18
 
40
19
  @classmethod
@@ -51,11 +30,7 @@ node_modules/
51
30
  env_files = [
52
31
  f
53
32
  for f in raw_files
54
- if f.is_file()
55
- and not any(
56
- part in ["venv", ".venv", "env", "node_modules", ".git", "__pycache__"]
57
- for part in f.parts
58
- )
33
+ if f.is_file() and not any(part in EXCLUDED_SCAN_DIRS for part in f.parts)
59
34
  ]
60
35
 
61
36
  leaked_files = []
@@ -69,22 +44,24 @@ node_modules/
69
44
 
70
45
  if leaked_files:
71
46
  Console.error(
72
- "CRITICAL SECURITY RISK: .env files found that are NOT in .gitignore!"
47
+ f"CRITICAL SECURITY RISK: .env files found that are NOT in {GITIGNORE_FILE.name}!"
73
48
  )
74
49
  for lf in leaked_files:
75
50
  print(f" 👉 {lf}")
76
51
 
77
52
  ans = (
78
- input("🛑 Add these files to .gitignore automatically? [Y/n]: ")
53
+ input(
54
+ f"🛑 Add these files to {GITIGNORE_FILE.name} automatically? [Y/n]: "
55
+ )
79
56
  .strip()
80
57
  .lower()
81
58
  )
82
59
  if ans != "n":
83
- with open(".gitignore", "a", encoding="utf-8") as f:
60
+ with open(GITIGNORE_FILE, "a", encoding="utf-8") as f:
84
61
  f.write("\n# Auto-added by Smart Commit to prevent leaks\n")
85
62
  for lf in leaked_files:
86
63
  f.write(f"{lf}\n")
87
- Console.success("Files successfully added to .gitignore.")
64
+ Console.success(f"Files successfully added to {GITIGNORE_FILE.name}.")
88
65
  else:
89
66
  Console.warning(
90
67
  "Action declined. Please be careful: secrets might be pushed!"
@@ -2,14 +2,14 @@ import subprocess
2
2
  import re
3
3
  from ..exceptions import ValidationError
4
4
  from ..logger import Console, logger
5
+ from ..constants import CONVENTIONAL_COMMIT_PATTERN
5
6
 
6
7
 
7
8
  class ValidatorService:
8
9
  @classmethod
9
10
  def check_conventional_commit(cls, message: str):
10
11
  """Validates message against Conventional Commits standard."""
11
- pattern = r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?:\s.+"
12
- if not re.match(pattern, message):
12
+ if not re.match(CONVENTIONAL_COMMIT_PATTERN, message):
13
13
  logger.warning(f"Non-conventional commit message: {message}")
14
14
  Console.warning(
15
15
  "Commit message does not follow Conventional Commits (e.g., 'feat: add auth')."
@@ -1,65 +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 .logger import Console, logger
9
-
10
-
11
- def main():
12
- parser = argparse.ArgumentParser(description="Smart Commit & Push Tool")
13
- parser.add_argument("-b", "--branch", help="Specify target branch")
14
- parser.add_argument("-m", "--message", help="Commit message")
15
- args = parser.parse_args()
16
-
17
- try:
18
- logger.info("=== Smart Commit Process Started ===")
19
-
20
- config = ConfigService.load_or_create()
21
- protected_branches = config.get("protected_branches", ["main", "master"])
22
- commands = config.get("commands", [])
23
-
24
- GitService.ensure_repo()
25
- SecurityService.ensure_gitignore()
26
- SecurityService.check_env_leaks()
27
-
28
- current_branch = GitService.get_current_branch()
29
- branch = args.branch or current_branch
30
- GitService.switch_branch(branch)
31
- if branch in protected_branches:
32
- Console.warning(
33
- f"You are pushing directly to a protected branch: '{branch}'."
34
- )
35
- ans = input("Are you sure you want to continue? [y/N]: ").strip().lower()
36
- if ans != "y":
37
- raise SmartCommitError("Operation cancelled by user.")
38
-
39
- message = args.message
40
- if not message:
41
- message = input("📝 Enter commit message: ").strip()
42
-
43
- if not message:
44
- raise SmartCommitError("Commit message cannot be empty.")
45
-
46
- ValidatorService.check_conventional_commit(message)
47
- if commands:
48
- Console.info("--- 🛠 RUNNING PIPELINE (from pyproject.toml) ---")
49
- ValidatorService.run_commands(commands)
50
-
51
- Console.info("--- 🚀 FINALIZING COMMIT ---")
52
- GitService.smart_stage()
53
- GitService.commit(message)
54
- GitService.push_with_retry(branch)
55
-
56
- except SmartCommitError as e:
57
- Console.error(str(e))
58
- sys.exit(1)
59
- except KeyboardInterrupt:
60
- Console.warning("\nProcess interrupted by user.")
61
- sys.exit(0)
62
-
63
-
64
- if __name__ == "__main__":
65
- main()
@@ -1,50 +0,0 @@
1
- import sys
2
- from pathlib import Path
3
- from .exceptions import SmartCommitError
4
- from .logger import Console, logger
5
-
6
- if sys.version_info >= (3, 11):
7
- import tomllib
8
- else:
9
- import tomli as tomllib
10
-
11
-
12
- class ConfigService:
13
- DEFAULT_CONFIG = """
14
- # Auto-generated by Smart Commit
15
- [tool.smart_commit]
16
- repository_url = ""
17
- commands = ["echo 'Running pre-commit pipeline...'"]
18
- protected_branches = ["main", "master", "prod", "release"]
19
- """
20
-
21
- @classmethod
22
- def load_or_create(cls) -> dict:
23
- """Reads configuration. If the section is missing, appends default settings."""
24
- path = Path("pyproject.toml")
25
-
26
- if not path.exists():
27
- Console.warning("pyproject.toml not found. Creating a base file...")
28
- path.touch()
29
-
30
- try:
31
- with open(path, "rb") as f:
32
- data = tomllib.load(f)
33
- except Exception as e:
34
- raise SmartCommitError(f"Failed to parse pyproject.toml: {e}")
35
-
36
- config = data.get("tool", {}).get("smart_commit")
37
-
38
- if config is None:
39
- Console.info(
40
- "Section [tool.smart_commit] not found. Generating default pipeline..."
41
- )
42
- logger.info("Appending default tool.smart_commit to pyproject.toml")
43
- with open(path, "a", encoding="utf-8") as f:
44
- f.write(cls.DEFAULT_CONFIG)
45
-
46
- with open(path, "rb") as f:
47
- data = tomllib.load(f)
48
- config = data.get("tool", {}).get("smart_commit", {})
49
-
50
- return config