gitpr-cli 0.0.26__py3-none-any.whl → 0.0.27__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitpr-cli
3
- Version: 0.0.26
3
+ Version: 0.0.27
4
4
  Summary: Automação de PRs, Commits e Code Review com IA (Gemini e DeepSeek)
5
5
  Author-email: Natan Fiuza <contato@natanfiuza.dev.br>
6
6
  Requires-Python: >=3.10
@@ -16,8 +16,13 @@ Requires-Dist: textual
16
16
  Requires-Dist: requests
17
17
  Dynamic: license-file
18
18
 
19
+
19
20
  # **GitPR CLI 🚀**
20
21
 
22
+ <p align="center">
23
+ <img src="docs/logo.png" alt="GitPR Logo" width="200">
24
+ </p>
25
+
21
26
  GitPR CLI is a command-line automation tool that uses **Google Gemini** and **DeepSeek** artificial intelligence to analyze your code changes (git diff) or entire files. The tool automatically generates commit messages in the *Conventional Commits* standard, detailed Pull Request descriptions, and deep Code Reviews aimed at reducing technical debt.
22
27
 
23
28
  ## **🛠️ Technologies and Libraries Used**
@@ -140,6 +145,14 @@ You can pass the following *flags* for specific actions:
140
145
  * `-u` or `--update`: Checks and installs the latest version of GitPR (Auto-Updater).
141
146
 
142
147
  > **⚙️ Technical Note (--hook):** GitPR has a hidden flag `--hook <file>` that is triggered exclusively by the Git Hooks system in the background. It allows the AI to inject the suggested message directly into Git's temporary file, without cluttering your terminal.
148
+ >
149
+ > **⚙️ Technical Note (--pre-save):** GitPR has a hidden debug flag `--pre-save` that can be combined with any AI command (e.g.: `gitpr -c --pre-save`). Before each AI call, it saves the full payload that will be sent to the model (system instruction + prompt + character counters) to a `_{action}-{datetime}.json` file in the current folder, and then proceeds normally. Useful for inspecting very large prompts. Note: when the response comes from the local cache, no call is made and no file is generated.
150
+
151
+ ### 📦 Huge Diffs (Map-Reduce)
152
+
153
+ When your diff is too large for a single AI call (over ~90k estimated tokens), GitPR automatically splits it into batches by file, asks the AI for a technical summary of each part (Map), and unifies everything into the final commit message, review, or PR description (Reduce). No flags needed — it activates on demand and shows the progress in the console.
154
+
155
+ 📚 Full documentation: [docs/map-reduce-diff.md](docs/map-reduce-diff.md)
143
156
 
144
157
  ## 🛡️ Local Linter (Static Analysis)
145
158
 
@@ -1,25 +1,25 @@
1
- gitpr_cli-0.0.26.dist-info/licenses/LICENSE,sha256=UQzLC6mwqb-ZNDkBBAqOxC-SMQcEID0gaM6oFcu6u48,27030
1
+ gitpr_cli-0.0.27.dist-info/licenses/LICENSE,sha256=UQzLC6mwqb-ZNDkBBAqOxC-SMQcEID0gaM6oFcu6u48,27030
2
2
  src/__init__.py,sha256=U6ksarernNcryHO20aj4rNG7w0thT7xdMs5KzBJyPvE,28
3
- src/ai_providers.py,sha256=gjWhl38AAVtCVDd4E_YOBlC_gKvtRBSoOj0b_kJ6N1I,8316
4
- src/blame_engine.py,sha256=Ci7t08xTcC1kBWAjQ_ulP-Zraau_PC35MDfAngc4AYk,10396
3
+ src/ai_providers.py,sha256=cxPuytZ9LfvhQbWFPBJ4dud3lHLpAQpa3cbY8bKESk8,10954
4
+ src/blame_engine.py,sha256=z-uh8mHOF--JxY_nnbp5r_9y_a_IO4Zn42P0AeLBpto,10436
5
5
  src/cache.py,sha256=XggMTy00GlZfeF1VoLEYCdxL5h0eTg11TJfPIRPx7fs,3201
6
6
  src/chat_memory.py,sha256=vdYuElIQb6-flFm4SfzapnQZGRIwgbLECzIcsAc0Oo8,8724
7
7
  src/config.py,sha256=VJ9ekDlTVG5ETWDuO1kZWlgrncXbrvduxa5Zg7wN_ZY,9064
8
- src/core.py,sha256=TGtSA_7wyNoa-B6ytFhunCpEmpW9miQzvlizUQcyhis,18259
8
+ src/core.py,sha256=M7dPzenRa7HtOVPvFXltwg3OR2ca2EsJFJY60ajUijg,24297
9
9
  src/i18n.py,sha256=QED7hTSdWCC1lSNJ-l6VuOba18OrWgFTMQcZ7cQmXbY,3599
10
- src/issue_engine.py,sha256=435kPPgdsFMcY3nvutxdIfJAnLP0U4FMCPdbok6C2sc,3626
10
+ src/issue_engine.py,sha256=XJ7WNLoeg3wct079wS7OQqU62f_avn3A8LmMuPWchzE,3642
11
11
  src/linter_engine.py,sha256=D04wvOgRa-vZEYknm-z-Cg7V_3gnoQz_64PMorWnISo,4414
12
- src/main.py,sha256=QryicYBsYiRaNCUTs9eJbM-x-S3Z4-jWvjGR3SwkmJ8,31412
12
+ src/main.py,sha256=s-7JO9MudWZ5mqurDtzTtcj8GoMNirlptgbTQ6y6-vU,31742
13
13
  src/security.py,sha256=Li0zM8ZURTK2nKRHIt49TWLO4DIddBEHzXPrVycv0Rs,1343
14
- src/spinner.py,sha256=nnnoz91qt2NMEA3aMIT27XH7KImSMNVt_SyRAjNS8rk,6922
14
+ src/spinner.py,sha256=16ngB2oT1t7O-c3D2DWCJbFb9nMyD-HWv6KcvhKTelQ,7651
15
15
  src/tui_issue.py,sha256=gcAJCIfMcdeMjrFJUWQQvdgBv7fnOBtxQxwdBk2Sk40,1789
16
- src/updater.py,sha256=BDBRFpL9ek4MvduIKa3A_UH1KM-YCOCaXwPvN-eVREw,5886
16
+ src/updater.py,sha256=QPvYVEBpORF37rfD2oB_vVKnfNIw5amlaJk3NDSckuA,5886
17
17
  src/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
18
  src/ui/chat_app.py,sha256=y-py5UpisQbPLqqDON8AIkyA7X1IM5LAn1GbeYr2p_U,25598
19
19
  src/ui/help_screen.py,sha256=tjbokXhwyzLnkOC0S56NbpX-78hcFQnvsQMpNU1wt0c,1862
20
20
  src/ui/issue_app.py,sha256=8MdBcOVdCHPFx6EUhs80CiZ2G-29KulskkCz9qMZ_Lg,4737
21
- gitpr_cli-0.0.26.dist-info/METADATA,sha256=RReNTAFsjM1rMKH3aordrfMn90pZygCt0jn-tTo3X-M,17450
22
- gitpr_cli-0.0.26.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
23
- gitpr_cli-0.0.26.dist-info/entry_points.txt,sha256=mh_4F_idM4cj0XhfRGBDQb5Nnku93YJ-dyKh5SufwlE,39
24
- gitpr_cli-0.0.26.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
25
- gitpr_cli-0.0.26.dist-info/RECORD,,
21
+ gitpr_cli-0.0.27.dist-info/METADATA,sha256=RaWiAINchrmj4I8CpZxeaObnRVH4IwpEgneVOFCxkG4,18530
22
+ gitpr_cli-0.0.27.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
23
+ gitpr_cli-0.0.27.dist-info/entry_points.txt,sha256=mh_4F_idM4cj0XhfRGBDQb5Nnku93YJ-dyKh5SufwlE,39
24
+ gitpr_cli-0.0.27.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
25
+ gitpr_cli-0.0.27.dist-info/RECORD,,
src/ai_providers.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import time
3
+ from datetime import datetime
3
4
  import click
4
5
  from google import genai
5
6
  from openai import OpenAI
@@ -10,13 +11,65 @@ from src.spinner import Spinner
10
11
  from src.i18n import __,CURRENT_LANG
11
12
 
12
13
 
13
- def call_ai_model(provider, api_key, api_model, prompt, system_instruction, quiet=False):
14
+ # Hidden --pre-save debug flag: when enabled, every AI payload is dumped
15
+ # to a JSON file in the current directory before being sent to the model.
16
+ PRE_SAVE_ENABLED = False
17
+
18
+
19
+ def set_pre_save(enabled):
20
+ """Enable/disable the pre-save payload dump (set once from the CLI)."""
21
+ global PRE_SAVE_ENABLED
22
+ PRE_SAVE_ENABLED = enabled
23
+
24
+
25
+ def _save_pre_save_payload(action, provider, api_model, system_instruction, prompt=None, chat_history=None, new_message=None):
26
+ """
27
+ Dump the full AI payload to a _{action}-{datetime}.json file in the current directory.
28
+ Returns the filename on success or None on failure (a debug dump must never break the flow).
29
+ """
30
+ filename = f"_{action}-{datetime.now().strftime('%Y%m%d%H%M%S%f')}.json"
31
+
32
+ payload = {
33
+ "datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
34
+ "action": action,
35
+ "provider": provider,
36
+ "model": api_model,
37
+ "system_instruction": system_instruction,
38
+ "system_instruction_chars": len(system_instruction) if system_instruction else 0,
39
+ }
40
+
41
+ if chat_history is not None:
42
+ payload["chat_history"] = chat_history
43
+ payload["new_message"] = new_message
44
+ payload["chat_history_chars"] = len(json.dumps(chat_history, ensure_ascii=False)) if chat_history else 0
45
+ payload["new_message_chars"] = len(new_message) if new_message else 0
46
+ payload["total_chars"] = payload["system_instruction_chars"] + payload["chat_history_chars"] + payload["new_message_chars"]
47
+ else:
48
+ payload["prompt"] = prompt
49
+ payload["prompt_chars"] = len(prompt) if prompt else 0
50
+ payload["total_chars"] = payload["system_instruction_chars"] + payload["prompt_chars"]
51
+
52
+ try:
53
+ with open(filename, "w", encoding="utf-8") as f:
54
+ json.dump(payload, f, ensure_ascii=False, indent=2)
55
+ return filename
56
+ except Exception:
57
+ return None
58
+
59
+
60
+ def call_ai_model(provider, api_key, api_model, prompt, system_instruction, quiet=False, action="ai_call"):
14
61
  """
15
62
  Unified engine for AI calls.
16
63
  Supports 'gemini' and 'deepseek'.
17
64
  """
18
65
  max_retries = 3
19
66
  retry_delay = 2
67
+
68
+ if PRE_SAVE_ENABLED:
69
+ saved_file = _save_pre_save_payload(action, provider, api_model, system_instruction, prompt=prompt)
70
+ if saved_file and not quiet:
71
+ click.secho(__("📝 Pre-save: AI payload saved to {filename}", filename=saved_file), fg="yellow", dim=True)
72
+
20
73
  spinner = Spinner(quiet=quiet)
21
74
  spinner.start()
22
75
 
@@ -141,6 +194,11 @@ def call_ai_chat(provider, api_key, api_model, system_instruction, chat_history,
141
194
  Dedicated engine for the Interactive Chat.
142
195
  Keeps the historical context and returns free Markdown (does not force JSON).
143
196
  """
197
+ if PRE_SAVE_ENABLED:
198
+ saved_file = _save_pre_save_payload("chat", provider, api_model, system_instruction, chat_history=chat_history, new_message=new_message)
199
+ if saved_file and not quiet:
200
+ click.secho(__("📝 Pre-save: AI payload saved to {filename}", filename=saved_file), fg="yellow", dim=True)
201
+
144
202
  spinner = Spinner(quiet=quiet)
145
203
  spinner.start()
146
204
 
src/blame_engine.py CHANGED
@@ -83,7 +83,7 @@ def analyze_commit_with_ai(commit_hash, file_path):
83
83
 
84
84
  click.secho(__(" 🤖 Consulting AI ({api_model}) about commit {commit_hash}...", api_model=api_model, commit_hash=commit_hash[:8]), fg="cyan", dim=True)
85
85
 
86
- result_json = call_ai_model(provider, api_key, api_model, prompt, sys_inst)
86
+ result_json = call_ai_model(provider, api_key, api_model, prompt, sys_inst, action="blame")
87
87
 
88
88
  if result_json and "status" in result_json:
89
89
  return result_json
@@ -210,7 +210,7 @@ def run_blame_analysis(file_path, start_line, end_line, return_data=False):
210
210
  sys_inst = __('You are a Software Architect. Generate ONLY a JSON object in the format {"resumo": "summary text"}.')
211
211
 
212
212
  click.secho(__(" 🤖 Consulting AI ({api_model}) for the Executive Summary...", api_model=api_model), fg="cyan", dim=True)
213
- summary_json = call_ai_model(provider, api_key, api_model, summary_prompt, sys_inst)
213
+ summary_json = call_ai_model(provider, api_key, api_model, summary_prompt, sys_inst, action="blame_summary")
214
214
 
215
215
  resumo_texto = summary_json.get("resumo", __("Summary not available.")) if summary_json else __("Summary not available.")
216
216
 
src/core.py CHANGED
@@ -2,33 +2,98 @@ import os
2
2
  import re
3
3
  import json
4
4
  import stat
5
+ import time
5
6
  import click
6
7
  import subprocess
7
8
  import urllib.request
8
9
  import urllib.error
10
+ from pathlib import Path
9
11
  from google import genai
12
+ from dotenv import load_dotenv, set_key
10
13
  from src.security import decrypt_data
11
14
  from src.cache import get_cached_response, save_cached_response, get_cached_pr_descriptions
12
15
  from src.config import get_api_key, get_api_model, get_skill_dir, resolve_skill_path
13
16
  from src.ai_providers import call_ai_model
14
17
  from src.i18n import __, CURRENT_LANG
15
-
16
- # Filtro Smart Diff: ficheiros que gastam tokens da IA sem acrescentar valor semântico
17
- SMART_EXCLUDES = [
18
- ":(exclude)*.lock",
19
- ":(exclude)package-lock.json",
20
- ":(exclude)yarn.lock",
21
- ":(exclude)pnpm-lock.yaml",
22
- ":(exclude)composer.lock",
23
- ":(exclude)poetry.lock",
24
- ":(exclude)Pipfile.lock",
25
- ":(exclude)Gemfile.lock",
26
- ":(exclude)go.sum",
27
- ":(exclude)*.min.js",
28
- ":(exclude)*.min.css",
29
- ":(exclude)*.svg"
18
+ from src.updater import __lang_version__
19
+
20
+ # Smart Diff filter: files that consume AI tokens without adding semantic value.
21
+ # The pattern list is managed remotely (templates/gitpr.smart-excludes.json) and
22
+ # cached at ~/.gitpr/conf/ — see _load_smart_excludes() for the update logic.
23
+ _FALLBACK_SMART_EXCLUDES = [
24
+ "*.lock",
25
+ "package-lock.json",
26
+ "yarn.lock",
27
+ "pnpm-lock.yaml",
28
+ "composer.lock",
29
+ "poetry.lock",
30
+ "Pipfile.lock",
31
+ "Gemfile.lock",
32
+ "go.sum",
33
+ "*.min.js",
34
+ "*.min.css",
35
+ "*.svg"
30
36
  ]
31
37
 
38
+ SMART_EXCLUDES_URL = "https://raw.githubusercontent.com/natanfiuza/gitpr/main/templates/gitpr.smart-excludes.json"
39
+
40
+
41
+ def _load_smart_excludes():
42
+ """
43
+ Load the smart-exclude patterns and return them as git pathspec exclusions.
44
+
45
+ Resolution order:
46
+ 1. Local copy at ~/.gitpr/conf/gitpr.smart-excludes.json when its version
47
+ marker (SMART_EXCLUDES_VERSION in ~/.gitpr/.env) matches __lang_version__.
48
+ 2. Fresh download from the remote template (saved locally + marker updated).
49
+ 3. Stale local copy when the download fails.
50
+ 4. _FALLBACK_SMART_EXCLUDES as last resort.
51
+ Silent on failure — diff generation must never break because of this list.
52
+ """
53
+ env_file = Path.home() / ".gitpr" / ".env"
54
+ load_dotenv(env_file)
55
+
56
+ conf_dir = Path.home() / ".gitpr" / "conf"
57
+ local_file = conf_dir / "gitpr.smart-excludes.json"
58
+ needs_update = os.getenv("SMART_EXCLUDES_VERSION") != __lang_version__
59
+
60
+ def _to_pathspecs(data):
61
+ return [f":(exclude){pattern}" for pattern in data.get("excludes", [])]
62
+
63
+ # 1. Local copy is present and up to date
64
+ if local_file.exists() and not needs_update:
65
+ try:
66
+ with open(local_file, "r", encoding="utf-8", errors="replace") as f:
67
+ return _to_pathspecs(json.load(f))
68
+ except Exception:
69
+ pass
70
+
71
+ # 2. Download the updated list from the remote template
72
+ try:
73
+ with urllib.request.urlopen(SMART_EXCLUDES_URL, timeout=3) as response:
74
+ data = json.loads(response.read().decode("utf-8"))
75
+ conf_dir.mkdir(parents=True, exist_ok=True)
76
+ with open(local_file, "w", encoding="utf-8") as f:
77
+ json.dump(data, f, ensure_ascii=False, indent=2)
78
+ set_key(str(env_file), "SMART_EXCLUDES_VERSION", __lang_version__)
79
+ return _to_pathspecs(data)
80
+ except Exception:
81
+ pass
82
+
83
+ # 3. Offline fallback: reuse the local copy even if outdated
84
+ if local_file.exists():
85
+ try:
86
+ with open(local_file, "r", encoding="utf-8", errors="replace") as f:
87
+ return _to_pathspecs(json.load(f))
88
+ except Exception:
89
+ pass
90
+
91
+ # 4. Last resort: built-in defaults
92
+ return [f":(exclude){pattern}" for pattern in _FALLBACK_SMART_EXCLUDES]
93
+
94
+
95
+ SMART_EXCLUDES = _load_smart_excludes()
96
+
32
97
 
33
98
  def get_doc_url(filename):
34
99
  """Returns the complete URL for a docs/ file, with language suffix if needed."""
@@ -62,7 +127,7 @@ def get_git_diff(quiet=False):
62
127
  click.secho(f"📚 {__('Understand why:')} {get_doc_url('untracked-files.md')}\n", fg="blue", underline=True)
63
128
 
64
129
  # Run the normal diff that captures tracked and staged files
65
- cmd = ["git", "diff", "HEAD", "--"] + SMART_EXCLUDES
130
+ cmd = ["git", "diff", "-U1", "-w", "-M", "-B", "HEAD", "--"] + SMART_EXCLUDES
66
131
  result = subprocess.run(
67
132
  cmd,
68
133
  capture_output=True,
@@ -153,6 +218,35 @@ def get_skill_context(action_type="pr"):
153
218
  # Return empty if it does not exist
154
219
  return ""
155
220
 
221
+ def estimate_token_count(text):
222
+ """Estimates the token count using the safe rule of 4 characters per token."""
223
+ return len(text) // 4
224
+
225
+ def split_diff_into_chunks(diff_text, max_tokens=90000):
226
+ """Splits the diff based on the token limit while preserving file header integrity."""
227
+ if estimate_token_count(diff_text) <= max_tokens:
228
+ return [diff_text]
229
+
230
+ parts = re.split(r'(^diff --git a/)', diff_text, flags=re.MULTILINE)
231
+ chunks = []
232
+ current_chunk = ""
233
+
234
+ for i in range(1, len(parts), 2):
235
+ file_diff = parts[i] + parts[i+1] if i + 1 < len(parts) else parts[i]
236
+
237
+ if estimate_token_count(current_chunk + file_diff) > max_tokens and current_chunk:
238
+ chunks.append(current_chunk)
239
+ current_chunk = file_diff
240
+ else:
241
+ current_chunk += file_diff
242
+
243
+ if current_chunk:
244
+ chunks.append(current_chunk)
245
+
246
+ if not chunks:
247
+ chunks = [diff_text]
248
+
249
+ return chunks
156
250
 
157
251
  def generate_pr_content(action_folder, action_type, diff_text, provider="gemini"):
158
252
  """Sends the diff to the AI using System Instruction and returns a parsed JSON."""
@@ -212,10 +306,46 @@ def generate_pr_content(action_folder, action_type, diff_text, provider="gemini"
212
306
  click.secho(__("❌ Error: Could not determine model for provider '{provider}'.", provider=provider), fg="red")
213
307
  return None
214
308
 
309
+ # API CALL
215
310
  # API CALL
216
311
  click.secho(__("🤖 GitPR is analyzing your code using {provider} ({model})...\n", provider=provider.capitalize(), model=api_model), fg="cyan")
217
312
 
218
- result_json = call_ai_model(provider, api_key, api_model, prompt, instrucao_sistema)
313
+ chunks = split_diff_into_chunks(diff_text, max_tokens=90000)
314
+
315
+ if len(chunks) == 1:
316
+ result_json = call_ai_model(provider, api_key, api_model, prompt, instrucao_sistema, action=action_folder)
317
+ else:
318
+ click.secho(__("📦 Huge diff detected! Processing in {count} batches (Map-Reduce)...", count=len(chunks)), fg="yellow", bold=True)
319
+ click.secho(f"📚 {__('Understand why:')} {get_doc_url('map-reduce-diff.md')}\n", fg="blue", underline=True)
320
+ resumos_parciais = []
321
+
322
+ for i, chunk in enumerate(chunks, 1):
323
+ click.secho(__("⏳ Analyzing batch {current}/{total}...", current=i, total=len(chunks)), fg="cyan", dim=True)
324
+
325
+ prompt_parcial = __("Generate ONLY a JSON object in the format {json_format} containing a technical summary of what was changed in this part ({idx}) of the diff:\n", json_format='{"resumo": "..."}', idx=i) + chunk
326
+
327
+ resposta_parcial = call_ai_model(provider, api_key, api_model, prompt_parcial, instrucao_sistema, quiet=True, action=f"{action_folder}_chunk_{i}")
328
+
329
+ if resposta_parcial and "resumo" in resposta_parcial:
330
+ resumos_parciais.append(f"### Batch {i}\n{resposta_parcial['resumo']}")
331
+
332
+ time.sleep(1)
333
+
334
+ if not resumos_parciais:
335
+ click.secho(__("❌ Failed to extract context from the partial batches."), fg="red")
336
+ return None
337
+
338
+ click.secho(__("🔄 Unifying intelligence and generating the final analysis..."), fg="yellow")
339
+ diff_unificado = "\n\n".join(resumos_parciais)
340
+
341
+ if action_type == "commit":
342
+ prompt = __("Generate ONLY a JSON object in the format {json_format} for the commit message, unifying these technical summaries:\n", json_format='{"commit_message": "..."}') + diff_unificado
343
+ elif action_type in ["review", "fullreview", "filereview"]:
344
+ prompt = __("Generate ONLY a JSON object in the format {json_format} with a code review focused on improvements, using these summaries:\n", json_format='{"review": "..."}') + diff_unificado
345
+ else:
346
+ prompt = __("Unify these technical summaries and generate ONLY a JSON object in the format {json_format} describing the Pull Request:\n", json_format='{"commit_message": "...", "pr_description": "..."}') + diff_unificado
347
+
348
+ result_json = call_ai_model(provider, api_key, api_model, prompt, instrucao_sistema, action=action_folder)
219
349
 
220
350
  # SAVE TO CACHE AND RETURN
221
351
  if result_json:
@@ -325,7 +455,7 @@ def get_git_full_diff():
325
455
 
326
456
  # Diff between that HASH and your CURRENT WORKSPACE (without using HEAD)
327
457
  # By passing only the hash, Git compares that commit with the files on your disk.
328
- cmd = ["git", "diff", ancestor_hash, "--"] + SMART_EXCLUDES
458
+ cmd = ["git", "diff", "-U1", "-w", "-M", "-B", ancestor_hash, "--"] + SMART_EXCLUDES
329
459
  result = subprocess.run(
330
460
  cmd,
331
461
  capture_output=True,
src/issue_engine.py CHANGED
@@ -78,7 +78,7 @@ def generate_issue_content(context_text, context_type="diff"):
78
78
 
79
79
  click.secho(__("🤖 Structuring Issue using {provider} ({api_model})...", provider=provider.capitalize(), api_model=api_model), fg="cyan", dim=True)
80
80
 
81
- result_json = call_ai_model(provider, api_key, api_model, prompt, sys_inst)
81
+ result_json = call_ai_model(provider, api_key, api_model, prompt, sys_inst, action="issue")
82
82
 
83
83
  if result_json and "titulo" in result_json and "corpo" in result_json:
84
84
  # Save to Cache
src/main.py CHANGED
@@ -143,6 +143,7 @@ HELP_PRIORITY: dict[str, int] = {
143
143
  @click.option('-ih', '--installhooks', is_flag=True, help=__("Automatically installs validation Git Hooks in the project."))
144
144
  @click.option('--hook', type=click.Path(), hidden=True, help=__("Commit file path (internal hook use)."))
145
145
  @click.option('-q', '--quiet', is_flag=True, hidden=True, help=__("Hides banner and non-essential logs (internal use)."))
146
+ @click.option('--pre-save', is_flag=True, hidden=True, help=__("Saves the full AI payload (system + prompt) to a JSON file before each AI call (debug)."))
146
147
  @click.option('-i', '--input', type=click.Path(), help=__("Path to a specific file for full analysis."))
147
148
  @click.option('-b', '--blame', type=str, help=__("Analyzes the origin of a business rule (e.g., file.py:10-20 or just file.py)."))
148
149
  @click.option('-ht', '--history', is_flag=True, help=__("Uses the entire branch history (Git Log + PR Cache) as context to generate the issue."))
@@ -151,7 +152,7 @@ HELP_PRIORITY: dict[str, int] = {
151
152
  @click.option('-p', '--provider', type=click.Choice(['gemini', 'deepseek', 'ollama']), help=__("Forces the use of a specific AI provider for this execution."))
152
153
  @click.option('--lang', type=str, help=__("Forces the interface language for this execution (e.g.: en_us, pt_br)."))
153
154
  @click.option('-h', '--help', 'help_flag', is_flag=True, help=__("Shows this message and exits. Use with another flag for contextual help (e.g., -h --issue)."))
154
- def cli(commit, review, fullreview, linter, skill, update, installhooks, hook, quiet, provider, input, blame, history, issue, chat, help_flag, lang):
155
+ def cli(commit, review, fullreview, linter, skill, update, installhooks, hook, quiet, pre_save, provider, input, blame, history, issue, chat, help_flag, lang):
155
156
  """
156
157
  GitPR CLI - Intelligent PR Automation and AI Code Review.
157
158
 
@@ -236,6 +237,11 @@ def cli(commit, review, fullreview, linter, skill, update, installhooks, hook, q
236
237
  except OSError:
237
238
  pass
238
239
 
240
+ # Enable AI payload dump for inspection (hidden debug flag)
241
+ if pre_save:
242
+ from src.ai_providers import set_pre_save
243
+ set_pre_save(True)
244
+
239
245
  if linter:
240
246
  diff_text = get_git_diff()
241
247
 
src/spinner.py CHANGED
@@ -13,6 +13,7 @@ import urllib.request
13
13
  from pathlib import Path
14
14
  from dotenv import load_dotenv, set_key
15
15
  from src.i18n import __, CURRENT_LANG
16
+ from src.updater import __lang_version__
16
17
 
17
18
  # ANSI color codes
18
19
  MAGENTA = '\033[35m'
@@ -51,6 +52,12 @@ _FALLBACK_WORDS = [
51
52
  __("Calculating"),__("Reflecting"), __("Computing"),
52
53
  ]
53
54
 
55
+ def _parse_env_words(raw):
56
+ """Parses the .env word list (supports | or , separator)."""
57
+ sep = "|" if "|" in raw else ","
58
+ return [w.strip() for w in raw.split(sep) if w.strip()]
59
+
60
+
54
61
  def _load_thinking_words():
55
62
  """Loads the word list from .env or downloads from the remote template."""
56
63
  env_file = str(Path.home() / ".gitpr" / ".env")
@@ -60,12 +67,16 @@ def _load_thinking_words():
60
67
 
61
68
  raw = os.getenv("SPINNER_THINKING_WORDS", "").strip()
62
69
 
63
- if raw:
64
- # .env already has words: supports | or , separator
65
- sep = "|" if "|" in raw else ","
66
- return [w.strip() for w in raw.split(sep) if w.strip()]
70
+ # Version gate: when __lang_version__ changes, force a re-download so the
71
+ # words stay in sync with the published template (same pattern as
72
+ # LANG_VERSION in i18n.py and SMART_EXCLUDES_VERSION in core.py).
73
+ needs_update = os.getenv("THINKING_WORDS_VERSION") != __lang_version__
67
74
 
68
- # .env has no words: download from GitHub
75
+ if raw and not needs_update:
76
+ # .env already has up-to-date words: supports | or , separator
77
+ return _parse_env_words(raw)
78
+
79
+ # .env has no words (or they are outdated): download from GitHub
69
80
  try:
70
81
  with urllib.request.urlopen(THINKING_WORDS_URL, timeout=10) as resp:
71
82
  content = resp.read().decode("utf-8")
@@ -83,12 +94,17 @@ def _load_thinking_words():
83
94
  words.append(word)
84
95
 
85
96
  if words:
86
- # Save to .env as pipe-separated
97
+ # Save to .env as pipe-separated and stamp the version marker
87
98
  set_key(env_file, "SPINNER_THINKING_WORDS", "|".join(words))
99
+ set_key(env_file, "THINKING_WORDS_VERSION", __lang_version__)
88
100
  return words
89
101
  except Exception:
90
102
  pass
91
103
 
104
+ # Download failed: reuse the stale .env words if available
105
+ if raw:
106
+ return _parse_env_words(raw)
107
+
92
108
  # Fallback: use internal list
93
109
  return list(_FALLBACK_WORDS)
94
110
 
src/updater.py CHANGED
@@ -7,8 +7,8 @@ import click
7
7
  from datetime import datetime
8
8
 
9
9
  # Current version of your local executable (Update this on every new build!)
10
- __version__ = "0.0.26" # GitPR current version
11
- __lang_version__ = "v0.0.4" # Language dictionary version control
10
+ __version__ = "0.0.27" # GitPR current version
11
+ __lang_version__ = "v0.0.5" # Language dictionary version control
12
12
  GITHUB_API_URL = "https://api.github.com/repos/natanfiuza/gitpr/releases/latest"
13
13
  PYPI_API_URL = "https://pypi.org/pypi/gitpr-cli/json"
14
14