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/__init__.py +1 -0
- git_explain/cli.py +481 -0
- git_explain/gemini.py +324 -0
- git_explain/git.py +170 -0
- git_explain/heuristics.py +123 -0
- git_explain/run.py +54 -0
- git_explain-1.1.0.dist-info/METADATA +143 -0
- git_explain-1.1.0.dist-info/RECORD +12 -0
- git_explain-1.1.0.dist-info/WHEEL +5 -0
- git_explain-1.1.0.dist-info/entry_points.txt +2 -0
- git_explain-1.1.0.dist-info/licenses/LICENSE +201 -0
- git_explain-1.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|