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.
- {git_explain-2.5.0 → git_explain-2.5.1}/PKG-INFO +1 -1
- git_explain-2.5.1/git_explain/__init__.py +1 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/cli.py +372 -275
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain.egg-info/PKG-INFO +1 -1
- git_explain-2.5.0/git_explain/__init__.py +0 -1
- {git_explain-2.5.0 → git_explain-2.5.1}/LICENSE +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/README.md +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/__main__.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/commit_infer.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/gemini.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/git.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/heuristics.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/path_topics.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain/run.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain.egg-info/SOURCES.txt +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain.egg-info/dependency_links.txt +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain.egg-info/entry_points.txt +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain.egg-info/requires.txt +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/git_explain.egg-info/top_level.txt +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/pyproject.toml +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/setup.cfg +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_cli_utils.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_commit_infer.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_gemini.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_gemini_suggest_commands.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_git.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_heuristics.py +0 -0
- {git_explain-2.5.0 → git_explain-2.5.1}/tests/test_run_apply.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.5.1"
|
|
@@ -413,93 +413,63 @@ def main(
|
|
|
413
413
|
)
|
|
414
414
|
|
|
415
415
|
|
|
416
|
-
def
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
"[
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
624
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
]
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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(
|
|
678
|
-
title="
|
|
732
|
+
_render_plan(updated),
|
|
733
|
+
title="Updated commands",
|
|
679
734
|
border_style="green",
|
|
680
735
|
)
|
|
681
736
|
)
|
|
737
|
+
return updated
|
|
682
738
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
.
|
|
690
|
-
|
|
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
|
-
|
|
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
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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 +0,0 @@
|
|
|
1
|
-
__version__ = "2.5.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|