git-copilot-commit 0.5.0__py3-none-any.whl → 0.5.2__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 +30 -62
- git_copilot_commit/git.py +43 -13
- git_copilot_commit/github_copilot.py +205 -134
- git_copilot_commit/prompts/split-commit-planner-prompt.md +0 -1
- git_copilot_commit/settings.py +9 -3
- git_copilot_commit/split_commits.py +26 -55
- {git_copilot_commit-0.5.0.dist-info → git_copilot_commit-0.5.2.dist-info}/METADATA +34 -25
- git_copilot_commit-0.5.2.dist-info/RECORD +15 -0
- git_copilot_commit-0.5.0.dist-info/RECORD +0 -15
- {git_copilot_commit-0.5.0.dist-info → git_copilot_commit-0.5.2.dist-info}/WHEEL +0 -0
- {git_copilot_commit-0.5.0.dist-info → git_copilot_commit-0.5.2.dist-info}/entry_points.txt +0 -0
- {git_copilot_commit-0.5.0.dist-info → git_copilot_commit-0.5.2.dist-info}/licenses/LICENSE +0 -0
git_copilot_commit/cli.py
CHANGED
|
@@ -19,11 +19,9 @@ from .git import GitRepository, GitError, GitStatus, NotAGitRepositoryError
|
|
|
19
19
|
from .split_commits import (
|
|
20
20
|
PatchUnit,
|
|
21
21
|
SplitCommitPlan,
|
|
22
|
-
SplitCommitLimitExceededError,
|
|
23
22
|
SplitPlanningError,
|
|
24
23
|
build_split_plan_prompt,
|
|
25
24
|
build_status_for_patch_units,
|
|
26
|
-
evaluate_auto_split,
|
|
27
25
|
extract_patch_units,
|
|
28
26
|
group_patch_units,
|
|
29
27
|
parse_split_plan_response,
|
|
@@ -37,7 +35,6 @@ app = typer.Typer(help=__doc__, add_completion=False)
|
|
|
37
35
|
|
|
38
36
|
COMMIT_MESSAGE_PROMPT_FILENAME = "commit-message-generator-prompt.md"
|
|
39
37
|
SPLIT_COMMIT_PLANNER_PROMPT_FILENAME = "split-commit-planner-prompt.md"
|
|
40
|
-
DEFAULT_AUTO_MAX_COMMITS = 10
|
|
41
38
|
SPLIT_DIFF_ARGS = [
|
|
42
39
|
"--binary",
|
|
43
40
|
"--full-index",
|
|
@@ -75,7 +72,7 @@ SplitOption = Annotated[
|
|
|
75
72
|
"--split",
|
|
76
73
|
help=(
|
|
77
74
|
"Split staged hunks into multiple commits automatically. Pass "
|
|
78
|
-
"`--split=N` to
|
|
75
|
+
"`--split=N` to express a preference for N commits."
|
|
79
76
|
),
|
|
80
77
|
),
|
|
81
78
|
]
|
|
@@ -128,11 +125,7 @@ def preprocess_cli_args(args: Sequence[str]) -> list[str]:
|
|
|
128
125
|
index += 1
|
|
129
126
|
continue
|
|
130
127
|
|
|
131
|
-
if (
|
|
132
|
-
in_commit_command
|
|
133
|
-
and arg == "--split"
|
|
134
|
-
and index + 1 < len(args)
|
|
135
|
-
):
|
|
128
|
+
if in_commit_command and arg == "--split" and index + 1 < len(args):
|
|
136
129
|
split_value = args[index + 1].strip().lower()
|
|
137
130
|
if split_value == "auto":
|
|
138
131
|
processed_args.append("--split")
|
|
@@ -352,21 +345,6 @@ def generate_commit_message_for_status(
|
|
|
352
345
|
)
|
|
353
346
|
|
|
354
347
|
|
|
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
|
-
|
|
370
348
|
def commit_with_retry_no_verify(
|
|
371
349
|
repo: GitRepository,
|
|
372
350
|
message: str,
|
|
@@ -471,7 +449,6 @@ def request_split_commit_plan(
|
|
|
471
449
|
status: GitStatus,
|
|
472
450
|
patch_units: tuple[PatchUnit, ...],
|
|
473
451
|
*,
|
|
474
|
-
max_commits: int,
|
|
475
452
|
preferred_commits: int | None = None,
|
|
476
453
|
model: str | None = None,
|
|
477
454
|
context: str = "",
|
|
@@ -482,7 +459,6 @@ def request_split_commit_plan(
|
|
|
482
459
|
planner_prompt = build_split_plan_prompt(
|
|
483
460
|
status,
|
|
484
461
|
patch_units,
|
|
485
|
-
max_commits=max_commits,
|
|
486
462
|
preferred_commits=preferred_commits,
|
|
487
463
|
context=context,
|
|
488
464
|
)
|
|
@@ -499,7 +475,6 @@ def request_split_commit_plan(
|
|
|
499
475
|
return parse_split_plan_response(
|
|
500
476
|
response,
|
|
501
477
|
patch_units,
|
|
502
|
-
max_commits=max_commits,
|
|
503
478
|
)
|
|
504
479
|
except github_copilot.CopilotError as exc:
|
|
505
480
|
print_copilot_error("Could not generate a split commit plan", exc)
|
|
@@ -541,25 +516,31 @@ def request_split_commit_messages(
|
|
|
541
516
|
raise typer.Exit(1)
|
|
542
517
|
|
|
543
518
|
|
|
544
|
-
def
|
|
545
|
-
|
|
519
|
+
def confirm_split_commit_count(
|
|
520
|
+
plan: SplitCommitPlan,
|
|
521
|
+
*,
|
|
522
|
+
preferred_commits: int,
|
|
523
|
+
yes: bool = False,
|
|
546
524
|
) -> SplitCommitPlan:
|
|
547
|
-
"""Ask whether to proceed when the planner exceeds the
|
|
525
|
+
"""Ask whether to proceed when the planner exceeds the preferred count."""
|
|
526
|
+
actual_commits = len(plan.commits)
|
|
527
|
+
if actual_commits <= preferred_commits:
|
|
528
|
+
return plan
|
|
529
|
+
|
|
548
530
|
console.print(
|
|
549
|
-
|
|
531
|
+
"[yellow]Split planning produced "
|
|
532
|
+
f"{actual_commits} commits, exceeding the preferred count of "
|
|
533
|
+
f"{preferred_commits}.[/yellow]"
|
|
550
534
|
)
|
|
551
535
|
|
|
552
536
|
if yes:
|
|
553
|
-
|
|
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)
|
|
537
|
+
return plan
|
|
557
538
|
|
|
558
539
|
if Confirm.ask(
|
|
559
|
-
f"Proceed with [bold]{
|
|
540
|
+
f"Proceed with [bold]{actual_commits} commits[/] anyway?",
|
|
560
541
|
default=False,
|
|
561
542
|
):
|
|
562
|
-
return
|
|
543
|
+
return plan
|
|
563
544
|
|
|
564
545
|
console.print("Split commit plan cancelled.")
|
|
565
546
|
raise typer.Exit()
|
|
@@ -747,44 +728,24 @@ def handle_split_commit_flow(
|
|
|
747
728
|
return
|
|
748
729
|
|
|
749
730
|
if preferred_commits is None:
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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]")
|
|
731
|
+
console.print(
|
|
732
|
+
"[yellow]Planning split commits from the staged patch units.[/yellow]"
|
|
733
|
+
)
|
|
767
734
|
else:
|
|
768
735
|
console.print(
|
|
769
|
-
|
|
736
|
+
"[yellow]Planning split commits with a preference for "
|
|
737
|
+
f"{preferred_commits} commits.[/yellow]"
|
|
770
738
|
)
|
|
771
739
|
|
|
772
740
|
try:
|
|
773
741
|
split_plan = request_split_commit_plan(
|
|
774
742
|
status,
|
|
775
743
|
patch_units,
|
|
776
|
-
max_commits=(
|
|
777
|
-
DEFAULT_AUTO_MAX_COMMITS
|
|
778
|
-
if preferred_commits is None
|
|
779
|
-
else preferred_commits
|
|
780
|
-
),
|
|
781
744
|
preferred_commits=preferred_commits,
|
|
782
745
|
model=model,
|
|
783
746
|
context=context,
|
|
784
747
|
http_client_config=http_client_config,
|
|
785
748
|
)
|
|
786
|
-
except SplitCommitLimitExceededError as exc:
|
|
787
|
-
split_plan = resolve_split_commit_limit(exc, yes=yes)
|
|
788
749
|
except SplitPlanningError as exc:
|
|
789
750
|
console.print(
|
|
790
751
|
"[yellow]Split planning returned an invalid plan; falling back to a single commit.[/yellow]"
|
|
@@ -800,6 +761,13 @@ def handle_split_commit_flow(
|
|
|
800
761
|
)
|
|
801
762
|
return
|
|
802
763
|
|
|
764
|
+
if preferred_commits is not None:
|
|
765
|
+
split_plan = confirm_split_commit_count(
|
|
766
|
+
split_plan,
|
|
767
|
+
preferred_commits=preferred_commits,
|
|
768
|
+
yes=yes,
|
|
769
|
+
)
|
|
770
|
+
|
|
803
771
|
prepared_commits = request_split_commit_messages(
|
|
804
772
|
split_plan,
|
|
805
773
|
patch_units,
|
git_copilot_commit/git.py
CHANGED
|
@@ -121,16 +121,31 @@ class GitRepository:
|
|
|
121
121
|
Raises:
|
|
122
122
|
NotAGitRepositoryError: If the path is not a git repository.
|
|
123
123
|
"""
|
|
124
|
-
self.
|
|
124
|
+
self.cwd = (repo_path or Path.cwd()).resolve()
|
|
125
125
|
self.timeout = timeout
|
|
126
|
-
self.
|
|
126
|
+
self.repo_path = self._resolve_repo_root()
|
|
127
127
|
|
|
128
|
-
def
|
|
129
|
-
"""
|
|
128
|
+
def _resolve_repo_root(self) -> Path:
|
|
129
|
+
"""Resolve and cache the repository top-level path."""
|
|
130
130
|
try:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
result = subprocess.run(
|
|
132
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
133
|
+
cwd=self.cwd,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
timeout=self.timeout,
|
|
137
|
+
check=True,
|
|
138
|
+
)
|
|
139
|
+
except subprocess.CalledProcessError:
|
|
140
|
+
raise NotAGitRepositoryError(f"{self.cwd} is not a git repository")
|
|
141
|
+
except subprocess.TimeoutExpired:
|
|
142
|
+
raise GitCommandError("Git command timed out: git rev-parse --show-toplevel")
|
|
143
|
+
|
|
144
|
+
repo_root = result.stdout.strip()
|
|
145
|
+
if not repo_root:
|
|
146
|
+
raise NotAGitRepositoryError(f"{self.cwd} is not a git repository")
|
|
147
|
+
|
|
148
|
+
return Path(repo_root)
|
|
134
149
|
|
|
135
150
|
def _run_git_command(
|
|
136
151
|
self,
|
|
@@ -188,6 +203,21 @@ class GitRepository:
|
|
|
188
203
|
merged_env.update(env)
|
|
189
204
|
return merged_env
|
|
190
205
|
|
|
206
|
+
def _normalize_paths(self, paths: list[str]) -> list[str]:
|
|
207
|
+
"""Normalize user paths relative to the repository root."""
|
|
208
|
+
normalized_paths: list[str] = []
|
|
209
|
+
for path in paths:
|
|
210
|
+
path_obj = Path(path)
|
|
211
|
+
if path_obj.is_absolute():
|
|
212
|
+
normalized_paths.append(str(path_obj))
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
normalized_paths.append(
|
|
216
|
+
os.path.relpath(self.cwd / path_obj, start=self.repo_path)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return normalized_paths
|
|
220
|
+
|
|
191
221
|
def get_status(self, env: Mapping[str, str] | None = None) -> GitStatus:
|
|
192
222
|
"""
|
|
193
223
|
Get comprehensive git status information.
|
|
@@ -263,16 +293,16 @@ class GitRepository:
|
|
|
263
293
|
Stage files for commit.
|
|
264
294
|
|
|
265
295
|
Args:
|
|
266
|
-
paths: List of file paths to stage. If None, stages all files
|
|
296
|
+
paths: List of file paths to stage. If None, stages all files.
|
|
267
297
|
"""
|
|
268
298
|
if paths is None:
|
|
269
|
-
self._run_git_command(["add", "
|
|
299
|
+
self._run_git_command(["add", "--all"])
|
|
270
300
|
else:
|
|
271
|
-
self._run_git_command(["add"] + paths)
|
|
301
|
+
self._run_git_command(["add"] + self._normalize_paths(paths))
|
|
272
302
|
|
|
273
303
|
def stage_modified(self) -> None:
|
|
274
|
-
"""Stage all modified files
|
|
275
|
-
self._run_git_command(["add", "
|
|
304
|
+
"""Stage all modified tracked files."""
|
|
305
|
+
self._run_git_command(["add", "--update"])
|
|
276
306
|
|
|
277
307
|
def unstage_files(self, paths: list[str] | None = None) -> None:
|
|
278
308
|
"""
|
|
@@ -284,7 +314,7 @@ class GitRepository:
|
|
|
284
314
|
if paths is None:
|
|
285
315
|
self._run_git_command(["reset", "HEAD"])
|
|
286
316
|
else:
|
|
287
|
-
self._run_git_command(["reset", "HEAD"] + paths)
|
|
317
|
+
self._run_git_command(["reset", "HEAD"] + self._normalize_paths(paths))
|
|
288
318
|
|
|
289
319
|
def create_alternate_index(self, from_ref: str = "HEAD") -> AlternateGitIndex:
|
|
290
320
|
"""Create a temporary git index initialized from the provided ref."""
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
4
|
import json
|
|
5
|
-
import
|
|
5
|
+
import random
|
|
6
6
|
import re
|
|
7
7
|
import secrets
|
|
8
8
|
import time
|
|
@@ -24,6 +24,7 @@ from .settings import Settings
|
|
|
24
24
|
APP_NAME = "github-copilot-commit"
|
|
25
25
|
CLI_AUTH_COMMAND = "git-copilot-commit authenticate"
|
|
26
26
|
DEFAULT_GITHUB_DOMAIN = "github.com"
|
|
27
|
+
CREDENTIALS_FILENAME = "copilot-auth.json"
|
|
27
28
|
USER_AGENT = "GitHubCopilotChat/0.35.0"
|
|
28
29
|
EDITOR_VERSION = "vscode/1.107.0"
|
|
29
30
|
EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0"
|
|
@@ -33,6 +34,11 @@ CLIENT_ID = base64.b64decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=").decode()
|
|
|
33
34
|
INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2
|
|
34
35
|
SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4
|
|
35
36
|
DEFAULT_MODEL_ID = "gpt-5.3-codex"
|
|
37
|
+
HTTP_RETRY_ATTEMPTS = 3
|
|
38
|
+
HTTP_RETRY_BASE_DELAY_SECONDS = 0.5
|
|
39
|
+
HTTP_RETRY_MAX_DELAY_SECONDS = 4.0
|
|
40
|
+
HTTP_RETRY_MAX_JITTER_SECONDS = 0.25
|
|
41
|
+
HTTP_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
36
42
|
DEFAULT_MODEL_PREFERENCES = (
|
|
37
43
|
"gpt-5.3-codex",
|
|
38
44
|
"gpt-5.4",
|
|
@@ -267,15 +273,8 @@ class CopilotConfig:
|
|
|
267
273
|
return cls(default_model=default_model)
|
|
268
274
|
|
|
269
275
|
|
|
270
|
-
def xdg_data_home() -> Path:
|
|
271
|
-
value = os.environ.get("XDG_DATA_HOME")
|
|
272
|
-
if value:
|
|
273
|
-
return Path(value).expanduser()
|
|
274
|
-
return Path.home() / ".local" / "share"
|
|
275
|
-
|
|
276
|
-
|
|
277
276
|
def credentials_path() -> Path:
|
|
278
|
-
return
|
|
277
|
+
return Settings().state_dir / CREDENTIALS_FILENAME
|
|
279
278
|
|
|
280
279
|
|
|
281
280
|
def config_path() -> Path:
|
|
@@ -365,7 +364,9 @@ def _maybe_enable_native_tls(native_tls: bool) -> None:
|
|
|
365
364
|
_NATIVE_TLS_ENABLED = True
|
|
366
365
|
|
|
367
366
|
|
|
368
|
-
def make_http_client(
|
|
367
|
+
def make_http_client(
|
|
368
|
+
http_client_config: HttpClientConfig | None = None,
|
|
369
|
+
) -> httpx.Client:
|
|
369
370
|
config = http_client_config or HttpClientConfig()
|
|
370
371
|
_maybe_enable_native_tls(config.use_native_tls)
|
|
371
372
|
|
|
@@ -376,6 +377,34 @@ def make_http_client(http_client_config: HttpClientConfig | None = None) -> http
|
|
|
376
377
|
)
|
|
377
378
|
|
|
378
379
|
|
|
380
|
+
def truncate_response_detail(detail: str) -> str:
|
|
381
|
+
detail = detail.strip()
|
|
382
|
+
if len(detail) > 400:
|
|
383
|
+
return f"{detail[:397]}..."
|
|
384
|
+
return detail
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def should_retry_status_code(status_code: int) -> bool:
|
|
388
|
+
return status_code in HTTP_RETRYABLE_STATUS_CODES
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def compute_retry_delay_seconds(attempt: int, retry_after: str | None = None) -> float:
|
|
392
|
+
if retry_after is not None:
|
|
393
|
+
try:
|
|
394
|
+
delay = float(retry_after.strip())
|
|
395
|
+
except ValueError:
|
|
396
|
+
delay = -1.0
|
|
397
|
+
else:
|
|
398
|
+
if delay >= 0:
|
|
399
|
+
return delay
|
|
400
|
+
|
|
401
|
+
backoff = min(
|
|
402
|
+
HTTP_RETRY_MAX_DELAY_SECONDS,
|
|
403
|
+
HTTP_RETRY_BASE_DELAY_SECONDS * (2**attempt),
|
|
404
|
+
)
|
|
405
|
+
return backoff + random.uniform(0.0, HTTP_RETRY_MAX_JITTER_SECONDS)
|
|
406
|
+
|
|
407
|
+
|
|
379
408
|
def request_json(
|
|
380
409
|
client: httpx.Client,
|
|
381
410
|
method: str,
|
|
@@ -385,31 +414,48 @@ def request_json(
|
|
|
385
414
|
data: dict[str, str] | None = None,
|
|
386
415
|
json_body: Any | None = None,
|
|
387
416
|
) -> Any:
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
417
|
+
for attempt in range(HTTP_RETRY_ATTEMPTS):
|
|
418
|
+
try:
|
|
419
|
+
response = client.request(
|
|
420
|
+
method,
|
|
421
|
+
url,
|
|
422
|
+
headers=headers,
|
|
423
|
+
data=data,
|
|
424
|
+
json=json_body,
|
|
425
|
+
)
|
|
426
|
+
except httpx.TransportError as exc:
|
|
427
|
+
if attempt < HTTP_RETRY_ATTEMPTS - 1:
|
|
428
|
+
time.sleep(compute_retry_delay_seconds(attempt))
|
|
429
|
+
continue
|
|
430
|
+
raise CopilotError(f"Request failed for {url}: {exc}") from exc
|
|
431
|
+
except httpx.HTTPError as exc:
|
|
432
|
+
raise CopilotError(f"Request failed for {url}: {exc}") from exc
|
|
433
|
+
|
|
434
|
+
if response.is_error:
|
|
435
|
+
detail = truncate_response_detail(response.text)
|
|
436
|
+
error = CopilotHttpError(
|
|
437
|
+
response.status_code, response.reason_phrase, detail
|
|
438
|
+
)
|
|
439
|
+
if (
|
|
440
|
+
should_retry_status_code(response.status_code)
|
|
441
|
+
and attempt < HTTP_RETRY_ATTEMPTS - 1
|
|
442
|
+
):
|
|
443
|
+
time.sleep(
|
|
444
|
+
compute_retry_delay_seconds(
|
|
445
|
+
attempt, response.headers.get("retry-after")
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
continue
|
|
449
|
+
raise error
|
|
398
450
|
|
|
399
|
-
|
|
400
|
-
detail = response.text.strip()
|
|
401
|
-
if len(detail) > 400:
|
|
402
|
-
detail = f"{detail[:397]}..."
|
|
403
|
-
raise CopilotHttpError(response.status_code, response.reason_phrase, detail)
|
|
451
|
+
break
|
|
404
452
|
|
|
405
453
|
content_type = response.headers.get("content-type", "")
|
|
406
454
|
if "application/json" not in content_type:
|
|
407
455
|
try:
|
|
408
456
|
return response.json()
|
|
409
457
|
except ValueError as exc:
|
|
410
|
-
detail = response.text
|
|
411
|
-
if len(detail) > 400:
|
|
412
|
-
detail = f"{detail[:397]}..."
|
|
458
|
+
detail = truncate_response_detail(response.text)
|
|
413
459
|
raise CopilotError(
|
|
414
460
|
f"Expected JSON from {url}, got {content_type or 'unknown content type'}: {detail}"
|
|
415
461
|
) from exc
|
|
@@ -1020,118 +1066,141 @@ def responses_completion(
|
|
|
1020
1066
|
"store": False,
|
|
1021
1067
|
}
|
|
1022
1068
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1069
|
+
for attempt in range(HTTP_RETRY_ATTEMPTS):
|
|
1070
|
+
text_parts: list[str] = []
|
|
1071
|
+
final_response: dict[str, Any] | None = None
|
|
1025
1072
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1073
|
+
try:
|
|
1074
|
+
with client.stream(
|
|
1075
|
+
"POST",
|
|
1076
|
+
url,
|
|
1077
|
+
headers=copilot_request_headers(
|
|
1078
|
+
credentials.copilot_token,
|
|
1079
|
+
intent="conversation-edits",
|
|
1080
|
+
accept="text/event-stream",
|
|
1081
|
+
),
|
|
1082
|
+
json=request_body,
|
|
1083
|
+
) as response:
|
|
1084
|
+
if response.is_error:
|
|
1085
|
+
detail = truncate_response_detail(
|
|
1086
|
+
response.read().decode("utf-8", errors="replace")
|
|
1087
|
+
)
|
|
1088
|
+
error = CopilotHttpError(
|
|
1089
|
+
response.status_code, response.reason_phrase, detail
|
|
1090
|
+
)
|
|
1091
|
+
if (
|
|
1092
|
+
should_retry_status_code(response.status_code)
|
|
1093
|
+
and attempt < HTTP_RETRY_ATTEMPTS - 1
|
|
1094
|
+
):
|
|
1095
|
+
time.sleep(
|
|
1096
|
+
compute_retry_delay_seconds(
|
|
1097
|
+
attempt, response.headers.get("retry-after")
|
|
1098
|
+
)
|
|
1099
|
+
)
|
|
1100
|
+
continue
|
|
1101
|
+
raise error
|
|
1102
|
+
|
|
1103
|
+
content_type = response.headers.get("content-type", "")
|
|
1104
|
+
if "text/event-stream" not in content_type:
|
|
1105
|
+
body = response.read()
|
|
1106
|
+
try:
|
|
1107
|
+
payload = json.loads(body)
|
|
1108
|
+
except ValueError as exc:
|
|
1109
|
+
detail = truncate_response_detail(
|
|
1110
|
+
body.decode("utf-8", errors="replace")
|
|
1111
|
+
)
|
|
1112
|
+
raise CopilotError(
|
|
1113
|
+
f"Expected an SSE stream from {url}, got {content_type or 'unknown content type'}: {detail}"
|
|
1114
|
+
) from exc
|
|
1115
|
+
return extract_response_text(payload)
|
|
1116
|
+
|
|
1117
|
+
for event in iter_sse_events(response, url):
|
|
1118
|
+
if not isinstance(event, dict):
|
|
1119
|
+
continue
|
|
1120
|
+
|
|
1121
|
+
event_type = event.get("type")
|
|
1122
|
+
if event_type == "response.output_text.delta":
|
|
1123
|
+
delta = event.get("delta")
|
|
1124
|
+
if isinstance(delta, str) and delta:
|
|
1125
|
+
text_parts.append(delta)
|
|
1126
|
+
continue
|
|
1127
|
+
|
|
1128
|
+
if event_type == "response.output_text.done" and not text_parts:
|
|
1129
|
+
text = event.get("text")
|
|
1130
|
+
if isinstance(text, str) and text.strip():
|
|
1131
|
+
text_parts.append(text)
|
|
1132
|
+
continue
|
|
1133
|
+
|
|
1134
|
+
if event_type == "error":
|
|
1135
|
+
error = event.get("error")
|
|
1136
|
+
if isinstance(error, dict):
|
|
1137
|
+
message = error.get("message")
|
|
1138
|
+
code = error.get("code")
|
|
1139
|
+
if isinstance(message, str) and message.strip():
|
|
1140
|
+
prefix = (
|
|
1141
|
+
f"{code}: "
|
|
1142
|
+
if isinstance(code, str) and code
|
|
1143
|
+
else ""
|
|
1144
|
+
)
|
|
1145
|
+
raise CopilotError(
|
|
1146
|
+
f"Responses stream error: {prefix}{message.strip()}"
|
|
1147
|
+
)
|
|
1148
|
+
raise CopilotError("Responses stream returned an error event.")
|
|
1149
|
+
|
|
1150
|
+
if event_type in {
|
|
1151
|
+
"response.completed",
|
|
1152
|
+
"response.failed",
|
|
1153
|
+
"response.incomplete",
|
|
1154
|
+
}:
|
|
1155
|
+
response_payload = event.get("response")
|
|
1156
|
+
if isinstance(response_payload, dict):
|
|
1157
|
+
final_response = response_payload
|
|
1158
|
+
except httpx.TransportError as exc:
|
|
1159
|
+
if attempt < HTTP_RETRY_ATTEMPTS - 1:
|
|
1160
|
+
time.sleep(compute_retry_delay_seconds(attempt))
|
|
1161
|
+
continue
|
|
1162
|
+
raise CopilotError(f"Request failed for {url}: {exc}") from exc
|
|
1163
|
+
except httpx.HTTPError as exc:
|
|
1164
|
+
raise CopilotError(f"Request failed for {url}: {exc}") from exc
|
|
1165
|
+
|
|
1166
|
+
text = "".join(text_parts).strip()
|
|
1167
|
+
if final_response is None:
|
|
1168
|
+
if text:
|
|
1169
|
+
return text
|
|
1170
|
+
raise CopilotError(
|
|
1171
|
+
"Responses stream ended without a terminal response event."
|
|
1172
|
+
)
|
|
1044
1173
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
if
|
|
1053
|
-
detail = f"{detail[:397]}..."
|
|
1174
|
+
status = final_response.get("status")
|
|
1175
|
+
if status == "failed":
|
|
1176
|
+
error = final_response.get("error")
|
|
1177
|
+
if isinstance(error, dict):
|
|
1178
|
+
message = error.get("message")
|
|
1179
|
+
code = error.get("code")
|
|
1180
|
+
if isinstance(message, str) and message.strip():
|
|
1181
|
+
prefix = f"{code}: " if isinstance(code, str) and code else ""
|
|
1054
1182
|
raise CopilotError(
|
|
1055
|
-
f"
|
|
1056
|
-
)
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
if
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
if event_type == "response.output_text.done" and not text_parts:
|
|
1071
|
-
text = event.get("text")
|
|
1072
|
-
if isinstance(text, str) and text.strip():
|
|
1073
|
-
text_parts.append(text)
|
|
1074
|
-
continue
|
|
1075
|
-
|
|
1076
|
-
if event_type == "error":
|
|
1077
|
-
error = event.get("error")
|
|
1078
|
-
if isinstance(error, dict):
|
|
1079
|
-
message = error.get("message")
|
|
1080
|
-
code = error.get("code")
|
|
1081
|
-
if isinstance(message, str) and message.strip():
|
|
1082
|
-
prefix = (
|
|
1083
|
-
f"{code}: " if isinstance(code, str) and code else ""
|
|
1084
|
-
)
|
|
1085
|
-
raise CopilotError(
|
|
1086
|
-
f"Responses stream error: {prefix}{message.strip()}"
|
|
1087
|
-
)
|
|
1088
|
-
raise CopilotError("Responses stream returned an error event.")
|
|
1089
|
-
|
|
1090
|
-
if event_type in {
|
|
1091
|
-
"response.completed",
|
|
1092
|
-
"response.failed",
|
|
1093
|
-
"response.incomplete",
|
|
1094
|
-
}:
|
|
1095
|
-
response_payload = event.get("response")
|
|
1096
|
-
if isinstance(response_payload, dict):
|
|
1097
|
-
final_response = response_payload
|
|
1098
|
-
except httpx.HTTPError as exc:
|
|
1099
|
-
raise CopilotError(f"Request failed for {url}: {exc}") from exc
|
|
1100
|
-
|
|
1101
|
-
text = "".join(text_parts).strip()
|
|
1102
|
-
if final_response is None:
|
|
1183
|
+
f"Responses API request failed: {prefix}{message.strip()}"
|
|
1184
|
+
)
|
|
1185
|
+
raise CopilotError("Responses API request failed.")
|
|
1186
|
+
|
|
1187
|
+
if status == "incomplete":
|
|
1188
|
+
details = final_response.get("incomplete_details")
|
|
1189
|
+
reason = "unknown"
|
|
1190
|
+
if isinstance(details, dict):
|
|
1191
|
+
raw_reason = details.get("reason")
|
|
1192
|
+
if isinstance(raw_reason, str) and raw_reason.strip():
|
|
1193
|
+
reason = raw_reason.strip()
|
|
1194
|
+
if text:
|
|
1195
|
+
return f"{text}\n\n[Response incomplete: {reason}]"
|
|
1196
|
+
raise CopilotError(f"Responses API response was incomplete: {reason}.")
|
|
1197
|
+
|
|
1103
1198
|
if text:
|
|
1104
1199
|
return text
|
|
1105
|
-
raise CopilotError("Responses stream ended without a terminal response event.")
|
|
1106
|
-
|
|
1107
|
-
status = final_response.get("status")
|
|
1108
|
-
if status == "failed":
|
|
1109
|
-
error = final_response.get("error")
|
|
1110
|
-
if isinstance(error, dict):
|
|
1111
|
-
message = error.get("message")
|
|
1112
|
-
code = error.get("code")
|
|
1113
|
-
if isinstance(message, str) and message.strip():
|
|
1114
|
-
prefix = f"{code}: " if isinstance(code, str) and code else ""
|
|
1115
|
-
raise CopilotError(
|
|
1116
|
-
f"Responses API request failed: {prefix}{message.strip()}"
|
|
1117
|
-
)
|
|
1118
|
-
raise CopilotError("Responses API request failed.")
|
|
1119
|
-
|
|
1120
|
-
if status == "incomplete":
|
|
1121
|
-
details = final_response.get("incomplete_details")
|
|
1122
|
-
reason = "unknown"
|
|
1123
|
-
if isinstance(details, dict):
|
|
1124
|
-
raw_reason = details.get("reason")
|
|
1125
|
-
if isinstance(raw_reason, str) and raw_reason.strip():
|
|
1126
|
-
reason = raw_reason.strip()
|
|
1127
|
-
if text:
|
|
1128
|
-
return f"{text}\n\n[Response incomplete: {reason}]"
|
|
1129
|
-
raise CopilotError(f"Responses API response was incomplete: {reason}.")
|
|
1130
1200
|
|
|
1131
|
-
|
|
1132
|
-
return text
|
|
1201
|
+
return extract_response_text(final_response)
|
|
1133
1202
|
|
|
1134
|
-
|
|
1203
|
+
raise AssertionError("Responses completion exhausted retries unexpectedly.")
|
|
1135
1204
|
|
|
1136
1205
|
|
|
1137
1206
|
def complete_text_prompt(
|
|
@@ -1375,7 +1444,9 @@ def login(
|
|
|
1375
1444
|
|
|
1376
1445
|
if existing and not force:
|
|
1377
1446
|
raise CopilotError(
|
|
1378
|
-
|
|
1447
|
+
"Cached credentials already exist at "
|
|
1448
|
+
f"{credentials_path()}. "
|
|
1449
|
+
"Re-run with --force to replace them."
|
|
1379
1450
|
)
|
|
1380
1451
|
|
|
1381
1452
|
domain = normalized_domain or DEFAULT_GITHUB_DOMAIN
|
git_copilot_commit/settings.py
CHANGED
|
@@ -3,6 +3,7 @@ Settings management using platformdirs for cross-platform directory paths.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import warnings
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
from platformdirs import (
|
|
@@ -50,10 +51,15 @@ class Settings:
|
|
|
50
51
|
def _save_config(self) -> None:
|
|
51
52
|
"""Save configuration to file."""
|
|
52
53
|
try:
|
|
53
|
-
with open(self.config_file, "w") as f:
|
|
54
|
+
with open(self.config_file, "w", encoding="utf-8") as f:
|
|
54
55
|
json.dump(self._config, f, indent=2)
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
f.write("\n")
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
warnings.warn(
|
|
59
|
+
f"Could not save config to {self.config_file}: {exc}",
|
|
60
|
+
RuntimeWarning,
|
|
61
|
+
stacklevel=2,
|
|
62
|
+
)
|
|
57
63
|
|
|
58
64
|
def get(self, key: str, default: Any = None) -> Any:
|
|
59
65
|
"""Get a configuration value."""
|
|
@@ -8,26 +8,12 @@ from typing import Any, Iterable, Sequence, cast
|
|
|
8
8
|
from .git import GitFile, GitStatus
|
|
9
9
|
|
|
10
10
|
DIFF_HEADER_PREFIX = "diff --git "
|
|
11
|
-
AUTO_SPLIT_UNIT_THRESHOLD = 3
|
|
12
|
-
AUTO_SPLIT_PATH_THRESHOLD = 4
|
|
13
11
|
|
|
14
12
|
|
|
15
13
|
class SplitPlanningError(ValueError):
|
|
16
14
|
"""Raised when a split-commit plan cannot be built or validated."""
|
|
17
15
|
|
|
18
16
|
|
|
19
|
-
class SplitCommitLimitExceededError(SplitPlanningError):
|
|
20
|
-
"""Raised when the planner returns a valid plan above the configured limit."""
|
|
21
|
-
|
|
22
|
-
def __init__(self, plan: "SplitCommitPlan", max_commits: int) -> None:
|
|
23
|
-
self.plan = plan
|
|
24
|
-
self.max_commits = max_commits
|
|
25
|
-
self.actual_commits = len(plan.commits)
|
|
26
|
-
super().__init__(
|
|
27
|
-
f"Planner returned {self.actual_commits} commits, exceeding the max of {max_commits}."
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
|
|
31
17
|
@dataclass(frozen=True, slots=True)
|
|
32
18
|
class FilePatch:
|
|
33
19
|
"""Structured representation of a single file-level diff patch."""
|
|
@@ -69,33 +55,6 @@ class SplitCommitPlan:
|
|
|
69
55
|
commits: tuple[SplitPlanCommit, ...]
|
|
70
56
|
|
|
71
57
|
|
|
72
|
-
def evaluate_auto_split(patch_units: Sequence[PatchUnit]) -> tuple[bool, str]:
|
|
73
|
-
"""Return whether automatic split planning should run for the patch set."""
|
|
74
|
-
if len(patch_units) < 2:
|
|
75
|
-
return False, "fewer than two independent patch units were found"
|
|
76
|
-
|
|
77
|
-
kinds = {unit.kind for unit in patch_units}
|
|
78
|
-
staged_statuses = {unit.staged_status for unit in patch_units}
|
|
79
|
-
paths = {unit.path for unit in patch_units}
|
|
80
|
-
|
|
81
|
-
if len(patch_units) >= AUTO_SPLIT_UNIT_THRESHOLD:
|
|
82
|
-
return True, f"found {len(patch_units)} independent patch units"
|
|
83
|
-
|
|
84
|
-
if len(kinds) >= 2:
|
|
85
|
-
return True, f"found mixed change kinds ({', '.join(sorted(kinds))})"
|
|
86
|
-
|
|
87
|
-
if len(staged_statuses) >= 2:
|
|
88
|
-
return True, (
|
|
89
|
-
"found mixed staged change types "
|
|
90
|
-
f"({', '.join(sorted(staged_statuses))})"
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
if len(paths) >= AUTO_SPLIT_PATH_THRESHOLD:
|
|
94
|
-
return True, f"found changes across {len(paths)} files"
|
|
95
|
-
|
|
96
|
-
return False, f"found only {len(patch_units)} similar patch units"
|
|
97
|
-
|
|
98
|
-
|
|
99
58
|
def extract_patch_units(diff_text: str) -> list[PatchUnit]:
|
|
100
59
|
"""Convert a staged diff into patch units suitable for split planning."""
|
|
101
60
|
units: list[PatchUnit] = []
|
|
@@ -212,13 +171,14 @@ def build_split_plan_prompt(
|
|
|
212
171
|
status: GitStatus,
|
|
213
172
|
patch_units: Sequence[PatchUnit],
|
|
214
173
|
*,
|
|
215
|
-
max_commits: int,
|
|
216
174
|
preferred_commits: int | None = None,
|
|
217
175
|
context: str = "",
|
|
218
176
|
) -> str:
|
|
219
177
|
"""Build the user prompt used to request a split-commit plan."""
|
|
220
178
|
if not patch_units:
|
|
221
|
-
raise SplitPlanningError(
|
|
179
|
+
raise SplitPlanningError(
|
|
180
|
+
"Cannot plan split commits without staged patch units."
|
|
181
|
+
)
|
|
222
182
|
|
|
223
183
|
prompt_parts = []
|
|
224
184
|
if context.strip():
|
|
@@ -226,7 +186,6 @@ def build_split_plan_prompt(
|
|
|
226
186
|
|
|
227
187
|
if preferred_commits is not None:
|
|
228
188
|
prompt_parts.append(f"Preferred commits: {preferred_commits}")
|
|
229
|
-
prompt_parts.append(f"Maximum commits: {max_commits}")
|
|
230
189
|
|
|
231
190
|
prompt_parts.extend(
|
|
232
191
|
[
|
|
@@ -256,8 +215,6 @@ def build_split_plan_prompt(
|
|
|
256
215
|
def parse_split_plan_response(
|
|
257
216
|
response_text: str,
|
|
258
217
|
patch_units: Sequence[PatchUnit],
|
|
259
|
-
*,
|
|
260
|
-
max_commits: int,
|
|
261
218
|
) -> SplitCommitPlan:
|
|
262
219
|
"""Parse and validate the planner model's JSON response."""
|
|
263
220
|
payload = parse_json_payload(response_text)
|
|
@@ -269,7 +226,9 @@ def parse_split_plan_response(
|
|
|
269
226
|
commits_data = payload
|
|
270
227
|
|
|
271
228
|
if not isinstance(commits_data, list) or not commits_data:
|
|
272
|
-
raise SplitPlanningError(
|
|
229
|
+
raise SplitPlanningError(
|
|
230
|
+
"Planner response did not include a non-empty commits list."
|
|
231
|
+
)
|
|
273
232
|
|
|
274
233
|
units_by_id = {unit.id: unit for unit in patch_units}
|
|
275
234
|
expected_ids = set(units_by_id)
|
|
@@ -305,7 +264,9 @@ def parse_split_plan_response(
|
|
|
305
264
|
seen_in_commit.add(unit_id)
|
|
306
265
|
unit_ids.append(unit_id)
|
|
307
266
|
|
|
308
|
-
ordered_unit_ids = tuple(
|
|
267
|
+
ordered_unit_ids = tuple(
|
|
268
|
+
sorted(unit_ids, key=lambda unit_id: units_by_id[unit_id].order)
|
|
269
|
+
)
|
|
309
270
|
validated_commits.append(SplitPlanCommit(unit_ids=ordered_unit_ids))
|
|
310
271
|
assigned_ids.extend(unit_ids)
|
|
311
272
|
|
|
@@ -326,8 +287,6 @@ def parse_split_plan_response(
|
|
|
326
287
|
)
|
|
327
288
|
|
|
328
289
|
plan = SplitCommitPlan(commits=tuple(validated_commits))
|
|
329
|
-
if len(plan.commits) > max_commits:
|
|
330
|
-
raise SplitCommitLimitExceededError(plan, max_commits)
|
|
331
290
|
|
|
332
291
|
return plan
|
|
333
292
|
|
|
@@ -354,7 +313,9 @@ def build_status_for_patch_units(patch_units: Sequence[PatchUnit]) -> GitStatus:
|
|
|
354
313
|
if file_key in seen_paths:
|
|
355
314
|
continue
|
|
356
315
|
seen_paths.add(file_key)
|
|
357
|
-
files.append(
|
|
316
|
+
files.append(
|
|
317
|
+
GitFile(path=unit.path, status=" ", staged_status=unit.staged_status)
|
|
318
|
+
)
|
|
358
319
|
|
|
359
320
|
return GitStatus(
|
|
360
321
|
files=files,
|
|
@@ -397,7 +358,9 @@ def parse_diff_paths(diff_header_line: str) -> tuple[str, str]:
|
|
|
397
358
|
try:
|
|
398
359
|
parts = shlex.split(diff_header_line)
|
|
399
360
|
except ValueError as exc:
|
|
400
|
-
raise SplitPlanningError(
|
|
361
|
+
raise SplitPlanningError(
|
|
362
|
+
f"Could not parse diff header: {diff_header_line}"
|
|
363
|
+
) from exc
|
|
401
364
|
|
|
402
365
|
if len(parts) < 4:
|
|
403
366
|
raise SplitPlanningError(f"Unexpected diff header: {diff_header_line}")
|
|
@@ -419,10 +382,16 @@ def classify_file_patch(
|
|
|
419
382
|
hunks: Sequence[str],
|
|
420
383
|
) -> tuple[str, str]:
|
|
421
384
|
"""Infer the staged status and planning kind for a file patch."""
|
|
422
|
-
if any(
|
|
385
|
+
if any(
|
|
386
|
+
line.startswith("GIT binary patch") or line.startswith("Binary files ")
|
|
387
|
+
for line in lines
|
|
388
|
+
):
|
|
423
389
|
return "M", "binary"
|
|
424
390
|
|
|
425
|
-
if any(
|
|
391
|
+
if any(
|
|
392
|
+
line.startswith("rename from ") or line.startswith("rename to ")
|
|
393
|
+
for line in lines
|
|
394
|
+
):
|
|
426
395
|
return "R", "rename"
|
|
427
396
|
|
|
428
397
|
if any(line.startswith("new file mode ") for line in lines):
|
|
@@ -431,7 +400,9 @@ def classify_file_patch(
|
|
|
431
400
|
if any(line.startswith("deleted file mode ") for line in lines):
|
|
432
401
|
return "D", "deleted_file"
|
|
433
402
|
|
|
434
|
-
if any(
|
|
403
|
+
if any(
|
|
404
|
+
line.startswith("old mode ") or line.startswith("new mode ") for line in lines
|
|
405
|
+
):
|
|
435
406
|
return "M", "mode_change"
|
|
436
407
|
|
|
437
408
|
if old_path == "/dev/null":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-copilot-commit
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Automatically generate and commit changes using GitHub Copilot
|
|
5
5
|
Author-email: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com>
|
|
6
6
|
License-File: LICENSE
|
|
@@ -14,6 +14,10 @@ Description-Content-Type: text/markdown
|
|
|
14
14
|
|
|
15
15
|
# `git-copilot-commit`
|
|
16
16
|
|
|
17
|
+
[](https://github.com/kdheepak/git-copilot-commit/actions/workflows/ci.yml)
|
|
18
|
+
[](https://pypi.org/project/git-copilot-commit/)
|
|
19
|
+
[](https://github.com/kdheepak/git-copilot-commit/blob/main/LICENSE)
|
|
20
|
+
|
|
17
21
|
AI-powered Git commit assistant that generates conventional commit messages using GitHub Copilot.
|
|
18
22
|
|
|
19
23
|

|
|
@@ -21,13 +25,13 @@ AI-powered Git commit assistant that generates conventional commit messages usin
|
|
|
21
25
|
## Features
|
|
22
26
|
|
|
23
27
|
- Generates commit messages based on your staged changes
|
|
24
|
-
- Supports multiple
|
|
28
|
+
- Supports multiple LLM models: GPT-4, Claude, Gemini, and more
|
|
25
29
|
- Allows editing of generated messages before committing
|
|
26
30
|
- Follows the [Conventional Commits](https://www.conventionalcommits.org/) standard
|
|
27
31
|
|
|
28
32
|
## Installation
|
|
29
33
|
|
|
30
|
-
### Install the tool using [`uv`]
|
|
34
|
+
### Install the tool using [`uv`]
|
|
31
35
|
|
|
32
36
|
Install [`uv`]:
|
|
33
37
|
|
|
@@ -39,14 +43,15 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
|
39
43
|
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
|
40
44
|
```
|
|
41
45
|
|
|
42
|
-
You can
|
|
46
|
+
You can run the latest version of tool directly every time by invoking this one command:
|
|
43
47
|
|
|
44
48
|
```bash
|
|
45
|
-
#
|
|
49
|
+
# Every invocation installs latest version into temporary environment and runs --help
|
|
46
50
|
uvx git-copilot-commit --help
|
|
47
51
|
```
|
|
48
52
|
|
|
49
|
-
Alternatively, you can install into a global isolated environment
|
|
53
|
+
Alternatively, you can install the tool once into a global isolated environment
|
|
54
|
+
and run `git-copilot-commit` to invoke it:
|
|
50
55
|
|
|
51
56
|
```bash
|
|
52
57
|
# Install into global isolated environment
|
|
@@ -58,14 +63,6 @@ git-copilot-commit --help
|
|
|
58
63
|
|
|
59
64
|
[`uv`]: https://github.com/astral-sh/uv
|
|
60
65
|
|
|
61
|
-
### Install with `pipx`
|
|
62
|
-
|
|
63
|
-
If you prefer to use `pipx`:
|
|
64
|
-
|
|
65
|
-
```bash
|
|
66
|
-
pipx install git-copilot-commit
|
|
67
|
-
```
|
|
68
|
-
|
|
69
66
|
## Prerequisites
|
|
70
67
|
|
|
71
68
|
- Active GitHub Copilot subscription
|
|
@@ -100,17 +97,26 @@ pipx install git-copilot-commit
|
|
|
100
97
|
|
|
101
98
|
```bash
|
|
102
99
|
$ uvx git-copilot-commit commit --help
|
|
103
|
-
Usage: git-copilot-commit commit [OPTIONS]
|
|
104
100
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
101
|
+
Usage: git-copilot-commit commit [OPTIONS]
|
|
102
|
+
|
|
103
|
+
Generate commit message based on changes in the current git repository and commit them.
|
|
104
|
+
|
|
105
|
+
╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────╮
|
|
106
|
+
│ --all -a Stage all files before committing │
|
|
107
|
+
│ --split Split staged hunks into multiple commits automatically. │
|
|
108
|
+
│ Pass `--split=N` to express a preference for N commits. │
|
|
109
|
+
│ --model -m MODEL_ID Model to use for generating commit message │
|
|
110
|
+
│ --yes -y Automatically accept the generated commit message │
|
|
111
|
+
│ --context -c TEXT Optional user-provided context to guide commit message │
|
|
112
|
+
│ --ca-bundle PATH Path to a custom CA bundle (PEM) │
|
|
113
|
+
│ --insecure Disable SSL certificate verification. │
|
|
114
|
+
│ --native-tls --no-native-tls Use the OS's native certificate store via 'truststore' │
|
|
115
|
+
│ for httpx instead of the Python bundle. Ignored if │
|
|
116
|
+
│ --ca-bundle or --insecure is used. │
|
|
117
|
+
│ [default: no-native-tls] │
|
|
118
|
+
│ --help Show this message and exit. │
|
|
119
|
+
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
114
120
|
```
|
|
115
121
|
|
|
116
122
|
## Examples
|
|
@@ -139,7 +145,7 @@ Split staged hunks into separate commits:
|
|
|
139
145
|
uvx git-copilot-commit commit --split
|
|
140
146
|
```
|
|
141
147
|
|
|
142
|
-
Prefer
|
|
148
|
+
Prefer two commits:
|
|
143
149
|
|
|
144
150
|
```bash
|
|
145
151
|
uvx git-copilot-commit commit --split 2
|
|
@@ -194,3 +200,6 @@ git ai-commit --all --yes --model claude-3.5-sonnet
|
|
|
194
200
|
> ```bash
|
|
195
201
|
> git config --global diff.context 3
|
|
196
202
|
> ```
|
|
203
|
+
>
|
|
204
|
+
> This may be useful because this tool sends the diffs with surrounding context
|
|
205
|
+
> to the LLM for generating a commit message
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
git_copilot_commit/__init__.py,sha256=v3x5oBkxwKJEZLv62QqSmP3iqNKLtZgrWZfH8eFzlQg,60
|
|
2
|
+
git_copilot_commit/cli.py,sha256=CyiGMEehjLc-J3OveVxXUK7mzPWpiwQgkSSB3IhSzoc,29910
|
|
3
|
+
git_copilot_commit/git.py,sha256=vNuh2j8TGmocLio4XgPpbXIktlgczNdEt2Fg1c40wuk,14962
|
|
4
|
+
git_copilot_commit/github_copilot.py,sha256=0CF8bnawxW6Bn5g7R7mbceFG4SCcMiueMOp4x8Vtqs8,49657
|
|
5
|
+
git_copilot_commit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
git_copilot_commit/settings.py,sha256=WrM10_J3F7QBfOVmPDWpNZrNHhmZSeN-9FqQZxgdWvQ,3730
|
|
7
|
+
git_copilot_commit/split_commits.py,sha256=sGk5xfAvTeRcnW4XQvvyNVgYGsAFNntkk-JuVXb8Y6Y,14851
|
|
8
|
+
git_copilot_commit/version.py,sha256=AieHOUX52g6N67HL0iLWtDKrgOYyulxwHWViu26Jrd4,105
|
|
9
|
+
git_copilot_commit/prompts/commit-message-generator-prompt.md,sha256=EHAS6w15vLQ-kgT1N7nPG2nWqdeTmlHje_kN9yZIoZQ,2378
|
|
10
|
+
git_copilot_commit/prompts/split-commit-planner-prompt.md,sha256=ABKuVVyrkHsb3QV6qyS--W5yvKBoZhtq8xJEb3OZQvI,1088
|
|
11
|
+
git_copilot_commit-0.5.2.dist-info/METADATA,sha256=wc5CFa1ed8A4vbY3B01AsmsHia4jIoEU-5j2-OnaZEA,6649
|
|
12
|
+
git_copilot_commit-0.5.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
13
|
+
git_copilot_commit-0.5.2.dist-info/entry_points.txt,sha256=-D4bQqiuSPwQJG2zx--vJbZD1iqB5coUfoJ_gmC3rSg,66
|
|
14
|
+
git_copilot_commit-0.5.2.dist-info/licenses/LICENSE,sha256=14lNZAoKJPI1U7eGpletjN_PFm1JwP1vT_0jFKY6eWg,1065
|
|
15
|
+
git_copilot_commit-0.5.2.dist-info/RECORD,,
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
git_copilot_commit/__init__.py,sha256=v3x5oBkxwKJEZLv62QqSmP3iqNKLtZgrWZfH8eFzlQg,60
|
|
2
|
-
git_copilot_commit/cli.py,sha256=FlzcgE-FNO8Mu2v0z-BXAs8f2WC-y4uKSior0zPmhMs,31204
|
|
3
|
-
git_copilot_commit/git.py,sha256=GtLkE177Dm3l-zvsslSrAyqshxPeeSEidniwhTmCbyw,13845
|
|
4
|
-
git_copilot_commit/github_copilot.py,sha256=D6BWqjX-hLHqw7vbfIO2p3rSeWbI5peAzqDyfKB9l2g,46822
|
|
5
|
-
git_copilot_commit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
git_copilot_commit/settings.py,sha256=hDesxrMptuceIVeuwGeLe74WwbWHmTLCKo0h4CDWY-I,3537
|
|
7
|
-
git_copilot_commit/split_commits.py,sha256=tq--WYaOz6VVSP6y1r-HQ9q0pDLP7tRpNaxwqsGyvdQ,16434
|
|
8
|
-
git_copilot_commit/version.py,sha256=AieHOUX52g6N67HL0iLWtDKrgOYyulxwHWViu26Jrd4,105
|
|
9
|
-
git_copilot_commit/prompts/commit-message-generator-prompt.md,sha256=EHAS6w15vLQ-kgT1N7nPG2nWqdeTmlHje_kN9yZIoZQ,2378
|
|
10
|
-
git_copilot_commit/prompts/split-commit-planner-prompt.md,sha256=YS9XgXoHs-utyGvvpHxnTAReCRAlOyRHkYxtpDw88pY,1147
|
|
11
|
-
git_copilot_commit-0.5.0.dist-info/METADATA,sha256=6Pjb8mroHvY9HwzkgwmXa1HPBpGabUvunL3UOfxkBTk,4399
|
|
12
|
-
git_copilot_commit-0.5.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
13
|
-
git_copilot_commit-0.5.0.dist-info/entry_points.txt,sha256=-D4bQqiuSPwQJG2zx--vJbZD1iqB5coUfoJ_gmC3rSg,66
|
|
14
|
-
git_copilot_commit-0.5.0.dist-info/licenses/LICENSE,sha256=14lNZAoKJPI1U7eGpletjN_PFm1JwP1vT_0jFKY6eWg,1065
|
|
15
|
-
git_copilot_commit-0.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|