devcommit 0.1.5.4__tar.gz → 0.1.5.6__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: devcommit
3
- Version: 0.1.5.4
3
+ Version: 0.1.5.6
4
4
  Summary: AI-powered git commit message generator
5
5
  License: GNU GENERAL PUBLIC LICENSE
6
6
  Version 3, 29 June 2007
@@ -782,6 +782,7 @@ A command-line AI tool for autocommits.
782
782
  # GEMINI_MODEL / OPENAI_MODEL / GROQ_MODEL / OPENROUTER_MODEL / ANTHROPIC_MODEL / OLLAMA_MODEL / CUSTOM_MODEL
783
783
  MODEL_NAME = gemini-2.5-flash
784
784
  COMMIT_MODE = auto
785
+ CHANGELOG_MODE = timestamped
785
786
  EOF
786
787
  ```
787
788
 
@@ -796,6 +797,7 @@ A command-line AI tool for autocommits.
796
797
  COMMIT_TYPE = conventional
797
798
  MODEL_NAME = gemini-2.0-flash-exp
798
799
  COMMIT_MODE = auto
800
+ CHANGELOG_MODE = timestamped
799
801
  EOF
800
802
  ```
801
803
 
@@ -1150,8 +1152,11 @@ devcommit --stageAll --changelog --files src/
1150
1152
 
1151
1153
  - **With `--stageAll`**: Changelog is generated from unstaged changes **before** staging
1152
1154
  - **Without `--stageAll`**: Changelog is generated from the last commit **after** committing
1153
- - Changelogs are saved as markdown files with datetime-based names inside `year/month` folders (e.g., `changelogs/2026/01/2026-01-28_00-55-30.md`)
1154
- - Default base directory: `changelogs/` (configurable via `CHANGELOG_DIR` in `.dcommit`)
1155
+ - Storage mode is controlled by `CHANGELOG_MODE`:
1156
+ - `timestamped` (default): new file per run in `changelogs/<branch>/<year>/<month>/<day>/<time>.md`
1157
+ - `branch`: one file per branch, updated in place at `changelogs/<branch>.md`
1158
+ - Default base directory is `changelogs/` (configurable via `CHANGELOG_DIR` in `.dcommit`)
1159
+ - Empty sections are automatically removed (including empty `## [Unreleased]`)
1155
1160
  - Uses Keep a Changelog format with AI-generated content
1156
1161
 
1157
1162
  **Example workflow:**
@@ -1163,7 +1168,9 @@ devcommit --stageAll --changelog --files src/
1163
1168
  # Stage all changes and generate changelog before committing
1164
1169
  devcommit --stageAll --changelog
1165
1170
 
1166
- # The changelog file is created in changelogs/<year>/<month>/ directory
1171
+ # The changelog file is created based on CHANGELOG_MODE
1172
+ # timestamped: changelogs/<branch>/<year>/<month>/<day>/<time>.md
1173
+ # branch: changelogs/<branch>.md
1167
1174
  # Then changes are staged and committed
1168
1175
  ```
1169
1176
 
@@ -1359,6 +1366,7 @@ All configuration can be set via **environment variables** or **`.dcommit` file*
1359
1366
  | `EXCLUDE_FILES` | Files to exclude from diff | `package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock` | Comma-separated file patterns |
1360
1367
  | `MAX_TOKENS` | Maximum tokens for AI response | `8192` | Any positive integer |
1361
1368
  | `CHANGELOG_DIR` | Directory for changelog files | `changelogs` | Any directory path |
1369
+ | `CHANGELOG_MODE` | Changelog file strategy | `timestamped` | `timestamped`, `branch` |
1362
1370
 
1363
1371
  ### Configuration Priority
1364
1372
 
@@ -77,6 +77,7 @@ A command-line AI tool for autocommits.
77
77
  # GEMINI_MODEL / OPENAI_MODEL / GROQ_MODEL / OPENROUTER_MODEL / ANTHROPIC_MODEL / OLLAMA_MODEL / CUSTOM_MODEL
78
78
  MODEL_NAME = gemini-2.5-flash
79
79
  COMMIT_MODE = auto
80
+ CHANGELOG_MODE = timestamped
80
81
  EOF
81
82
  ```
82
83
 
@@ -91,6 +92,7 @@ A command-line AI tool for autocommits.
91
92
  COMMIT_TYPE = conventional
92
93
  MODEL_NAME = gemini-2.0-flash-exp
93
94
  COMMIT_MODE = auto
95
+ CHANGELOG_MODE = timestamped
94
96
  EOF
95
97
  ```
96
98
 
@@ -445,8 +447,11 @@ devcommit --stageAll --changelog --files src/
445
447
 
446
448
  - **With `--stageAll`**: Changelog is generated from unstaged changes **before** staging
447
449
  - **Without `--stageAll`**: Changelog is generated from the last commit **after** committing
448
- - Changelogs are saved as markdown files with datetime-based names inside `year/month` folders (e.g., `changelogs/2026/01/2026-01-28_00-55-30.md`)
449
- - Default base directory: `changelogs/` (configurable via `CHANGELOG_DIR` in `.dcommit`)
450
+ - Storage mode is controlled by `CHANGELOG_MODE`:
451
+ - `timestamped` (default): new file per run in `changelogs/<branch>/<year>/<month>/<day>/<time>.md`
452
+ - `branch`: one file per branch, updated in place at `changelogs/<branch>.md`
453
+ - Default base directory is `changelogs/` (configurable via `CHANGELOG_DIR` in `.dcommit`)
454
+ - Empty sections are automatically removed (including empty `## [Unreleased]`)
450
455
  - Uses Keep a Changelog format with AI-generated content
451
456
 
452
457
  **Example workflow:**
@@ -458,7 +463,9 @@ devcommit --stageAll --changelog --files src/
458
463
  # Stage all changes and generate changelog before committing
459
464
  devcommit --stageAll --changelog
460
465
 
461
- # The changelog file is created in changelogs/<year>/<month>/ directory
466
+ # The changelog file is created based on CHANGELOG_MODE
467
+ # timestamped: changelogs/<branch>/<year>/<month>/<day>/<time>.md
468
+ # branch: changelogs/<branch>.md
462
469
  # Then changes are staged and committed
463
470
  ```
464
471
 
@@ -654,6 +661,7 @@ All configuration can be set via **environment variables** or **`.dcommit` file*
654
661
  | `EXCLUDE_FILES` | Files to exclude from diff | `package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock` | Comma-separated file patterns |
655
662
  | `MAX_TOKENS` | Maximum tokens for AI response | `8192` | Any positive integer |
656
663
  | `CHANGELOG_DIR` | Directory for changelog files | `changelogs` | Any directory path |
664
+ | `CHANGELOG_MODE` | Changelog file strategy | `timestamped` | `timestamped`, `branch` |
657
665
 
658
666
  ### Configuration Priority
659
667
 
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env python
2
+ """Generate changelog files from git diffs using AI"""
3
+
4
+ import os
5
+ import re
6
+ from datetime import datetime
7
+ from typing import List, Tuple
8
+
9
+ from devcommit.app.ai_providers import get_ai_provider
10
+ from devcommit.utils.git import get_current_branch
11
+ from devcommit.utils.logger import config
12
+
13
+ PLACEHOLDER_LINES = {
14
+ "list new features",
15
+ "list changes to existing functionality",
16
+ "list bug fixes",
17
+ "list removed features",
18
+ "none",
19
+ "n/a",
20
+ "na",
21
+ "no changes",
22
+ }
23
+
24
+ DEFAULT_CHANGELOG_MODE = "timestamped"
25
+ BRANCH_CHANGELOG_MODES = {"branch", "branch_single", "single", "per_branch"}
26
+
27
+
28
+ def generate_changelog_prompt() -> str:
29
+ """Generate the prompt for changelog creation"""
30
+ return """You are a changelog generator. Analyze the git diff and create a structured changelog in Keep a Changelog format.
31
+
32
+ Follow these guidelines:
33
+ 1. Use markdown format with clear sections
34
+ 2. Categorize changes into: Added, Changed, Fixed, Removed, Deprecated, Security
35
+ 3. Write clear, user-friendly descriptions (not implementation details)
36
+ 4. Group related changes together
37
+ 5. Focus on what changed from a user/developer perspective
38
+ 6. Be concise but informative
39
+ 7. Include only sections that have real content
40
+ 8. Use ## [Unreleased] only when it contains at least one non-empty subsection
41
+
42
+ Format:
43
+ # Changelog
44
+
45
+ ## [Unreleased] (optional)
46
+
47
+ ### Added (optional)
48
+ - Describe added features (only if any)
49
+
50
+ ### Changed (optional)
51
+ - Describe changed behavior (only if any)
52
+
53
+ Do not include empty section headings or placeholders."""
54
+
55
+
56
+ def _strip_fences(content: str) -> str:
57
+ lines = content.strip().splitlines()
58
+ if len(lines) >= 2 and lines[0].strip().startswith("```"):
59
+ if lines[-1].strip() == "```":
60
+ return "\n".join(lines[1:-1]).strip()
61
+ return content.strip()
62
+
63
+
64
+ def _split_by_heading(
65
+ lines: List[str], level: int
66
+ ) -> Tuple[List[str], List[Tuple[str, List[str]]]]:
67
+ """Split content by markdown heading level."""
68
+ heading_prefix = "#" * level + " "
69
+ preamble: List[str] = []
70
+ sections: List[Tuple[str, List[str]]] = []
71
+ current_heading: str | None = None
72
+ current_body: List[str] = []
73
+
74
+ for line in lines:
75
+ if line.startswith(heading_prefix):
76
+ if current_heading is not None:
77
+ sections.append((current_heading.strip(), current_body))
78
+ current_heading = line
79
+ current_body = []
80
+ continue
81
+
82
+ if current_heading is None:
83
+ preamble.append(line)
84
+ else:
85
+ current_body.append(line)
86
+
87
+ if current_heading is not None:
88
+ sections.append((current_heading.strip(), current_body))
89
+
90
+ return preamble, sections
91
+
92
+
93
+ def _strip_blank_edges(lines: List[str]) -> List[str]:
94
+ start = 0
95
+ end = len(lines)
96
+
97
+ while start < end and not lines[start].strip():
98
+ start += 1
99
+ while end > start and not lines[end - 1].strip():
100
+ end -= 1
101
+
102
+ return lines[start:end]
103
+
104
+
105
+ def _is_placeholder_line(line: str) -> bool:
106
+ stripped = line.strip()
107
+ if not stripped:
108
+ return True
109
+ if stripped in {"-", "*", "+"}:
110
+ return True
111
+
112
+ candidate = stripped
113
+ for marker in ("- ", "* ", "+ "):
114
+ if candidate.startswith(marker):
115
+ candidate = candidate[len(marker) :].strip()
116
+ break
117
+
118
+ candidate_lower = candidate.lower().rstrip(".")
119
+ if candidate_lower in PLACEHOLDER_LINES:
120
+ return True
121
+
122
+ if re.fullmatch(r"(none|no changes|n/?a)\.?$", candidate_lower):
123
+ return True
124
+
125
+ return False
126
+
127
+
128
+ def _has_informative_content(lines: List[str]) -> bool:
129
+ for line in lines:
130
+ stripped = line.strip()
131
+ if not stripped:
132
+ continue
133
+ if stripped.startswith("#"):
134
+ continue
135
+ if _is_placeholder_line(stripped):
136
+ continue
137
+ return True
138
+ return False
139
+
140
+
141
+ def _clean_second_level_body(lines: List[str]) -> List[str]:
142
+ intro, subsections = _split_by_heading(lines, 3)
143
+ cleaned: List[str] = []
144
+ kept_subsections: List[Tuple[str, List[str]]] = []
145
+
146
+ for heading, body in subsections:
147
+ body = _strip_blank_edges(body)
148
+ if _has_informative_content(body):
149
+ kept_subsections.append((heading, body))
150
+
151
+ intro = _strip_blank_edges(intro)
152
+ keep_intro = _has_informative_content(intro)
153
+ if not keep_intro and not kept_subsections:
154
+ return []
155
+
156
+ if keep_intro:
157
+ cleaned.extend(intro)
158
+
159
+ for index, (heading, body) in enumerate(kept_subsections):
160
+ if cleaned:
161
+ cleaned.append("")
162
+ cleaned.append(heading)
163
+ cleaned.extend(body)
164
+ if index < len(kept_subsections) - 1:
165
+ cleaned.append("")
166
+
167
+ return cleaned
168
+
169
+
170
+ def normalize_changelog_content(content: str) -> str:
171
+ """Normalize AI output and remove empty sections/placeholders."""
172
+ content = _strip_fences(content)
173
+ raw_lines = _strip_blank_edges(content.splitlines())
174
+ if not raw_lines:
175
+ return "# Changelog"
176
+
177
+ title = "# Changelog"
178
+ body_lines = raw_lines
179
+ if raw_lines[0].startswith("# "):
180
+ title = raw_lines[0].strip()
181
+ body_lines = _strip_blank_edges(raw_lines[1:])
182
+
183
+ _, sections = _split_by_heading(body_lines, 2)
184
+ rebuilt_lines: List[str] = [title]
185
+
186
+ if sections:
187
+ kept_sections: List[Tuple[str, List[str]]] = []
188
+ for heading, body in sections:
189
+ cleaned_body = _clean_second_level_body(body)
190
+ if cleaned_body:
191
+ kept_sections.append((heading, cleaned_body))
192
+
193
+ if kept_sections:
194
+ rebuilt_lines.append("")
195
+ for index, (heading, body) in enumerate(kept_sections):
196
+ rebuilt_lines.append(heading)
197
+ rebuilt_lines.extend(body)
198
+ if index < len(kept_sections) - 1:
199
+ rebuilt_lines.append("")
200
+ else:
201
+ body_lines = _strip_blank_edges(body_lines)
202
+ if _has_informative_content(body_lines):
203
+ rebuilt_lines.append("")
204
+ rebuilt_lines.extend(body_lines)
205
+
206
+ return "\n".join(_strip_blank_edges(rebuilt_lines)) + "\n"
207
+
208
+
209
+ def _to_section_name(heading: str) -> str:
210
+ return heading.removeprefix("###").strip()
211
+
212
+
213
+ def _extract_category_blocks(content: str) -> List[Tuple[str, List[str]]]:
214
+ """Extract non-empty category blocks from normalized changelog content."""
215
+ lines = _strip_blank_edges(content.splitlines())
216
+ if lines and lines[0].startswith("# "):
217
+ lines = _strip_blank_edges(lines[1:])
218
+
219
+ _, sections = _split_by_heading(lines, 2)
220
+ category_items: List[Tuple[str, List[str]]] = []
221
+
222
+ for _, section_body in sections:
223
+ section_intro, subsections = _split_by_heading(section_body, 3)
224
+ if subsections:
225
+ for heading, body in subsections:
226
+ body = _strip_blank_edges(body)
227
+ if _has_informative_content(body):
228
+ category_items.append((_to_section_name(heading), body))
229
+ continue
230
+
231
+ section_intro = _strip_blank_edges(section_intro)
232
+ if _has_informative_content(section_intro):
233
+ category_items.append(("Changed", section_intro))
234
+
235
+ if category_items:
236
+ preferred_order = [
237
+ "Added",
238
+ "Changed",
239
+ "Fixed",
240
+ "Removed",
241
+ "Deprecated",
242
+ "Security",
243
+ ]
244
+ ordered: List[Tuple[str, List[str]]] = []
245
+ used_indices: set[int] = set()
246
+
247
+ for name in preferred_order:
248
+ for idx, item in enumerate(category_items):
249
+ if idx in used_indices:
250
+ continue
251
+ category_name, _ = item
252
+ if category_name.lower() == name.lower():
253
+ ordered.append(item)
254
+ used_indices.add(idx)
255
+
256
+ for idx, item in enumerate(category_items):
257
+ if idx not in used_indices:
258
+ ordered.append(item)
259
+
260
+ return ordered
261
+
262
+ return category_items
263
+
264
+
265
+ def _strip_top_heading(content: str) -> str:
266
+ lines = _strip_blank_edges(content.splitlines())
267
+ if lines and lines[0].startswith("# "):
268
+ return "\n".join(_strip_blank_edges(lines[1:]))
269
+ return "\n".join(lines)
270
+
271
+
272
+ def _save_timestamped_changelog(
273
+ content: str, directory: str, branch_name: str, now: datetime
274
+ ) -> str:
275
+ year_directory = now.strftime("%Y")
276
+ month_directory = now.strftime("%m")
277
+ day_directory = now.strftime("%d")
278
+ target_directory = os.path.join(
279
+ directory, branch_name, year_directory, month_directory, day_directory
280
+ )
281
+ os.makedirs(target_directory, exist_ok=True)
282
+
283
+ filename = now.strftime("%H-%M-%S.md")
284
+ filepath = os.path.join(target_directory, filename)
285
+
286
+ with open(filepath, "w", encoding="utf-8") as file:
287
+ file.write(content)
288
+
289
+ return filepath
290
+
291
+
292
+ def _save_branch_changelog(
293
+ content: str, directory: str, branch_name: str, now: datetime
294
+ ) -> str:
295
+ os.makedirs(directory, exist_ok=True)
296
+ filepath = os.path.join(directory, f"{branch_name}.md")
297
+
298
+ timestamp = now.strftime("%Y-%m-%d %H:%M:%S")
299
+ categories = _extract_category_blocks(content)
300
+ entry_lines: List[str] = [f"## [{timestamp}]"]
301
+
302
+ if categories:
303
+ for section_name, section_lines in categories:
304
+ entry_lines.append("")
305
+ entry_lines.append(f"### {section_name}")
306
+ entry_lines.extend(section_lines)
307
+ else:
308
+ content_body = _strip_top_heading(content)
309
+ if content_body.strip():
310
+ entry_lines.append("")
311
+ entry_lines.extend(content_body.splitlines())
312
+
313
+ new_entry = "\n".join(_strip_blank_edges(entry_lines)).strip()
314
+
315
+ previous_body = ""
316
+ if os.path.exists(filepath):
317
+ with open(filepath, "r", encoding="utf-8") as file:
318
+ previous_body = _strip_top_heading(file.read()).strip()
319
+
320
+ output_lines = ["# Changelog", "", new_entry]
321
+ if previous_body:
322
+ output_lines.extend(["", previous_body])
323
+
324
+ with open(filepath, "w", encoding="utf-8") as file:
325
+ file.write("\n".join(_strip_blank_edges(output_lines)) + "\n")
326
+
327
+ return filepath
328
+
329
+
330
+ def generate_changelog(diff: str) -> str:
331
+ """Generate changelog content from git diff using AI.
332
+
333
+ Args:
334
+ diff: Git diff string
335
+
336
+ Returns:
337
+ Formatted markdown changelog content
338
+ """
339
+ prompt = generate_changelog_prompt()
340
+
341
+ provider = get_ai_provider(config)
342
+
343
+ max_tokens = config("MAX_TOKENS", default=8192, cast=int)
344
+ changelog_content = provider.generate_commit_message(
345
+ diff, prompt, max_tokens
346
+ )
347
+
348
+ return normalize_changelog_content(changelog_content)
349
+
350
+
351
+ def save_changelog(content: str, directory: str = None) -> str:
352
+ """Save changelog content to a file.
353
+
354
+ Args:
355
+ content: Changelog markdown content
356
+ directory: Directory to save changelog (default from config)
357
+
358
+ Returns:
359
+ Path to the saved changelog file
360
+ """
361
+ if directory is None:
362
+ directory = config("CHANGELOG_DIR", default="changelogs")
363
+
364
+ changelog_mode = (
365
+ config("CHANGELOG_MODE", default=DEFAULT_CHANGELOG_MODE).strip().lower()
366
+ )
367
+ normalized_content = normalize_changelog_content(content)
368
+
369
+ now = datetime.now()
370
+ branch_name = get_current_branch().replace("/", "-")
371
+ if changelog_mode in BRANCH_CHANGELOG_MODES:
372
+ return _save_branch_changelog(
373
+ normalized_content, directory, branch_name, now
374
+ )
375
+
376
+ return _save_timestamped_changelog(
377
+ normalized_content, directory, branch_name, now
378
+ )
@@ -85,6 +85,12 @@ EXCLUDE_FILES = *.lock, dist/*, build/*, node_modules/*
85
85
  # Directory for changelog files (default: changelogs)
86
86
  # Used when --changelog flag is passed
87
87
  CHANGELOG_DIR = changelogs
88
+
89
+ # Changelog storage mode (default: timestamped)
90
+ # Options:
91
+ # - timestamped: Create a new changelog file per run (existing behavior)
92
+ # - branch: Keep one changelog file per branch and prepend new entries
93
+ CHANGELOG_MODE = timestamped
88
94
  """
89
95
 
90
96
  if "VIRTUAL_ENV" in os.environ:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devcommit"
3
- version = "0.1.5.4"
3
+ version = "0.1.5.6"
4
4
  description = "AI-powered git commit message generator"
5
5
  readme = "README.md"
6
6
  license = {file = "COPYING"}
@@ -44,7 +44,7 @@ create-dcommit = "devcommit.create_config:create_dcommit"
44
44
 
45
45
  [tool.poetry]
46
46
  name = "devcommit"
47
- version = "0.1.5.4"
47
+ version = "0.1.5.6"
48
48
  description = "AI-powered git commit message generator"
49
49
  authors = ["Hordunlarmy <Hordunlarmy@gmail.com>"]
50
50
 
@@ -1,91 +0,0 @@
1
- #!/usr/bin/env python
2
- """Generate changelog files from git diffs using AI"""
3
-
4
- import os
5
- from datetime import datetime
6
-
7
- from devcommit.app.ai_providers import get_ai_provider
8
- from devcommit.utils.logger import config
9
-
10
-
11
- def generate_changelog_prompt() -> str:
12
- """Generate the prompt for changelog creation"""
13
- return """You are a changelog generator. Analyze the git diff and create a structured changelog in Keep a Changelog format.
14
-
15
- Follow these guidelines:
16
- 1. Use markdown format with clear sections
17
- 2. Categorize changes into: Added, Changed, Fixed, Removed, Deprecated, Security
18
- 3. Write clear, user-friendly descriptions (not implementation details)
19
- 4. Group related changes together
20
- 5. Focus on what changed from a user/developer perspective
21
- 6. Be concise but informative
22
-
23
- Format:
24
- # Changelog
25
-
26
- ## [Unreleased]
27
-
28
- ### Added
29
- - List new features
30
-
31
- ### Changed
32
- - List changes to existing functionality
33
-
34
- ### Fixed
35
- - List bug fixes
36
-
37
- ### Removed
38
- - List removed features
39
-
40
- Only include sections that have changes. Do not add empty sections."""
41
-
42
-
43
- def generate_changelog(diff: str) -> str:
44
- """Generate changelog content from git diff using AI.
45
-
46
- Args:
47
- diff: Git diff string
48
-
49
- Returns:
50
- Formatted markdown changelog content
51
- """
52
- prompt = generate_changelog_prompt()
53
-
54
- provider = get_ai_provider(config)
55
-
56
- max_tokens = config("MAX_TOKENS", default=8192, cast=int)
57
- changelog_content = provider.generate_commit_message(
58
- diff, prompt, max_tokens
59
- )
60
-
61
- return changelog_content
62
-
63
-
64
- def save_changelog(content: str, directory: str = None) -> str:
65
- """Save changelog content to a file.
66
-
67
- Args:
68
- content: Changelog markdown content
69
- directory: Directory to save changelog (default from config)
70
-
71
- Returns:
72
- Path to the saved changelog file
73
- """
74
- if directory is None:
75
- directory = config("CHANGELOG_DIR", default="changelogs")
76
-
77
- now = datetime.now()
78
- year_directory = now.strftime("%Y")
79
- month_directory = now.strftime("%m")
80
- target_directory = os.path.join(directory, year_directory, month_directory)
81
-
82
- # Create the year/month directory structure if it doesn't exist
83
- os.makedirs(target_directory, exist_ok=True)
84
-
85
- filename = now.strftime("%Y-%m-%d_%H-%M-%S.md")
86
- filepath = os.path.join(target_directory, filename)
87
-
88
- with open(filepath, "w", encoding="utf-8") as f:
89
- f.write(content)
90
-
91
- return filepath
File without changes