git-explain 1.1.0__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.
git_explain/gemini.py ADDED
@@ -0,0 +1,324 @@
1
+ """Suggest git add and commit from diff using Google Gemini."""
2
+
3
+ import os
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ from google import genai
9
+ from google.genai import types
10
+
11
+ SYSTEM_PROMPT = """You are given a list of changed/added files under ## Staged, ## Unstaged, ## Untracked.
12
+ Each file line is: <STATUS> <PATH> where STATUS is one of:
13
+ - A = added/new file
14
+ - M = modified
15
+ - D = deleted
16
+ - R = renamed
17
+ - C = copied
18
+
19
+ Suggest one commit that includes ALL of these files.
20
+
21
+ Rules:
22
+ 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.
23
+ 2. Line 2 must be: git commit -m "[TYPE] Message" with TYPE one of: FEAT, FIX, DOCS, REFACTOR, TEST.
24
+ 3. The message must be a short, specific summary of what the change does based on the file names (e.g. "Add README and feature status doc", "Fix Gemini model and add file-list mode"). Never use only generic words like "update", "changes", or "refactor" by themselves—always add what was updated (e.g. "Update docs and CLI prompt").
25
+ 4. Use imperative, no period at end. Maximum one short line.
26
+
27
+ Example for files README.md, FEATURES.md, git_explain/gemini.py:
28
+ git add README.md FEATURES.md git_explain/gemini.py
29
+ git commit -m "[DOCS] Add README and FEATURES doc, tune Gemini prompt"
30
+ """
31
+
32
+ SYSTEM_PROMPT_WITH_DIFF = """You are given:
33
+ 1. A list of changed/added files (## Staged, ## Unstaged, ## Untracked) with <STATUS> <PATH>.
34
+ 2. The full diff (## Staged diff, ## Unstaged diff, ## Untracked) showing exact code changes.
35
+
36
+ Use the diff to write a specific, detailed commit message. Do not use generic words like "update" or "changes"—describe what actually changed (e.g. "add opt-in --with-diff to send full diff to LLM for detailed messages", "tweak commit message edit flow to show suggestion before prompting to edit").
37
+
38
+ Output format (conventional commits style):
39
+ - Line 1: git add <path1> <path2> ... with EVERY path from the file list. Do not omit any.
40
+ - Line 2: git commit -m "type: subject" where type is exactly one of: feat, fix, docs, refactor, test.
41
+ The subject must be a short, specific summary in imperative mood, no period at end (e.g. "feat: allow editing commit message before apply", "fix: parse conventional commit line from AI").
42
+
43
+ Example:
44
+ git add git_explain/cli.py git_explain/gemini.py
45
+ git commit -m "feat: add opt-in --with-diff for detailed AI commit messages"
46
+ """
47
+
48
+ ADD_LINE_RE = re.compile(r"git\s+add\s+(.+)", re.IGNORECASE)
49
+ COMMIT_LINE_RE = re.compile(
50
+ r'git\s+commit\s+-m\s+["\']\[(FEAT|FIX|DOCS|REFACTOR|TESTS)\]\s*(.+?)["\']',
51
+ re.IGNORECASE,
52
+ )
53
+ # Conventional: "feat: subject" or "fix: subject" (use "tests" not "test")
54
+ COMMIT_LINE_CONVENTIONAL_RE = re.compile(
55
+ r'git\s+commit\s+-m\s+["\'](feat|fix|docs|refactor|tests)\s*:\s*(.+?)["\']',
56
+ re.IGNORECASE,
57
+ )
58
+ DEFAULT_MODEL = "gemini-2.5-flash"
59
+
60
+ _GENERIC_MESSAGES = {
61
+ "update",
62
+ "updates",
63
+ "change",
64
+ "changes",
65
+ "refactor",
66
+ "refactoring",
67
+ "fix",
68
+ "fixes",
69
+ "docs",
70
+ "documentation",
71
+ "test",
72
+ "tests",
73
+ "misc",
74
+ }
75
+
76
+
77
+ def _is_generic_message(message: str) -> bool:
78
+ msg = (message or "").strip().lower()
79
+ if not msg:
80
+ return True
81
+ if msg in _GENERIC_MESSAGES:
82
+ return True
83
+ # "update X" is okay, but bare "update" or "update stuff" isn't
84
+ if re.fullmatch(
85
+ r"(update|updates|change|changes|refactor|refactoring|misc)(\s+.+)?", msg
86
+ ):
87
+ return msg in _GENERIC_MESSAGES or len(msg.split()) < 2
88
+ if len(msg) < 12:
89
+ return True
90
+ return False
91
+
92
+
93
+ def _fallback_type_and_message(files: list[str]) -> tuple[str, str]:
94
+ # Backward-compat wrapper (shouldn't be used now that we parse status codes)
95
+ return _fallback_type_and_message_with_context(
96
+ files=files, added_any=False, has_commits=True
97
+ )
98
+
99
+
100
+ def _fallback_type_and_message_with_context(
101
+ *,
102
+ files: list[str],
103
+ added_any: bool,
104
+ has_commits: bool | None,
105
+ ) -> tuple[str, str]:
106
+ lower = [f.lower() for f in files]
107
+
108
+ docs_exts = {".md", ".rst", ".txt"}
109
+ code_exts = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java"}
110
+
111
+ def is_doc(f: str) -> bool:
112
+ return os.path.splitext(f)[1].lower() in docs_exts or f.endswith(
113
+ ("readme", "readme.md", "features.md")
114
+ )
115
+
116
+ def is_code(f: str) -> bool:
117
+ return os.path.splitext(f)[1].lower() in code_exts
118
+
119
+ def is_packaging(f: str) -> bool:
120
+ return f.endswith(
121
+ ("pyproject.toml", "requirements.txt", "setup.cfg", "setup.py")
122
+ )
123
+
124
+ docs_only = files and all(is_doc(f) for f in lower)
125
+ touches_docs = any(is_doc(f) for f in lower)
126
+ touches_packaging = any(is_packaging(f) for f in lower)
127
+
128
+ verb = "Add" if (added_any or has_commits is False) else "Update"
129
+
130
+ if docs_only:
131
+ commit_type = "DOCS"
132
+ elif verb == "Add":
133
+ commit_type = "FEAT"
134
+ else:
135
+ commit_type = "REFACTOR"
136
+
137
+ topics: list[str] = []
138
+ if any(f.endswith("readme.md") or f.endswith("readme") for f in lower):
139
+ topics.append("README")
140
+ if any(f.endswith("features.md") for f in lower):
141
+ topics.append("FEATURES doc")
142
+ if touches_docs and not docs_only:
143
+ topics.append("docs")
144
+ if any(f.startswith("git_explain/") for f in lower) or any(
145
+ "/git_explain/" in f for f in lower
146
+ ):
147
+ topics.append("git-explain CLI")
148
+ if any("git_explain/gemini.py" in f for f in lower):
149
+ topics.append("Gemini integration")
150
+ if any("git_explain/git.py" in f for f in lower):
151
+ topics.append("change detection")
152
+ if any("git_explain/cli.py" in f for f in lower):
153
+ topics.append("CLI output")
154
+ if touches_packaging:
155
+ topics.append("packaging config")
156
+
157
+ if not topics:
158
+ topics = ["project files"]
159
+
160
+ # Dedupe while keeping order
161
+ seen: set[str] = set()
162
+ topics = [t for t in topics if not (t in seen or seen.add(t))]
163
+
164
+ if len(topics) == 1:
165
+ msg = f"{verb} {topics[0]}"
166
+ elif len(topics) == 2:
167
+ msg = f"{verb} {topics[0]} and {topics[1]}"
168
+ else:
169
+ msg = f"{verb} {topics[0]}, {topics[1]}, and {topics[2]}"
170
+
171
+ if verb == "Add" and (has_commits is False):
172
+ # Make initial commits a little clearer but still "Add …"
173
+ msg = msg.replace("Add ", "Add initial ", 1) if msg.startswith("Add ") else msg
174
+
175
+ msg = msg.strip().rstrip(".")
176
+ if len(msg) > 72:
177
+ msg = msg[:72].rstrip()
178
+ return commit_type, msg
179
+
180
+
181
+ def _parse_changed_file_list(diff: str) -> tuple[list[tuple[str, str]], bool | None]:
182
+ """Parse the combined changed-file list into [(status, path)], plus has_commits if present."""
183
+ entries: list[tuple[str, str]] = []
184
+ section: str | None = None
185
+ has_commits: bool | None = None
186
+ for raw in diff.splitlines():
187
+ line = raw.strip()
188
+ if not line:
189
+ continue
190
+ if line.startswith("## "):
191
+ section = line[3:].strip()
192
+ continue
193
+ if section == "Meta" and line.lower().startswith("has_commits:"):
194
+ v = line.split(":", 1)[1].strip().lower()
195
+ if v in ("true", "false"):
196
+ has_commits = v == "true"
197
+ continue
198
+ m = re.match(r"^([AMDRCU])\s+(.+)$", line, re.IGNORECASE)
199
+ if m:
200
+ status = m.group(1).upper()
201
+ path = m.group(2).strip()
202
+ entries.append((status, path))
203
+ else:
204
+ # Backward compatibility: treat as modified path
205
+ entries.append(("M", line))
206
+ return entries, has_commits
207
+
208
+
209
+ @dataclass
210
+ class Suggestion:
211
+ add_args: list[str]
212
+ commit_type: str
213
+ commit_message: str
214
+
215
+
216
+ def _get_client() -> genai.Client:
217
+ api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
218
+ if not api_key:
219
+ raise RuntimeError("Set GEMINI_API_KEY or GOOGLE_API_KEY in environment.")
220
+ return genai.Client(api_key=api_key)
221
+
222
+
223
+ def suggest_commands(
224
+ diff: str, model: str | None = None, with_diff: bool = False
225
+ ) -> tuple[Suggestion | None, str]:
226
+ """Call Gemini with the file list (and optionally full diff); return (suggestion, raw_response). suggestion is None if unparseable."""
227
+ if not diff or not diff.strip():
228
+ return None, ""
229
+ model = model or os.environ.get("GEMINI_MODEL") or DEFAULT_MODEL
230
+ system_instruction = SYSTEM_PROMPT_WITH_DIFF if with_diff else SYSTEM_PROMPT
231
+ client = _get_client()
232
+ last_err = None
233
+ for attempt in range(2):
234
+ try:
235
+ response = client.models.generate_content(
236
+ model=model,
237
+ contents=diff.strip(),
238
+ config=types.GenerateContentConfig(
239
+ system_instruction=system_instruction,
240
+ temperature=0.2,
241
+ max_output_tokens=512 if with_diff else 256,
242
+ ),
243
+ )
244
+ break
245
+ except Exception as e:
246
+ last_err = e
247
+ err_str = str(e).lower()
248
+ if attempt == 0 and (
249
+ "429" in err_str
250
+ or "resource_exhausted" in err_str
251
+ or "quota" in err_str
252
+ ):
253
+ wait = 15
254
+ if "retry in " in err_str:
255
+ m = re.search(
256
+ r"retry in (\d+(?:\.\d+)?)\s*s", err_str, re.IGNORECASE
257
+ )
258
+ if m:
259
+ wait = min(60, max(5, int(float(m.group(1)) + 1)))
260
+ time.sleep(wait)
261
+ continue
262
+ raise
263
+ else:
264
+ if last_err is not None:
265
+ raise last_err
266
+ raise RuntimeError("Unexpected state in suggest_commands")
267
+ text = (response.text or "").strip()
268
+ raw = text
269
+ # Strip markdown code block if present
270
+ if text.startswith("```"):
271
+ lines = text.split("\n")
272
+ if lines[0].startswith("```"):
273
+ lines = lines[1:]
274
+ if lines and lines[-1].strip() == "```":
275
+ lines = lines[:-1]
276
+ text = "\n".join(lines)
277
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
278
+ add_args: list[str] = []
279
+ commit_type = "REFACTOR"
280
+ commit_message = "update"
281
+ for line in lines:
282
+ add_m = ADD_LINE_RE.match(line)
283
+ if add_m:
284
+ add_args = [f.strip() for f in add_m.group(1).split() if f.strip()]
285
+ continue
286
+ commit_m = COMMIT_LINE_CONVENTIONAL_RE.match(line) if with_diff else None
287
+ if commit_m:
288
+ commit_type = commit_m.group(1).upper()
289
+ commit_message = commit_m.group(2).strip().rstrip(".")
290
+ break
291
+ commit_m = COMMIT_LINE_RE.match(line)
292
+ if commit_m:
293
+ commit_type = commit_m.group(1).upper()
294
+ commit_message = commit_m.group(2).strip().rstrip(".")
295
+ break
296
+ if not add_args or not commit_message:
297
+ return None, raw
298
+
299
+ header_only = diff
300
+ if with_diff:
301
+ header_only = diff.split("\n## Diff", 1)[0]
302
+
303
+ entries, has_commits = _parse_changed_file_list(header_only.strip())
304
+ all_paths = [p for _, p in entries]
305
+ added_any = any(s == "A" for s, _ in entries)
306
+
307
+ # Always use the full path list we sent (model may truncate or omit)
308
+ if all_paths:
309
+ add_args = all_paths
310
+
311
+ # If we're adding new files (or this is an initial commit), don't label it REFACTOR
312
+ docs_only = all_paths and all(
313
+ os.path.splitext(p)[1].lower() in {".md", ".rst", ".txt"} for p in all_paths
314
+ )
315
+ if (added_any or has_commits is False) and commit_type == "REFACTOR":
316
+ commit_type = "DOCS" if docs_only else "FEAT"
317
+
318
+ if _is_generic_message(commit_message):
319
+ commit_type, commit_message = _fallback_type_and_message_with_context(
320
+ files=add_args, added_any=added_any, has_commits=has_commits
321
+ )
322
+ return Suggestion(
323
+ add_args=add_args, commit_type=commit_type, commit_message=commit_message
324
+ ), raw
git_explain/git.py ADDED
@@ -0,0 +1,170 @@
1
+ """Capture git diffs (staged and unstaged)."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ def get_repo_root(cwd: str | Path | None = None) -> Path:
8
+ """Return the git repository root. Raises if not in a repo."""
9
+ result = subprocess.run(
10
+ ["git", "rev-parse", "--show-toplevel"],
11
+ capture_output=True,
12
+ text=True,
13
+ cwd=cwd or ".",
14
+ )
15
+ if result.returncode != 0:
16
+ raise RuntimeError("Not a git repository (or any of the parent directories).")
17
+ return Path(result.stdout.strip())
18
+
19
+
20
+ def ensure_git_repo(cwd: str | Path | None = None) -> Path:
21
+ """Ensure current directory is inside a git repo; return repo root."""
22
+ r = subprocess.run(
23
+ ["git", "rev-parse", "--is-inside-work-tree"],
24
+ capture_output=True,
25
+ text=True,
26
+ cwd=cwd or ".",
27
+ )
28
+ if r.returncode != 0 or r.stdout.strip().lower() != "true":
29
+ raise RuntimeError("Not a git repository (or any of the parent directories).")
30
+ return get_repo_root(cwd)
31
+
32
+
33
+ def repo_has_commits(cwd: str | Path | None = None) -> bool:
34
+ """Return True if the repository has at least one commit."""
35
+ root = get_repo_root(cwd)
36
+ result = subprocess.run(
37
+ ["git", "rev-parse", "--verify", "HEAD"],
38
+ capture_output=True,
39
+ text=True,
40
+ cwd=root,
41
+ )
42
+ return result.returncode == 0
43
+
44
+
45
+ def _name_status(
46
+ args: list[str], cwd: str | Path | None = None
47
+ ) -> list[tuple[str, str]]:
48
+ """Run a git command that outputs --name-status and return (status, path) pairs.
49
+
50
+ Normalizes rename/copy lines to ('R', new_path) or ('C', new_path).
51
+ """
52
+ root = get_repo_root(cwd)
53
+ result = subprocess.run(
54
+ ["git"] + args,
55
+ capture_output=True,
56
+ text=True,
57
+ cwd=root,
58
+ )
59
+ if result.returncode != 0 or not result.stdout.strip():
60
+ return []
61
+ out: list[tuple[str, str]] = []
62
+ for raw in result.stdout.splitlines():
63
+ line = raw.strip()
64
+ if not line:
65
+ continue
66
+ # Typical formats:
67
+ # M\tpath
68
+ # A\tpath
69
+ # D\tpath
70
+ # R100\told\tnew
71
+ parts = line.split("\t")
72
+ if len(parts) >= 2:
73
+ status = parts[0].strip()
74
+ code = status[:1].upper()
75
+ path = parts[-1].strip()
76
+ if code and path:
77
+ out.append((code, path))
78
+ continue
79
+ # Fallback for whitespace-delimited output (should be rare)
80
+ toks = line.split()
81
+ if len(toks) >= 2:
82
+ out.append((toks[0][:1].upper(), toks[-1]))
83
+ return out
84
+
85
+
86
+ def get_staged_changes(cwd: str | Path | None = None) -> list[tuple[str, str]]:
87
+ """Return (status, path) for staged changes."""
88
+ return _name_status(["diff", "--cached", "--name-status"], cwd=cwd)
89
+
90
+
91
+ def get_unstaged_changes(cwd: str | Path | None = None) -> list[tuple[str, str]]:
92
+ """Return (status, path) for unstaged changes (tracked files)."""
93
+ return _name_status(["diff", "--name-status"], cwd=cwd)
94
+
95
+
96
+ def get_untracked_changes(cwd: str | Path | None = None) -> list[tuple[str, str]]:
97
+ """Return (status, path) for untracked files (not ignored by .gitignore)."""
98
+ root = get_repo_root(cwd)
99
+ result = subprocess.run(
100
+ ["git", "ls-files", "--others", "--exclude-standard"],
101
+ capture_output=True,
102
+ text=True,
103
+ cwd=root,
104
+ )
105
+ if result.returncode != 0 or not result.stdout.strip():
106
+ return []
107
+ paths = [p.strip() for p in result.stdout.strip().splitlines() if p.strip()]
108
+ return [("A", p) for p in paths]
109
+
110
+
111
+ def get_combined_diff(cwd: str | Path | None = None) -> tuple[str, Path]:
112
+ """Return (file_list_text, repo_root).
113
+
114
+ The text includes sections with status codes (A/M/D/R/C) and paths only (no file contents).
115
+ """
116
+ root = ensure_git_repo(cwd)
117
+ has_commits = repo_has_commits(cwd=root)
118
+ staged = get_staged_changes(cwd=root)
119
+ unstaged = get_unstaged_changes(cwd=root)
120
+ untracked = get_untracked_changes(cwd=root)
121
+ parts = []
122
+ parts.append(f"## Meta\nhas_commits: {str(has_commits).lower()}")
123
+ if staged:
124
+ parts.append("## Staged\n" + "\n".join([f"{s} {p}" for s, p in staged]))
125
+ if unstaged:
126
+ parts.append("## Unstaged\n" + "\n".join([f"{s} {p}" for s, p in unstaged]))
127
+ if untracked:
128
+ parts.append("## Untracked\n" + "\n".join([f"{s} {p}" for s, p in untracked]))
129
+ combined = "\n\n".join(parts) if parts else ""
130
+ return combined, root
131
+
132
+
133
+ def get_diff_for_paths(paths: list[str], cwd: str | Path | None = None) -> str:
134
+ """Return combined diff (staged + unstaged) for the given paths.
135
+ Untracked files are shown as full file content.
136
+ """
137
+ if not paths:
138
+ return ""
139
+ root = get_repo_root(cwd)
140
+ parts: list[str] = []
141
+
142
+ result = subprocess.run(
143
+ ["git", "diff", "--cached", "--"] + paths,
144
+ capture_output=True,
145
+ text=True,
146
+ cwd=root,
147
+ )
148
+ if result.returncode == 0 and result.stdout.strip():
149
+ parts.append("## Staged diff\n" + result.stdout.strip())
150
+
151
+ result = subprocess.run(
152
+ ["git", "diff", "--"] + paths,
153
+ capture_output=True,
154
+ text=True,
155
+ cwd=root,
156
+ )
157
+ if result.returncode == 0 and result.stdout.strip():
158
+ parts.append("## Unstaged diff\n" + result.stdout.strip())
159
+
160
+ untracked = get_untracked_changes(cwd=root)
161
+ untracked_set = {p for _, p in untracked}
162
+ for p in paths:
163
+ if p in untracked_set:
164
+ try:
165
+ content = (root / p).read_text(encoding="utf-8", errors="replace")
166
+ parts.append(f"## Untracked (new file): {p}\n{content}")
167
+ except Exception:
168
+ parts.append(f"## Untracked (new file): {p}\n<binary or unreadable>")
169
+
170
+ return "\n\n".join(parts)
@@ -0,0 +1,123 @@
1
+ """Heuristic suggestions when AI is disabled or unavailable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from git_explain.gemini import Suggestion
8
+
9
+
10
+ DOC_EXTS = {".md", ".rst", ".txt"}
11
+ TEST_HINTS = ("test", "tests", "pytest", "unittest")
12
+ CONFIG_FILES = {
13
+ "pyproject.toml",
14
+ "requirements.txt",
15
+ "setup.cfg",
16
+ "setup.py",
17
+ ".gitignore",
18
+ "license",
19
+ "license.txt",
20
+ "license.md",
21
+ }
22
+ CONFIG_EXTS = {".toml", ".yml", ".yaml", ".json", ".ini", ".cfg", ".lock"}
23
+
24
+
25
+ def _is_doc(path: str) -> bool:
26
+ p = path.lower()
27
+ base = os.path.basename(p)
28
+ return os.path.splitext(p)[1] in DOC_EXTS or base in {
29
+ "readme",
30
+ "readme.md",
31
+ "features.md",
32
+ }
33
+
34
+
35
+ def _is_test(path: str) -> bool:
36
+ p = path.lower()
37
+ base = os.path.basename(p)
38
+ if p.startswith("tests/") or "/tests/" in p:
39
+ return True
40
+ if (
41
+ base.startswith("test_")
42
+ or base.endswith("_test.py")
43
+ or base.endswith(".spec.ts")
44
+ or base.endswith(".spec.tsx")
45
+ ):
46
+ return True
47
+ return any(h in p for h in TEST_HINTS)
48
+
49
+
50
+ def _is_config(path: str) -> bool:
51
+ p = path.lower()
52
+ base = os.path.basename(p)
53
+ return base in CONFIG_FILES or os.path.splitext(p)[1] in CONFIG_EXTS
54
+
55
+
56
+ def suggest_from_changes(
57
+ *,
58
+ changes: list[tuple[str, str]],
59
+ has_commits: bool | None,
60
+ ) -> Suggestion:
61
+ """Create a Suggestion from [(status, path)] without calling AI."""
62
+ paths = [p for _, p in changes]
63
+ added_any = any(s.upper() == "A" for s, _ in changes) or has_commits is False
64
+
65
+ docs = [p for p in paths if _is_doc(p)]
66
+ tests = [p for p in paths if _is_test(p)]
67
+ configs = [p for p in paths if _is_config(p)]
68
+ has_tests = bool(tests)
69
+ has_configs = bool(configs)
70
+ non_docs = [p for p in paths if p not in docs]
71
+
72
+ docs_only = bool(paths) and len(docs) == len(paths)
73
+ mostly_tests_or_config = False
74
+ if non_docs:
75
+ tc = len([p for p in non_docs if p in tests or p in configs])
76
+ mostly_tests_or_config = tc / max(1, len(non_docs)) >= 0.6
77
+
78
+ verb = "Add" if added_any else "Update"
79
+
80
+ if docs_only:
81
+ commit_type = "DOCS"
82
+ elif mostly_tests_or_config:
83
+ if has_tests and not has_configs:
84
+ commit_type = "TEST"
85
+ elif has_configs and not has_tests:
86
+ commit_type = "CHORE"
87
+ else:
88
+ commit_type = "TEST"
89
+ elif added_any:
90
+ commit_type = "FEAT"
91
+ else:
92
+ commit_type = "REFACTOR"
93
+
94
+ topics: list[str] = []
95
+ if any(os.path.basename(p).lower() in {"readme.md", "readme"} for p in paths):
96
+ topics.append("README")
97
+ if any(os.path.basename(p).lower() == "features.md" for p in paths):
98
+ topics.append("FEATURES doc")
99
+ if tests:
100
+ topics.append("tests")
101
+ if configs:
102
+ topics.append("config")
103
+ if any("git_explain/" in p.replace("\\", "/").lower() for p in paths):
104
+ topics.append("git-explain CLI")
105
+
106
+ if not topics:
107
+ topics = ["changes"]
108
+
109
+ # Dedupe while preserving order
110
+ seen: set[str] = set()
111
+ topics = [t for t in topics if not (t in seen or seen.add(t))]
112
+
113
+ if len(topics) == 1:
114
+ message = f"{verb} {topics[0]}"
115
+ elif len(topics) == 2:
116
+ message = f"{verb} {topics[0]} and {topics[1]}"
117
+ else:
118
+ message = f"{verb} {topics[0]}, {topics[1]}, and {topics[2]}"
119
+
120
+ if added_any and has_commits is False and message.startswith("Add "):
121
+ message = message.replace("Add ", "Add initial ", 1)
122
+
123
+ return Suggestion(add_args=paths, commit_type=commit_type, commit_message=message)
git_explain/run.py ADDED
@@ -0,0 +1,54 @@
1
+ """Apply git add and commit from suggested message."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ def _has_staged_changes(repo_root: Path) -> bool:
8
+ # Works even for initial commit (unborn HEAD).
9
+ r = subprocess.run(
10
+ ["git", "status", "--porcelain"],
11
+ check=False,
12
+ cwd=repo_root,
13
+ capture_output=True,
14
+ text=True,
15
+ )
16
+ for raw in (r.stdout or "").splitlines():
17
+ if not raw:
18
+ continue
19
+ # XY <path> (or ?? for untracked). Staged changes => X != ' ' and X != '?'
20
+ if len(raw) >= 2 and raw[0] not in (" ", "?"):
21
+ return True
22
+ return False
23
+
24
+
25
+ def apply_commands(
26
+ repo_root: str | Path,
27
+ add_args: list[str],
28
+ commit_type: str,
29
+ commit_message: str,
30
+ ) -> None:
31
+ """Stage selected paths and commit. Raises on failure.
32
+
33
+ Uses `git add -A -- <paths...>` to properly handle deletes/renames.
34
+ Verifies that something is staged before attempting the commit.
35
+ """
36
+ root = Path(repo_root)
37
+ if add_args:
38
+ subprocess.run(
39
+ ["git", "add", "-A", "--"] + add_args,
40
+ check=True,
41
+ cwd=root,
42
+ capture_output=True,
43
+ text=True,
44
+ )
45
+ if not _has_staged_changes(root):
46
+ raise RuntimeError("Nothing staged after git add; aborting commit.")
47
+ full_message = f"[{commit_type}] {commit_message}"
48
+ subprocess.run(
49
+ ["git", "commit", "-m", full_message],
50
+ check=True,
51
+ cwd=root,
52
+ capture_output=True,
53
+ text=True,
54
+ )