git-copilot-commit 0.4.5__py3-none-any.whl → 0.5.0__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.
- git_copilot_commit/cli.py +821 -123
- git_copilot_commit/git.py +144 -21
- git_copilot_commit/github_copilot.py +282 -42
- git_copilot_commit/prompts/commit-message-generator-prompt.md +2 -0
- git_copilot_commit/prompts/split-commit-planner-prompt.md +41 -0
- git_copilot_commit/settings.py +21 -0
- git_copilot_commit/split_commits.py +503 -0
- {git_copilot_commit-0.4.5.dist-info → git_copilot_commit-0.5.0.dist-info}/METADATA +17 -2
- git_copilot_commit-0.5.0.dist-info/RECORD +15 -0
- git_copilot_commit-0.5.0.dist-info/entry_points.txt +2 -0
- git_copilot_commit-0.4.5.dist-info/RECORD +0 -13
- git_copilot_commit-0.4.5.dist-info/entry_points.txt +0 -2
- {git_copilot_commit-0.4.5.dist-info → git_copilot_commit-0.5.0.dist-info}/WHEEL +0 -0
- {git_copilot_commit-0.4.5.dist-info → git_copilot_commit-0.5.0.dist-info}/licenses/LICENSE +0 -0
git_copilot_commit/cli.py
CHANGED
|
@@ -2,15 +2,32 @@
|
|
|
2
2
|
git-copilot-commit - AI-powered Git commit assistant
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from dataclasses import dataclass
|
|
5
6
|
from pathlib import Path
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Annotated, Sequence
|
|
6
10
|
|
|
7
11
|
import rich
|
|
8
12
|
import typer
|
|
9
13
|
from rich.console import Console
|
|
10
14
|
from rich.panel import Panel
|
|
11
15
|
from rich.prompt import Confirm
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
from typer.main import get_command
|
|
17
|
+
|
|
18
|
+
from .git import GitRepository, GitError, GitStatus, NotAGitRepositoryError
|
|
19
|
+
from .split_commits import (
|
|
20
|
+
PatchUnit,
|
|
21
|
+
SplitCommitPlan,
|
|
22
|
+
SplitCommitLimitExceededError,
|
|
23
|
+
SplitPlanningError,
|
|
24
|
+
build_split_plan_prompt,
|
|
25
|
+
build_status_for_patch_units,
|
|
26
|
+
evaluate_auto_split,
|
|
27
|
+
extract_patch_units,
|
|
28
|
+
group_patch_units,
|
|
29
|
+
parse_split_plan_response,
|
|
30
|
+
)
|
|
14
31
|
from .settings import Settings
|
|
15
32
|
from .version import __version__
|
|
16
33
|
from . import github_copilot
|
|
@@ -18,6 +35,129 @@ from . import github_copilot
|
|
|
18
35
|
console = Console()
|
|
19
36
|
app = typer.Typer(help=__doc__, add_completion=False)
|
|
20
37
|
|
|
38
|
+
COMMIT_MESSAGE_PROMPT_FILENAME = "commit-message-generator-prompt.md"
|
|
39
|
+
SPLIT_COMMIT_PLANNER_PROMPT_FILENAME = "split-commit-planner-prompt.md"
|
|
40
|
+
DEFAULT_AUTO_MAX_COMMITS = 10
|
|
41
|
+
SPLIT_DIFF_ARGS = [
|
|
42
|
+
"--binary",
|
|
43
|
+
"--full-index",
|
|
44
|
+
"--find-renames",
|
|
45
|
+
"--no-color",
|
|
46
|
+
"--no-ext-diff",
|
|
47
|
+
"--src-prefix=a/",
|
|
48
|
+
"--dst-prefix=b/",
|
|
49
|
+
"--unified=3",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
CA_BUNDLE_HELP = "Path to a custom CA bundle (PEM)"
|
|
53
|
+
NATIVE_TLS_HELP = (
|
|
54
|
+
"Use the OS's native certificate store via 'truststore' for httpx instead of "
|
|
55
|
+
"the Python bundle. Ignored if --ca-bundle or --insecure is used."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
CaBundleOption = Annotated[
|
|
59
|
+
str | None,
|
|
60
|
+
typer.Option("--ca-bundle", metavar="PATH", help=CA_BUNDLE_HELP),
|
|
61
|
+
]
|
|
62
|
+
InsecureOption = Annotated[
|
|
63
|
+
bool,
|
|
64
|
+
typer.Option("--insecure", help="Disable SSL certificate verification."),
|
|
65
|
+
]
|
|
66
|
+
NativeTlsOption = Annotated[
|
|
67
|
+
bool,
|
|
68
|
+
typer.Option("--native-tls/--no-native-tls", help=NATIVE_TLS_HELP),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
SplitOption = Annotated[
|
|
73
|
+
bool,
|
|
74
|
+
typer.Option(
|
|
75
|
+
"--split",
|
|
76
|
+
help=(
|
|
77
|
+
"Split staged hunks into multiple commits automatically. Pass "
|
|
78
|
+
"`--split=N` to prefer up to N commits."
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
]
|
|
82
|
+
SplitCountOption = Annotated[
|
|
83
|
+
int | None,
|
|
84
|
+
typer.Option(
|
|
85
|
+
"--split-count",
|
|
86
|
+
hidden=True,
|
|
87
|
+
min=1,
|
|
88
|
+
),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class PreparedSplitCommit:
|
|
94
|
+
"""A split commit with its generated message and assigned patch units."""
|
|
95
|
+
|
|
96
|
+
message: str
|
|
97
|
+
patch_units: tuple[PatchUnit, ...]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def preprocess_cli_args(args: Sequence[str]) -> list[str]:
|
|
101
|
+
"""Normalize CLI arguments before Click parses them."""
|
|
102
|
+
processed_args: list[str] = []
|
|
103
|
+
in_commit_command = False
|
|
104
|
+
index = 0
|
|
105
|
+
|
|
106
|
+
while index < len(args):
|
|
107
|
+
arg = args[index]
|
|
108
|
+
|
|
109
|
+
if not in_commit_command and not arg.startswith("-"):
|
|
110
|
+
processed_args.append(arg)
|
|
111
|
+
if arg == "commit":
|
|
112
|
+
in_commit_command = True
|
|
113
|
+
index += 1
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if in_commit_command and arg.startswith("--split="):
|
|
117
|
+
split_value = arg.split("=", 1)[1].strip().lower()
|
|
118
|
+
if split_value == "auto":
|
|
119
|
+
processed_args.append("--split")
|
|
120
|
+
index += 1
|
|
121
|
+
continue
|
|
122
|
+
if split_value.isdigit():
|
|
123
|
+
processed_args.extend(["--split-count", split_value])
|
|
124
|
+
index += 1
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
processed_args.append(arg)
|
|
128
|
+
index += 1
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
in_commit_command
|
|
133
|
+
and arg == "--split"
|
|
134
|
+
and index + 1 < len(args)
|
|
135
|
+
):
|
|
136
|
+
split_value = args[index + 1].strip().lower()
|
|
137
|
+
if split_value == "auto":
|
|
138
|
+
processed_args.append("--split")
|
|
139
|
+
index += 2
|
|
140
|
+
continue
|
|
141
|
+
if split_value.isdigit():
|
|
142
|
+
processed_args.extend(["--split-count", split_value])
|
|
143
|
+
index += 2
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
processed_args.append(arg)
|
|
147
|
+
index += 1
|
|
148
|
+
|
|
149
|
+
return processed_args
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def run(args: Sequence[str] | None = None) -> None:
|
|
153
|
+
"""Run the CLI entrypoint with argument normalization."""
|
|
154
|
+
raw_args = list(args) if args is not None else sys.argv[1:]
|
|
155
|
+
command = get_command(app)
|
|
156
|
+
command.main(
|
|
157
|
+
args=preprocess_cli_args(raw_args),
|
|
158
|
+
prog_name=Path(sys.argv[0]).name,
|
|
159
|
+
)
|
|
160
|
+
|
|
21
161
|
|
|
22
162
|
def version_callback(value: bool):
|
|
23
163
|
if value:
|
|
@@ -47,12 +187,10 @@ def main(
|
|
|
47
187
|
)
|
|
48
188
|
|
|
49
189
|
|
|
50
|
-
def get_prompt_locations():
|
|
190
|
+
def get_prompt_locations(filename: str):
|
|
51
191
|
"""Get potential prompt file locations in order of preference."""
|
|
52
192
|
import importlib.resources
|
|
53
193
|
|
|
54
|
-
filename = "commit-message-generator-prompt.md"
|
|
55
|
-
|
|
56
194
|
return [
|
|
57
195
|
Path(Settings().data_dir) / "prompts" / filename, # User customizable
|
|
58
196
|
importlib.resources.files("git_copilot_commit")
|
|
@@ -61,37 +199,84 @@ def get_prompt_locations():
|
|
|
61
199
|
]
|
|
62
200
|
|
|
63
201
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
202
|
+
def resolve_prompt_file() -> Path | None:
|
|
203
|
+
settings = Settings()
|
|
204
|
+
try:
|
|
205
|
+
configured_prompt_file = settings.default_prompt_file
|
|
206
|
+
except ValueError:
|
|
207
|
+
console.print(
|
|
208
|
+
f"[red]Configured default prompt file in {settings.config_file} is invalid.[/red]"
|
|
209
|
+
)
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
|
|
212
|
+
if configured_prompt_file is None:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
return Path(configured_prompt_file).expanduser()
|
|
73
216
|
|
|
74
217
|
|
|
75
218
|
def load_system_prompt() -> str:
|
|
76
219
|
"""Load the system prompt from the markdown file."""
|
|
77
|
-
|
|
220
|
+
resolved_prompt_file = resolve_prompt_file()
|
|
221
|
+
if resolved_prompt_file is not None:
|
|
222
|
+
try:
|
|
223
|
+
return resolved_prompt_file.read_text(encoding="utf-8")
|
|
224
|
+
except OSError as exc:
|
|
225
|
+
console.print(
|
|
226
|
+
f"[red]Error reading prompt file {resolved_prompt_file}: {exc}[/red]"
|
|
227
|
+
)
|
|
228
|
+
raise typer.Exit(1)
|
|
229
|
+
|
|
230
|
+
return load_named_prompt(COMMIT_MESSAGE_PROMPT_FILENAME)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def load_named_prompt(filename: str) -> str:
|
|
234
|
+
"""Load a packaged prompt by filename, optionally overridden via user data."""
|
|
235
|
+
for path in get_prompt_locations(filename):
|
|
78
236
|
try:
|
|
79
237
|
return path.read_text(encoding="utf-8")
|
|
80
238
|
except (FileNotFoundError, AttributeError):
|
|
81
239
|
continue
|
|
82
240
|
|
|
83
|
-
console.print("[red]Error: Prompt file not found in any location[/red]")
|
|
241
|
+
console.print(f"[red]Error: Prompt file {filename} not found in any location[/red]")
|
|
84
242
|
raise typer.Exit(1)
|
|
85
243
|
|
|
86
244
|
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
245
|
+
def build_http_client_config(
|
|
246
|
+
*,
|
|
247
|
+
ca_bundle: str | None,
|
|
248
|
+
insecure: bool,
|
|
249
|
+
native_tls: bool,
|
|
250
|
+
) -> github_copilot.HttpClientConfig:
|
|
251
|
+
if ca_bundle is not None:
|
|
252
|
+
ca_bundle = os.path.expanduser(ca_bundle)
|
|
253
|
+
return github_copilot.HttpClientConfig(
|
|
254
|
+
native_tls=native_tls,
|
|
255
|
+
insecure=insecure,
|
|
256
|
+
ca_bundle=ca_bundle,
|
|
257
|
+
)
|
|
91
258
|
|
|
92
|
-
# Refresh status after staging
|
|
93
|
-
status = repo.get_status()
|
|
94
259
|
|
|
260
|
+
def print_copilot_error(message: str, exc: github_copilot.CopilotError) -> None:
|
|
261
|
+
"""Render Copilot errors, with rich formatting for model selection issues."""
|
|
262
|
+
if isinstance(exc, github_copilot.ModelSelectionError):
|
|
263
|
+
console.print(f"[red]{message}[/red]")
|
|
264
|
+
github_copilot.print_model_selection_error(exc)
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
console.print(f"[red]{message}: {exc}[/red]")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def display_selected_model(model: github_copilot.CopilotModel) -> None:
|
|
271
|
+
"""Show the resolved Copilot model for the current command."""
|
|
272
|
+
details = [github_copilot.infer_api_surface(model)]
|
|
273
|
+
if model.vendor:
|
|
274
|
+
details.insert(0, model.vendor)
|
|
275
|
+
console.print(f"[green]Using model:[/green] {model.id} ({', '.join(details)})")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def build_commit_message_prompt(status: GitStatus, context: str = "") -> str:
|
|
279
|
+
"""Build the prompt used to generate a commit message."""
|
|
95
280
|
if not status.has_staged_changes:
|
|
96
281
|
console.print("[red]No staged changes to commit.[/red]")
|
|
97
282
|
raise typer.Exit()
|
|
@@ -106,36 +291,91 @@ def generate_commit_message(
|
|
|
106
291
|
if context.strip():
|
|
107
292
|
prompt_parts.insert(0, f"User-provided context:\n\n{context.strip()}\n\n")
|
|
108
293
|
|
|
109
|
-
|
|
294
|
+
return "\n".join(prompt_parts)
|
|
110
295
|
|
|
111
|
-
prompt = "\n".join(prompt_parts)
|
|
112
296
|
|
|
113
|
-
|
|
114
|
-
|
|
297
|
+
def normalize_model_name(model: str | None) -> str | None:
|
|
298
|
+
"""Normalize model names accepted by the CLI to Copilot API model ids."""
|
|
299
|
+
if model is not None and model.startswith("github_copilot/"):
|
|
300
|
+
return model.replace("github_copilot/", "", 1)
|
|
301
|
+
return model
|
|
115
302
|
|
|
116
|
-
if model.startswith("github_copilot/"):
|
|
117
|
-
model = model.replace("github_copilot/", "")
|
|
118
303
|
|
|
304
|
+
def ask_copilot_with_system_prompt(
|
|
305
|
+
system_prompt: str,
|
|
306
|
+
prompt: str,
|
|
307
|
+
model: str | None = None,
|
|
308
|
+
http_client_config: github_copilot.HttpClientConfig | None = None,
|
|
309
|
+
) -> str:
|
|
310
|
+
"""Send a prepared prompt to Copilot using the provided system prompt."""
|
|
119
311
|
return github_copilot.ask(
|
|
120
312
|
f"""
|
|
121
313
|
# System Prompt
|
|
122
314
|
|
|
123
|
-
{
|
|
315
|
+
{system_prompt}
|
|
124
316
|
|
|
125
317
|
# Prompt
|
|
126
318
|
|
|
127
319
|
{prompt}
|
|
128
320
|
""",
|
|
321
|
+
model=normalize_model_name(model),
|
|
322
|
+
http_client_config=http_client_config,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def generate_commit_message_for_prompt(
|
|
327
|
+
prompt: str,
|
|
328
|
+
model: str | None = None,
|
|
329
|
+
http_client_config: github_copilot.HttpClientConfig | None = None,
|
|
330
|
+
) -> str:
|
|
331
|
+
"""Generate a conventional commit message from a prepared prompt."""
|
|
332
|
+
return ask_copilot_with_system_prompt(
|
|
333
|
+
load_system_prompt(),
|
|
334
|
+
prompt,
|
|
335
|
+
model=model,
|
|
336
|
+
http_client_config=http_client_config,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def generate_commit_message_for_status(
|
|
341
|
+
status: GitStatus,
|
|
342
|
+
model: str | None = None,
|
|
343
|
+
context: str = "",
|
|
344
|
+
http_client_config: github_copilot.HttpClientConfig | None = None,
|
|
345
|
+
) -> str:
|
|
346
|
+
"""Generate a commit message for a staged status snapshot."""
|
|
347
|
+
prompt = build_commit_message_prompt(status, context=context)
|
|
348
|
+
return generate_commit_message_for_prompt(
|
|
349
|
+
prompt,
|
|
350
|
+
model=model,
|
|
351
|
+
http_client_config=http_client_config,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def generate_commit_message(
|
|
356
|
+
repo: GitRepository,
|
|
357
|
+
model: str | None = None,
|
|
358
|
+
context: str = "",
|
|
359
|
+
http_client_config: github_copilot.HttpClientConfig | None = None,
|
|
360
|
+
) -> str:
|
|
361
|
+
"""Generate a conventional commit message using the repository's staged diff."""
|
|
362
|
+
return generate_commit_message_for_status(
|
|
363
|
+
repo.get_status(),
|
|
129
364
|
model=model,
|
|
365
|
+
context=context,
|
|
366
|
+
http_client_config=http_client_config,
|
|
130
367
|
)
|
|
131
368
|
|
|
132
369
|
|
|
133
370
|
def commit_with_retry_no_verify(
|
|
134
|
-
repo: GitRepository,
|
|
371
|
+
repo: GitRepository,
|
|
372
|
+
message: str,
|
|
373
|
+
use_editor: bool = False,
|
|
374
|
+
env: dict[str, str] | None = None,
|
|
135
375
|
) -> str:
|
|
136
376
|
"""Run commit and offer one retry with -n on failure."""
|
|
137
377
|
try:
|
|
138
|
-
return repo.commit(message, use_editor=use_editor)
|
|
378
|
+
return repo.commit(message, use_editor=use_editor, env=env)
|
|
139
379
|
except GitError as e:
|
|
140
380
|
console.print(f"[red]Commit failed: {e}[/red]")
|
|
141
381
|
if not Confirm.ask(
|
|
@@ -145,24 +385,526 @@ def commit_with_retry_no_verify(
|
|
|
145
385
|
raise typer.Exit(1)
|
|
146
386
|
|
|
147
387
|
try:
|
|
148
|
-
return repo.commit(message, use_editor=use_editor, no_verify=True)
|
|
388
|
+
return repo.commit(message, use_editor=use_editor, no_verify=True, env=env)
|
|
149
389
|
except GitError as retry_error:
|
|
150
390
|
console.print(f"[red]Commit with -n failed: {retry_error}[/red]")
|
|
151
391
|
raise typer.Exit(1)
|
|
152
392
|
|
|
153
393
|
|
|
394
|
+
def ensure_copilot_authentication(
|
|
395
|
+
http_client_config: github_copilot.HttpClientConfig,
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Authenticate if no cached Copilot credentials are available."""
|
|
398
|
+
try:
|
|
399
|
+
existing_credentials = github_copilot.load_credentials()
|
|
400
|
+
except github_copilot.CopilotError:
|
|
401
|
+
existing_credentials = None
|
|
402
|
+
|
|
403
|
+
if existing_credentials is not None:
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
github_copilot.login(
|
|
408
|
+
force=True,
|
|
409
|
+
http_client_config=http_client_config,
|
|
410
|
+
)
|
|
411
|
+
except github_copilot.CopilotError as exc:
|
|
412
|
+
print_copilot_error("Authentication failed", exc)
|
|
413
|
+
raise typer.Exit(1)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def stage_changes_for_commit(
|
|
417
|
+
repo: GitRepository, status: GitStatus, all_files: bool
|
|
418
|
+
) -> GitStatus:
|
|
419
|
+
"""Stage changes according to the command options and return refreshed status."""
|
|
420
|
+
if all_files:
|
|
421
|
+
repo.stage_files()
|
|
422
|
+
console.print("[green]Staged all files.[/green]")
|
|
423
|
+
return repo.get_status()
|
|
424
|
+
|
|
425
|
+
if status.has_unstaged_changes or status.has_untracked_files:
|
|
426
|
+
git_status_output = repo._run_git_command(["status"])
|
|
427
|
+
console.print(git_status_output.stdout)
|
|
428
|
+
|
|
429
|
+
if status.has_unstaged_changes:
|
|
430
|
+
if Confirm.ask(
|
|
431
|
+
"Modified files found. Add [bold yellow]all unstaged changes[/] to staging?",
|
|
432
|
+
default=True,
|
|
433
|
+
):
|
|
434
|
+
repo.stage_modified()
|
|
435
|
+
console.print("[green]Staged modified files.[/green]")
|
|
436
|
+
|
|
437
|
+
if status.has_untracked_files:
|
|
438
|
+
if Confirm.ask(
|
|
439
|
+
"Untracked files found. Add [bold yellow]all untracked files and unstaged changes[/] to staging?",
|
|
440
|
+
default=True,
|
|
441
|
+
):
|
|
442
|
+
repo.stage_files()
|
|
443
|
+
console.print("[green]Staged untracked files.[/green]")
|
|
444
|
+
|
|
445
|
+
return repo.get_status()
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def request_commit_message(
|
|
449
|
+
status: GitStatus,
|
|
450
|
+
model: str | None = None,
|
|
451
|
+
context: str = "",
|
|
452
|
+
http_client_config: github_copilot.HttpClientConfig | None = None,
|
|
453
|
+
) -> str:
|
|
454
|
+
"""Request a commit message for the provided staged state."""
|
|
455
|
+
try:
|
|
456
|
+
with console.status(
|
|
457
|
+
"[yellow]Generating commit message based on [bold]`git diff --staged`[/] ...[/yellow]"
|
|
458
|
+
):
|
|
459
|
+
return generate_commit_message_for_status(
|
|
460
|
+
status,
|
|
461
|
+
model=model,
|
|
462
|
+
context=context,
|
|
463
|
+
http_client_config=http_client_config,
|
|
464
|
+
)
|
|
465
|
+
except github_copilot.CopilotError as exc:
|
|
466
|
+
print_copilot_error("Could not generate a commit message", exc)
|
|
467
|
+
raise typer.Exit(1)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def request_split_commit_plan(
|
|
471
|
+
status: GitStatus,
|
|
472
|
+
patch_units: tuple[PatchUnit, ...],
|
|
473
|
+
*,
|
|
474
|
+
max_commits: int,
|
|
475
|
+
preferred_commits: int | None = None,
|
|
476
|
+
model: str | None = None,
|
|
477
|
+
context: str = "",
|
|
478
|
+
http_client_config: github_copilot.HttpClientConfig | None = None,
|
|
479
|
+
) -> SplitCommitPlan:
|
|
480
|
+
"""Request and validate a split-commit plan for the staged patch units."""
|
|
481
|
+
try:
|
|
482
|
+
planner_prompt = build_split_plan_prompt(
|
|
483
|
+
status,
|
|
484
|
+
patch_units,
|
|
485
|
+
max_commits=max_commits,
|
|
486
|
+
preferred_commits=preferred_commits,
|
|
487
|
+
context=context,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
with console.status(
|
|
491
|
+
"[yellow]Planning split commits from [bold]staged hunks[/] ...[/yellow]"
|
|
492
|
+
):
|
|
493
|
+
response = ask_copilot_with_system_prompt(
|
|
494
|
+
load_named_prompt(SPLIT_COMMIT_PLANNER_PROMPT_FILENAME),
|
|
495
|
+
planner_prompt,
|
|
496
|
+
model=model,
|
|
497
|
+
http_client_config=http_client_config,
|
|
498
|
+
)
|
|
499
|
+
return parse_split_plan_response(
|
|
500
|
+
response,
|
|
501
|
+
patch_units,
|
|
502
|
+
max_commits=max_commits,
|
|
503
|
+
)
|
|
504
|
+
except github_copilot.CopilotError as exc:
|
|
505
|
+
print_copilot_error("Could not generate a split commit plan", exc)
|
|
506
|
+
raise typer.Exit(1)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def request_split_commit_messages(
|
|
510
|
+
plan: SplitCommitPlan,
|
|
511
|
+
patch_units: tuple[PatchUnit, ...],
|
|
512
|
+
*,
|
|
513
|
+
model: str | None = None,
|
|
514
|
+
context: str = "",
|
|
515
|
+
http_client_config: github_copilot.HttpClientConfig | None = None,
|
|
516
|
+
) -> list[PreparedSplitCommit]:
|
|
517
|
+
"""Generate commit messages for each planned split-commit group."""
|
|
518
|
+
try:
|
|
519
|
+
prepared_commits: list[PreparedSplitCommit] = []
|
|
520
|
+
grouped_units = group_patch_units(patch_units, plan)
|
|
521
|
+
total_commits = len(grouped_units)
|
|
522
|
+
|
|
523
|
+
for index, unit_group in enumerate(grouped_units, start=1):
|
|
524
|
+
with console.status(
|
|
525
|
+
f"[yellow]Generating commit message {index}/{total_commits} based on [bold]planned staged diff[/] ...[/yellow]"
|
|
526
|
+
):
|
|
527
|
+
message = generate_commit_message_for_status(
|
|
528
|
+
build_status_for_patch_units(unit_group),
|
|
529
|
+
model=model,
|
|
530
|
+
context=context,
|
|
531
|
+
http_client_config=http_client_config,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
prepared_commits.append(
|
|
535
|
+
PreparedSplitCommit(message=message, patch_units=tuple(unit_group))
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
return prepared_commits
|
|
539
|
+
except github_copilot.CopilotError as exc:
|
|
540
|
+
print_copilot_error("Could not generate split commit messages", exc)
|
|
541
|
+
raise typer.Exit(1)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def resolve_split_commit_limit(
|
|
545
|
+
exc: SplitCommitLimitExceededError, *, yes: bool = False
|
|
546
|
+
) -> SplitCommitPlan:
|
|
547
|
+
"""Ask whether to proceed when the planner exceeds the configured limit."""
|
|
548
|
+
console.print(
|
|
549
|
+
f"[yellow]Split planning produced {exc.actual_commits} commits, exceeding the automatic review limit of {exc.max_commits}.[/yellow]"
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
if yes:
|
|
553
|
+
console.print(
|
|
554
|
+
"[red]Cannot ask whether to proceed because --yes was used. Re-run without --yes to review the larger plan.[/red]"
|
|
555
|
+
)
|
|
556
|
+
raise typer.Exit(1)
|
|
557
|
+
|
|
558
|
+
if Confirm.ask(
|
|
559
|
+
f"Proceed with [bold]{exc.actual_commits} commits[/] anyway?",
|
|
560
|
+
default=False,
|
|
561
|
+
):
|
|
562
|
+
return exc.plan
|
|
563
|
+
|
|
564
|
+
console.print("Split commit plan cancelled.")
|
|
565
|
+
raise typer.Exit()
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def display_commit_message(commit_message: str) -> None:
|
|
569
|
+
"""Render the generated commit message."""
|
|
570
|
+
console.print("[yellow]Generated commit message.[/yellow]")
|
|
571
|
+
console.print(
|
|
572
|
+
Panel(
|
|
573
|
+
f"[bold]{commit_message}[/]",
|
|
574
|
+
title="Commit Message",
|
|
575
|
+
border_style="cyan",
|
|
576
|
+
width=len(commit_message) + 5,
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def display_split_commit_plan(prepared_commits: list[PreparedSplitCommit]) -> None:
|
|
582
|
+
"""Render the split-commit plan preview."""
|
|
583
|
+
console.print("[yellow]Generated split commit plan.[/yellow]")
|
|
584
|
+
|
|
585
|
+
for index, prepared_commit in enumerate(prepared_commits, start=1):
|
|
586
|
+
paths = list(dict.fromkeys(unit.path for unit in prepared_commit.patch_units))
|
|
587
|
+
file_lines = "\n".join(f"- {path}" for path in paths)
|
|
588
|
+
console.print(
|
|
589
|
+
Panel(
|
|
590
|
+
f"[bold]{prepared_commit.message}[/]\n\nFiles:\n{file_lines}",
|
|
591
|
+
title=f"Commit {index}",
|
|
592
|
+
border_style="cyan",
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def execute_commit_action(
|
|
598
|
+
repo: GitRepository, commit_message: str, yes: bool = False
|
|
599
|
+
) -> str:
|
|
600
|
+
"""Run the chosen commit action using the provided message."""
|
|
601
|
+
if yes:
|
|
602
|
+
return commit_with_retry_no_verify(repo, commit_message)
|
|
603
|
+
|
|
604
|
+
choice = typer.prompt(
|
|
605
|
+
"Choose action: (c)ommit, (e)dit message, (q)uit",
|
|
606
|
+
default="c",
|
|
607
|
+
show_default=True,
|
|
608
|
+
).lower()
|
|
609
|
+
|
|
610
|
+
if choice == "q":
|
|
611
|
+
console.print("Commit cancelled.")
|
|
612
|
+
raise typer.Exit()
|
|
613
|
+
if choice == "e":
|
|
614
|
+
console.print("[cyan]Opening git editor...[/cyan]")
|
|
615
|
+
return commit_with_retry_no_verify(repo, commit_message, use_editor=True)
|
|
616
|
+
if choice == "c":
|
|
617
|
+
return commit_with_retry_no_verify(repo, commit_message)
|
|
618
|
+
|
|
619
|
+
console.print("Invalid choice. Commit cancelled.")
|
|
620
|
+
raise typer.Exit()
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def execute_split_commit_plan(
|
|
624
|
+
repo: GitRepository,
|
|
625
|
+
prepared_commits: list[PreparedSplitCommit],
|
|
626
|
+
*,
|
|
627
|
+
yes: bool = False,
|
|
628
|
+
) -> list[str]:
|
|
629
|
+
"""Run the split-commit plan against temporary alternate indexes."""
|
|
630
|
+
use_editor = False
|
|
631
|
+
if not yes:
|
|
632
|
+
choice = typer.prompt(
|
|
633
|
+
"Choose action: (c)ommit all, (e)dit each message, (q)uit",
|
|
634
|
+
default="c",
|
|
635
|
+
show_default=True,
|
|
636
|
+
).lower()
|
|
637
|
+
|
|
638
|
+
if choice == "q":
|
|
639
|
+
console.print("Commit cancelled.")
|
|
640
|
+
raise typer.Exit()
|
|
641
|
+
if choice == "e":
|
|
642
|
+
use_editor = True
|
|
643
|
+
elif choice != "c":
|
|
644
|
+
console.print("Invalid choice. Commit cancelled.")
|
|
645
|
+
raise typer.Exit()
|
|
646
|
+
|
|
647
|
+
commit_shas: list[str] = []
|
|
648
|
+
total_commits = len(prepared_commits)
|
|
649
|
+
|
|
650
|
+
for index, prepared_commit in enumerate(prepared_commits, start=1):
|
|
651
|
+
console.print(
|
|
652
|
+
f"[cyan]Creating commit {index}/{total_commits}:[/cyan] {prepared_commit.message}"
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
with repo.temporary_alternate_index() as alternate_index:
|
|
656
|
+
try:
|
|
657
|
+
for patch_unit in prepared_commit.patch_units:
|
|
658
|
+
repo.check_patch_for_alternate_index(
|
|
659
|
+
patch_unit.patch,
|
|
660
|
+
index=alternate_index,
|
|
661
|
+
)
|
|
662
|
+
repo.apply_patch_to_alternate_index(
|
|
663
|
+
patch_unit.patch,
|
|
664
|
+
index=alternate_index,
|
|
665
|
+
)
|
|
666
|
+
except GitError as exc:
|
|
667
|
+
console.print(
|
|
668
|
+
f"[red]Failed to apply the planned changes for commit {index}: {exc}[/red]"
|
|
669
|
+
)
|
|
670
|
+
raise typer.Exit(1)
|
|
671
|
+
|
|
672
|
+
commit_shas.append(
|
|
673
|
+
commit_with_retry_no_verify(
|
|
674
|
+
repo,
|
|
675
|
+
prepared_commit.message,
|
|
676
|
+
use_editor=use_editor,
|
|
677
|
+
env=alternate_index.env,
|
|
678
|
+
)
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
return commit_shas
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def handle_single_commit_flow(
|
|
685
|
+
repo: GitRepository,
|
|
686
|
+
status: GitStatus,
|
|
687
|
+
*,
|
|
688
|
+
model: str | None = None,
|
|
689
|
+
yes: bool = False,
|
|
690
|
+
context: str = "",
|
|
691
|
+
http_client_config: github_copilot.HttpClientConfig | None = None,
|
|
692
|
+
) -> None:
|
|
693
|
+
"""Generate, display, and execute the single-commit flow."""
|
|
694
|
+
commit_message = request_commit_message(
|
|
695
|
+
status,
|
|
696
|
+
model=model,
|
|
697
|
+
context=context,
|
|
698
|
+
http_client_config=http_client_config,
|
|
699
|
+
)
|
|
700
|
+
display_commit_message(commit_message)
|
|
701
|
+
|
|
702
|
+
commit_sha = execute_commit_action(repo, commit_message, yes=yes)
|
|
703
|
+
console.print(f"[green]✓ Successfully committed: {commit_sha[:8]}[/green]")
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def handle_split_commit_flow(
|
|
707
|
+
repo: GitRepository,
|
|
708
|
+
status: GitStatus,
|
|
709
|
+
*,
|
|
710
|
+
preferred_commits: int | None = None,
|
|
711
|
+
model: str | None = None,
|
|
712
|
+
yes: bool = False,
|
|
713
|
+
context: str = "",
|
|
714
|
+
http_client_config: github_copilot.HttpClientConfig | None = None,
|
|
715
|
+
) -> None:
|
|
716
|
+
"""Generate, display, and execute the split-commit flow."""
|
|
717
|
+
patch_units = tuple(
|
|
718
|
+
extract_patch_units(repo.get_staged_diff(extra_args=SPLIT_DIFF_ARGS))
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
if not patch_units:
|
|
722
|
+
console.print(
|
|
723
|
+
"[yellow]No split patch units were extracted; falling back to a single commit.[/yellow]"
|
|
724
|
+
)
|
|
725
|
+
handle_single_commit_flow(
|
|
726
|
+
repo,
|
|
727
|
+
status,
|
|
728
|
+
model=model,
|
|
729
|
+
yes=yes,
|
|
730
|
+
context=context,
|
|
731
|
+
http_client_config=http_client_config,
|
|
732
|
+
)
|
|
733
|
+
return
|
|
734
|
+
|
|
735
|
+
if len(patch_units) == 1:
|
|
736
|
+
console.print(
|
|
737
|
+
"[yellow]Only one staged patch unit was found; creating a single commit.[/yellow]"
|
|
738
|
+
)
|
|
739
|
+
handle_single_commit_flow(
|
|
740
|
+
repo,
|
|
741
|
+
status,
|
|
742
|
+
model=model,
|
|
743
|
+
yes=yes,
|
|
744
|
+
context=context,
|
|
745
|
+
http_client_config=http_client_config,
|
|
746
|
+
)
|
|
747
|
+
return
|
|
748
|
+
|
|
749
|
+
if preferred_commits is None:
|
|
750
|
+
should_split, reason = evaluate_auto_split(patch_units)
|
|
751
|
+
if not should_split:
|
|
752
|
+
console.print(
|
|
753
|
+
"[yellow]Auto split not triggered: "
|
|
754
|
+
f"{reason}. Creating a single commit. Use [bold]--split N[/] to suggest an upper bound.[/yellow]"
|
|
755
|
+
)
|
|
756
|
+
handle_single_commit_flow(
|
|
757
|
+
repo,
|
|
758
|
+
status,
|
|
759
|
+
model=model,
|
|
760
|
+
yes=yes,
|
|
761
|
+
context=context,
|
|
762
|
+
http_client_config=http_client_config,
|
|
763
|
+
)
|
|
764
|
+
return
|
|
765
|
+
|
|
766
|
+
console.print(f"[yellow]Auto split triggered: {reason}.[/yellow]")
|
|
767
|
+
else:
|
|
768
|
+
console.print(
|
|
769
|
+
f"[yellow]Planning up to {preferred_commits} commits from the staged patch units.[/yellow]"
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
try:
|
|
773
|
+
split_plan = request_split_commit_plan(
|
|
774
|
+
status,
|
|
775
|
+
patch_units,
|
|
776
|
+
max_commits=(
|
|
777
|
+
DEFAULT_AUTO_MAX_COMMITS
|
|
778
|
+
if preferred_commits is None
|
|
779
|
+
else preferred_commits
|
|
780
|
+
),
|
|
781
|
+
preferred_commits=preferred_commits,
|
|
782
|
+
model=model,
|
|
783
|
+
context=context,
|
|
784
|
+
http_client_config=http_client_config,
|
|
785
|
+
)
|
|
786
|
+
except SplitCommitLimitExceededError as exc:
|
|
787
|
+
split_plan = resolve_split_commit_limit(exc, yes=yes)
|
|
788
|
+
except SplitPlanningError as exc:
|
|
789
|
+
console.print(
|
|
790
|
+
"[yellow]Split planning returned an invalid plan; falling back to a single commit.[/yellow]"
|
|
791
|
+
)
|
|
792
|
+
console.print(f"[yellow]Reason:[/yellow] {exc}")
|
|
793
|
+
handle_single_commit_flow(
|
|
794
|
+
repo,
|
|
795
|
+
status,
|
|
796
|
+
model=model,
|
|
797
|
+
yes=yes,
|
|
798
|
+
context=context,
|
|
799
|
+
http_client_config=http_client_config,
|
|
800
|
+
)
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
prepared_commits = request_split_commit_messages(
|
|
804
|
+
split_plan,
|
|
805
|
+
patch_units,
|
|
806
|
+
model=model,
|
|
807
|
+
context=context,
|
|
808
|
+
http_client_config=http_client_config,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
if len(prepared_commits) == 1:
|
|
812
|
+
console.print(
|
|
813
|
+
"[yellow]Split planning resulted in a single commit; using the standard commit flow.[/yellow]"
|
|
814
|
+
)
|
|
815
|
+
display_commit_message(prepared_commits[0].message)
|
|
816
|
+
commit_sha = execute_commit_action(repo, prepared_commits[0].message, yes=yes)
|
|
817
|
+
console.print(f"[green]✓ Successfully committed: {commit_sha[:8]}[/green]")
|
|
818
|
+
return
|
|
819
|
+
|
|
820
|
+
display_split_commit_plan(prepared_commits)
|
|
821
|
+
commit_shas = execute_split_commit_plan(repo, prepared_commits, yes=yes)
|
|
822
|
+
|
|
823
|
+
console.print(f"[green]✓ Successfully created {len(commit_shas)} commits.[/green]")
|
|
824
|
+
for commit_sha, prepared_commit in zip(commit_shas, prepared_commits, strict=True):
|
|
825
|
+
console.print(f"[green]{commit_sha[:8]}[/green] {prepared_commit.message}")
|
|
826
|
+
|
|
827
|
+
|
|
154
828
|
@app.command("authenticate")
|
|
155
829
|
@app.command("login", hidden=True)
|
|
156
830
|
def authenticate(
|
|
831
|
+
enterprise_domain: str | None = typer.Option(
|
|
832
|
+
None,
|
|
833
|
+
"--enterprise-domain",
|
|
834
|
+
help="GitHub Enterprise hostname. Omit for github.com.",
|
|
835
|
+
),
|
|
157
836
|
force: bool = typer.Option(
|
|
158
837
|
False, "--force", help="Replace cached GitHub Copilot credentials"
|
|
159
838
|
),
|
|
839
|
+
ca_bundle: CaBundleOption = None,
|
|
840
|
+
insecure: InsecureOption = False,
|
|
841
|
+
native_tls: NativeTlsOption = False,
|
|
160
842
|
):
|
|
161
843
|
"""Authenticate with GitHub Copilot and cache credentials locally."""
|
|
844
|
+
http_client_config = build_http_client_config(
|
|
845
|
+
ca_bundle=ca_bundle,
|
|
846
|
+
insecure=insecure,
|
|
847
|
+
native_tls=native_tls,
|
|
848
|
+
)
|
|
849
|
+
try:
|
|
850
|
+
github_copilot.login(
|
|
851
|
+
enterprise_domain=enterprise_domain,
|
|
852
|
+
force=force,
|
|
853
|
+
http_client_config=http_client_config,
|
|
854
|
+
)
|
|
855
|
+
except github_copilot.CopilotError as exc:
|
|
856
|
+
print_copilot_error("Authentication failed", exc)
|
|
857
|
+
raise typer.Exit(1)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
@app.command("summary")
|
|
861
|
+
def summary(
|
|
862
|
+
ca_bundle: CaBundleOption = None,
|
|
863
|
+
insecure: InsecureOption = False,
|
|
864
|
+
native_tls: NativeTlsOption = False,
|
|
865
|
+
):
|
|
866
|
+
"""Show the current cached GitHub Copilot login summary."""
|
|
867
|
+
http_client_config = build_http_client_config(
|
|
868
|
+
ca_bundle=ca_bundle,
|
|
869
|
+
insecure=insecure,
|
|
870
|
+
native_tls=native_tls,
|
|
871
|
+
)
|
|
872
|
+
try:
|
|
873
|
+
github_copilot.show_login_summary(http_client_config=http_client_config)
|
|
874
|
+
except github_copilot.CopilotError as exc:
|
|
875
|
+
print_copilot_error("Could not load login summary", exc)
|
|
876
|
+
raise typer.Exit(1)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
@app.command("models")
|
|
880
|
+
def models_command(
|
|
881
|
+
vendor: str | None = typer.Option(
|
|
882
|
+
None,
|
|
883
|
+
"--vendor",
|
|
884
|
+
help="Filter listed models by vendor: anthropic, gemini/google, or openai.",
|
|
885
|
+
),
|
|
886
|
+
ca_bundle: CaBundleOption = None,
|
|
887
|
+
insecure: InsecureOption = False,
|
|
888
|
+
native_tls: NativeTlsOption = False,
|
|
889
|
+
):
|
|
890
|
+
"""List available Copilot models for the current account."""
|
|
891
|
+
http_client_config = build_http_client_config(
|
|
892
|
+
ca_bundle=ca_bundle,
|
|
893
|
+
insecure=insecure,
|
|
894
|
+
native_tls=native_tls,
|
|
895
|
+
)
|
|
896
|
+
|
|
162
897
|
try:
|
|
163
|
-
github_copilot.
|
|
898
|
+
credentials, models = github_copilot.get_available_models(
|
|
899
|
+
vendor=vendor,
|
|
900
|
+
http_client_config=http_client_config,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
console.print(f"[green]Copilot base URL:[/green] {credentials.base_url()}")
|
|
904
|
+
console.print(f"[green]Model count:[/green] {len(models)}")
|
|
905
|
+
github_copilot.print_model_table(models)
|
|
164
906
|
except github_copilot.CopilotError as exc:
|
|
165
|
-
|
|
907
|
+
print_copilot_error("Could not load models", exc)
|
|
166
908
|
raise typer.Exit(1)
|
|
167
909
|
|
|
168
910
|
|
|
@@ -171,8 +913,14 @@ def commit(
|
|
|
171
913
|
all_files: bool = typer.Option(
|
|
172
914
|
False, "--all", "-a", help="Stage all files before committing"
|
|
173
915
|
),
|
|
916
|
+
split: SplitOption = False,
|
|
917
|
+
split_count: SplitCountOption = None,
|
|
174
918
|
model: str | None = typer.Option(
|
|
175
|
-
None,
|
|
919
|
+
None,
|
|
920
|
+
"--model",
|
|
921
|
+
"-m",
|
|
922
|
+
metavar="MODEL_ID",
|
|
923
|
+
help="Model to use for generating commit message",
|
|
176
924
|
),
|
|
177
925
|
yes: bool = typer.Option(
|
|
178
926
|
False, "--yes", "-y", help="Automatically accept the generated commit message"
|
|
@@ -183,6 +931,9 @@ def commit(
|
|
|
183
931
|
"-c",
|
|
184
932
|
help="Optional user-provided context to guide commit message",
|
|
185
933
|
),
|
|
934
|
+
ca_bundle: CaBundleOption = None,
|
|
935
|
+
insecure: InsecureOption = False,
|
|
936
|
+
native_tls: NativeTlsOption = False,
|
|
186
937
|
):
|
|
187
938
|
"""
|
|
188
939
|
Generate commit message based on changes in the current git repository and commit them.
|
|
@@ -193,22 +944,12 @@ def commit(
|
|
|
193
944
|
console.print("[red]Error: Not in a git repository[/red]")
|
|
194
945
|
raise typer.Exit(1)
|
|
195
946
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
try:
|
|
203
|
-
github_copilot.login(force=True)
|
|
204
|
-
except github_copilot.CopilotError as exc:
|
|
205
|
-
console.print(f"[red]Authentication failed: {exc}[/red]")
|
|
206
|
-
raise typer.Exit(1)
|
|
207
|
-
|
|
208
|
-
# Load settings and use default model if none provided
|
|
209
|
-
settings = Settings()
|
|
210
|
-
if model is None:
|
|
211
|
-
model = settings.default_model
|
|
947
|
+
http_client_config = build_http_client_config(
|
|
948
|
+
ca_bundle=ca_bundle,
|
|
949
|
+
insecure=insecure,
|
|
950
|
+
native_tls=native_tls,
|
|
951
|
+
)
|
|
952
|
+
ensure_copilot_authentication(http_client_config)
|
|
212
953
|
|
|
213
954
|
# Get initial status
|
|
214
955
|
status = repo.get_status()
|
|
@@ -217,90 +958,47 @@ def commit(
|
|
|
217
958
|
console.print("[yellow]No changes to commit.[/yellow]")
|
|
218
959
|
raise typer.Exit()
|
|
219
960
|
|
|
220
|
-
|
|
221
|
-
if all_files:
|
|
222
|
-
repo.stage_files() # Stage all files
|
|
223
|
-
console.print("[green]Staged all files.[/green]")
|
|
224
|
-
else:
|
|
225
|
-
# Show git status once if there are unstaged or untracked files to prompt about
|
|
226
|
-
if status.has_unstaged_changes or status.has_untracked_files:
|
|
227
|
-
git_status_output = repo._run_git_command(["status"])
|
|
228
|
-
console.print(git_status_output.stdout)
|
|
229
|
-
|
|
230
|
-
if status.has_unstaged_changes:
|
|
231
|
-
if Confirm.ask(
|
|
232
|
-
"Modified files found. Add [bold yellow]all unstaged changes[/] to staging?",
|
|
233
|
-
default=True,
|
|
234
|
-
):
|
|
235
|
-
repo.stage_modified()
|
|
236
|
-
console.print("[green]Staged modified files.[/green]")
|
|
237
|
-
if status.has_untracked_files:
|
|
238
|
-
if Confirm.ask(
|
|
239
|
-
"Untracked files found. Add [bold yellow]all untracked files and unstaged changes[/] to staging?",
|
|
240
|
-
default=True,
|
|
241
|
-
):
|
|
242
|
-
repo.stage_files()
|
|
243
|
-
console.print("[green]Staged untracked files.[/green]")
|
|
961
|
+
status = stage_changes_for_commit(repo, status, all_files=all_files)
|
|
244
962
|
|
|
245
963
|
if context:
|
|
246
964
|
console.print(
|
|
247
965
|
Panel(context.strip(), title="User Context", border_style="magenta")
|
|
248
966
|
)
|
|
249
967
|
|
|
968
|
+
normalized_model = normalize_model_name(model)
|
|
250
969
|
try:
|
|
251
|
-
github_copilot.ensure_auth_ready(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
"[yellow]Generating commit message based on [bold]`git diff --staged`[/] ...[/yellow]"
|
|
256
|
-
):
|
|
257
|
-
commit_message = generate_commit_message(repo, model, context=context)
|
|
970
|
+
selected_model = github_copilot.ensure_auth_ready(
|
|
971
|
+
model=normalized_model,
|
|
972
|
+
http_client_config=http_client_config,
|
|
973
|
+
)
|
|
258
974
|
except github_copilot.CopilotError as exc:
|
|
259
|
-
|
|
975
|
+
print_copilot_error("Could not select a model", exc)
|
|
260
976
|
raise typer.Exit(1)
|
|
261
977
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
978
|
+
display_selected_model(selected_model)
|
|
979
|
+
model = selected_model.id
|
|
980
|
+
|
|
981
|
+
if split or split_count is not None:
|
|
982
|
+
handle_split_commit_flow(
|
|
983
|
+
repo,
|
|
984
|
+
status,
|
|
985
|
+
preferred_commits=split_count,
|
|
986
|
+
model=model,
|
|
987
|
+
yes=yes,
|
|
988
|
+
context=context,
|
|
989
|
+
http_client_config=http_client_config,
|
|
271
990
|
)
|
|
272
|
-
|
|
991
|
+
return
|
|
273
992
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
show_default=True,
|
|
283
|
-
).lower()
|
|
284
|
-
|
|
285
|
-
if choice == "q":
|
|
286
|
-
console.print("Commit cancelled.")
|
|
287
|
-
raise typer.Exit()
|
|
288
|
-
elif choice == "e":
|
|
289
|
-
# Use git's built-in editor with generated message as template
|
|
290
|
-
console.print("[cyan]Opening git editor...[/cyan]")
|
|
291
|
-
commit_sha = commit_with_retry_no_verify(
|
|
292
|
-
repo, commit_message, use_editor=True
|
|
293
|
-
)
|
|
294
|
-
elif choice == "c":
|
|
295
|
-
# Commit with generated message
|
|
296
|
-
commit_sha = commit_with_retry_no_verify(repo, commit_message)
|
|
297
|
-
else:
|
|
298
|
-
console.print("Invalid choice. Commit cancelled.")
|
|
299
|
-
raise typer.Exit()
|
|
300
|
-
|
|
301
|
-
# Show success message
|
|
302
|
-
console.print(f"[green]✓ Successfully committed: {commit_sha[:8]}[/green]")
|
|
993
|
+
handle_single_commit_flow(
|
|
994
|
+
repo,
|
|
995
|
+
status,
|
|
996
|
+
model=model,
|
|
997
|
+
yes=yes,
|
|
998
|
+
context=context,
|
|
999
|
+
http_client_config=http_client_config,
|
|
1000
|
+
)
|
|
303
1001
|
|
|
304
1002
|
|
|
305
1003
|
if __name__ == "__main__":
|
|
306
|
-
|
|
1004
|
+
run()
|