git-explain 2.1.9__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {git_explain-2.1.9 → git_explain-2.2.0}/PKG-INFO +13 -3
  2. {git_explain-2.1.9 → git_explain-2.2.0}/README.md +11 -1
  3. git_explain-2.2.0/git_explain/__init__.py +1 -0
  4. git_explain-2.2.0/git_explain/__main__.py +3 -0
  5. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/cli.py +52 -47
  6. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/commit_infer.py +1 -1
  7. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/gemini.py +97 -45
  8. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/heuristics.py +34 -20
  9. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/path_topics.py +80 -0
  10. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/run.py +37 -2
  11. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain.egg-info/PKG-INFO +13 -3
  12. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain.egg-info/SOURCES.txt +1 -0
  13. {git_explain-2.1.9 → git_explain-2.2.0}/pyproject.toml +1 -1
  14. {git_explain-2.1.9 → git_explain-2.2.0}/tests/test_gemini.py +54 -31
  15. {git_explain-2.1.9 → git_explain-2.2.0}/tests/test_heuristics.py +3 -3
  16. {git_explain-2.1.9 → git_explain-2.2.0}/tests/test_run_apply.py +1 -1
  17. git_explain-2.1.9/git_explain/__init__.py +0 -1
  18. {git_explain-2.1.9 → git_explain-2.2.0}/LICENSE +0 -0
  19. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain/git.py +0 -0
  20. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain.egg-info/dependency_links.txt +0 -0
  21. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain.egg-info/entry_points.txt +0 -0
  22. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain.egg-info/requires.txt +0 -0
  23. {git_explain-2.1.9 → git_explain-2.2.0}/git_explain.egg-info/top_level.txt +0 -0
  24. {git_explain-2.1.9 → git_explain-2.2.0}/setup.cfg +0 -0
  25. {git_explain-2.1.9 → git_explain-2.2.0}/tests/test_cli_utils.py +0 -0
  26. {git_explain-2.1.9 → git_explain-2.2.0}/tests/test_commit_infer.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-explain
3
- Version: 2.1.9
3
+ Version: 2.2.0
4
4
  Summary: CLI that suggests git add/commit from diffs using Gemini
5
- Author: git-explain contributors
5
+ Author: nazarli-shabnam
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/nazarli-shabnam/git-explain
8
8
  Project-URL: Source, https://github.com/nazarli-shabnam/git-explain
@@ -51,6 +51,14 @@ pip install git-explain
51
51
  pip install -e .
52
52
  ```
53
53
 
54
+ **Clone:** install deps (`requirements.txt` is fine), `cd` into the repo, then:
55
+
56
+ ```bash
57
+ python -m git_explain
58
+ ```
59
+
60
+ Run that from the repo root so Python picks up the `git_explain` folder—no `pip install -e .` needed.
61
+
54
62
  Optional: install a specific tag from GitHub instead of PyPI:
55
63
 
56
64
  ```bash
@@ -70,6 +78,8 @@ pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.1.8"
70
78
 
71
79
  Heuristics guess a sensible type and message from paths and statuses. **No account, no key, no network** for that path.
72
80
 
81
+ Suggested commits follow **[Conventional Commits](https://www.conventionalcommits.org/)**—`feat: …`, `fix: …`, optional `(scope)`, and so on—so changelogs and release tools can read them.
82
+
73
83
  ---
74
84
 
75
85
  ## Optional: Gemini
@@ -80,7 +90,7 @@ If you want sharper messages, set **`GEMINI_API_KEY`** (or `GOOGLE_API_KEY`) in
80
90
  |--------|----------------|
81
91
  | `git-explain --ai` | AI sees **paths and change type** only (no file contents). |
82
92
  | `git-explain --ai --with-diff` | AI also sees the **diff**—better detail; only use if you’re OK sending that to the API. |
83
- | `git-explain --suggest` | **Staged files only**; prints one **`git commit -m "..."`** line for scripting. Needs AI; don’t combine with other flags. |
93
+ | `git-explain --suggest` | **Staged only**; prints one plain `git commit -m ""` line (easy to copy). Needs AI; don’t combine with other flags. |
84
94
 
85
95
  Everything else (`--auto`, `--staged-only`, `--cwd`, model override, shell completion): **`git-explain --help`**.
86
96
 
@@ -20,6 +20,14 @@ pip install git-explain
20
20
  pip install -e .
21
21
  ```
22
22
 
23
+ **Clone:** install deps (`requirements.txt` is fine), `cd` into the repo, then:
24
+
25
+ ```bash
26
+ python -m git_explain
27
+ ```
28
+
29
+ Run that from the repo root so Python picks up the `git_explain` folder—no `pip install -e .` needed.
30
+
23
31
  Optional: install a specific tag from GitHub instead of PyPI:
24
32
 
25
33
  ```bash
@@ -39,6 +47,8 @@ pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.1.8"
39
47
 
40
48
  Heuristics guess a sensible type and message from paths and statuses. **No account, no key, no network** for that path.
41
49
 
50
+ Suggested commits follow **[Conventional Commits](https://www.conventionalcommits.org/)**—`feat: …`, `fix: …`, optional `(scope)`, and so on—so changelogs and release tools can read them.
51
+
42
52
  ---
43
53
 
44
54
  ## Optional: Gemini
@@ -49,7 +59,7 @@ If you want sharper messages, set **`GEMINI_API_KEY`** (or `GOOGLE_API_KEY`) in
49
59
  |--------|----------------|
50
60
  | `git-explain --ai` | AI sees **paths and change type** only (no file contents). |
51
61
  | `git-explain --ai --with-diff` | AI also sees the **diff**—better detail; only use if you’re OK sending that to the API. |
52
- | `git-explain --suggest` | **Staged files only**; prints one **`git commit -m "..."`** line for scripting. Needs AI; don’t combine with other flags. |
62
+ | `git-explain --suggest` | **Staged only**; prints one plain `git commit -m ""` line (easy to copy). Needs AI; don’t combine with other flags. |
53
63
 
54
64
  Everything else (`--auto`, `--staged-only`, `--cwd`, model override, shell completion): **`git-explain --help`**.
55
65
 
@@ -0,0 +1 @@
1
+ __version__ = "2.2.0"
@@ -0,0 +1,3 @@
1
+ from git_explain.cli import app
2
+
3
+ app()
@@ -2,7 +2,7 @@
2
2
 
3
3
  import re
4
4
  import subprocess
5
- from dataclasses import dataclass
5
+ from dataclasses import dataclass, replace
6
6
  from pathlib import Path
7
7
  from typing import Iterable
8
8
 
@@ -12,14 +12,18 @@ from rich.console import Console
12
12
  from rich.panel import Panel
13
13
  from rich.text import Text
14
14
 
15
- from git_explain.gemini import suggest_commands
15
+ from git_explain.gemini import Suggestion, suggest_commands
16
16
  from git_explain.heuristics import suggest_from_changes
17
17
  from git_explain.git import (
18
18
  get_combined_diff,
19
19
  get_diff_for_paths,
20
20
  get_staged_diff_for_paths,
21
21
  )
22
- from git_explain.run import apply_commands, normalize_commit_subject_for_dash_m
22
+ from git_explain.run import (
23
+ apply_commands,
24
+ format_commit_message,
25
+ normalize_commit_subject_for_dash_m,
26
+ )
23
27
 
24
28
  load_dotenv()
25
29
  app = typer.Typer()
@@ -344,13 +348,10 @@ def run(
344
348
  raise typer.Exit(1)
345
349
 
346
350
  cmsg = normalize_commit_subject_for_dash_m(sug.commit_message)
347
- console.print(
348
- Panel(
349
- f'git commit -m "[{sug.commit_type}] {cmsg}"',
350
- title="Suggested commit command",
351
- border_style="green",
352
- )
351
+ full = format_commit_message(
352
+ sug.commit_type, cmsg, scope=sug.scope, breaking=sug.breaking
353
353
  )
354
+ print(f'git commit -m "{full}"')
354
355
  return
355
356
 
356
357
  if staged_only:
@@ -422,9 +423,8 @@ def run(
422
423
 
423
424
  def suggest_for(
424
425
  change_items: list[tuple[str, str]], title: str
425
- ) -> tuple[list[str], str, str, str, str | None]:
426
- # Returns (paths, type, message, raw_text, ai_fallback_reason).
427
- # ai_fallback_reason is set when --ai was used but heuristics were used instead.
426
+ ) -> tuple[Suggestion, str | None]:
427
+ """Return (suggestion, ai_fallback_reason)."""
428
428
  paths_for_infer = [p for _, p in change_items]
429
429
  infer_diff: str | None = None
430
430
  if paths_for_infer:
@@ -444,7 +444,7 @@ def run(
444
444
  if diff_text:
445
445
  payload = payload + "\n\n## Diff\n" + diff_text
446
446
  try:
447
- sug, raw = suggest_commands(
447
+ sug, _raw = suggest_commands(
448
448
  payload,
449
449
  model=model,
450
450
  with_diff=with_diff,
@@ -452,27 +452,20 @@ def run(
452
452
  )
453
453
  if sug is None:
454
454
  raise RuntimeError("Could not parse AI suggestion.")
455
- return sug.add_args, sug.commit_type, sug.commit_message, raw, None
455
+ return sug, None
456
456
  except Exception as e:
457
- # Fall back to heuristics on quota / API errors
458
457
  h = suggest_from_changes(
459
458
  changes=change_items,
460
459
  has_commits=has_commits,
461
460
  diff_text=infer_diff,
462
461
  )
463
- return (
464
- h.add_args,
465
- h.commit_type,
466
- h.commit_message,
467
- "",
468
- str(e),
469
- )
462
+ return h, str(e)
470
463
  h = suggest_from_changes(
471
464
  changes=change_items,
472
465
  has_commits=has_commits,
473
466
  diff_text=infer_diff,
474
467
  )
475
- return h.add_args, h.commit_type, h.commit_message, "", None
468
+ return h, None
476
469
 
477
470
  selected_pairs = [(ch.status, ch.path) for ch in selected]
478
471
  unique_paths = {p for _, p in selected_pairs}
@@ -492,18 +485,18 @@ def run(
492
485
  if mode_input in ("one", "split"):
493
486
  mode = mode_input
494
487
 
495
- plan: list[tuple[str, list[str], str, str]] = []
488
+ plan: list[tuple[str, Suggestion]] = []
496
489
  ai_fallback_notes: list[tuple[str, str]] = []
497
490
  if mode == "split":
498
491
  groups = _group_changes(selected_pairs)
499
492
  for gname, items in groups.items():
500
- paths, ctype, cmsg, _raw, fb = suggest_for(items, title=gname.capitalize())
501
- plan.append((gname, paths, ctype, cmsg))
493
+ sug, fb = suggest_for(items, title=gname.capitalize())
494
+ plan.append((gname, sug))
502
495
  if fb:
503
496
  ai_fallback_notes.append((gname, fb))
504
497
  else:
505
- paths, ctype, cmsg, _raw, fb = suggest_for(selected_pairs, title="Selected")
506
- plan.append(("one", paths, ctype, cmsg))
498
+ sug, fb = suggest_for(selected_pairs, title="Selected")
499
+ plan.append(("one", sug))
507
500
  if fb:
508
501
  ai_fallback_notes.append(("", fb))
509
502
 
@@ -530,12 +523,15 @@ def run(
530
523
  )
531
524
  )
532
525
 
533
- def _render_plan(pl: list[tuple[str, list[str], str, str]]) -> str:
526
+ def _render_plan(pl: list[tuple[str, Suggestion]]) -> str:
534
527
  rendered: list[str] = []
535
- for name, paths, ctype, cmsg in pl:
536
- add_line = "git add -A -- " + " ".join(_ps_quote(p) for p in paths)
537
- subj = normalize_commit_subject_for_dash_m(cmsg)
538
- commit_line = f'git commit -m "[{ctype}] {subj}"'
528
+ for name, sug in pl:
529
+ add_line = "git add -A -- " + " ".join(_ps_quote(p) for p in sug.add_args)
530
+ subj = normalize_commit_subject_for_dash_m(sug.commit_message)
531
+ full = format_commit_message(
532
+ sug.commit_type, subj, scope=sug.scope, breaking=sug.breaking
533
+ )
534
+ commit_line = f'git commit -m "{full}"'
539
535
  rendered.append(f"### {name}\n{add_line}\n{commit_line}")
540
536
  return "\n\n".join(rendered)
541
537
 
@@ -557,29 +553,35 @@ def run(
557
553
  .lower()
558
554
  )
559
555
  if edit_choice in ("y", "yes"):
560
- updated: list[tuple[str, list[str], str, str]] = []
561
- for name, paths, ctype, cmsg in plan:
556
+ updated: list[tuple[str, Suggestion]] = []
557
+ for name, sug in plan:
558
+ current = format_commit_message(
559
+ sug.commit_type,
560
+ sug.commit_message,
561
+ scope=sug.scope,
562
+ breaking=sug.breaking,
563
+ )
562
564
  console.print(
563
- f"[dim]{name}:[/dim] current message: [bold][{ctype}] {cmsg}[/bold]"
565
+ f"[dim]{name}:[/dim] current message: [bold]{current}[/bold]"
564
566
  )
565
567
  try:
566
568
  from prompt_toolkit import prompt as pt_prompt
567
569
 
568
570
  new_msg = (
569
571
  pt_prompt(
570
- "New commit message (subject only, no [TYPE] prefix): ",
571
- default=cmsg,
572
+ "New commit message (subject only, type/scope added automatically): ",
573
+ default=sug.commit_message,
572
574
  ).strip()
573
- or cmsg
575
+ or sug.commit_message
574
576
  )
575
577
  except Exception:
576
578
  new_msg = (
577
579
  typer.prompt(
578
- "New commit message (subject only, no [TYPE] prefix)",
579
- default=cmsg,
580
+ "New commit message (subject only, type/scope added automatically)",
581
+ default=sug.commit_message,
580
582
  ).strip()
581
- ) or cmsg
582
- updated.append((name, paths, ctype, new_msg))
583
+ ) or sug.commit_message
584
+ updated.append((name, replace(sug, commit_message=new_msg)))
583
585
  plan = updated
584
586
  console.print(
585
587
  Panel(
@@ -601,13 +603,16 @@ def run(
601
603
  do_apply = choice == "auto" or choice in ("y", "yes")
602
604
 
603
605
  if do_apply:
604
- for name, paths, ctype, cmsg in plan:
606
+ for name, sug in plan:
605
607
  try:
606
608
  apply_commands(
607
609
  repo_root,
608
- [] if staged_only else paths,
609
- ctype,
610
- cmsg,
610
+ [] if staged_only else sug.add_args,
611
+ sug.commit_type,
612
+ sug.commit_message,
613
+ scope=sug.scope,
614
+ body=sug.body,
615
+ breaking=sug.breaking,
611
616
  staged_only=staged_only,
612
617
  )
613
618
  console.print(f"[green]Commit created ({name}).[/green]")
@@ -47,7 +47,7 @@ def refine_type_and_message_from_diff(
47
47
  when the diff matches known bugfix patterns.
48
48
  """
49
49
  ct = (commit_type or "").upper()
50
- if ct in ("DOCS", "TEST", "TESTS", "CHORE"):
50
+ if ct in ("DOCS", "TEST", "TESTS", "CHORE", "BUILD", "CI", "STYLE", "PERF"):
51
51
  return commit_type, commit_message
52
52
 
53
53
  subject = infer_fix_subject_from_diff(diff_text)
@@ -12,12 +12,31 @@ from git_explain.commit_infer import refine_type_and_message_from_diff
12
12
  from git_explain.path_topics import (
13
13
  area_scope_suffix,
14
14
  basename_fallback_topic,
15
+ infer_scope,
15
16
  infra_deploy_topics,
16
17
  is_test_path,
17
18
  test_subject_hints,
18
19
  )
19
20
 
20
- SYSTEM_PROMPT = """You are given a list of changed/added files under ## Staged, ## Unstaged, ## Untracked.
21
+ VALID_TYPES = frozenset(
22
+ {
23
+ "FEAT",
24
+ "FIX",
25
+ "DOCS",
26
+ "REFACTOR",
27
+ "TEST",
28
+ "CHORE",
29
+ "BUILD",
30
+ "CI",
31
+ "STYLE",
32
+ "PERF",
33
+ "REVERT",
34
+ }
35
+ )
36
+
37
+ _TYPE_RE_ALT = "|".join(sorted(VALID_TYPES | {"TESTS"}, key=len, reverse=True))
38
+
39
+ SYSTEM_PROMPT = f"""You are given a list of changed/added files under ## Staged, ## Unstaged, ## Untracked.
21
40
  Each file line is: <STATUS> <PATH> where STATUS is one of:
22
41
  - A = added/new file
23
42
  - M = modified
@@ -25,56 +44,81 @@ Each file line is: <STATUS> <PATH> where STATUS is one of:
25
44
  - R = renamed
26
45
  - C = copied
27
46
 
28
- Suggest one commit that includes ALL of these files.
47
+ Suggest one commit that includes ALL of these files using Conventional Commits format.
29
48
 
30
49
  Rules:
31
50
  1. Line 1 must be: git add <path1> <path2> ... with EVERY PATH from the list (all sections). Do not omit any file. Do not truncate. Do not include status letters.
32
- 2. Line 2 must be: git commit -m "[TYPE] Message" with TYPE one of: FEAT, FIX, DOCS, REFACTOR, TEST, CHORE.
33
- 3. The message must describe **what the change does** (behavior, feature, fix)—not a comma-separated list of folders or path segments. You may mention one path if it disambiguates, but the subject must state substance (e.g. "Document FEATURES and tighten Gemini prompt for commit suggestions" not "update gemini, readme, features"). Never use only generic words like "update", "changes", or "refactor" by themselves.
34
- 4. Infer concrete artifacts from paths when obvious: Dockerfiles, Docker Compose files, nginx configs, .env/.env.example templates, CI workflows—not vague summaries like "add changes" or "add files" with no subject. For test paths (e.g. tests/test_foo.py), name the area under test (e.g. "Expand tests for foo and bar")—not "update project files".
35
- 5. Use [FIX] (or "fix:" with --with-diff) when the change corrects broken behavior, wrong CLI flow, or misleading errors—not [REFACTOR] for those cases.
36
- 6. Use imperative mood, no period at end. The subject may be up to about 200 characters when needed—finish the thought completely (no cut-off mid-phrase).
51
+ 2. Line 2 must be: git commit -m "type(scope): description"
52
+ - type: exactly one of: feat, fix, docs, refactor, test, chore, build, ci, style, perf
53
+ - scope (optional): a noun in parentheses describing the area of the codebase (e.g., cli, api, parser). Omit if the change spans many unrelated areas.
54
+ - If the change introduces a breaking API change, add ! after the type/scope: feat!: or feat(api)!:
55
+ - description: imperative mood, lowercase first letter, no period at end. Up to about 200 characters—finish the thought completely.
56
+ 3. The description must state **what the change does** (behavior, feature, fix)—not a comma-separated list of folders or path segments. You may mention one path if it disambiguates. Never use only generic words like "update", "changes", or "refactor" by themselves.
57
+ 4. Infer concrete artifacts from paths when obvious: Dockerfiles, Docker Compose files, nginx configs, .env/.env.example templates, CI workflows—not vague summaries. For test paths (e.g. tests/test_foo.py), name the area under test (e.g. "expand tests for foo and bar").
58
+ 5. Use fix when the change corrects broken behavior, wrong CLI flow, or misleading errors—not refactor for those cases.
59
+ 6. Use build for build system / dependency changes (Dockerfile, pyproject.toml, requirements.txt, Makefile). Use ci for CI/CD config (.github/workflows, .gitlab-ci.yml, etc.).
37
60
 
38
61
  Example for files README.md, FEATURES.md, git_explain/gemini.py:
39
62
  git add README.md FEATURES.md git_explain/gemini.py
40
- git commit -m "[DOCS] Add README and FEATURES doc, tune Gemini prompt"
63
+ git commit -m "docs: add README and FEATURES doc, tune Gemini prompt"
41
64
 
42
- Example for Docker + nginx + env templates under api/ and apps/frontend/:
43
- git add api/app/Dockerfile apps/frontend/nginx.conf
44
- git commit -m "[CHORE] Add Docker and nginx config with env examples for api and frontend"
65
+ Example for Docker + nginx under api/:
66
+ git add api/Dockerfile api/nginx.conf
67
+ git commit -m "build(api): add Docker and nginx configuration"
45
68
  """
46
69
 
47
- SYSTEM_PROMPT_WITH_DIFF = """You are given:
70
+ SYSTEM_PROMPT_WITH_DIFF = f"""You are given:
48
71
  1. A list of changed/added files (## Staged, ## Unstaged, ## Untracked) with <STATUS> <PATH>.
49
- 2. The full diff (## Staged diff, ## Unstaged diff, ## Untracked) showing exact code changes.
72
+ 2. The full diff (## Diff) showing exact code changes.
50
73
 
51
- Use the diff to write a **specific, detailed** commit message about **what changed in behavior, UI, data flow, or APIs**. Quote or paraphrase the actual diff: new props, renamed state, conditional logic, extracted components, bug fixes, etc.
52
- **Do not** summarize by only listing directories, modules, or file names (e.g. forbidden: "update layout, issues, workspace-views"). If many files move together, state the **theme** of the change in plain language (e.g. "align filter panels and toolbar actions across issue list and workspace views").
53
- Avoid hollow words like "update" or "changes" without saying what moved or why. Naming Docker, nginx, env templates, or workflows is fine when the diff is about those.
54
- Prefer **fix:** when the diff corrects incorrect behavior or user-visible bugs; use **refactor:** only for internal restructuring without behavior change.
74
+ Use the diff to write a **specific, detailed** commit message in Conventional Commits format about **what changed in behavior, UI, data flow, or APIs**. Quote or paraphrase the actual diff: new props, renamed state, conditional logic, extracted components, bug fixes, etc.
75
+ **Do not** summarize by only listing directories, modules, or file names. If many files move together, state the **theme** of the change in plain language.
76
+ Avoid hollow words like "update" or "changes" without saying what moved or why.
77
+ Prefer fix when the diff corrects incorrect behavior or user-visible bugs; use refactor only for internal restructuring without behavior change.
78
+ Use build for build/dependency changes, ci for CI/CD config changes.
55
79
 
56
- Output format (conventional commits style):
80
+ Output format:
57
81
  - Line 1: git add <path1> <path2> ... with EVERY path from the file list. Do not omit any.
58
- - Line 2: git commit -m "type: subject" where type is exactly one of: feat, fix, docs, refactor, test, chore.
59
- Subject: imperative mood, no period at end. Use **up to about 200 characters** when the diff needs it—**complete the sentence**; never stop mid-word or mid-parenthetical.
82
+ - Line 2: git commit -m "type(scope): description"
83
+ - type: exactly one of: feat, fix, docs, refactor, test, chore, build, ci, style, perf
84
+ - scope (optional): noun in parentheses describing the area. Omit if change spans many areas.
85
+ - If breaking change, add ! after type/scope: feat!: or feat(api)!:
86
+ - description: imperative mood, lowercase first letter, no period at end. Up to 200 characters—complete the sentence, never cut mid-word.
60
87
 
61
88
  Example:
62
89
  git add git_explain/cli.py git_explain/gemini.py
63
- git commit -m "feat: add opt-in --with-diff for detailed AI commit messages"
90
+ git commit -m "feat(cli): add opt-in --with-diff for detailed AI commit messages"
64
91
  """
65
92
 
66
93
  ADD_LINE_RE = re.compile(r"git\s+add\s+(.+)", re.IGNORECASE)
94
+
67
95
  COMMIT_LINE_RE = re.compile(
68
- r'git\s+commit\s+-m\s+["\']\[(FEAT|FIX|DOCS|REFACTOR|TESTS|CHORE)\]\s*(.+?)["\']',
96
+ r"git\s+commit\s+-m\s+[\"']"
97
+ rf"({_TYPE_RE_ALT})"
98
+ r"(?:\(([^)]*)\))?"
99
+ r"(!?)"
100
+ r"\s*:\s*(.+?)"
101
+ r"[\"']",
69
102
  re.IGNORECASE,
70
103
  )
71
- # Conventional: "feat: subject" or "fix: subject" (use "tests" not "test")
72
- COMMIT_LINE_CONVENTIONAL_RE = re.compile(
73
- r'git\s+commit\s+-m\s+["\'](feat|fix|docs|refactor|tests|chore)\s*:\s*(.+?)["\']',
104
+
105
+ _COMMIT_LINE_BRACKET_RE = re.compile(
106
+ r"git\s+commit\s+-m\s+[\"']"
107
+ rf"\[({_TYPE_RE_ALT})\]"
108
+ r"\s*(.+?)"
109
+ r"[\"']",
74
110
  re.IGNORECASE,
75
111
  )
112
+
76
113
  DEFAULT_MODEL = "gemini-2.5-flash"
77
114
 
115
+
116
+ def _normalize_type(t: str) -> str:
117
+ upper = (t or "").upper()
118
+ if upper == "TESTS":
119
+ return "TEST"
120
+ return upper if upper in VALID_TYPES else "CHORE"
121
+
78
122
  # Single-line subject for `git commit -m` (no body); allow longer than classic 72 when users want detail.
79
123
  MAX_COMMIT_SUBJECT_CHARS = 200
80
124
 
@@ -318,22 +362,18 @@ def _fallback_type_and_message_with_context(
318
362
  all_tests_only = len(test_files) == len(files)
319
363
  hints = test_subject_hints(files)
320
364
  if all_tests_only and hints:
321
- head = " and ".join(hints[:3])
322
- tail = f" (+{len(hints) - 3} more)" if len(hints) > 3 else ""
323
- topics.append(f"tests for {head}{tail}")
365
+ if len(hints) <= 4:
366
+ head = ", ".join(hints[:-1]) + " and " + hints[-1] if len(hints) > 1 else hints[0]
367
+ else:
368
+ head = ", ".join(hints[:4])
369
+ topics.append(f"tests for {head}")
324
370
  else:
325
371
  topics.append("tests")
326
372
  if touches_docs and not docs_only:
327
373
  topics.append("docs")
328
374
  code_topics = _code_topics(files)
329
375
  if code_topics:
330
- if len(code_topics) <= 3:
331
- label = ", ".join(code_topics)
332
- else:
333
- head = ", ".join(code_topics[:3])
334
- rest = len(code_topics) - 3
335
- label = f"{head} and {rest} related areas"
336
- topics.append(label)
376
+ topics.append(", ".join(code_topics[:5]))
337
377
  if touches_packaging:
338
378
  topics.append("packaging config")
339
379
 
@@ -405,6 +445,9 @@ class Suggestion:
405
445
  add_args: list[str]
406
446
  commit_type: str
407
447
  commit_message: str
448
+ scope: str | None = None
449
+ body: str | None = None
450
+ breaking: bool = False
408
451
 
409
452
 
410
453
  def _get_client() -> genai.Client:
@@ -481,20 +524,24 @@ def suggest_commands(
481
524
  add_args: list[str] = []
482
525
  commit_type = "REFACTOR"
483
526
  commit_message = "update"
527
+ scope: str | None = None
528
+ breaking = False
484
529
  for line in lines:
485
530
  add_m = ADD_LINE_RE.match(line)
486
531
  if add_m:
487
532
  add_args = [f.strip() for f in add_m.group(1).split() if f.strip()]
488
533
  continue
489
- commit_m = COMMIT_LINE_CONVENTIONAL_RE.match(line) if with_diff else None
490
- if commit_m:
491
- commit_type = commit_m.group(1).upper()
492
- commit_message = commit_m.group(2).strip().rstrip(".")
493
- break
494
534
  commit_m = COMMIT_LINE_RE.match(line)
495
535
  if commit_m:
496
- commit_type = commit_m.group(1).upper()
497
- commit_message = commit_m.group(2).strip().rstrip(".")
536
+ commit_type = _normalize_type(commit_m.group(1))
537
+ scope = commit_m.group(2) or None
538
+ breaking = commit_m.group(3) == "!"
539
+ commit_message = commit_m.group(4).strip().rstrip(".")
540
+ break
541
+ bracket_m = _COMMIT_LINE_BRACKET_RE.match(line)
542
+ if bracket_m:
543
+ commit_type = _normalize_type(bracket_m.group(1))
544
+ commit_message = bracket_m.group(2).strip().rstrip(".")
498
545
  break
499
546
  if not add_args or not commit_message:
500
547
  return None, raw
@@ -507,11 +554,9 @@ def suggest_commands(
507
554
  all_paths = [p for _, p in entries]
508
555
  added_any = any(s == "A" for s, _ in entries)
509
556
 
510
- # Always use the full path list we sent (model may truncate or omit)
511
557
  if all_paths:
512
558
  add_args = all_paths
513
559
 
514
- # If we're adding new files (or this is an initial commit), don't label it REFACTOR
515
560
  docs_only = all_paths and all(
516
561
  os.path.splitext(p)[1].lower() in {".md", ".rst", ".txt"} for p in all_paths
517
562
  )
@@ -523,6 +568,9 @@ def suggest_commands(
523
568
  files=add_args, added_any=added_any, has_commits=has_commits
524
569
  )
525
570
 
571
+ if scope is None:
572
+ scope = infer_scope(add_args)
573
+
526
574
  infer_body = unified_diff_for_infer
527
575
  if not (infer_body and infer_body.strip()) and with_diff and "\n## Diff" in diff:
528
576
  infer_body = diff.split("\n## Diff", 1)[1]
@@ -531,5 +579,9 @@ def suggest_commands(
531
579
  )
532
580
 
533
581
  return Suggestion(
534
- add_args=add_args, commit_type=commit_type, commit_message=commit_message
582
+ add_args=add_args,
583
+ commit_type=commit_type,
584
+ commit_message=commit_message,
585
+ scope=scope,
586
+ breaking=breaking,
535
587
  ), raw
@@ -14,7 +14,10 @@ from git_explain.gemini import (
14
14
  from git_explain.path_topics import (
15
15
  area_scope_suffix,
16
16
  basename_fallback_topic,
17
+ infer_scope,
17
18
  infra_deploy_topics,
19
+ is_build_path,
20
+ is_ci_path,
18
21
  is_infra_deploy_path,
19
22
  is_test_path,
20
23
  test_subject_hints,
@@ -105,25 +108,33 @@ def suggest_from_changes(
105
108
  docs = [p for p in paths if _is_doc(p)]
106
109
  tests = [p for p in paths if is_test_path(p)]
107
110
  configs = [p for p in paths if _is_config(p)]
111
+ ci_files = [p for p in paths if is_ci_path(p)]
112
+ build_files = [p for p in paths if is_build_path(p)]
108
113
  has_tests = bool(tests)
109
114
  has_configs = bool(configs)
110
115
  non_docs = [p for p in paths if p not in docs]
111
116
 
112
117
  docs_only = bool(paths) and len(docs) == len(paths)
118
+ ci_only = bool(paths) and len(ci_files) == len(paths)
119
+ build_only = bool(paths) and len(build_files) == len(paths)
113
120
  mostly_tests_or_config = False
114
121
  if non_docs:
115
122
  tc = len([p for p in non_docs if p in tests or p in configs])
116
123
  mostly_tests_or_config = tc / max(1, len(non_docs)) >= 0.6
117
124
 
118
125
  if has_commits is False:
119
- verb = "Add"
126
+ verb = "add"
120
127
  elif added_any and not modified_any:
121
- verb = "Add"
128
+ verb = "add"
122
129
  else:
123
- verb = "Update"
130
+ verb = "update"
124
131
 
125
132
  if docs_only:
126
133
  commit_type = "DOCS"
134
+ elif ci_only:
135
+ commit_type = "CI"
136
+ elif build_only:
137
+ commit_type = "BUILD"
127
138
  elif mostly_tests_or_config:
128
139
  if has_tests and not has_configs:
129
140
  commit_type = "TEST"
@@ -146,22 +157,18 @@ def suggest_from_changes(
146
157
  all_tests_only = bool(paths) and len(tests) == len(paths)
147
158
  hints = test_subject_hints(paths)
148
159
  if all_tests_only and hints:
149
- head = " and ".join(hints[:3])
150
- tail = f" (+{len(hints) - 3} more)" if len(hints) > 3 else ""
151
- topics.append(f"tests for {head}{tail}")
160
+ if len(hints) <= 4:
161
+ head = ", ".join(hints[:-1]) + " and " + hints[-1] if len(hints) > 1 else hints[0]
162
+ else:
163
+ head = ", ".join(hints[:4])
164
+ topics.append(f"tests for {head}")
152
165
  else:
153
166
  topics.append("tests")
154
167
  if any(_is_plain_config(p) for p in paths):
155
168
  topics.append("config")
156
169
  code_topics = _code_topics(paths)
157
170
  if code_topics:
158
- if len(code_topics) <= 3:
159
- label = ", ".join(code_topics)
160
- else:
161
- head = ", ".join(code_topics[:3])
162
- rest = len(code_topics) - 3
163
- label = f"{head} and {rest} related areas"
164
- topics.append(label)
171
+ topics.append(", ".join(code_topics[:5]))
165
172
 
166
173
  # Dedupe while preserving order
167
174
  seen: set[str] = set()
@@ -178,15 +185,15 @@ def suggest_from_changes(
178
185
  else:
179
186
  message = f"{verb} {topics[0]}, {topics[1]}, and {topics[2]}"
180
187
 
181
- scope = area_scope_suffix(paths)
182
- if scope:
183
- scope_key = _alnum_key(scope.replace("for", "", 1))
188
+ scope_suffix = area_scope_suffix(paths)
189
+ if scope_suffix:
190
+ scope_key = _alnum_key(scope_suffix.replace("for", "", 1))
184
191
  msg_key = _alnum_key(message)
185
192
  if scope_key and scope_key not in msg_key:
186
- message += scope
193
+ message += scope_suffix
187
194
 
188
- if added_any and has_commits is False and message.startswith("Add "):
189
- message = message.replace("Add ", "Add initial ", 1)
195
+ if added_any and has_commits is False and message.startswith("add "):
196
+ message = message.replace("add ", "add initial ", 1)
190
197
 
191
198
  message = truncate_commit_subject(message, MAX_COMMIT_SUBJECT_CHARS)
192
199
 
@@ -194,4 +201,11 @@ def suggest_from_changes(
194
201
  commit_type, message, diff_text
195
202
  )
196
203
 
197
- return Suggestion(add_args=paths, commit_type=commit_type, commit_message=message)
204
+ cc_scope = infer_scope(paths)
205
+
206
+ return Suggestion(
207
+ add_args=paths,
208
+ commit_type=commit_type,
209
+ commit_message=message,
210
+ scope=cc_scope,
211
+ )
@@ -158,6 +158,86 @@ def area_scope_suffix(paths: list[str]) -> str:
158
158
  return f" for {labels[0]}, {labels[1]}, and {labels[2]}"
159
159
 
160
160
 
161
+ _CI_FILES = {
162
+ ".gitlab-ci.yml",
163
+ ".travis.yml",
164
+ "azure-pipelines.yml",
165
+ "jenkinsfile",
166
+ "bitbucket-pipelines.yml",
167
+ }
168
+
169
+
170
+ def is_ci_path(path: str) -> bool:
171
+ """True if path looks like a CI/CD configuration file."""
172
+ p = _norm(path).lower()
173
+ base = os.path.basename(p)
174
+ if ".github/workflows/" in p or ".github/actions/" in p:
175
+ return True
176
+ if base in _CI_FILES:
177
+ return True
178
+ if ".circleci/" in p:
179
+ return True
180
+ return False
181
+
182
+
183
+ _BUILD_FILES = {
184
+ "pyproject.toml",
185
+ "setup.py",
186
+ "setup.cfg",
187
+ "requirements.txt",
188
+ "requirements-dev.txt",
189
+ "makefile",
190
+ "gnumakefile",
191
+ "package.json",
192
+ "package-lock.json",
193
+ "yarn.lock",
194
+ "pnpm-lock.yaml",
195
+ "cargo.toml",
196
+ "cargo.lock",
197
+ "go.mod",
198
+ "go.sum",
199
+ "gemfile",
200
+ "gemfile.lock",
201
+ "build.gradle",
202
+ "pom.xml",
203
+ }
204
+
205
+
206
+ def is_build_path(path: str) -> bool:
207
+ """True if path is a build system, packaging, or containerization file."""
208
+ p = _norm(path).lower()
209
+ base = os.path.basename(p)
210
+ if base in _BUILD_FILES:
211
+ return True
212
+ return is_infra_deploy_path(path)
213
+
214
+
215
+ def infer_scope(paths: list[str]) -> str | None:
216
+ """Infer a conventional commits scope from file paths.
217
+
218
+ Returns a single scope when all non-root files share one top-level
219
+ directory, otherwise None.
220
+ """
221
+ if not paths:
222
+ return None
223
+
224
+ candidates: set[str] = set()
225
+ for raw in paths:
226
+ p = _norm(raw).lower()
227
+ parts = [x for x in p.split("/") if x]
228
+ if len(parts) < 2:
229
+ continue
230
+ first = parts[0]
231
+ if first in ("apps", "packages", "services") and len(parts) >= 3:
232
+ candidates.add(parts[1])
233
+ else:
234
+ candidates.add(first)
235
+
236
+ if len(candidates) == 1:
237
+ return candidates.pop().replace("_", "-")
238
+ return None
239
+
240
+
161
241
  def basename_fallback_topic(paths: list[str], max_names: int = 4) -> str | None:
162
242
  """Short description from basenames when no other topic matched."""
163
243
  bases: list[str] = []
@@ -7,6 +7,32 @@ from pathlib import Path
7
7
  _GIT_TEXT = {"encoding": "utf-8", "errors": "replace"}
8
8
 
9
9
 
10
+ def _lowercase_first(s: str) -> str:
11
+ """Lowercase the first character unless the first word is an acronym."""
12
+ if not s:
13
+ return s
14
+ first_space = s.find(" ")
15
+ first_word = s[:first_space] if first_space > 0 else s
16
+ if first_word == first_word.upper() and len(first_word) > 1:
17
+ return s
18
+ return s[0].lower() + s[1:]
19
+
20
+
21
+ def format_commit_message(
22
+ commit_type: str,
23
+ commit_message: str,
24
+ *,
25
+ scope: str | None = None,
26
+ breaking: bool = False,
27
+ ) -> str:
28
+ """Format a conventional commit subject: type(scope)!: description."""
29
+ type_str = commit_type.lower()
30
+ scope_str = f"({scope})" if scope else ""
31
+ bang = "!" if breaking else ""
32
+ msg = _lowercase_first(commit_message)
33
+ return f"{type_str}{scope_str}{bang}: {msg}"
34
+
35
+
10
36
  def normalize_commit_subject_for_dash_m(message: str | None) -> str:
11
37
  """Single line for ``git commit -m``: newlines/tabs become spaces, strip ends.
12
38
 
@@ -39,6 +65,9 @@ def apply_commands(
39
65
  commit_type: str,
40
66
  commit_message: str,
41
67
  *,
68
+ scope: str | None = None,
69
+ body: str | None = None,
70
+ breaking: bool = False,
42
71
  staged_only: bool = False,
43
72
  ) -> None:
44
73
  """Stage selected paths and commit. Raises on failure.
@@ -67,9 +96,15 @@ def apply_commands(
67
96
  )
68
97
  raise RuntimeError("Nothing staged after git add; aborting commit.")
69
98
  safe_msg = normalize_commit_subject_for_dash_m(commit_message)
70
- full_message = f"[{commit_type}] {safe_msg}"
99
+ full_message = format_commit_message(
100
+ commit_type, safe_msg, scope=scope, breaking=breaking
101
+ )
102
+ cmd = ["git", "commit", "-m", full_message]
103
+ if body:
104
+ safe_body = normalize_commit_subject_for_dash_m(body)
105
+ cmd.extend(["-m", safe_body])
71
106
  subprocess.run(
72
- ["git", "commit", "-m", full_message],
107
+ cmd,
73
108
  check=True,
74
109
  cwd=root,
75
110
  capture_output=True,
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-explain
3
- Version: 2.1.9
3
+ Version: 2.2.0
4
4
  Summary: CLI that suggests git add/commit from diffs using Gemini
5
- Author: git-explain contributors
5
+ Author: nazarli-shabnam
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/nazarli-shabnam/git-explain
8
8
  Project-URL: Source, https://github.com/nazarli-shabnam/git-explain
@@ -51,6 +51,14 @@ pip install git-explain
51
51
  pip install -e .
52
52
  ```
53
53
 
54
+ **Clone:** install deps (`requirements.txt` is fine), `cd` into the repo, then:
55
+
56
+ ```bash
57
+ python -m git_explain
58
+ ```
59
+
60
+ Run that from the repo root so Python picks up the `git_explain` folder—no `pip install -e .` needed.
61
+
54
62
  Optional: install a specific tag from GitHub instead of PyPI:
55
63
 
56
64
  ```bash
@@ -70,6 +78,8 @@ pip install "git+https://github.com/nazarli-shabnam/git-explain.git@v2.1.8"
70
78
 
71
79
  Heuristics guess a sensible type and message from paths and statuses. **No account, no key, no network** for that path.
72
80
 
81
+ Suggested commits follow **[Conventional Commits](https://www.conventionalcommits.org/)**—`feat: …`, `fix: …`, optional `(scope)`, and so on—so changelogs and release tools can read them.
82
+
73
83
  ---
74
84
 
75
85
  ## Optional: Gemini
@@ -80,7 +90,7 @@ If you want sharper messages, set **`GEMINI_API_KEY`** (or `GOOGLE_API_KEY`) in
80
90
  |--------|----------------|
81
91
  | `git-explain --ai` | AI sees **paths and change type** only (no file contents). |
82
92
  | `git-explain --ai --with-diff` | AI also sees the **diff**—better detail; only use if you’re OK sending that to the API. |
83
- | `git-explain --suggest` | **Staged files only**; prints one **`git commit -m "..."`** line for scripting. Needs AI; don’t combine with other flags. |
93
+ | `git-explain --suggest` | **Staged only**; prints one plain `git commit -m ""` line (easy to copy). Needs AI; don’t combine with other flags. |
84
94
 
85
95
  Everything else (`--auto`, `--staged-only`, `--cwd`, model override, shell completion): **`git-explain --help`**.
86
96
 
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  git_explain/__init__.py
5
+ git_explain/__main__.py
5
6
  git_explain/cli.py
6
7
  git_explain/commit_infer.py
7
8
  git_explain/gemini.py
@@ -10,7 +10,7 @@ requires-python = ">=3.10"
10
10
  dynamic = ["version"]
11
11
  license = "MIT"
12
12
  authors = [
13
- { name = "git-explain contributors" },
13
+ { name = "nazarli-shabnam" },
14
14
  ]
15
15
  keywords = ["git", "cli", "commit", "ai", "gemini"]
16
16
  classifiers = [
@@ -1,54 +1,76 @@
1
1
  from git_explain.gemini import (
2
- COMMIT_LINE_CONVENTIONAL_RE,
3
2
  COMMIT_LINE_RE,
3
+ _COMMIT_LINE_BRACKET_RE,
4
4
  _fallback_type_and_message_with_context,
5
5
  _is_generic_message,
6
+ _normalize_type,
6
7
  truncate_commit_subject,
7
8
  )
8
9
 
9
10
 
10
- def test_commit_line_re_matches_tests_not_test() -> None:
11
- """COMMIT_LINE_RE should match [TESTS] but not [TEST]."""
12
- line_tests = 'git commit -m "[TESTS] Add unit tests"'
13
- m = COMMIT_LINE_RE.match(line_tests)
14
- assert m is not None
15
- assert m.group(1).upper() == "TESTS"
16
- assert "Add unit tests" in m.group(2)
11
+ def test_commit_line_re_matches_conventional_types() -> None:
12
+ """COMMIT_LINE_RE should match conventional commits: type: subject."""
13
+ for line, expected_type in [
14
+ ('git commit -m "feat: add new feature"', "FEAT"),
15
+ ('git commit -m "fix: correct the bug"', "FIX"),
16
+ ('git commit -m "docs: update readme"', "DOCS"),
17
+ ('git commit -m "refactor: simplify logic"', "REFACTOR"),
18
+ ('git commit -m "test: add unit tests"', "TEST"),
19
+ ('git commit -m "chore: update deps"', "CHORE"),
20
+ ('git commit -m "build: update dockerfile"', "BUILD"),
21
+ ('git commit -m "ci: add github workflow"', "CI"),
22
+ ('git commit -m "style: fix formatting"', "STYLE"),
23
+ ('git commit -m "perf: optimize query"', "PERF"),
24
+ ]:
25
+ m = COMMIT_LINE_RE.match(line)
26
+ assert m is not None, f"Expected match for {line}"
27
+ assert _normalize_type(m.group(1)) == expected_type
17
28
 
18
- line_test = 'git commit -m "[TEST] Add unit test"'
19
- m = COMMIT_LINE_RE.match(line_test)
20
- assert m is None
21
29
 
30
+ def test_commit_line_re_matches_scope_and_breaking() -> None:
31
+ """COMMIT_LINE_RE should parse scope and breaking change indicator."""
32
+ line = 'git commit -m "feat(cli): add new flag"'
33
+ m = COMMIT_LINE_RE.match(line)
34
+ assert m is not None
35
+ assert m.group(1).upper() == "FEAT"
36
+ assert m.group(2) == "cli"
37
+ assert m.group(3) == ""
38
+ assert "add new flag" in m.group(4)
22
39
 
23
- def test_commit_line_conventional_re_matches_tests() -> None:
24
- """COMMIT_LINE_CONVENTIONAL_RE should match 'tests:' not 'test:'."""
25
- line = 'git commit -m "tests: add unit tests"'
26
- m = COMMIT_LINE_CONVENTIONAL_RE.match(line)
40
+ line = 'git commit -m "feat(api)!: drop legacy endpoint"'
41
+ m = COMMIT_LINE_RE.match(line)
27
42
  assert m is not None
28
- assert m.group(1).lower() == "tests"
43
+ assert m.group(2) == "api"
44
+ assert m.group(3) == "!"
29
45
 
30
- line_test = 'git commit -m "test: add unit test"'
31
- m = COMMIT_LINE_CONVENTIONAL_RE.match(line_test)
32
- assert m is None
46
+ line = 'git commit -m "fix!: breaking bugfix"'
47
+ m = COMMIT_LINE_RE.match(line)
48
+ assert m is not None
49
+ assert m.group(2) is None
50
+ assert m.group(3) == "!"
33
51
 
34
52
 
35
- def test_commit_line_re_matches_other_types() -> None:
53
+ def test_bracket_re_matches_legacy_format() -> None:
54
+ """_COMMIT_LINE_BRACKET_RE should match [TYPE] format as fallback."""
36
55
  for line in [
37
56
  'git commit -m "[FEAT] Add feature"',
38
57
  'git commit -m "[FIX] Fix bug"',
39
58
  'git commit -m "[DOCS] Update readme"',
40
- 'git commit -m "[REFACTOR] Simplify logic"',
41
- 'git commit -m "[CHORE] Add Docker and nginx config"',
59
+ 'git commit -m "[TESTS] Add tests"',
60
+ 'git commit -m "[CHORE] Add Docker config"',
61
+ 'git commit -m "[BUILD] Update dockerfile"',
62
+ 'git commit -m "[CI] Add workflow"',
42
63
  ]:
43
- m = COMMIT_LINE_RE.match(line)
44
- assert m is not None, f"Expected match for {line}"
64
+ m = _COMMIT_LINE_BRACKET_RE.match(line)
65
+ assert m is not None, f"Expected bracket match for {line}"
45
66
 
46
67
 
47
- def test_commit_line_conventional_matches_chore() -> None:
48
- line = 'git commit -m "chore: add docker compose"'
49
- m = COMMIT_LINE_CONVENTIONAL_RE.match(line)
50
- assert m is not None
51
- assert m.group(1).lower() == "chore"
68
+ def test_normalize_type_converts_tests_to_test() -> None:
69
+ assert _normalize_type("TESTS") == "TEST"
70
+ assert _normalize_type("tests") == "TEST"
71
+ assert _normalize_type("TEST") == "TEST"
72
+ assert _normalize_type("feat") == "FEAT"
73
+ assert _normalize_type("unknown") == "CHORE"
52
74
 
53
75
 
54
76
  def test_is_generic_message_flags_vague_add_changes() -> None:
@@ -143,7 +165,7 @@ def test_fallback_prefers_stems_when_folder_is_same() -> None:
143
165
  assert "cli" in low or "gemini" in low or "git" in low
144
166
 
145
167
 
146
- def test_fallback_many_folders_uses_related_areas_not_plus_more() -> None:
168
+ def test_fallback_many_folders_lists_topics_without_overflow() -> None:
147
169
  folders = [f"ui/src/area{i}/File.tsx" for i in range(8)]
148
170
  _ctype, msg = _fallback_type_and_message_with_context(
149
171
  files=folders,
@@ -151,4 +173,5 @@ def test_fallback_many_folders_uses_related_areas_not_plus_more() -> None:
151
173
  has_commits=True,
152
174
  )
153
175
  assert "(+" not in msg
154
- assert "related areas" in msg.lower()
176
+ assert "related areas" not in msg.lower()
177
+ assert "area0" in msg.lower() or "area1" in msg.lower()
@@ -72,8 +72,8 @@ def test_test_only_paths_get_specific_test_message() -> None:
72
72
  assert "project files" not in m
73
73
 
74
74
 
75
- def test_docker_nginx_env_paths_get_specific_chore_message() -> None:
76
- """Infra paths should not collapse to 'Add changes'."""
75
+ def test_docker_nginx_env_paths_get_specific_build_message() -> None:
76
+ """Infra paths should not collapse to 'add changes'."""
77
77
  s = suggest_from_changes(
78
78
  changes=[
79
79
  ("A", "api/app/.env.example"),
@@ -85,7 +85,7 @@ def test_docker_nginx_env_paths_get_specific_chore_message() -> None:
85
85
  ],
86
86
  has_commits=True,
87
87
  )
88
- assert s.commit_type == "CHORE"
88
+ assert s.commit_type == "BUILD"
89
89
  m = s.commit_message.lower()
90
90
  assert "docker" in m
91
91
  assert "nginx" in m
@@ -44,7 +44,7 @@ def test_apply_commands_newlines_in_message_become_single_subject_line(
44
44
  (repo / "a.txt").write_text("x\n", encoding="utf-8")
45
45
  apply_commands(repo, ["a.txt"], "FIX", "first line\nsecond line")
46
46
  subj = _git(repo, "log", "-1", "--format=%s").stdout.strip()
47
- assert subj == "[FIX] first line second line"
47
+ assert subj == "fix: first line second line"
48
48
 
49
49
 
50
50
  def test_apply_commands_deleted_file(tmp_path) -> None:
@@ -1 +0,0 @@
1
- __version__ = "2.1.9"
File without changes
File without changes