git-copilot-commit 0.4.6__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 +766 -138
- git_copilot_commit/git.py +144 -21
- git_copilot_commit/github_copilot.py +207 -25
- 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.6.dist-info → git_copilot_commit-0.5.0.dist-info}/METADATA +16 -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.6.dist-info/RECORD +0 -13
- git_copilot_commit-0.4.6.dist-info/entry_points.txt +0 -2
- {git_copilot_commit-0.4.6.dist-info → git_copilot_commit-0.5.0.dist-info}/WHEEL +0 -0
- {git_copilot_commit-0.4.6.dist-info → git_copilot_commit-0.5.0.dist-info}/licenses/LICENSE +0 -0
git_copilot_commit/cli.py
CHANGED
|
@@ -2,16 +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
|
|
6
|
-
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Annotated, Sequence
|
|
7
10
|
|
|
8
11
|
import rich
|
|
9
12
|
import typer
|
|
10
13
|
from rich.console import Console
|
|
11
14
|
from rich.panel import Panel
|
|
12
15
|
from rich.prompt import Confirm
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
)
|
|
15
31
|
from .settings import Settings
|
|
16
32
|
from .version import __version__
|
|
17
33
|
from . import github_copilot
|
|
@@ -19,9 +35,21 @@ from . import github_copilot
|
|
|
19
35
|
console = Console()
|
|
20
36
|
app = typer.Typer(help=__doc__, add_completion=False)
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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)"
|
|
25
53
|
NATIVE_TLS_HELP = (
|
|
26
54
|
"Use the OS's native certificate store via 'truststore' for httpx instead of "
|
|
27
55
|
"the Python bundle. Ignored if --ca-bundle or --insecure is used."
|
|
@@ -41,6 +69,96 @@ NativeTlsOption = Annotated[
|
|
|
41
69
|
]
|
|
42
70
|
|
|
43
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
|
+
|
|
161
|
+
|
|
44
162
|
def version_callback(value: bool):
|
|
45
163
|
if value:
|
|
46
164
|
rich.print(f"git-copilot-commit [bold yellow]{__version__}[/]")
|
|
@@ -69,12 +187,10 @@ def main(
|
|
|
69
187
|
)
|
|
70
188
|
|
|
71
189
|
|
|
72
|
-
def get_prompt_locations():
|
|
190
|
+
def get_prompt_locations(filename: str):
|
|
73
191
|
"""Get potential prompt file locations in order of preference."""
|
|
74
192
|
import importlib.resources
|
|
75
193
|
|
|
76
|
-
filename = "commit-message-generator-prompt.md"
|
|
77
|
-
|
|
78
194
|
return [
|
|
79
195
|
Path(Settings().data_dir) / "prompts" / filename, # User customizable
|
|
80
196
|
importlib.resources.files("git_copilot_commit")
|
|
@@ -83,26 +199,46 @@ def get_prompt_locations():
|
|
|
83
199
|
]
|
|
84
200
|
|
|
85
201
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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()
|
|
95
216
|
|
|
96
217
|
|
|
97
218
|
def load_system_prompt() -> str:
|
|
98
219
|
"""Load the system prompt from the markdown file."""
|
|
99
|
-
|
|
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):
|
|
100
236
|
try:
|
|
101
237
|
return path.read_text(encoding="utf-8")
|
|
102
238
|
except (FileNotFoundError, AttributeError):
|
|
103
239
|
continue
|
|
104
240
|
|
|
105
|
-
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]")
|
|
106
242
|
raise typer.Exit(1)
|
|
107
243
|
|
|
108
244
|
|
|
@@ -112,6 +248,8 @@ def build_http_client_config(
|
|
|
112
248
|
insecure: bool,
|
|
113
249
|
native_tls: bool,
|
|
114
250
|
) -> github_copilot.HttpClientConfig:
|
|
251
|
+
if ca_bundle is not None:
|
|
252
|
+
ca_bundle = os.path.expanduser(ca_bundle)
|
|
115
253
|
return github_copilot.HttpClientConfig(
|
|
116
254
|
native_tls=native_tls,
|
|
117
255
|
insecure=insecure,
|
|
@@ -119,17 +257,26 @@ def build_http_client_config(
|
|
|
119
257
|
)
|
|
120
258
|
|
|
121
259
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"""Generate a conventional commit message using Copilot API."""
|
|
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
|
|
129
266
|
|
|
130
|
-
|
|
131
|
-
|
|
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)})")
|
|
132
276
|
|
|
277
|
+
|
|
278
|
+
def build_commit_message_prompt(status: GitStatus, context: str = "") -> str:
|
|
279
|
+
"""Build the prompt used to generate a commit message."""
|
|
133
280
|
if not status.has_staged_changes:
|
|
134
281
|
console.print("[red]No staged changes to commit.[/red]")
|
|
135
282
|
raise typer.Exit()
|
|
@@ -144,37 +291,91 @@ def generate_commit_message(
|
|
|
144
291
|
if context.strip():
|
|
145
292
|
prompt_parts.insert(0, f"User-provided context:\n\n{context.strip()}\n\n")
|
|
146
293
|
|
|
147
|
-
|
|
294
|
+
return "\n".join(prompt_parts)
|
|
148
295
|
|
|
149
|
-
prompt = "\n".join(prompt_parts)
|
|
150
296
|
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
153
302
|
|
|
154
|
-
if model.startswith("github_copilot/"):
|
|
155
|
-
model = model.replace("github_copilot/", "")
|
|
156
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."""
|
|
157
311
|
return github_copilot.ask(
|
|
158
312
|
f"""
|
|
159
313
|
# System Prompt
|
|
160
314
|
|
|
161
|
-
{
|
|
315
|
+
{system_prompt}
|
|
162
316
|
|
|
163
317
|
# Prompt
|
|
164
318
|
|
|
165
319
|
{prompt}
|
|
166
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,
|
|
167
335
|
model=model,
|
|
168
336
|
http_client_config=http_client_config,
|
|
169
337
|
)
|
|
170
338
|
|
|
171
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(),
|
|
364
|
+
model=model,
|
|
365
|
+
context=context,
|
|
366
|
+
http_client_config=http_client_config,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
172
370
|
def commit_with_retry_no_verify(
|
|
173
|
-
repo: GitRepository,
|
|
371
|
+
repo: GitRepository,
|
|
372
|
+
message: str,
|
|
373
|
+
use_editor: bool = False,
|
|
374
|
+
env: dict[str, str] | None = None,
|
|
174
375
|
) -> str:
|
|
175
376
|
"""Run commit and offer one retry with -n on failure."""
|
|
176
377
|
try:
|
|
177
|
-
return repo.commit(message, use_editor=use_editor)
|
|
378
|
+
return repo.commit(message, use_editor=use_editor, env=env)
|
|
178
379
|
except GitError as e:
|
|
179
380
|
console.print(f"[red]Commit failed: {e}[/red]")
|
|
180
381
|
if not Confirm.ask(
|
|
@@ -184,15 +385,454 @@ def commit_with_retry_no_verify(
|
|
|
184
385
|
raise typer.Exit(1)
|
|
185
386
|
|
|
186
387
|
try:
|
|
187
|
-
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)
|
|
188
389
|
except GitError as retry_error:
|
|
189
390
|
console.print(f"[red]Commit with -n failed: {retry_error}[/red]")
|
|
190
391
|
raise typer.Exit(1)
|
|
191
392
|
|
|
192
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
|
+
|
|
193
828
|
@app.command("authenticate")
|
|
194
829
|
@app.command("login", hidden=True)
|
|
195
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
|
+
),
|
|
196
836
|
force: bool = typer.Option(
|
|
197
837
|
False, "--force", help="Replace cached GitHub Copilot credentials"
|
|
198
838
|
),
|
|
@@ -208,11 +848,63 @@ def authenticate(
|
|
|
208
848
|
)
|
|
209
849
|
try:
|
|
210
850
|
github_copilot.login(
|
|
851
|
+
enterprise_domain=enterprise_domain,
|
|
211
852
|
force=force,
|
|
212
853
|
http_client_config=http_client_config,
|
|
213
854
|
)
|
|
214
855
|
except github_copilot.CopilotError as exc:
|
|
215
|
-
|
|
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
|
+
|
|
897
|
+
try:
|
|
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)
|
|
906
|
+
except github_copilot.CopilotError as exc:
|
|
907
|
+
print_copilot_error("Could not load models", exc)
|
|
216
908
|
raise typer.Exit(1)
|
|
217
909
|
|
|
218
910
|
|
|
@@ -221,8 +913,14 @@ def commit(
|
|
|
221
913
|
all_files: bool = typer.Option(
|
|
222
914
|
False, "--all", "-a", help="Stage all files before committing"
|
|
223
915
|
),
|
|
916
|
+
split: SplitOption = False,
|
|
917
|
+
split_count: SplitCountOption = None,
|
|
224
918
|
model: str | None = typer.Option(
|
|
225
|
-
None,
|
|
919
|
+
None,
|
|
920
|
+
"--model",
|
|
921
|
+
"-m",
|
|
922
|
+
metavar="MODEL_ID",
|
|
923
|
+
help="Model to use for generating commit message",
|
|
226
924
|
),
|
|
227
925
|
yes: bool = typer.Option(
|
|
228
926
|
False, "--yes", "-y", help="Automatically accept the generated commit message"
|
|
@@ -246,31 +944,12 @@ def commit(
|
|
|
246
944
|
console.print("[red]Error: Not in a git repository[/red]")
|
|
247
945
|
raise typer.Exit(1)
|
|
248
946
|
|
|
249
|
-
try:
|
|
250
|
-
existing_credentials = github_copilot.load_credentials()
|
|
251
|
-
except github_copilot.CopilotError:
|
|
252
|
-
existing_credentials = None
|
|
253
|
-
|
|
254
947
|
http_client_config = build_http_client_config(
|
|
255
948
|
ca_bundle=ca_bundle,
|
|
256
949
|
insecure=insecure,
|
|
257
950
|
native_tls=native_tls,
|
|
258
951
|
)
|
|
259
|
-
|
|
260
|
-
if existing_credentials is None:
|
|
261
|
-
try:
|
|
262
|
-
github_copilot.login(
|
|
263
|
-
force=True,
|
|
264
|
-
http_client_config=http_client_config,
|
|
265
|
-
)
|
|
266
|
-
except github_copilot.CopilotError as exc:
|
|
267
|
-
console.print(f"[red]Authentication failed: {exc}[/red]")
|
|
268
|
-
raise typer.Exit(1)
|
|
269
|
-
|
|
270
|
-
# Load settings and use default model if none provided
|
|
271
|
-
settings = Settings()
|
|
272
|
-
if model is None:
|
|
273
|
-
model = settings.default_model
|
|
952
|
+
ensure_copilot_authentication(http_client_config)
|
|
274
953
|
|
|
275
954
|
# Get initial status
|
|
276
955
|
status = repo.get_status()
|
|
@@ -279,98 +958,47 @@ def commit(
|
|
|
279
958
|
console.print("[yellow]No changes to commit.[/yellow]")
|
|
280
959
|
raise typer.Exit()
|
|
281
960
|
|
|
282
|
-
|
|
283
|
-
if all_files:
|
|
284
|
-
repo.stage_files() # Stage all files
|
|
285
|
-
console.print("[green]Staged all files.[/green]")
|
|
286
|
-
else:
|
|
287
|
-
# Show git status once if there are unstaged or untracked files to prompt about
|
|
288
|
-
if status.has_unstaged_changes or status.has_untracked_files:
|
|
289
|
-
git_status_output = repo._run_git_command(["status"])
|
|
290
|
-
console.print(git_status_output.stdout)
|
|
291
|
-
|
|
292
|
-
if status.has_unstaged_changes:
|
|
293
|
-
if Confirm.ask(
|
|
294
|
-
"Modified files found. Add [bold yellow]all unstaged changes[/] to staging?",
|
|
295
|
-
default=True,
|
|
296
|
-
):
|
|
297
|
-
repo.stage_modified()
|
|
298
|
-
console.print("[green]Staged modified files.[/green]")
|
|
299
|
-
if status.has_untracked_files:
|
|
300
|
-
if Confirm.ask(
|
|
301
|
-
"Untracked files found. Add [bold yellow]all untracked files and unstaged changes[/] to staging?",
|
|
302
|
-
default=True,
|
|
303
|
-
):
|
|
304
|
-
repo.stage_files()
|
|
305
|
-
console.print("[green]Staged untracked files.[/green]")
|
|
961
|
+
status = stage_changes_for_commit(repo, status, all_files=all_files)
|
|
306
962
|
|
|
307
963
|
if context:
|
|
308
964
|
console.print(
|
|
309
965
|
Panel(context.strip(), title="User Context", border_style="magenta")
|
|
310
966
|
)
|
|
311
967
|
|
|
968
|
+
normalized_model = normalize_model_name(model)
|
|
312
969
|
try:
|
|
313
|
-
github_copilot.ensure_auth_ready(
|
|
314
|
-
model=
|
|
970
|
+
selected_model = github_copilot.ensure_auth_ready(
|
|
971
|
+
model=normalized_model,
|
|
315
972
|
http_client_config=http_client_config,
|
|
316
973
|
)
|
|
317
|
-
|
|
318
|
-
# Generate or use provided commit message
|
|
319
|
-
with console.status(
|
|
320
|
-
"[yellow]Generating commit message based on [bold]`git diff --staged`[/] ...[/yellow]"
|
|
321
|
-
):
|
|
322
|
-
commit_message = generate_commit_message(
|
|
323
|
-
repo,
|
|
324
|
-
model,
|
|
325
|
-
context=context,
|
|
326
|
-
http_client_config=http_client_config,
|
|
327
|
-
)
|
|
328
974
|
except github_copilot.CopilotError as exc:
|
|
329
|
-
|
|
975
|
+
print_copilot_error("Could not select a model", exc)
|
|
330
976
|
raise typer.Exit(1)
|
|
331
977
|
|
|
332
|
-
|
|
978
|
+
display_selected_model(selected_model)
|
|
979
|
+
model = selected_model.id
|
|
333
980
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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,
|
|
341
990
|
)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
# Confirm commit or edit message (skip if --yes flag is used)
|
|
345
|
-
if yes:
|
|
346
|
-
# Automatically commit with generated message
|
|
347
|
-
commit_sha = commit_with_retry_no_verify(repo, commit_message)
|
|
348
|
-
else:
|
|
349
|
-
choice = typer.prompt(
|
|
350
|
-
"Choose action: (c)ommit, (e)dit message, (q)uit",
|
|
351
|
-
default="c",
|
|
352
|
-
show_default=True,
|
|
353
|
-
).lower()
|
|
991
|
+
return
|
|
354
992
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
)
|
|
364
|
-
elif choice == "c":
|
|
365
|
-
# Commit with generated message
|
|
366
|
-
commit_sha = commit_with_retry_no_verify(repo, commit_message)
|
|
367
|
-
else:
|
|
368
|
-
console.print("Invalid choice. Commit cancelled.")
|
|
369
|
-
raise typer.Exit()
|
|
370
|
-
|
|
371
|
-
# Show success message
|
|
372
|
-
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
|
+
)
|
|
373
1001
|
|
|
374
1002
|
|
|
375
1003
|
if __name__ == "__main__":
|
|
376
|
-
|
|
1004
|
+
run()
|