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 ADDED
@@ -0,0 +1,2 @@
1
+ dist
2
+ __pycache__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitdude
3
- Version: 1.0.2
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
@@ -1,5 +1,5 @@
1
1
  """GitDude — AI-powered Git workflow assistant."""
2
2
 
3
- __version__ = "1.0.2"
3
+ __version__ = "1.2"
4
4
  __author__ = "GitDude Contributors"
5
5
  __license__ = "MIT"
@@ -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
- all_diff = git_ops.get_all_diff(repo)
178
- if not all_diff:
179
- all_diff = git_ops.get_diff_unstaged(repo)
180
- diff_text = all_diff
181
- _c.print("[dim]No staged changes detected — using full working tree diff.[/dim]")
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.0.2"
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