devcommit 0.1.5.4__py3-none-any.whl → 0.1.5.6__py3-none-any.whl

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.
@@ -2,11 +2,28 @@
2
2
  """Generate changelog files from git diffs using AI"""
3
3
 
4
4
  import os
5
+ import re
5
6
  from datetime import datetime
7
+ from typing import List, Tuple
6
8
 
7
9
  from devcommit.app.ai_providers import get_ai_provider
10
+ from devcommit.utils.git import get_current_branch
8
11
  from devcommit.utils.logger import config
9
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
+
10
27
 
11
28
  def generate_changelog_prompt() -> str:
12
29
  """Generate the prompt for changelog creation"""
@@ -19,25 +36,295 @@ Follow these guidelines:
19
36
  4. Group related changes together
20
37
  5. Focus on what changed from a user/developer perspective
21
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
22
41
 
23
42
  Format:
24
43
  # Changelog
25
44
 
26
- ## [Unreleased]
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
+
27
208
 
28
- ### Added
29
- - List new features
209
+ def _to_section_name(heading: str) -> str:
210
+ return heading.removeprefix("###").strip()
30
211
 
31
- ### Changed
32
- - List changes to existing functionality
33
212
 
34
- ### Fixed
35
- - List bug fixes
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:])
36
218
 
37
- ### Removed
38
- - List removed features
219
+ _, sections = _split_by_heading(lines, 2)
220
+ category_items: List[Tuple[str, List[str]]] = []
39
221
 
40
- Only include sections that have changes. Do not add empty sections."""
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
41
328
 
42
329
 
43
330
  def generate_changelog(diff: str) -> str:
@@ -58,7 +345,7 @@ def generate_changelog(diff: str) -> str:
58
345
  diff, prompt, max_tokens
59
346
  )
60
347
 
61
- return changelog_content
348
+ return normalize_changelog_content(changelog_content)
62
349
 
63
350
 
64
351
  def save_changelog(content: str, directory: str = None) -> str:
@@ -74,18 +361,18 @@ def save_changelog(content: str, directory: str = None) -> str:
74
361
  if directory is None:
75
362
  directory = config("CHANGELOG_DIR", default="changelogs")
76
363
 
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)
364
+ changelog_mode = (
365
+ config("CHANGELOG_MODE", default=DEFAULT_CHANGELOG_MODE).strip().lower()
366
+ )
367
+ normalized_content = normalize_changelog_content(content)
87
368
 
88
- with open(filepath, "w", encoding="utf-8") as f:
89
- f.write(content)
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
+ )
90
375
 
91
- return filepath
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
  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
 
@@ -2,17 +2,17 @@ devcommit/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
2
2
  devcommit/__version__.py,sha256=V-XdLtsUi6WXZQjdiIm5Hd_DM77oCPs6tmPghmb3GbQ,226
3
3
  devcommit/app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  devcommit/app/ai_providers.py,sha256=Gqgs03oVfzR830dBSJ_VKn896DpXGWGuaAdsNtvSbI8,11938
5
- devcommit/app/changelog.py,sha256=6IAhHjDy1LrcEfmzF9kVZgka7zZJt2hGDgkFAdpRrjs,2425
5
+ devcommit/app/changelog.py,sha256=6rxOyiAJ2SyO_4mq1QGWuQZ--tXvurWBsToCzC60GU4,11470
6
6
  devcommit/app/gemini_ai.py,sha256=xEn94dt-kKg5fa6Wpxu09eKfN916pfWO_IWEBOGuEQE,3638
7
7
  devcommit/app/prompt.py,sha256=isjyLul4UTWJSW8kL6fSVjTBTTej0x93o5BFKW9LsZE,4325
8
- devcommit/create_config.py,sha256=dbW4nobO046t0dVoLbTZOKHMB4p1dATqH4d0K1MxR5g,3978
8
+ devcommit/create_config.py,sha256=kW3HOSqn_hCTWAD0yiP0OYsupzR4iyzYA_5JE6MiVsM,4215
9
9
  devcommit/main.py,sha256=SNjvUFeqrqjcpI9cLXOQanNavH4jUnrwbHBUxzcWmnc,73750
10
10
  devcommit/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  devcommit/utils/git.py,sha256=o32VbzT-0MT-AgfdtZXqn-H9SUINmVRG6AvYIJdcpDU,26858
12
12
  devcommit/utils/logger.py,sha256=HXYOU2Vd3M5O4T6fshcg5FcfWER4kQiKOiJ1vM6_0mw,1597
13
13
  devcommit/utils/parser.py,sha256=VXxHPZ75Jx6aILG1o14y1hGNlcvutQUWkoEtR_RR9Zw,2280
14
- devcommit-0.1.5.4.dist-info/METADATA,sha256=IjyewCUyLs24yjCg5pFPZuJKt0SyfYVOvXLI46sS1uw,67453
15
- devcommit-0.1.5.4.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
16
- devcommit-0.1.5.4.dist-info/entry_points.txt,sha256=cTvXe20Iqbeo2Jz0wSHEFLFacH4NkYfmy6Sr1THL1Es,103
17
- devcommit-0.1.5.4.dist-info/licenses/COPYING,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
18
- devcommit-0.1.5.4.dist-info/RECORD,,
14
+ devcommit-0.1.5.6.dist-info/METADATA,sha256=CM57SVCvyXuwTk-UpwSb4Oy6SXR6-FRyjQjEaBbl6Xw,67930
15
+ devcommit-0.1.5.6.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
16
+ devcommit-0.1.5.6.dist-info/entry_points.txt,sha256=cTvXe20Iqbeo2Jz0wSHEFLFacH4NkYfmy6Sr1THL1Es,103
17
+ devcommit-0.1.5.6.dist-info/licenses/COPYING,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
18
+ devcommit-0.1.5.6.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.3.1
2
+ Generator: poetry-core 2.3.2
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any