git-copilot-commit 0.5.0__tar.gz → 0.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/.justfile +1 -1
  2. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/PKG-INFO +34 -25
  3. git_copilot_commit-0.5.2/README.md +191 -0
  4. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/src/git_copilot_commit/cli.py +30 -62
  5. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/src/git_copilot_commit/git.py +43 -13
  6. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/src/git_copilot_commit/github_copilot.py +205 -134
  7. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/src/git_copilot_commit/prompts/split-commit-planner-prompt.md +0 -1
  8. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/src/git_copilot_commit/settings.py +9 -3
  9. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/src/git_copilot_commit/split_commits.py +26 -55
  10. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/tests/test_cli.py +171 -21
  11. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/tests/test_git.py +63 -1
  12. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/tests/test_github_copilot_utils.py +141 -1
  13. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/tests/test_settings.py +21 -0
  14. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/tests/test_split_commits.py +7 -81
  15. git_copilot_commit-0.5.0/README.md +0 -182
  16. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/.github/dependabot.yml +0 -0
  17. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/.github/workflows/ci.yml +0 -0
  18. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/.gitignore +0 -0
  19. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/.python-version +0 -0
  20. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/LICENSE +0 -0
  21. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/pyproject.toml +0 -0
  22. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/src/git_copilot_commit/__init__.py +0 -0
  23. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/src/git_copilot_commit/prompts/commit-message-generator-prompt.md +0 -0
  24. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/src/git_copilot_commit/py.typed +0 -0
  25. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/src/git_copilot_commit/version.py +0 -0
  26. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/tests/conftest.py +0 -0
  27. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/uv.lock +0 -0
  28. {git_copilot_commit-0.5.0 → git_copilot_commit-0.5.2}/vhs/demo.vhs +0 -0
@@ -38,7 +38,7 @@ bump type="patch":
38
38
  new_version=$(just next-version {{type}})
39
39
  echo "New version: $new_version"
40
40
 
41
- git commit --allow-empty -m "Bump version to $new_version"
41
+ git commit --allow-empty -m "chore(build): Bump version to $new_version"
42
42
  git tag "$new_version"
43
43
 
44
44
  echo "✓ Created commit and tag for $new_version"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-copilot-commit
3
- Version: 0.5.0
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
+ [![CI](https://img.shields.io/github/actions/workflow/status/kdheepak/git-copilot-commit/ci.yml?branch=main&label=CI)](https://github.com/kdheepak/git-copilot-commit/actions/workflows/ci.yml)
18
+ [![PyPI](https://img.shields.io/pypi/v/git-copilot-commit)](https://pypi.org/project/git-copilot-commit/)
19
+ [![License](https://img.shields.io/github/license/kdheepak/git-copilot-commit)](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
  ![Screenshot of git-copilot-commit in action](https://github.com/user-attachments/assets/6a6d70a6-6060-44e6-8cf4-a6532e9e9142)
@@ -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 AI models: GPT-4, Claude, Gemini, and more
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`] (recommended)
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 install and run the latest version of tool directly every time by invoking this one command:
46
+ You can run the latest version of tool directly every time by invoking this one command:
43
47
 
44
48
  ```bash
45
- # Install latest version into temporary environment and run --help
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 and run `git-copilot-commit`:
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
- Automatically commit changes in the current git repository.
106
-
107
- Options:
108
- -a, --all Stage all files before committing
109
- --split Split staged hunks into multiple commits. Use
110
- `--split=N` to prefer up to N commits.
111
- -m, --model TEXT Model to use for generating commit message
112
- -y, --yes Automatically accept the generated commit message
113
- --help Show this message and exit.
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 up to two commits:
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,191 @@
1
+ # `git-copilot-commit`
2
+
3
+ [![CI](https://img.shields.io/github/actions/workflow/status/kdheepak/git-copilot-commit/ci.yml?branch=main&label=CI)](https://github.com/kdheepak/git-copilot-commit/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/git-copilot-commit)](https://pypi.org/project/git-copilot-commit/)
5
+ [![License](https://img.shields.io/github/license/kdheepak/git-copilot-commit)](https://github.com/kdheepak/git-copilot-commit/blob/main/LICENSE)
6
+
7
+ AI-powered Git commit assistant that generates conventional commit messages using GitHub Copilot.
8
+
9
+ ![Screenshot of git-copilot-commit in action](https://github.com/user-attachments/assets/6a6d70a6-6060-44e6-8cf4-a6532e9e9142)
10
+
11
+ ## Features
12
+
13
+ - Generates commit messages based on your staged changes
14
+ - Supports multiple LLM models: GPT-4, Claude, Gemini, and more
15
+ - Allows editing of generated messages before committing
16
+ - Follows the [Conventional Commits](https://www.conventionalcommits.org/) standard
17
+
18
+ ## Installation
19
+
20
+ ### Install the tool using [`uv`]
21
+
22
+ Install [`uv`]:
23
+
24
+ ```bash
25
+ # On macOS and Linux.
26
+ curl -LsSf https://astral.sh/uv/install.sh | sh
27
+
28
+ # On Windows.
29
+ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
30
+ ```
31
+
32
+ You can run the latest version of tool directly every time by invoking this one command:
33
+
34
+ ```bash
35
+ # Every invocation installs latest version into temporary environment and runs --help
36
+ uvx git-copilot-commit --help
37
+ ```
38
+
39
+ Alternatively, you can install the tool once into a global isolated environment
40
+ and run `git-copilot-commit` to invoke it:
41
+
42
+ ```bash
43
+ # Install into global isolated environment
44
+ uv tool install git-copilot-commit
45
+
46
+ # Run --help to see available commands
47
+ git-copilot-commit --help
48
+ ```
49
+
50
+ [`uv`]: https://github.com/astral-sh/uv
51
+
52
+ ## Prerequisites
53
+
54
+ - Active GitHub Copilot subscription
55
+
56
+ ## Quick Start
57
+
58
+ 1. Authenticate with GitHub Copilot:
59
+
60
+ ```bash
61
+ uvx git-copilot-commit authenticate
62
+ ```
63
+
64
+ If your cached GitHub token is revoked or expires, refresh it with:
65
+
66
+ ```bash
67
+ uvx git-copilot-commit authenticate --force
68
+ ```
69
+
70
+ 2. Make changes in your repository.
71
+
72
+ 3. Generate and commit:
73
+
74
+ ```bash
75
+ uvx git-copilot-commit commit
76
+ # Or, if you want to stage all files and accept the generated commit message, use:
77
+ uvx git-copilot-commit commit --all --yes
78
+ ```
79
+
80
+ ## Usage
81
+
82
+ ### Commit changes
83
+
84
+ ```bash
85
+ $ uvx git-copilot-commit commit --help
86
+
87
+ Usage: git-copilot-commit commit [OPTIONS]
88
+
89
+ Generate commit message based on changes in the current git repository and commit them.
90
+
91
+ ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────╮
92
+ │ --all -a Stage all files before committing │
93
+ │ --split Split staged hunks into multiple commits automatically. │
94
+ │ Pass `--split=N` to express a preference for N commits. │
95
+ │ --model -m MODEL_ID Model to use for generating commit message │
96
+ │ --yes -y Automatically accept the generated commit message │
97
+ │ --context -c TEXT Optional user-provided context to guide commit message │
98
+ │ --ca-bundle PATH Path to a custom CA bundle (PEM) │
99
+ │ --insecure Disable SSL certificate verification. │
100
+ │ --native-tls --no-native-tls Use the OS's native certificate store via 'truststore' │
101
+ │ for httpx instead of the Python bundle. Ignored if │
102
+ │ --ca-bundle or --insecure is used. │
103
+ │ [default: no-native-tls] │
104
+ │ --help Show this message and exit. │
105
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯
106
+ ```
107
+
108
+ ## Examples
109
+
110
+ Commit all changes:
111
+
112
+ ```bash
113
+ uvx git-copilot-commit commit --all
114
+ ```
115
+
116
+ Accept the generated commit message without editing:
117
+
118
+ ```bash
119
+ uvx git-copilot-commit commit --yes
120
+ ```
121
+
122
+ Use a specific model:
123
+
124
+ ```bash
125
+ uvx git-copilot-commit commit --model claude-3.5-sonnet
126
+ ```
127
+
128
+ Split staged hunks into separate commits:
129
+
130
+ ```bash
131
+ uvx git-copilot-commit commit --split
132
+ ```
133
+
134
+ Prefer two commits:
135
+
136
+ ```bash
137
+ uvx git-copilot-commit commit --split 2
138
+ ```
139
+
140
+ ## Commit Message Format
141
+
142
+ Follows [Conventional Commits](https://www.conventionalcommits.org/):
143
+
144
+ ```plaintext
145
+ <type>[optional scope]: <description>
146
+ ```
147
+
148
+ **Types:**
149
+
150
+ - `feat`: New feature
151
+ - `fix`: Bug fix
152
+ - `docs`: Documentation
153
+ - `style`: Formatting only
154
+ - `refactor`: Code restructure
155
+ - `perf`: Performance
156
+ - `test`: Tests
157
+ - `chore`: Maintenance
158
+ - `revert`: Revert changes
159
+
160
+ ## Git Configuration
161
+
162
+ Add a git alias by adding the following to your `~/.gitconfig`:
163
+
164
+ ```ini
165
+ [alias]
166
+ ai-commit = "!f() { uvx git-copilot-commit commit $@; }; f"
167
+ ```
168
+
169
+ Now you can run to review the message before committing:
170
+
171
+ ```bash
172
+ git ai-commit
173
+ ```
174
+
175
+ Alternatively, you can stage all files and auto accept the commit message and
176
+ specify which model should be used to generate the commit in one CLI invocation.
177
+
178
+ ```bash
179
+ git ai-commit --all --yes --model claude-3.5-sonnet
180
+ ```
181
+
182
+ > [!TIP]
183
+ >
184
+ > Show more context in diffs by running the following command:
185
+ >
186
+ > ```bash
187
+ > git config --global diff.context 3
188
+ > ```
189
+ >
190
+ > This may be useful because this tool sends the diffs with surrounding context
191
+ > to the LLM for generating a commit message
@@ -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 prefer up to N commits."
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 resolve_split_commit_limit(
545
- exc: SplitCommitLimitExceededError, *, yes: bool = False
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 configured limit."""
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
- f"[yellow]Split planning produced {exc.actual_commits} commits, exceeding the automatic review limit of {exc.max_commits}.[/yellow]"
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
- 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)
537
+ return plan
557
538
 
558
539
  if Confirm.ask(
559
- f"Proceed with [bold]{exc.actual_commits} commits[/] anyway?",
540
+ f"Proceed with [bold]{actual_commits} commits[/] anyway?",
560
541
  default=False,
561
542
  ):
562
- return exc.plan
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
- 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]")
731
+ console.print(
732
+ "[yellow]Planning split commits from the staged patch units.[/yellow]"
733
+ )
767
734
  else:
768
735
  console.print(
769
- f"[yellow]Planning up to {preferred_commits} commits from the staged patch units.[/yellow]"
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,
@@ -121,16 +121,31 @@ class GitRepository:
121
121
  Raises:
122
122
  NotAGitRepositoryError: If the path is not a git repository.
123
123
  """
124
- self.repo_path = repo_path or Path.cwd()
124
+ self.cwd = (repo_path or Path.cwd()).resolve()
125
125
  self.timeout = timeout
126
- self._validate_git_repo()
126
+ self.repo_path = self._resolve_repo_root()
127
127
 
128
- def _validate_git_repo(self) -> None:
129
- """Ensure we're in a git repository."""
128
+ def _resolve_repo_root(self) -> Path:
129
+ """Resolve and cache the repository top-level path."""
130
130
  try:
131
- self._run_git_command(["rev-parse", "--git-dir"])
132
- except GitCommandError:
133
- raise NotAGitRepositoryError(f"{self.repo_path} is not a git repository")
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 (git add .).
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 (git add -u)."""
275
- self._run_git_command(["add", "-u"])
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."""