devgen-cli 0.2.2__tar.gz → 0.2.4__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 (52) hide show
  1. {devgen_cli-0.2.2/devgen_cli.egg-info → devgen_cli-0.2.4}/PKG-INFO +34 -3
  2. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/README.md +33 -2
  3. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/cli/commit.py +35 -1
  4. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/cli/config.py +27 -0
  5. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/cli/main.py +1 -1
  6. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/modules/changelog_generator.py +10 -5
  7. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/modules/commit_generator.py +222 -21
  8. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/modules/license_generator.py +1 -3
  9. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/providers/openai.py +3 -0
  10. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/providers/openrouter.py +3 -0
  11. devgen_cli-0.2.4/devgen/templates/commit/commit_message.j2 +11 -0
  12. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/utils.py +56 -10
  13. {devgen_cli-0.2.2 → devgen_cli-0.2.4/devgen_cli.egg-info}/PKG-INFO +34 -3
  14. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/pyproject.toml +1 -1
  15. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/setup.py +1 -1
  16. devgen_cli-0.2.2/devgen/templates/commit/commit_message.j2 +0 -15
  17. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/LICENSE +0 -0
  18. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/MANIFEST.in +0 -0
  19. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/__init__.py +0 -0
  20. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/ai.py +0 -0
  21. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/cli/__init__.py +0 -0
  22. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/cli/changelog.py +0 -0
  23. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/cli/gitignore.py +0 -0
  24. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/cli/license.py +0 -0
  25. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/cli/release.py +0 -0
  26. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/cli/setup.py +0 -0
  27. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/modules/__init__.py +0 -0
  28. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/modules/gitignore_generator.py +0 -0
  29. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/modules/release_note_generator.py +0 -0
  30. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/providers/__init__.py +0 -0
  31. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/providers/anthropic.py +0 -0
  32. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/providers/gemini.py +0 -0
  33. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/providers/huggingface.py +0 -0
  34. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/agpl-3.0.json +0 -0
  35. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/apache-2.0.json +0 -0
  36. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/bsd-2-clause.json +0 -0
  37. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/bsd-3-clause.json +0 -0
  38. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/bsl-1.0.json +0 -0
  39. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/cc0-1.0.json +0 -0
  40. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/epl-2.0.json +0 -0
  41. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/gpl-2.0.json +0 -0
  42. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/gpl-3.0.json +0 -0
  43. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/lgpl-2.1.json +0 -0
  44. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/mit.json +0 -0
  45. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/mpl-2.0.json +0 -0
  46. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen/templates/licenses/unlicense.json +0 -0
  47. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen_cli.egg-info/SOURCES.txt +0 -0
  48. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen_cli.egg-info/dependency_links.txt +0 -0
  49. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen_cli.egg-info/entry_points.txt +0 -0
  50. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen_cli.egg-info/requires.txt +0 -0
  51. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/devgen_cli.egg-info/top_level.txt +0 -0
  52. {devgen_cli-0.2.2 → devgen_cli-0.2.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devgen-cli
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: A collection of developer tools
5
5
  Home-page: https://github.com/S4NKALP/devgen
6
6
  Author: Sankalp Tharu
@@ -57,7 +57,7 @@ Dynamic: requires-python
57
57
  ## ⚡ Why DevGen?
58
58
 
59
59
  **🧠 AI Brain**
60
- Semantic commit messages powered by Gemini, OpenAI, Claude, HuggingFace, and OpenRouter. It reads your diffs and understands your code.
60
+ Semantic, context-aware commit messages powered by Gemini, OpenAI, Claude, HuggingFace, and OpenRouter. It reads your diffs, identifies project manifests, and understands your code.
61
61
 
62
62
  **🚀 Battle Tested**
63
63
  Generates **Conventional Commits** and **Semantic Versioning** compliant changelogs that actually make sense.
@@ -84,6 +84,9 @@ uv tool install devgen-cli
84
84
 
85
85
  # Standard pip install
86
86
  pip install devgen-cli
87
+
88
+ # Enable Shell Completion (bash/zsh/fish)
89
+ devgen --install-completion
87
90
  ```
88
91
 
89
92
  ## 🚀 Quick Start
@@ -117,8 +120,21 @@ devgen commit run --dry-run
117
120
 
118
121
  # Commit and push in one go
119
122
  devgen commit run --push
123
+
124
+ # Review and edit AI messages before committing
125
+ devgen commit run --check
126
+
127
+ # Made a mistake? Undo the last AI commit and keep changes staged
128
+ devgen commit undo
120
129
  ```
121
130
 
131
+ ### 🧠 Context Awareness
132
+ DevGen isn't just looking at the code changes; it's looking at the big picture. It automatically detects and analyzes:
133
+ - **Manifests**: `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, etc.
134
+ - **Lock Files**: `uv.lock`, `package-lock.json`, `poetry.lock`, etc.
135
+
136
+ This allows the AI to understand *exactly* which library versions were updated, leading to hyper-accurate dependency commit messages.
137
+
122
138
  ### 📝 Changelogs & Release Notes
123
139
 
124
140
  Turn your git history into beautiful, readable documentation.
@@ -151,7 +167,22 @@ Your settings live in `~/.devgen.yaml`. You can tweak your AI provider, model, a
151
167
  | :--------- | :----------------------------------------------------------- |
152
168
  | `provider` | `gemini`, `openai`, `anthropic`, `huggingface`, `openrouter` |
153
169
  | `model` | Specific model name (e.g., `gemini-2.5-flash`, `gpt-4o`) |
154
- | `emoji` | Enable/disable gitmojis in commits (`true`/`false`) |
170
+ | `emoji` | Enable/disable gitmojis in commits (`true`/`false`) |
171
+ | `custom_template` | Custom Jinja2 template for commit messages. |
172
+
173
+ ### 🎨 Custom Templates
174
+ You can define your own commit message structure in `~/.devgen.yaml`. Use `devgen config info` to see available variables like `{{ group_name }}`, `{{ diff_text }}`, and `{{ context }}`.
175
+
176
+ Example:
177
+ ```yaml
178
+ custom_template: |
179
+ {{ group_name }}: {{ diff_text }}
180
+ ---
181
+ Manifests: {{ context }}
182
+ ```
183
+
184
+ > [!TIP]
185
+ > Use `devgen config info` to see a full list of available variables and a template tip!
155
186
 
156
187
  ## 🤝 Contributing
157
188
 
@@ -20,7 +20,7 @@
20
20
  ## ⚡ Why DevGen?
21
21
 
22
22
  **🧠 AI Brain**
23
- Semantic commit messages powered by Gemini, OpenAI, Claude, HuggingFace, and OpenRouter. It reads your diffs and understands your code.
23
+ Semantic, context-aware commit messages powered by Gemini, OpenAI, Claude, HuggingFace, and OpenRouter. It reads your diffs, identifies project manifests, and understands your code.
24
24
 
25
25
  **🚀 Battle Tested**
26
26
  Generates **Conventional Commits** and **Semantic Versioning** compliant changelogs that actually make sense.
@@ -47,6 +47,9 @@ uv tool install devgen-cli
47
47
 
48
48
  # Standard pip install
49
49
  pip install devgen-cli
50
+
51
+ # Enable Shell Completion (bash/zsh/fish)
52
+ devgen --install-completion
50
53
  ```
51
54
 
52
55
  ## 🚀 Quick Start
@@ -80,8 +83,21 @@ devgen commit run --dry-run
80
83
 
81
84
  # Commit and push in one go
82
85
  devgen commit run --push
86
+
87
+ # Review and edit AI messages before committing
88
+ devgen commit run --check
89
+
90
+ # Made a mistake? Undo the last AI commit and keep changes staged
91
+ devgen commit undo
83
92
  ```
84
93
 
94
+ ### 🧠 Context Awareness
95
+ DevGen isn't just looking at the code changes; it's looking at the big picture. It automatically detects and analyzes:
96
+ - **Manifests**: `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, etc.
97
+ - **Lock Files**: `uv.lock`, `package-lock.json`, `poetry.lock`, etc.
98
+
99
+ This allows the AI to understand *exactly* which library versions were updated, leading to hyper-accurate dependency commit messages.
100
+
85
101
  ### 📝 Changelogs & Release Notes
86
102
 
87
103
  Turn your git history into beautiful, readable documentation.
@@ -114,7 +130,22 @@ Your settings live in `~/.devgen.yaml`. You can tweak your AI provider, model, a
114
130
  | :--------- | :----------------------------------------------------------- |
115
131
  | `provider` | `gemini`, `openai`, `anthropic`, `huggingface`, `openrouter` |
116
132
  | `model` | Specific model name (e.g., `gemini-2.5-flash`, `gpt-4o`) |
117
- | `emoji` | Enable/disable gitmojis in commits (`true`/`false`) |
133
+ | `emoji` | Enable/disable gitmojis in commits (`true`/`false`) |
134
+ | `custom_template` | Custom Jinja2 template for commit messages. |
135
+
136
+ ### 🎨 Custom Templates
137
+ You can define your own commit message structure in `~/.devgen.yaml`. Use `devgen config info` to see available variables like `{{ group_name }}`, `{{ diff_text }}`, and `{{ context }}`.
138
+
139
+ Example:
140
+ ```yaml
141
+ custom_template: |
142
+ {{ group_name }}: {{ diff_text }}
143
+ ---
144
+ Manifests: {{ context }}
145
+ ```
146
+
147
+ > [!TIP]
148
+ > Use `devgen config info` to see a full list of available variables and a template tip!
118
149
 
119
150
  ## 🤝 Contributing
120
151
 
@@ -1,5 +1,6 @@
1
1
  from typing import Annotated
2
2
 
3
+ import questionary
3
4
  import typer
4
5
 
5
6
  from devgen.modules.commit_generator import run_commit_engine
@@ -50,12 +51,19 @@ def run_commit(
50
51
  help="Force regeneration of commit messages.",
51
52
  ),
52
53
  ] = False,
54
+ check: Annotated[
55
+ bool,
56
+ typer.Option(
57
+ "--check",
58
+ help="Review/edit commit message before committing.",
59
+ ),
60
+ ] = False,
53
61
  ) -> None:
54
62
  log_file = get_main_log_path()
55
63
  logger = configure_logger("devgen.cli.commit", log_file, console=debug)
56
64
  logger.info(f"Log file: {log_file}")
57
65
  logger.info(
58
- f"Options: dry_run={dry_run}, push={push}, debug={debug}, force={force_rebuild}"
66
+ f"Options: dry_run={dry_run}, push={push}, debug={debug}, force={force_rebuild}, check={check}"
59
67
  )
60
68
 
61
69
  run_commit_engine(
@@ -63,6 +71,7 @@ def run_commit(
63
71
  push=push,
64
72
  debug=debug,
65
73
  force_rebuild=force_rebuild,
74
+ check=check,
66
75
  logger=logger,
67
76
  )
68
77
 
@@ -94,3 +103,28 @@ def validate() -> None:
94
103
  typer.echo(f"- {f}")
95
104
  else:
96
105
  typer.secho("[i] No staged files.", fg=typer.colors.RED)
106
+
107
+
108
+ @app.command("undo")
109
+ def undo_commit() -> None:
110
+ """Undoes the last commit but keeps changes staged."""
111
+ from devgen.utils import run_git_command
112
+
113
+ try:
114
+ # Check if there's at least one commit
115
+ run_git_command(["git", "rev-parse", "HEAD"])
116
+ except Exception:
117
+ typer.secho("No commits found to undo.", fg=typer.colors.RED)
118
+ return
119
+
120
+ if questionary.confirm(
121
+ "Are you sure you want to undo the last commit? (Changes will remain staged)",
122
+ default=False,
123
+ ).ask():
124
+ try:
125
+ run_git_command(["git", "reset", "--soft", "HEAD~1"])
126
+ typer.secho(
127
+ "Last commit undone. Changes are still staged.", fg=typer.colors.GREEN
128
+ )
129
+ except Exception as e:
130
+ typer.secho(f"Failed to undo commit: {e}", fg=typer.colors.RED)
@@ -167,3 +167,30 @@ def set_config() -> None:
167
167
  _save_config(config)
168
168
  typer.secho("\nConfiguration saved.", fg=typer.colors.GREEN)
169
169
  typer.echo(yaml.dump(new_config, default_flow_style=False))
170
+
171
+
172
+ @app.command("info")
173
+ def config_info() -> None:
174
+ """Show information about configuration options and templates."""
175
+ from rich.console import Console
176
+ from rich.table import Table
177
+
178
+ console = Console()
179
+ table = Table(title="Custom Template Variables", box=None)
180
+ table.add_column("Variable", style="cyan")
181
+ table.add_column("Description", style="white")
182
+
183
+ table.add_row("{{ group_name }}", "The folder name being committed (or 'root').")
184
+ table.add_row("{{ diff_text }}", "The git diff of the changes.")
185
+ table.add_row("{{ context }}", "Project context (manifest files content).")
186
+
187
+ console.print(table)
188
+ console.print(
189
+ "\n[bold]Tip:[/bold] If you hardcode emojis in your template, the AI will likely include them regardless of the 'emoji' setting.",
190
+ style="yellow",
191
+ )
192
+ console.print("\n[bold]Example Template:[/bold]")
193
+ console.print(
194
+ "custom_template: |\n [type]: [desc]\n \n Diff: {{ diff_text }}\n",
195
+ style="dim",
196
+ )
@@ -15,7 +15,7 @@ from devgen.cli.setup import app as setup_app
15
15
  app = typer.Typer(
16
16
  name="devgen",
17
17
  help="devgen-py: AI-Powered Git Commit & Release Automation.",
18
- add_completion=False,
18
+ add_completion=True,
19
19
  no_args_is_help=True,
20
20
  rich_markup_mode="markdown",
21
21
  )
@@ -120,7 +120,7 @@ class ChangelogGenerator:
120
120
  ) -> str:
121
121
  """Generates markdown changelog from parsed commits."""
122
122
  date_str = datetime.now().strftime("%Y-%m-%d")
123
- md = [f"# {version} ({date_str})\n"]
123
+ md = [f"## {version} ({date_str})\n"]
124
124
 
125
125
  # Order: Breaking, Features, Fixes, Docs, Others
126
126
  order = [
@@ -163,14 +163,19 @@ class ChangelogGenerator:
163
163
 
164
164
  if output_file:
165
165
  path = Path(output_file)
166
- # If file exists, prepend? For now, just overwrite or append logic could be complex.
167
- # Let's implement prepend logic if file exists.
168
166
  if path.exists():
169
167
  old_content = path.read_text(encoding="utf-8")
170
- new_content = old_content + "\n\n" + md_content
168
+ # Prepend the new content, assume # CHANGELOG is at the top or needs to be
169
+ if old_content.strip().startswith("# CHANGELOG"):
170
+ lines = old_content.split("\n", 1)
171
+ header = lines[0]
172
+ rest = lines[1] if len(lines) > 1 else ""
173
+ new_content = f"{header}\n\n{md_content}\n{rest.lstrip()}"
174
+ else:
175
+ new_content = f"# CHANGELOG\n\n{md_content}\n\n{old_content}"
171
176
  path.write_text(new_content, encoding="utf-8")
172
177
  else:
173
- path.write_text(md_content, encoding="utf-8")
178
+ path.write_text(f"# CHANGELOG\n\n{md_content}", encoding="utf-8")
174
179
 
175
180
  self.logger.info(f"Changelog written to {output_file}")
176
181
  print(f" Changelog updated: {output_file}")
@@ -1,3 +1,5 @@
1
+ import sys
2
+ import questionary
1
3
  import subprocess
2
4
  from collections import defaultdict
3
5
  from datetime import datetime
@@ -13,6 +15,7 @@ from devgen.utils import (
13
15
  load_template_env,
14
16
  run_git_command,
15
17
  sanitize_ai_commit_message,
18
+ render_custom_template,
16
19
  )
17
20
  from rich.console import Console
18
21
  from rich.panel import Panel
@@ -26,6 +29,19 @@ class CommitEngineError(Exception):
26
29
  pass
27
30
 
28
31
 
32
+ # Token Optimization Constants
33
+ MAX_DIFF_SIZE = 8000 # Maximum characters for a single group diff
34
+ IGNORE_PATTERNS = [
35
+ "uv.lock",
36
+ "package-lock.json",
37
+ "pnpm-lock.yaml",
38
+ "yarn.lock",
39
+ "composer.lock",
40
+ "Gemfile.lock",
41
+ "poetry.lock",
42
+ ]
43
+
44
+
29
45
  class CommitEngine:
30
46
  """
31
47
  Engine for generating AI-powered commit messages.
@@ -38,6 +54,7 @@ class CommitEngine:
38
54
  push: bool = False,
39
55
  debug: bool = False,
40
56
  force_rebuild: bool = False,
57
+ check: bool = False,
41
58
  provider: str = "gemini",
42
59
  model: str = "gemini-2.5-flash",
43
60
  logger: Any | None = None,
@@ -47,6 +64,7 @@ class CommitEngine:
47
64
  self.push = push
48
65
  self.debug = debug
49
66
  self.force_rebuild = force_rebuild
67
+ self.check = check
50
68
  self.provider = provider
51
69
  self.model = model
52
70
  self.logger = logger or configure_logger(
@@ -72,6 +90,7 @@ class CommitEngine:
72
90
  def detect_changes(self) -> List[str]:
73
91
  """Detects changed, deleted, or untracked files."""
74
92
  try:
93
+ # Get modified, deleted and untracked files
75
94
  out = run_git_command(
76
95
  [
77
96
  "git",
@@ -82,27 +101,93 @@ class CommitEngine:
82
101
  "--exclude-standard",
83
102
  ]
84
103
  )
85
- return [f.strip() for f in out.split("\n") if f.strip()]
104
+ files = [f.strip() for f in out.split("\n") if f.strip()]
105
+
106
+ # Also get staged files (in case of a previous failed run)
107
+ staged_out = run_git_command(["git", "diff", "--name-only", "--cached"])
108
+ staged_files = [f.strip() for f in staged_out.split("\n") if f.strip()]
109
+
110
+ # Combine and deduplicate
111
+ all_files = list(set(files + staged_files))
112
+ return all_files
86
113
  except subprocess.CalledProcessError as e:
87
114
  msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
88
115
  self.logger.error(msg)
89
116
  raise CommitEngineError(msg) from e
90
117
 
91
118
  def group_files(self, files: List[str]) -> Dict[str, List[str]]:
92
- """Groups files by their parent directory."""
119
+ """Groups files by their parent directory with smart merging if limit is exceeded."""
120
+ max_groups = self.config.get("max_groups", 5)
121
+
122
+ # 1. Initial grouping by immediate parent
93
123
  groups = defaultdict(list)
94
124
  for f in files:
95
125
  parent = str(Path(f).parent)
96
126
  key = "root" if parent == "." else parent
97
127
  groups[key].append(f)
98
- return groups
128
+
129
+ if len(groups) <= max_groups:
130
+ return dict(groups)
131
+
132
+ self.logger.info(
133
+ f"Too many groups ({len(groups)}). Merging based on max_groups={max_groups}"
134
+ )
135
+
136
+ # 2. Iteratively merge the deepest group into its parent until we hit the limit
137
+ while len(groups) > max_groups:
138
+ # Find the deepest path among the current group keys
139
+ # Skip 'root' as it's the top level
140
+ potential_merges = [k for k in groups.keys() if k != "root"]
141
+ if not potential_merges:
142
+ # This could happen if only 'root' is left or if max_groups is very small
143
+ break
144
+
145
+ # Deepest path is the one with the most segments
146
+ deepest = max(potential_merges, key=lambda p: len(Path(p).parts))
147
+
148
+ # Find the parent of this deepest path
149
+ parent_path = str(Path(deepest).parent)
150
+ new_key = "root" if parent_path == "." else parent_path
151
+
152
+ # Merge files into the new key
153
+ self.logger.debug(f"Merging group '{deepest}' into '{new_key}'")
154
+ groups[new_key].extend(groups.pop(deepest))
155
+
156
+ return dict(groups)
99
157
 
100
158
  def generate_diff(self, files: List[str]) -> str:
101
- """Generates diff for specific files."""
159
+ """Generates diff for specific files, with truncation for token optimization."""
102
160
  try:
103
- return run_git_command(
104
- ["git", "--no-pager", "diff", "--staged", "--", *files]
105
- )
161
+ # Filter out very large metadata files that don't need full diffs
162
+ summary_info = []
163
+ files_to_diff = []
164
+ for f in files:
165
+ if any(p in f for p in IGNORE_PATTERNS):
166
+ summary_info.append(f"[METADATA UPDATED] {f}")
167
+ else:
168
+ files_to_diff.append(f)
169
+
170
+ diff = ""
171
+ if files_to_diff:
172
+ diff = run_git_command(
173
+ ["git", "--no-pager", "diff", "--staged", "--", *files_to_diff]
174
+ )
175
+
176
+ full_content = "\n".join(summary_info + [diff]).strip()
177
+
178
+ # Truncate if too large
179
+ if len(full_content) > MAX_DIFF_SIZE:
180
+ self.logger.info(
181
+ f"Truncating diff from {len(full_content)} to {MAX_DIFF_SIZE} chars"
182
+ )
183
+ half = MAX_DIFF_SIZE // 2
184
+ return (
185
+ full_content[:half]
186
+ + "\n\n... [DIFF TRUNCATED FOR TOKEN OPTIMIZATION] ...\n\n"
187
+ + full_content[-half:]
188
+ )
189
+
190
+ return full_content
106
191
  except subprocess.CalledProcessError as e:
107
192
  msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
108
193
  self.logger.error(msg)
@@ -165,9 +250,52 @@ class CommitEngine:
165
250
  f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
166
251
  )
167
252
  self.logger.error(msg)
253
+ # Check for "no upstream branch" specifically to give a hint?
254
+ if "no upstream branch" in msg.lower():
255
+ self.console.print(
256
+ "[warning]No upstream branch. Skipping push.[/warning]"
257
+ )
258
+ return
168
259
  raise CommitEngineError(msg) from e
169
260
  self.console.print("[bold green]Push successful.[/bold green]")
170
261
 
262
+ def _get_manifest_context(self) -> str:
263
+ """Finds and reads manifest files to provide context."""
264
+ manifests = [
265
+ "pyproject.toml",
266
+ "package.json",
267
+ "go.mod",
268
+ "Cargo.toml",
269
+ "Gemfile",
270
+ "requirements.txt",
271
+ "uv.lock",
272
+ "package-lock.json",
273
+ "pnpm-lock.yaml",
274
+ "yarn.lock",
275
+ "composer.lock",
276
+ "Gemfile.lock",
277
+ "poetry.lock",
278
+ ]
279
+ found = []
280
+ for m in manifests:
281
+ path = Path(m)
282
+ if path.exists():
283
+ try:
284
+ content = path.read_text(encoding="utf-8")
285
+ # Take only first 100 lines to avoid token bloat
286
+ lines = content.splitlines()
287
+ summary = "\n".join(lines[:100])
288
+ if len(lines) > 100:
289
+ summary += "\n... (truncated)"
290
+ found.append(f"File: {m}\n---\n{summary}\n---")
291
+ except Exception:
292
+ continue
293
+
294
+ if not found:
295
+ return ""
296
+
297
+ return "\n\n### Project Context (Manifests)\n" + "\n".join(found)
298
+
171
299
  def generate_message(self, group: str, diff: str, cache: Dict[str, str]) -> str:
172
300
  """Generates a commit message using AI or cache."""
173
301
  if not self.force_rebuild and group in cache:
@@ -181,9 +309,33 @@ class CommitEngine:
181
309
  model = self.kwargs.get("model") or self.config.get("model") or self.model
182
310
  api_key = self.kwargs.get("api_key") or self.config.get("api_key")
183
311
  use_emoji = self.config.get("emoji", True)
312
+ custom_template = self.config.get("custom_template")
313
+
314
+ manifest_context = self._get_manifest_context()
315
+ if manifest_context:
316
+ self.logger.info("Including manifest context in prompt")
317
+
318
+ if custom_template:
319
+ self.logger.info("Using custom template from config")
320
+ prompt = render_custom_template(
321
+ custom_template,
322
+ group_name=group,
323
+ diff_text=diff,
324
+ use_emoji=use_emoji,
325
+ context=manifest_context,
326
+ )
327
+ else:
328
+ template = self.template_env.get_template("commit_message.j2")
329
+ prompt = template.render(
330
+ group_name=group,
331
+ diff_text=diff,
332
+ use_emoji=use_emoji,
333
+ context=manifest_context,
334
+ )
184
335
 
185
- template = self.template_env.get_template("commit_message.j2")
186
- prompt = template.render(group_name=group, diff_text=diff, use_emoji=use_emoji)
336
+ # Automatically append emoji instruction based on global setting
337
+ emoji_instr = "Use emojis (🚀, 🐛)." if use_emoji else "No emojis."
338
+ prompt = f"{prompt.strip()}\n\n- {emoji_instr}"
187
339
 
188
340
  with self.console.status("[bold blue]Generating commit message...[/bold blue]"):
189
341
  raw = generate_with_ai(
@@ -239,20 +391,66 @@ class CommitEngine:
239
391
  return True
240
392
 
241
393
  msg = self.generate_message(group, diff, cache)
242
- if not msg:
243
- self.logger.error(f"Empty message for {group}")
244
- return False
394
+ try:
395
+ if not msg:
396
+ self.logger.error(f"Empty message for {group}")
397
+ self._reset_group(files)
398
+ return False
245
399
 
246
- if self.dry_run:
247
- self._log_dry_run(group, msg)
248
- try:
249
- run_git_command(["git", "reset", "HEAD", "--", *files])
250
- except subprocess.CalledProcessError:
251
- pass
252
- else:
253
- self.commit_staged(msg)
400
+ if self.dry_run:
401
+ self._log_dry_run(group, msg)
402
+ self._reset_group(files)
403
+ else:
404
+ if self.check:
405
+ self.console.print(
406
+ Panel(
407
+ Markdown(msg),
408
+ title=f"Proposed Commit Message [group: {group}]",
409
+ border_style="cyan",
410
+ )
411
+ )
412
+ choice = questionary.select(
413
+ "How would you like to proceed?",
414
+ choices=[
415
+ "Confirm",
416
+ "Edit",
417
+ "Abort",
418
+ ],
419
+ default="Confirm",
420
+ ).ask()
421
+
422
+ if not choice or choice == "Abort":
423
+ self.logger.info(f"Commit aborted by user at group {group}")
424
+ self._reset_group(files)
425
+ raise KeyboardInterrupt("User aborted")
426
+
427
+ if choice == "Edit":
428
+ msg = questionary.text(
429
+ "Edit commit message:",
430
+ multiline=True,
431
+ default=msg,
432
+ ).ask()
433
+ if not msg:
434
+ self.logger.info(
435
+ f"Empty edit, commit cancelled for {group}"
436
+ )
437
+ self._reset_group(files)
438
+ return True
439
+
440
+ self.commit_staged(msg)
254
441
 
255
- return True
442
+ return True
443
+ except Exception as e:
444
+ self.logger.error(f"Failed to process group {group}: {e}")
445
+ self._reset_group(files)
446
+ return False
447
+
448
+ def _reset_group(self, files: List[str]):
449
+ """Unstages files for a group."""
450
+ try:
451
+ run_git_command(["git", "reset", "HEAD", "--", *files])
452
+ except subprocess.CalledProcessError:
453
+ pass # Ignore reset errors
256
454
 
257
455
  def execute(self):
258
456
  """Main execution method."""
@@ -301,6 +499,9 @@ def run_commit_engine(**kwargs):
301
499
  try:
302
500
  engine = CommitEngine(**kwargs)
303
501
  engine.execute()
502
+ except KeyboardInterrupt:
503
+ logger.warning("Interrupted by user. Exiting...")
504
+ sys.exit(0)
304
505
  except Exception as e:
305
506
  logger.error(f"Commit engine failed: {e}", exc_info=True)
306
507
 
@@ -8,9 +8,7 @@ class LicenseGenerator:
8
8
  """Generates license files from templates."""
9
9
 
10
10
  def __init__(self):
11
- self.templates_dir = (
12
- Path(__file__).parent.parent / "prompt_templates" / "licenses"
13
- )
11
+ self.templates_dir = Path(__file__).parent.parent / "templates" / "licenses"
14
12
 
15
13
  def list_licenses(self) -> List[Dict[str, str]]:
16
14
  """Lists available license templates."""
@@ -39,6 +39,9 @@ class OpenaiProvider:
39
39
  # Add error handling if the client fails to initialize
40
40
  raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
41
41
 
42
+ # Remove debug from kwargs if present
43
+ kwargs.pop("debug", None)
44
+
42
45
  # 2. Use the modern API syntax: client.chat.completions.create
43
46
  response = client.chat.completions.create(
44
47
  model=model or self.DEFAULT_MODEL,
@@ -19,6 +19,9 @@ class OpenrouterProvider:
19
19
  api_key=api_key,
20
20
  )
21
21
 
22
+ # Remove debug from kwargs if present
23
+ kwargs.pop("debug", None)
24
+
22
25
  response = client.chat.completions.create(
23
26
  model=model,
24
27
  messages=[{"role": "user", "content": prompt}],
@@ -0,0 +1,11 @@
1
+ Analyze the following git diff for `{{ group_name }}` and generate a semantic commit message.
2
+
3
+ Guidelines:
4
+ - Title line: `<type>(<scope>): <summary>` (scope is `{{ group_name }}` or `root`).
5
+ - Optional body: A short summary of why.
6
+ - Details: Bullet points for `New Features`, `Enhancements`, `Bug Fixes`.
7
+ - Output ONLY the message itself, without any labels like "Title:" or "Format:".
8
+
9
+ Diff for `{{ group_name }}`:
10
+ {{ diff_text }}
11
+ {{ context }}
@@ -31,25 +31,63 @@ def is_file_recent(file_path: Path | str, max_age_minutes: int = 120) -> bool:
31
31
 
32
32
 
33
33
  def sanitize_ai_commit_message(raw_text: str) -> str:
34
+ """
35
+ Cleans up the AI-generated commit message.
36
+ It looks for a conventional commit header and extracts everything from there.
37
+ It handles bolded headers and common AI prefixes.
38
+ """
39
+ if not raw_text:
40
+ return ""
41
+
34
42
  lines = raw_text.strip().split("\n")
35
- cleaned_lines = []
36
- in_block = False
37
- # Regex for conventional commit header
43
+
44
+ # Regex for conventional commit header (including optional bolding)
45
+ # Examples:
46
+ # - feat(root): summary
47
+ # - **fix: bug fix**
48
+ # - chore(deps)!: breaking change
38
49
  header_pattern = re.compile(
39
- r"^(feat|fix|chore|refactor|docs|style|test|build|ci)(\(.*\))?!?: .*"
50
+ r"^(\*\*)?(feat|fix|chore|refactor|docs|style|test|build|ci)(\(.*\))?!?: .*",
51
+ re.IGNORECASE,
40
52
  )
41
53
 
54
+ cleaned_lines = []
55
+ found_header = False
56
+
42
57
  for line in lines:
43
58
  stripped = line.strip()
44
- if in_block:
45
- if header_pattern.match(stripped) or "**Sponsor**" in line:
59
+ if not found_header:
60
+ # First, strip bolding if it wraps the whole line or parts of it
61
+ # This is simpler than handling it in the regex
62
+ candidate = stripped.replace("**", "").strip()
63
+
64
+ # Clean the line by removing labels like "1. Title:", "### Message:", etc.
65
+ clean_line = re.sub(
66
+ r"^(#+\s+)?(\d+\.?|\*|-)?\s*(Title|Commit Message|Message):\s*",
67
+ "",
68
+ candidate,
69
+ flags=re.IGNORECASE,
70
+ ).strip()
71
+
72
+ if header_pattern.match(clean_line):
73
+ found_header = True
74
+ cleaned_lines.append(clean_line)
75
+ else:
76
+ # If we hit another header or a known separator, we stop
77
+ if "**Sponsor**" in line:
46
78
  break
47
79
  cleaned_lines.append(line)
48
- elif header_pattern.match(stripped):
49
- in_block = True
50
- cleaned_lines.append(line)
51
80
 
52
- return "\n".join(cleaned_lines).strip() if cleaned_lines else ""
81
+ if cleaned_lines:
82
+ return "\n".join(cleaned_lines).strip()
83
+
84
+ # Fallback: if no conventional commit header found, just take the first non-empty line
85
+ # to avoid failing completely, but log a warning if possible.
86
+ for line in lines:
87
+ if line.strip():
88
+ return line.strip()
89
+
90
+ return ""
53
91
 
54
92
 
55
93
  def parse_markdown_sections(
@@ -174,6 +212,13 @@ def load_template_env(sub_dir: str) -> Environment:
174
212
  return Environment(loader=FileSystemLoader(template_dir))
175
213
 
176
214
 
215
+ def render_custom_template(template_str: str, **context) -> str:
216
+ """Renders a Jinja2 template from a string."""
217
+ env = Environment()
218
+ template = env.from_string(template_str)
219
+ return template.render(**context)
220
+
221
+
177
222
  def load_config() -> Dict[str, Any]:
178
223
  config_path = Path.home() / ".devgen.yaml"
179
224
 
@@ -183,6 +228,7 @@ def load_config() -> Dict[str, Any]:
183
228
  "model": "gemini-2.5-flash",
184
229
  "api_key": "",
185
230
  "emoji": True,
231
+ "max_groups": 5,
186
232
  }
187
233
  try:
188
234
  with config_path.open("w", encoding="utf-8") as f:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devgen-cli
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: A collection of developer tools
5
5
  Home-page: https://github.com/S4NKALP/devgen
6
6
  Author: Sankalp Tharu
@@ -57,7 +57,7 @@ Dynamic: requires-python
57
57
  ## ⚡ Why DevGen?
58
58
 
59
59
  **🧠 AI Brain**
60
- Semantic commit messages powered by Gemini, OpenAI, Claude, HuggingFace, and OpenRouter. It reads your diffs and understands your code.
60
+ Semantic, context-aware commit messages powered by Gemini, OpenAI, Claude, HuggingFace, and OpenRouter. It reads your diffs, identifies project manifests, and understands your code.
61
61
 
62
62
  **🚀 Battle Tested**
63
63
  Generates **Conventional Commits** and **Semantic Versioning** compliant changelogs that actually make sense.
@@ -84,6 +84,9 @@ uv tool install devgen-cli
84
84
 
85
85
  # Standard pip install
86
86
  pip install devgen-cli
87
+
88
+ # Enable Shell Completion (bash/zsh/fish)
89
+ devgen --install-completion
87
90
  ```
88
91
 
89
92
  ## 🚀 Quick Start
@@ -117,8 +120,21 @@ devgen commit run --dry-run
117
120
 
118
121
  # Commit and push in one go
119
122
  devgen commit run --push
123
+
124
+ # Review and edit AI messages before committing
125
+ devgen commit run --check
126
+
127
+ # Made a mistake? Undo the last AI commit and keep changes staged
128
+ devgen commit undo
120
129
  ```
121
130
 
131
+ ### 🧠 Context Awareness
132
+ DevGen isn't just looking at the code changes; it's looking at the big picture. It automatically detects and analyzes:
133
+ - **Manifests**: `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, etc.
134
+ - **Lock Files**: `uv.lock`, `package-lock.json`, `poetry.lock`, etc.
135
+
136
+ This allows the AI to understand *exactly* which library versions were updated, leading to hyper-accurate dependency commit messages.
137
+
122
138
  ### 📝 Changelogs & Release Notes
123
139
 
124
140
  Turn your git history into beautiful, readable documentation.
@@ -151,7 +167,22 @@ Your settings live in `~/.devgen.yaml`. You can tweak your AI provider, model, a
151
167
  | :--------- | :----------------------------------------------------------- |
152
168
  | `provider` | `gemini`, `openai`, `anthropic`, `huggingface`, `openrouter` |
153
169
  | `model` | Specific model name (e.g., `gemini-2.5-flash`, `gpt-4o`) |
154
- | `emoji` | Enable/disable gitmojis in commits (`true`/`false`) |
170
+ | `emoji` | Enable/disable gitmojis in commits (`true`/`false`) |
171
+ | `custom_template` | Custom Jinja2 template for commit messages. |
172
+
173
+ ### 🎨 Custom Templates
174
+ You can define your own commit message structure in `~/.devgen.yaml`. Use `devgen config info` to see available variables like `{{ group_name }}`, `{{ diff_text }}`, and `{{ context }}`.
175
+
176
+ Example:
177
+ ```yaml
178
+ custom_template: |
179
+ {{ group_name }}: {{ diff_text }}
180
+ ---
181
+ Manifests: {{ context }}
182
+ ```
183
+
184
+ > [!TIP]
185
+ > Use `devgen config info` to see a full list of available variables and a template tip!
155
186
 
156
187
  ## 🤝 Contributing
157
188
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devgen-cli"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "A collection of developer tools"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -6,7 +6,7 @@ from setuptools import find_packages, setup
6
6
 
7
7
  setup(
8
8
  name="devgen-cli",
9
- version="0.2.2",
9
+ version="0.2.4",
10
10
  packages=find_packages(),
11
11
  include_package_data=True,
12
12
  install_requires=[
@@ -1,15 +0,0 @@
1
-
2
- Analyze the following git diff for the directory `{{ group_name }}` and generate a detailed semantic release commit message4. Format the output as:
3
- 1. A summary title in semantic release format (type(scope): summary) — scope can be the directory or 'root'.
4
- 2. A short summary paragraph if needed.
5
- 3. Bullet points listing the main changes, grouped as 'New Features', 'Enhancements & Refinements', and 'Bug Fixes' if relevant. Use bold for feature/section names.
6
- {% if use_emoji %}
7
- 4. Use appropriate emojis for the commit type and sections (e.g., 🚀 for features, 🐛 for bugs).
8
- {% else %}
9
- 4. Do NOT use any emojis in the commit message.
10
- {% endif %}
11
- Output only the commit message, don't include explanations or extra text.
12
-
13
- Here is the git diff for `{{ group_name }}`:
14
-
15
- {{ diff_text }}
File without changes
File without changes
File without changes
File without changes