gitdude 1.0.2__tar.gz → 1.2__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.
- gitdude-1.2/.gitignore +2 -0
- {gitdude-1.0.2 → gitdude-1.2}/PKG-INFO +1 -1
- {gitdude-1.0.2 → gitdude-1.2}/gitdude/__init__.py +1 -1
- {gitdude-1.0.2 → gitdude-1.2}/gitdude/git_ops.py +114 -0
- {gitdude-1.0.2 → gitdude-1.2}/gitdude/main.py +195 -5
- {gitdude-1.0.2 → gitdude-1.2}/pyproject.toml +1 -1
- gitdude-1.0.2/.gitignore +0 -1
- {gitdude-1.0.2 → gitdude-1.2}/.env.example +0 -0
- {gitdude-1.0.2 → gitdude-1.2}/README.md +0 -0
- {gitdude-1.0.2 → gitdude-1.2}/gitdude/ai.py +0 -0
- {gitdude-1.0.2 → gitdude-1.2}/gitdude/config.py +0 -0
- {gitdude-1.0.2 → gitdude-1.2}/gitdude/utils.py +0 -0
gitdude-1.2/.gitignore
ADDED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitdude
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2
|
|
4
4
|
Summary: AI-powered Git workflow assistant — commit, review, recover, and more with a single command
|
|
5
5
|
Project-URL: Homepage, https://github.com/utkarshgupta188/gitdude
|
|
6
6
|
Project-URL: Repository, https://github.com/utkarshgupta188/gitdude
|
|
@@ -44,6 +44,21 @@ def get_untracked_and_new_files(repo: Repo) -> str:
|
|
|
44
44
|
return "\n".join(lines)
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def get_untracked_diff(repo: Repo) -> str:
|
|
48
|
+
"""Return the content of untracked files in a pseudo-diff format."""
|
|
49
|
+
diff_parts = []
|
|
50
|
+
for file_path_str in repo.untracked_files:
|
|
51
|
+
try:
|
|
52
|
+
full_path = Path(repo.working_dir) / file_path_str
|
|
53
|
+
if full_path.is_file():
|
|
54
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
55
|
+
# Format as a pseudo-diff for the AI
|
|
56
|
+
diff_parts.append(f"--- /dev/null\n+++ b/{file_path_str}\n@@ -0,0 +1,1 @@\n+{content}")
|
|
57
|
+
except Exception:
|
|
58
|
+
continue
|
|
59
|
+
return "\n".join(diff_parts)
|
|
60
|
+
|
|
61
|
+
|
|
47
62
|
def get_status(repo: Repo) -> str:
|
|
48
63
|
"""Return git status output."""
|
|
49
64
|
return repo.git.status()
|
|
@@ -233,3 +248,102 @@ def stash(repo: Repo) -> str:
|
|
|
233
248
|
|
|
234
249
|
def stash_pop(repo: Repo) -> str:
|
|
235
250
|
return repo.git.stash("pop")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def get_log(repo: Repo, count: int = 10) -> str:
|
|
254
|
+
"""Return the last N commit log entries as a compact string."""
|
|
255
|
+
if not has_commits(repo):
|
|
256
|
+
return "(no commits yet)"
|
|
257
|
+
try:
|
|
258
|
+
return repo.git.log(f"--oneline", f"-{count}", "--no-color")
|
|
259
|
+
except GitCommandError:
|
|
260
|
+
return "(unable to read log)"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# Helpers for split, chat commands
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def get_changed_files(repo: Repo) -> list[str]:
|
|
269
|
+
"""Return a list of all modified, added, deleted, and untracked file paths."""
|
|
270
|
+
files = set()
|
|
271
|
+
|
|
272
|
+
if has_commits(repo):
|
|
273
|
+
# Staged changes
|
|
274
|
+
for item in repo.index.diff("HEAD"):
|
|
275
|
+
files.add(item.a_path)
|
|
276
|
+
if item.b_path:
|
|
277
|
+
files.add(item.b_path)
|
|
278
|
+
|
|
279
|
+
# Unstaged changes
|
|
280
|
+
for item in repo.index.diff(None):
|
|
281
|
+
files.add(item.a_path)
|
|
282
|
+
if item.b_path:
|
|
283
|
+
files.add(item.b_path)
|
|
284
|
+
|
|
285
|
+
# Untracked files
|
|
286
|
+
for f in repo.untracked_files:
|
|
287
|
+
files.add(f)
|
|
288
|
+
|
|
289
|
+
return sorted(files)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def stage_files(repo: Repo, file_list: list[str]) -> None:
|
|
293
|
+
"""Stage specific files by path."""
|
|
294
|
+
for f in file_list:
|
|
295
|
+
full_path = Path(repo.working_dir) / f
|
|
296
|
+
if full_path.exists():
|
|
297
|
+
repo.index.add([f])
|
|
298
|
+
else:
|
|
299
|
+
# File was deleted
|
|
300
|
+
try:
|
|
301
|
+
repo.index.remove([f])
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def get_file_tree(repo: Repo, max_depth: int = 3) -> str:
|
|
307
|
+
"""Return a directory tree listing of the repo, ignoring common junk."""
|
|
308
|
+
ignore = {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build", ".egg-info"}
|
|
309
|
+
lines = []
|
|
310
|
+
|
|
311
|
+
def _walk(directory: Path, prefix: str, depth: int):
|
|
312
|
+
if depth > max_depth:
|
|
313
|
+
return
|
|
314
|
+
try:
|
|
315
|
+
entries = sorted(directory.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
316
|
+
except PermissionError:
|
|
317
|
+
return
|
|
318
|
+
for entry in entries:
|
|
319
|
+
if entry.name in ignore:
|
|
320
|
+
continue
|
|
321
|
+
if entry.is_dir():
|
|
322
|
+
lines.append(f"{prefix}📁 {entry.name}/")
|
|
323
|
+
_walk(entry, prefix + " ", depth + 1)
|
|
324
|
+
else:
|
|
325
|
+
lines.append(f"{prefix}📄 {entry.name}")
|
|
326
|
+
|
|
327
|
+
root = Path(repo.working_dir)
|
|
328
|
+
lines.append(f"📁 {root.name}/")
|
|
329
|
+
_walk(root, " ", 1)
|
|
330
|
+
return "\n".join(lines[:200]) # Cap output
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def read_file_contents(repo: Repo, paths: list[str], max_chars: int = 5000) -> str:
|
|
334
|
+
"""Read the content of specific files from the repo root, if they exist."""
|
|
335
|
+
parts = []
|
|
336
|
+
root = Path(repo.working_dir)
|
|
337
|
+
total = 0
|
|
338
|
+
for p in paths:
|
|
339
|
+
full = root / p
|
|
340
|
+
if full.is_file() and total < max_chars:
|
|
341
|
+
try:
|
|
342
|
+
content = full.read_text(encoding="utf-8", errors="replace")
|
|
343
|
+
chunk = content[:max_chars - total]
|
|
344
|
+
parts.append(f"### {p}\n```\n{chunk}\n```")
|
|
345
|
+
total += len(chunk)
|
|
346
|
+
except Exception:
|
|
347
|
+
continue
|
|
348
|
+
return "\n\n".join(parts) if parts else "(no key files found)"
|
|
349
|
+
|
|
@@ -174,11 +174,14 @@ def cmd_push(
|
|
|
174
174
|
else:
|
|
175
175
|
staged = git_ops.get_staged_diff(repo)
|
|
176
176
|
if not staged:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
177
|
+
# Fallback to working tree diff
|
|
178
|
+
console.print("[dim]No staged changes detected — using full working tree diff.[/dim]")
|
|
179
|
+
diff_text = git_ops.get_all_diff(repo)
|
|
180
|
+
|
|
181
|
+
# Include untracked files contents
|
|
182
|
+
untracked_diff = git_ops.get_untracked_diff(repo)
|
|
183
|
+
if untracked_diff:
|
|
184
|
+
diff_text += "\n" + untracked_diff
|
|
182
185
|
else:
|
|
183
186
|
diff_text = staged
|
|
184
187
|
|
|
@@ -856,6 +859,193 @@ def cmd_config(
|
|
|
856
859
|
_run_interactive_config()
|
|
857
860
|
|
|
858
861
|
|
|
862
|
+
# ---------------------------------------------------------------------------
|
|
863
|
+
# branch — AI branch naming
|
|
864
|
+
# ---------------------------------------------------------------------------
|
|
865
|
+
|
|
866
|
+
@app.command("branch")
|
|
867
|
+
def cmd_branch(
|
|
868
|
+
description: str = typer.Argument(..., help="Plain-English description of what the branch is for"),
|
|
869
|
+
):
|
|
870
|
+
"""
|
|
871
|
+
[bold green]Generate a clean branch name from a description.[/bold green]
|
|
872
|
+
"""
|
|
873
|
+
_ensure_configured()
|
|
874
|
+
from gitdude import git_ops
|
|
875
|
+
from gitdude.ai import ask_ai
|
|
876
|
+
from gitdude.utils import ai_panel, success_panel, warning_panel
|
|
877
|
+
|
|
878
|
+
repo = git_ops.get_repo()
|
|
879
|
+
|
|
880
|
+
prompt = (
|
|
881
|
+
"You are an expert at naming git branches.\n"
|
|
882
|
+
"Given the following description, generate a single clean branch name.\n\n"
|
|
883
|
+
"Rules:\n"
|
|
884
|
+
"- Use the format: type/short-descriptive-slug\n"
|
|
885
|
+
"- Types: feat, fix, chore, docs, refactor, test, hotfix\n"
|
|
886
|
+
"- Use lowercase, hyphens only, no spaces or special characters\n"
|
|
887
|
+
"- Keep it short (max 5 words in the slug)\n"
|
|
888
|
+
"- Respond with ONLY the branch name. No explanation, no quotes.\n\n"
|
|
889
|
+
f"Description: {description}"
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
branch_name = ask_ai(prompt, spinner_msg="🧠 Generating branch name...")
|
|
893
|
+
branch_name = branch_name.strip().strip('"').strip("'").replace(" ", "-").lower()
|
|
894
|
+
|
|
895
|
+
ai_panel(f"[bold]{branch_name}[/bold]", title="🌿 Suggested Branch Name")
|
|
896
|
+
|
|
897
|
+
action = questionary.select(
|
|
898
|
+
"Action",
|
|
899
|
+
choices=["create branch", "edit name", "cancel"],
|
|
900
|
+
default="create branch",
|
|
901
|
+
).ask()
|
|
902
|
+
|
|
903
|
+
if not action or action == "cancel":
|
|
904
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
905
|
+
raise typer.Exit(0)
|
|
906
|
+
|
|
907
|
+
if action == "edit name":
|
|
908
|
+
branch_name = questionary.text("Branch name:", default=branch_name).ask()
|
|
909
|
+
if not branch_name:
|
|
910
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
911
|
+
raise typer.Exit(0)
|
|
912
|
+
|
|
913
|
+
try:
|
|
914
|
+
repo.git.checkout("-b", branch_name)
|
|
915
|
+
success_panel(f"Switched to new branch [bold]{branch_name}[/bold]", title="🌿 Branch Created")
|
|
916
|
+
except Exception as e:
|
|
917
|
+
warning_panel(f"Could not create branch: {e}", title="⚠️ Error")
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
# ---------------------------------------------------------------------------
|
|
921
|
+
# split — Smart commit splitter
|
|
922
|
+
# ---------------------------------------------------------------------------
|
|
923
|
+
|
|
924
|
+
@app.command("split")
|
|
925
|
+
def cmd_split():
|
|
926
|
+
"""
|
|
927
|
+
[bold green]AI-guided splitting of messy changes into logical commits.[/bold green]
|
|
928
|
+
"""
|
|
929
|
+
_ensure_configured()
|
|
930
|
+
from gitdude import git_ops
|
|
931
|
+
from gitdude.ai import ask_ai
|
|
932
|
+
from gitdude.utils import ai_panel, success_panel, warning_panel, info_panel, console as _c
|
|
933
|
+
import json
|
|
934
|
+
|
|
935
|
+
repo = git_ops.get_repo()
|
|
936
|
+
|
|
937
|
+
# Gather all changed files
|
|
938
|
+
changed = git_ops.get_changed_files(repo)
|
|
939
|
+
if not changed:
|
|
940
|
+
warning_panel("No changes detected. Nothing to split.", title="⚠️ Nothing to Split")
|
|
941
|
+
raise typer.Exit(0)
|
|
942
|
+
|
|
943
|
+
# Get the full diff for AI context
|
|
944
|
+
diff_text = git_ops.get_all_diff(repo)
|
|
945
|
+
untracked_diff = git_ops.get_untracked_diff(repo)
|
|
946
|
+
if untracked_diff:
|
|
947
|
+
diff_text += "\n" + untracked_diff
|
|
948
|
+
|
|
949
|
+
file_list = "\n".join(f"- {f}" for f in changed)
|
|
950
|
+
|
|
951
|
+
prompt = (
|
|
952
|
+
"You are an expert at organizing git commits.\n"
|
|
953
|
+
"Given the following list of changed files and their diff, group them into logical commits.\n\n"
|
|
954
|
+
"Rules:\n"
|
|
955
|
+
"- Each group should represent ONE logical change (a feature, a fix, a refactor, etc.)\n"
|
|
956
|
+
"- Provide a conventional commit message for each group\n"
|
|
957
|
+
"- Respond in STRICT JSON format, nothing else\n"
|
|
958
|
+
"- Format: [{\"message\": \"feat: add X\", \"files\": [\"path/to/file1\", \"path/to/file2\"]}, ...]\n\n"
|
|
959
|
+
f"Changed files:\n{file_list}\n\n"
|
|
960
|
+
f"Diff:\n{diff_text[:8000]}"
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
raw = ask_ai(prompt, spinner_msg="🧠 Analyzing changes...")
|
|
964
|
+
|
|
965
|
+
# Parse AI response
|
|
966
|
+
try:
|
|
967
|
+
# Extract JSON from response (AI might wrap it in markdown)
|
|
968
|
+
raw = raw.strip()
|
|
969
|
+
if raw.startswith("```"):
|
|
970
|
+
raw = raw.split("\n", 1)[1]
|
|
971
|
+
raw = raw.rsplit("```", 1)[0]
|
|
972
|
+
groups = json.loads(raw.strip())
|
|
973
|
+
except (json.JSONDecodeError, IndexError):
|
|
974
|
+
warning_panel("AI returned an unexpected format. Showing raw response:", title="⚠️ Parse Error")
|
|
975
|
+
_c.print(f"[dim]{raw[:2000]}[/dim]")
|
|
976
|
+
raise typer.Exit(1)
|
|
977
|
+
|
|
978
|
+
# Display the groups
|
|
979
|
+
for i, g in enumerate(groups, 1):
|
|
980
|
+
files_str = "\n".join(f" • {f}" for f in g.get("files", []))
|
|
981
|
+
ai_panel(f"[bold]{g.get('message', 'No message')}[/bold]\n{files_str}", title=f"📦 Group {i}")
|
|
982
|
+
|
|
983
|
+
# Let user commit groups one by one
|
|
984
|
+
for i, g in enumerate(groups, 1):
|
|
985
|
+
action = questionary.select(
|
|
986
|
+
f"Commit group {i}: {g.get('message', '')}?",
|
|
987
|
+
choices=["commit", "skip", "stop"],
|
|
988
|
+
default="commit",
|
|
989
|
+
).ask()
|
|
990
|
+
|
|
991
|
+
if not action or action == "stop":
|
|
992
|
+
_c.print("[dim]Stopped.[/dim]")
|
|
993
|
+
break
|
|
994
|
+
|
|
995
|
+
if action == "skip":
|
|
996
|
+
continue
|
|
997
|
+
|
|
998
|
+
files = g.get("files", [])
|
|
999
|
+
msg = g.get("message", "chore: update")
|
|
1000
|
+
|
|
1001
|
+
try:
|
|
1002
|
+
git_ops.stage_files(repo, files)
|
|
1003
|
+
repo.index.commit(msg)
|
|
1004
|
+
success_panel(f"[bold]{msg}[/bold]\n({len(files)} file(s))", title=f"✅ Committed Group {i}")
|
|
1005
|
+
except Exception as e:
|
|
1006
|
+
warning_panel(f"Error committing group {i}: {e}", title="⚠️ Error")
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
# ---------------------------------------------------------------------------
|
|
1010
|
+
# chat — Ask your codebase
|
|
1011
|
+
# ---------------------------------------------------------------------------
|
|
1012
|
+
|
|
1013
|
+
@app.command("chat")
|
|
1014
|
+
def cmd_chat(
|
|
1015
|
+
question: str = typer.Argument(..., help="Your question about the codebase"),
|
|
1016
|
+
):
|
|
1017
|
+
"""
|
|
1018
|
+
[bold green]Ask AI questions about your codebase.[/bold green]
|
|
1019
|
+
"""
|
|
1020
|
+
_ensure_configured()
|
|
1021
|
+
from gitdude import git_ops
|
|
1022
|
+
from gitdude.ai import ask_ai
|
|
1023
|
+
from gitdude.utils import ai_panel
|
|
1024
|
+
|
|
1025
|
+
repo = git_ops.get_repo()
|
|
1026
|
+
status = git_ops.get_status(repo)
|
|
1027
|
+
tree = git_ops.get_file_tree(repo)
|
|
1028
|
+
recent_log = git_ops.get_log(repo, count=10)
|
|
1029
|
+
|
|
1030
|
+
# Read key files for context
|
|
1031
|
+
key_files = ["README.md", "readme.md", "pyproject.toml", "package.json", "Cargo.toml", "go.mod"]
|
|
1032
|
+
file_context = git_ops.read_file_contents(repo, key_files)
|
|
1033
|
+
|
|
1034
|
+
prompt = (
|
|
1035
|
+
"You are a helpful senior software engineer who knows this codebase intimately.\n"
|
|
1036
|
+
"Answer the user's question based on the project context provided below.\n"
|
|
1037
|
+
"Be specific, reference file names and functions when possible.\n\n"
|
|
1038
|
+
f"## Project File Tree\n{tree}\n\n"
|
|
1039
|
+
f"## Git Status\n{status}\n\n"
|
|
1040
|
+
f"## Recent Commits\n{recent_log}\n\n"
|
|
1041
|
+
f"## Key Files\n{file_context}\n\n"
|
|
1042
|
+
f"## User Question\n{question}"
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
answer = ask_ai(prompt, spinner_msg="🧠 Thinking about your codebase...")
|
|
1046
|
+
ai_panel(answer, title="💬 Answer")
|
|
1047
|
+
|
|
1048
|
+
|
|
859
1049
|
# ---------------------------------------------------------------------------
|
|
860
1050
|
# Entry point
|
|
861
1051
|
# ---------------------------------------------------------------------------
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitdude"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2"
|
|
8
8
|
description = "AI-powered Git workflow assistant — commit, review, recover, and more with a single command"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
gitdude-1.0.2/.gitignore
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
dist
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|