git-explain 2.5.0__tar.gz → 2.5.1__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.
Files changed (28) hide show
  1. {git_explain-2.5.0 → git_explain-2.5.1}/PKG-INFO +1 -1
  2. git_explain-2.5.1/git_explain/__init__.py +1 -0
  3. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/cli.py +372 -275
  4. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain.egg-info/PKG-INFO +1 -1
  5. git_explain-2.5.0/git_explain/__init__.py +0 -1
  6. {git_explain-2.5.0 → git_explain-2.5.1}/LICENSE +0 -0
  7. {git_explain-2.5.0 → git_explain-2.5.1}/README.md +0 -0
  8. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/__main__.py +0 -0
  9. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/commit_infer.py +0 -0
  10. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/gemini.py +0 -0
  11. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/git.py +0 -0
  12. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/heuristics.py +0 -0
  13. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/path_topics.py +0 -0
  14. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/run.py +0 -0
  15. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain.egg-info/SOURCES.txt +0 -0
  16. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain.egg-info/dependency_links.txt +0 -0
  17. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain.egg-info/entry_points.txt +0 -0
  18. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain.egg-info/requires.txt +0 -0
  19. {git_explain-2.5.0 → git_explain-2.5.1}/git_explain.egg-info/top_level.txt +0 -0
  20. {git_explain-2.5.0 → git_explain-2.5.1}/pyproject.toml +0 -0
  21. {git_explain-2.5.0 → git_explain-2.5.1}/setup.cfg +0 -0
  22. {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_cli_utils.py +0 -0
  23. {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_commit_infer.py +0 -0
  24. {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_gemini.py +0 -0
  25. {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_gemini_suggest_commands.py +0 -0
  26. {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_git.py +0 -0
  27. {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_heuristics.py +0 -0
  28. {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_run_apply.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-explain
3
- Version: 2.5.0
3
+ Version: 2.5.1
4
4
  Summary: CLI that suggests git add/commit from diffs using Gemini
5
5
  Author: nazarli-shabnam
6
6
  Author-email: shabnamnezerli@gmail.com
@@ -0,0 +1 @@
1
+ __version__ = "2.5.1"
@@ -413,93 +413,63 @@ def main(
413
413
  )
414
414
 
415
415
 
416
- def run(
417
- cwd: Path | None = None,
418
- auto: bool = False,
419
- staged_only: bool = False,
420
- model: str | None = None,
421
- with_diff: bool = False,
422
- suggest: bool = False,
416
+ def _handle_suggest_only_mode(
417
+ *,
418
+ changes: list[Change],
419
+ has_commits: bool | None,
420
+ ai_model: str | None,
421
+ repo_root: Path,
423
422
  ) -> None:
424
- console.print(Text("git-explain", style="bold"))
425
-
426
- try:
427
- combined, repo_root = get_combined_diff(cwd=cwd)
428
- except RuntimeError as e:
429
- console.print(f"[red]Error:[/red] {e}")
423
+ """Implements --suggest: print one ``git commit -m "..."`` line and exit."""
424
+ staged_changes = [c for c in changes if "Staged" in c.sections]
425
+ if not staged_changes:
426
+ console.print(
427
+ "[yellow]Warning:[/yellow] --suggest requires staged changes. "
428
+ "Stage files first (git add ...), then run --suggest again."
429
+ )
430
430
  raise typer.Exit(1)
431
-
432
- repo_env = repo_root / ".env"
433
- if repo_env.is_file():
434
- _load_ai_env_from_dotenv(repo_env)
435
-
436
- if not combined.strip():
437
- console.print("[yellow]No staged, unstaged, or untracked changes.[/yellow]")
438
- return
439
- ai_model = _resolve_project_ai_model(repo_env, model)
440
- if repo_env.is_file():
441
- _load_ai_env_from_dotenv(repo_env)
442
- has_commits, changes = _parse_combined(combined)
443
- console.print(Panel(combined, title="Changed files", border_style="dim"))
444
-
445
- if suggest:
446
- staged_changes = [c for c in changes if "Staged" in c.sections]
447
- if not staged_changes:
448
- console.print(
449
- "[yellow]Warning:[/yellow] --suggest requires staged changes. "
450
- "Stage files first (git add ...), then run --suggest again."
451
- )
452
- raise typer.Exit(1)
453
- selected_pairs = [(ch.status, ch.path) for ch in staged_changes]
454
- payload = _render_combined(has_commits, selected_pairs, title="Staged")
455
- paths = [p for _, p in selected_pairs]
456
- staged_diff = get_staged_diff_for_paths(paths, cwd=repo_root)
457
- if staged_diff:
458
- payload = payload + "\n\n## Diff\n" + staged_diff
459
- infer_diff = (
460
- staged_diff[:_DIFF_INFER_MAX_CHARS]
461
- if len(staged_diff) > _DIFF_INFER_MAX_CHARS
462
- else staged_diff
431
+ selected_pairs = [(ch.status, ch.path) for ch in staged_changes]
432
+ payload = _render_combined(has_commits, selected_pairs, title="Staged")
433
+ paths = [p for _, p in selected_pairs]
434
+ staged_diff = get_staged_diff_for_paths(paths, cwd=repo_root)
435
+ if staged_diff:
436
+ payload = payload + "\n\n## Diff\n" + staged_diff
437
+ infer_diff = (
438
+ staged_diff[:_DIFF_INFER_MAX_CHARS]
439
+ if len(staged_diff) > _DIFF_INFER_MAX_CHARS
440
+ else staged_diff
441
+ )
442
+ if not ai_model:
443
+ console.print(
444
+ "[red]Error:[/red] --suggest requires an AI model. "
445
+ "Set AI_MODEL in repo .env or pass --model."
463
446
  )
464
- if not ai_model:
465
- console.print(
466
- "[red]Error:[/red] --suggest requires an AI model. "
467
- "Set AI_MODEL in repo .env or pass --model."
468
- )
469
- raise typer.Exit(1)
470
- try:
471
- sug, _raw = suggest_commands(
472
- payload,
473
- model=ai_model,
474
- with_diff=bool(staged_diff),
475
- unified_diff_for_infer=infer_diff,
476
- fallback_notifier=_gemini_fallback_notifier(),
477
- )
478
- if sug is None:
479
- raise RuntimeError("Could not parse AI suggestion.")
480
- except Exception as e:
481
- console.print(
482
- f"[red]Error:[/red] --suggest requires AI and failed to get a suggestion: {e}"
483
- )
484
- raise typer.Exit(1)
485
-
486
- cmsg = normalize_commit_subject_for_dash_m(sug.commit_message)
487
- full = format_commit_message(
488
- sug.commit_type, cmsg, scope=sug.scope, breaking=sug.breaking
447
+ raise typer.Exit(1)
448
+ try:
449
+ sug, _raw = suggest_commands(
450
+ payload,
451
+ model=ai_model,
452
+ with_diff=bool(staged_diff),
453
+ unified_diff_for_infer=infer_diff,
454
+ fallback_notifier=_gemini_fallback_notifier(),
489
455
  )
490
- print(f'git commit -m "{full}"')
491
- return
492
-
493
- if staged_only:
494
- changes = [c for c in changes if "Staged" in c.sections]
456
+ if sug is None:
457
+ raise RuntimeError("Could not parse AI suggestion.")
458
+ except Exception as e:
495
459
  console.print(
496
- "[dim]Note:[/dim] staged-only mode: only already-staged files are selectable."
460
+ f"[red]Error:[/red] --suggest requires AI and failed to get a suggestion: {e}"
497
461
  )
462
+ raise typer.Exit(1)
498
463
 
499
- if not changes:
500
- console.print("[yellow]No selectable changes found.[/yellow]")
501
- return
464
+ cmsg = normalize_commit_subject_for_dash_m(sug.commit_message)
465
+ full = format_commit_message(
466
+ sug.commit_type, cmsg, scope=sug.scope, breaking=sug.breaking
467
+ )
468
+ print(f'git commit -m "{full}"')
502
469
 
470
+
471
+ def _select_files(changes: list[Change]) -> list[Change] | None:
472
+ """Interactive file-selection UI. Returns None (after printing why) if nothing usable was selected."""
503
473
  norm_paths = [c.path.replace("\\", "/") for c in changes]
504
474
  display_items: list[tuple[str, list[int]]] = []
505
475
  for idx, ch in enumerate(changes):
@@ -518,7 +488,7 @@ def run(
518
488
  picks, path_tokens = _parse_selection(selection, len(display_items))
519
489
  if not picks and not path_tokens:
520
490
  console.print("[yellow]No files selected.[/yellow]")
521
- return
491
+ return None
522
492
 
523
493
  selected_indices: set[int] = set()
524
494
  for display_idx in picks:
@@ -534,233 +504,360 @@ def run(
534
504
 
535
505
  if not selected_indices:
536
506
  console.print("[yellow]No files matched your selection.[/yellow]")
537
- return
507
+ return None
538
508
 
539
- selected = [changes[i] for i in sorted(selected_indices)]
540
- if not staged_only:
541
- risky = [
542
- c for c in selected if ("Staged" in c.sections and "Unstaged" in c.sections)
543
- ]
544
- if risky:
545
- msg = "\n".join([f"- {c.path}" for c in risky])
546
- console.print(
547
- Panel(
548
- "These files have both staged and unstaged changes.\n"
549
- "If you apply, git-explain will stage the whole file, which can override partial staging.\n\n"
550
- + msg
551
- + "\n\nTip: re-run with --staged-only to commit only what's already staged.",
552
- title="Warning: partial staging",
553
- border_style="yellow",
554
- )
555
- )
556
- cont = typer.prompt("Continue anyway? (y/n)", default="n").strip().lower()
557
- if cont not in ("y", "yes"):
558
- return
559
-
560
- def suggest_for(
561
- change_items: list[tuple[str, str]], title: str
562
- ) -> tuple[Suggestion, str | None]:
563
- """Return (suggestion, ai_fallback_reason)."""
564
- paths_for_infer = [p for _, p in change_items]
565
- infer_diff: str | None = None
566
- if paths_for_infer:
567
- raw_d = get_diff_for_paths(paths_for_infer, cwd=repo_root)
568
- if raw_d.strip():
569
- infer_diff = (
570
- raw_d[:_DIFF_INFER_MAX_CHARS]
571
- if len(raw_d) > _DIFF_INFER_MAX_CHARS
572
- else raw_d
573
- )
509
+ return [changes[i] for i in sorted(selected_indices)]
574
510
 
575
- if ai_model:
576
- payload = _render_combined(has_commits, change_items, title=title)
577
- if with_diff:
578
- paths_for_diff = [p for _, p in change_items]
579
- diff_text = get_diff_for_paths(paths_for_diff, cwd=repo_root)
580
- if diff_text:
581
- payload = payload + "\n\n## Diff\n" + diff_text
582
- try:
583
- fb_label = title if mode == "split" else None
584
- sug, _raw = suggest_commands(
585
- payload,
586
- model=ai_model,
587
- with_diff=with_diff,
588
- unified_diff_for_infer=infer_diff,
589
- fallback_notifier=_gemini_fallback_notifier(fb_label),
590
- )
591
- if sug is None:
592
- raise RuntimeError("Could not parse AI suggestion.")
593
- return sug, None
594
- except _AI_CALL_ERRORS as e:
595
- h = suggest_from_changes(
596
- changes=change_items,
597
- has_commits=has_commits,
598
- diff_text=infer_diff,
599
- )
600
- return h, str(e)
601
- h = suggest_from_changes(
602
- changes=change_items,
603
- has_commits=has_commits,
604
- diff_text=infer_diff,
511
+
512
+ def _warn_if_partial_staging_risk(selected: list[Change], staged_only: bool) -> bool:
513
+ """Return False if the user declined to continue past a partial-staging warning."""
514
+ if staged_only:
515
+ return True
516
+ risky = [
517
+ c for c in selected if ("Staged" in c.sections and "Unstaged" in c.sections)
518
+ ]
519
+ if not risky:
520
+ return True
521
+ msg = "\n".join([f"- {c.path}" for c in risky])
522
+ console.print(
523
+ Panel(
524
+ "These files have both staged and unstaged changes.\n"
525
+ "If you apply, git-explain will stage the whole file, which can override partial staging.\n\n"
526
+ + msg
527
+ + "\n\nTip: re-run with --staged-only to commit only what's already staged.",
528
+ title="Warning: partial staging",
529
+ border_style="yellow",
605
530
  )
606
- return h, None
531
+ )
532
+ cont = typer.prompt("Continue anyway? (y/n)", default="n").strip().lower()
533
+ return cont in ("y", "yes")
607
534
 
608
- selected_pairs = [(ch.status, ch.path) for ch in selected]
609
- unique_paths = {p for _, p in selected_pairs}
610
535
 
611
- mode = "one"
612
- if len(unique_paths) > 1:
613
- if staged_only:
614
- console.print(
615
- "[dim]Note:[/dim] split commits are not available with --staged-only: "
616
- "each commit would need its own staging, but this mode skips git add. "
617
- "Using a single commit for everything currently staged."
536
+ def _suggest_for_group(
537
+ change_items: list[tuple[str, str]],
538
+ title: str,
539
+ *,
540
+ repo_root: Path,
541
+ has_commits: bool | None,
542
+ ai_model: str | None,
543
+ with_diff: bool,
544
+ mode: str,
545
+ ) -> tuple[Suggestion, str | None]:
546
+ """Return (suggestion, ai_fallback_reason)."""
547
+ paths_for_infer = [p for _, p in change_items]
548
+ infer_diff: str | None = None
549
+ if paths_for_infer:
550
+ raw_d = get_diff_for_paths(paths_for_infer, cwd=repo_root)
551
+ if raw_d.strip():
552
+ infer_diff = (
553
+ raw_d[:_DIFF_INFER_MAX_CHARS]
554
+ if len(raw_d) > _DIFF_INFER_MAX_CHARS
555
+ else raw_d
618
556
  )
619
- else:
620
- mode_input = (
621
- typer.prompt("Commit mode: one or split", default="one").strip().lower()
557
+
558
+ if ai_model:
559
+ payload = _render_combined(has_commits, change_items, title=title)
560
+ if with_diff:
561
+ paths_for_diff = [p for _, p in change_items]
562
+ diff_text = get_diff_for_paths(paths_for_diff, cwd=repo_root)
563
+ if diff_text:
564
+ payload = payload + "\n\n## Diff\n" + diff_text
565
+ try:
566
+ fb_label = title if mode == "split" else None
567
+ sug, _raw = suggest_commands(
568
+ payload,
569
+ model=ai_model,
570
+ with_diff=with_diff,
571
+ unified_diff_for_infer=infer_diff,
572
+ fallback_notifier=_gemini_fallback_notifier(fb_label),
622
573
  )
623
- if mode_input in ("one", "split"):
624
- mode = mode_input
574
+ if sug is None:
575
+ raise RuntimeError("Could not parse AI suggestion.")
576
+ return sug, None
577
+ except _AI_CALL_ERRORS as e:
578
+ h = suggest_from_changes(
579
+ changes=change_items,
580
+ has_commits=has_commits,
581
+ diff_text=infer_diff,
582
+ )
583
+ return h, str(e)
584
+ h = suggest_from_changes(
585
+ changes=change_items,
586
+ has_commits=has_commits,
587
+ diff_text=infer_diff,
588
+ )
589
+ return h, None
590
+
625
591
 
592
+ def _determine_commit_mode(unique_paths: set[str], staged_only: bool) -> str:
593
+ if len(unique_paths) <= 1:
594
+ return "one"
595
+ if staged_only:
596
+ console.print(
597
+ "[dim]Note:[/dim] split commits are not available with --staged-only: "
598
+ "each commit would need its own staging, but this mode skips git add. "
599
+ "Using a single commit for everything currently staged."
600
+ )
601
+ return "one"
602
+ mode_input = (
603
+ typer.prompt("Commit mode: one or split", default="one").strip().lower()
604
+ )
605
+ return mode_input if mode_input in ("one", "split") else "one"
606
+
607
+
608
+ def _build_commit_plan(
609
+ selected_pairs: list[tuple[str, str]],
610
+ *,
611
+ mode: str,
612
+ repo_root: Path,
613
+ has_commits: bool | None,
614
+ ai_model: str | None,
615
+ with_diff: bool,
616
+ ) -> tuple[list[tuple[str, Suggestion]], list[tuple[str, str]]]:
626
617
  plan: list[tuple[str, Suggestion]] = []
627
618
  ai_fallback_notes: list[tuple[str, str]] = []
628
619
  if mode == "split":
629
620
  groups = _group_changes(selected_pairs)
630
621
  for gname, items in groups.items():
631
- sug, fb = suggest_for(items, title=gname.capitalize())
622
+ sug, fb = _suggest_for_group(
623
+ items,
624
+ title=gname.capitalize(),
625
+ repo_root=repo_root,
626
+ has_commits=has_commits,
627
+ ai_model=ai_model,
628
+ with_diff=with_diff,
629
+ mode=mode,
630
+ )
632
631
  plan.append((gname, sug))
633
632
  if fb:
634
633
  ai_fallback_notes.append((gname, fb))
635
634
  else:
636
- sug, fb = suggest_for(selected_pairs, title="Selected")
635
+ sug, fb = _suggest_for_group(
636
+ selected_pairs,
637
+ title="Selected",
638
+ repo_root=repo_root,
639
+ has_commits=has_commits,
640
+ ai_model=ai_model,
641
+ with_diff=with_diff,
642
+ mode=mode,
643
+ )
637
644
  plan.append(("one", sug))
638
645
  if fb:
639
646
  ai_fallback_notes.append(("", fb))
647
+ return plan, ai_fallback_notes
640
648
 
641
- if ai_model and ai_fallback_notes:
642
- key_help = "Check AI_API_KEY, AI_MODEL, AI_MODEL_FALLBACKS, quota/model availability, and network."
643
- lines = [
644
- "[bold]Configured AI was not used for the suggestion below.[/bold]",
645
- "Commit message(s) come from [bold]local heuristics[/bold] instead.",
646
- "",
647
- ]
648
- if mode == "split":
649
- for gname, reason in ai_fallback_notes:
650
- lines.append(f"[dim]{gname}:[/dim] {reason}")
651
- else:
652
- lines.append(ai_fallback_notes[0][1])
653
- lines.append("")
654
- lines.append(f"[dim]{key_help}[/dim]")
655
- console.print(
656
- Panel(
657
- "\n".join(lines),
658
- title="[yellow]Warning: AI unavailable[/yellow]",
659
- border_style="yellow",
660
- )
649
+
650
+ def _print_ai_fallback_warning(
651
+ mode: str, ai_fallback_notes: list[tuple[str, str]]
652
+ ) -> None:
653
+ key_help = "Check AI_API_KEY, AI_MODEL, AI_MODEL_FALLBACKS, quota/model availability, and network."
654
+ lines = [
655
+ "[bold]Configured AI was not used for the suggestion below.[/bold]",
656
+ "Commit message(s) come from [bold]local heuristics[/bold] instead.",
657
+ "",
658
+ ]
659
+ if mode == "split":
660
+ for gname, reason in ai_fallback_notes:
661
+ lines.append(f"[dim]{gname}:[/dim] {reason}")
662
+ else:
663
+ lines.append(ai_fallback_notes[0][1])
664
+ lines.append("")
665
+ lines.append(f"[dim]{key_help}[/dim]")
666
+ console.print(
667
+ Panel(
668
+ "\n".join(lines),
669
+ title="[yellow]Warning: AI unavailable[/yellow]",
670
+ border_style="yellow",
661
671
  )
672
+ )
662
673
 
663
- def _render_plan(pl: list[tuple[str, Suggestion]]) -> str:
664
- rendered: list[str] = []
665
- for name, sug in pl:
666
- add_line = "git add -A -- " + " ".join(_ps_quote(p) for p in sug.add_args)
667
- subj = normalize_commit_subject_for_dash_m(sug.commit_message)
668
- full = format_commit_message(
669
- sug.commit_type, subj, scope=sug.scope, breaking=sug.breaking
670
- )
671
- commit_line = f'git commit -m "{full}"'
672
- rendered.append(f"### {name}\n{add_line}\n{commit_line}")
673
- return "\n\n".join(rendered)
674
674
 
675
+ def _render_plan(pl: list[tuple[str, Suggestion]]) -> str:
676
+ rendered: list[str] = []
677
+ for name, sug in pl:
678
+ add_line = "git add -A -- " + " ".join(_ps_quote(p) for p in sug.add_args)
679
+ subj = normalize_commit_subject_for_dash_m(sug.commit_message)
680
+ full = format_commit_message(
681
+ sug.commit_type, subj, scope=sug.scope, breaking=sug.breaking
682
+ )
683
+ commit_line = f'git commit -m "{full}"'
684
+ rendered.append(f"### {name}\n{add_line}\n{commit_line}")
685
+ return "\n\n".join(rendered)
686
+
687
+
688
+ def _maybe_edit_plan(
689
+ plan: list[tuple[str, Suggestion]], auto: bool
690
+ ) -> list[tuple[str, Suggestion]]:
691
+ if auto:
692
+ return plan
693
+ edit_choice = (
694
+ typer.prompt(
695
+ "Edit commit message(s) before applying? (y/n)",
696
+ default="n",
697
+ )
698
+ .strip()
699
+ .lower()
700
+ )
701
+ if edit_choice not in ("y", "yes"):
702
+ return plan
703
+ updated: list[tuple[str, Suggestion]] = []
704
+ for name, sug in plan:
705
+ current = format_commit_message(
706
+ sug.commit_type,
707
+ sug.commit_message,
708
+ scope=sug.scope,
709
+ breaking=sug.breaking,
710
+ )
711
+ console.print(f"[dim]{name}:[/dim] current message: [bold]{current}[/bold]")
712
+ try:
713
+ from prompt_toolkit import prompt as pt_prompt
714
+
715
+ new_msg = (
716
+ pt_prompt(
717
+ "New commit message (subject only, type/scope added automatically): ",
718
+ default=sug.commit_message,
719
+ ).strip()
720
+ or sug.commit_message
721
+ )
722
+ except Exception:
723
+ new_msg = (
724
+ typer.prompt(
725
+ "New commit message (subject only, type/scope added automatically)",
726
+ default=sug.commit_message,
727
+ ).strip()
728
+ ) or sug.commit_message
729
+ updated.append((name, replace(sug, commit_message=new_msg)))
675
730
  console.print(
676
731
  Panel(
677
- _render_plan(plan),
678
- title="Suggested commands",
732
+ _render_plan(updated),
733
+ title="Updated commands",
679
734
  border_style="green",
680
735
  )
681
736
  )
737
+ return updated
682
738
 
683
- if not auto:
684
- edit_choice = (
685
- typer.prompt(
686
- "Edit commit message(s) before applying? (y/n)",
687
- default="n",
739
+
740
+ def _confirm_apply(plan: list[tuple[str, Suggestion]], auto: bool) -> bool:
741
+ if auto:
742
+ return True
743
+ prompt = (
744
+ "Apply these commit(s)? (y/n)"
745
+ if len(plan) > 1
746
+ else "Apply these commands? (y/n)"
747
+ )
748
+ choice = typer.prompt(prompt, default="y").strip().lower()
749
+ return choice in ("y", "yes")
750
+
751
+
752
+ def _apply_plan(
753
+ plan: list[tuple[str, Suggestion]], repo_root: Path, staged_only: bool
754
+ ) -> None:
755
+ for name, sug in plan:
756
+ try:
757
+ apply_commands(
758
+ repo_root,
759
+ [] if staged_only else sug.add_args,
760
+ sug.commit_type,
761
+ sug.commit_message,
762
+ scope=sug.scope,
763
+ body=sug.body,
764
+ breaking=sug.breaking,
765
+ staged_only=staged_only,
688
766
  )
689
- .strip()
690
- .lower()
767
+ console.print(f"[green]Commit created ({name}).[/green]")
768
+ except subprocess.CalledProcessError as e:
769
+ console.print("[red]git command failed.[/red]")
770
+ console.print(f"[dim]Command:[/dim] {e.cmd}")
771
+ if e.stdout:
772
+ console.print(e.stdout)
773
+ if e.stderr:
774
+ console.print(e.stderr)
775
+ raise typer.Exit(1)
776
+ except RuntimeError as e:
777
+ console.print(f"[red]Error:[/red] {e}")
778
+ raise typer.Exit(1)
779
+
780
+
781
+ def run(
782
+ cwd: Path | None = None,
783
+ auto: bool = False,
784
+ staged_only: bool = False,
785
+ model: str | None = None,
786
+ with_diff: bool = False,
787
+ suggest: bool = False,
788
+ ) -> None:
789
+ console.print(Text("git-explain", style="bold"))
790
+
791
+ try:
792
+ combined, repo_root = get_combined_diff(cwd=cwd)
793
+ except RuntimeError as e:
794
+ console.print(f"[red]Error:[/red] {e}")
795
+ raise typer.Exit(1)
796
+
797
+ repo_env = repo_root / ".env"
798
+ if repo_env.is_file():
799
+ _load_ai_env_from_dotenv(repo_env)
800
+
801
+ if not combined.strip():
802
+ console.print("[yellow]No staged, unstaged, or untracked changes.[/yellow]")
803
+ return
804
+ ai_model = _resolve_project_ai_model(repo_env, model)
805
+ if repo_env.is_file():
806
+ _load_ai_env_from_dotenv(repo_env)
807
+ has_commits, changes = _parse_combined(combined)
808
+ console.print(Panel(combined, title="Changed files", border_style="dim"))
809
+
810
+ if suggest:
811
+ _handle_suggest_only_mode(
812
+ changes=changes,
813
+ has_commits=has_commits,
814
+ ai_model=ai_model,
815
+ repo_root=repo_root,
691
816
  )
692
- if edit_choice in ("y", "yes"):
693
- updated: list[tuple[str, Suggestion]] = []
694
- for name, sug in plan:
695
- current = format_commit_message(
696
- sug.commit_type,
697
- sug.commit_message,
698
- scope=sug.scope,
699
- breaking=sug.breaking,
700
- )
701
- console.print(
702
- f"[dim]{name}:[/dim] current message: [bold]{current}[/bold]"
703
- )
704
- try:
705
- from prompt_toolkit import prompt as pt_prompt
706
-
707
- new_msg = (
708
- pt_prompt(
709
- "New commit message (subject only, type/scope added automatically): ",
710
- default=sug.commit_message,
711
- ).strip()
712
- or sug.commit_message
713
- )
714
- except Exception:
715
- new_msg = (
716
- typer.prompt(
717
- "New commit message (subject only, type/scope added automatically)",
718
- default=sug.commit_message,
719
- ).strip()
720
- ) or sug.commit_message
721
- updated.append((name, replace(sug, commit_message=new_msg)))
722
- plan = updated
723
- console.print(
724
- Panel(
725
- _render_plan(plan),
726
- title="Updated commands",
727
- border_style="green",
728
- )
729
- )
817
+ return
730
818
 
731
- if auto:
732
- do_apply = True
733
- else:
734
- prompt = (
735
- "Apply these commit(s)? (y/n)"
736
- if len(plan) > 1
737
- else "Apply these commands? (y/n)"
819
+ if staged_only:
820
+ changes = [c for c in changes if "Staged" in c.sections]
821
+ console.print(
822
+ "[dim]Note:[/dim] staged-only mode: only already-staged files are selectable."
738
823
  )
739
- choice = typer.prompt(prompt, default="y").strip().lower()
740
- do_apply = choice in ("y", "yes")
741
824
 
742
- if do_apply:
743
- for name, sug in plan:
744
- try:
745
- apply_commands(
746
- repo_root,
747
- [] if staged_only else sug.add_args,
748
- sug.commit_type,
749
- sug.commit_message,
750
- scope=sug.scope,
751
- body=sug.body,
752
- breaking=sug.breaking,
753
- staged_only=staged_only,
754
- )
755
- console.print(f"[green]Commit created ({name}).[/green]")
756
- except subprocess.CalledProcessError as e:
757
- console.print("[red]git command failed.[/red]")
758
- console.print(f"[dim]Command:[/dim] {e.cmd}")
759
- if e.stdout:
760
- console.print(e.stdout)
761
- if e.stderr:
762
- console.print(e.stderr)
763
- raise typer.Exit(1)
764
- except RuntimeError as e:
765
- console.print(f"[red]Error:[/red] {e}")
766
- raise typer.Exit(1)
825
+ if not changes:
826
+ console.print("[yellow]No selectable changes found.[/yellow]")
827
+ return
828
+
829
+ selected = _select_files(changes)
830
+ if selected is None:
831
+ return
832
+
833
+ if not _warn_if_partial_staging_risk(selected, staged_only):
834
+ return
835
+
836
+ selected_pairs = [(ch.status, ch.path) for ch in selected]
837
+ unique_paths = {p for _, p in selected_pairs}
838
+ mode = _determine_commit_mode(unique_paths, staged_only)
839
+
840
+ plan, ai_fallback_notes = _build_commit_plan(
841
+ selected_pairs,
842
+ mode=mode,
843
+ repo_root=repo_root,
844
+ has_commits=has_commits,
845
+ ai_model=ai_model,
846
+ with_diff=with_diff,
847
+ )
848
+
849
+ if ai_model and ai_fallback_notes:
850
+ _print_ai_fallback_warning(mode, ai_fallback_notes)
851
+
852
+ console.print(
853
+ Panel(
854
+ _render_plan(plan),
855
+ title="Suggested commands",
856
+ border_style="green",
857
+ )
858
+ )
859
+
860
+ plan = _maybe_edit_plan(plan, auto)
861
+
862
+ if _confirm_apply(plan, auto):
863
+ _apply_plan(plan, repo_root, staged_only)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-explain
3
- Version: 2.5.0
3
+ Version: 2.5.1
4
4
  Summary: CLI that suggests git add/commit from diffs using Gemini
5
5
  Author: nazarli-shabnam
6
6
  Author-email: shabnamnezerli@gmail.com
@@ -1 +0,0 @@
1
- __version__ = "2.5.0"
File without changes
File without changes
File without changes
File without changes