devcommit 0.1.4.6__tar.gz → 0.1.4.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devcommit
3
- Version: 0.1.4.6
3
+ Version: 0.1.4.7
4
4
  Summary: AI-powered git commit message generator
5
5
  License: GNU GENERAL PUBLIC LICENSE
6
6
  Version 3, 29 June 2007
@@ -721,24 +721,27 @@ A command-line AI tool for autocommits.
721
721
 
722
722
  ## Installation
723
723
 
724
- 1. **Install DevCommit**
725
-
724
+ 1. **Install DevCommit**
725
+
726
726
  **Option 1: Using pip (local installation)**
727
+
727
728
  ```bash
728
729
  pip install devcommit
729
730
  ```
730
-
731
+
731
732
  **Option 2: Using pipx (global installation, recommended)**
733
+
732
734
  ```bash
733
735
  # Install pipx if you don't have it
734
736
  python3 -m pip install --user pipx
735
737
  python3 -m pipx ensurepath
736
-
738
+
737
739
  # Install DevCommit globally
738
740
  pipx install devcommit
739
741
  ```
742
+
740
743
  > **💡 Why pipx?** pipx installs CLI tools in isolated environments, preventing dependency conflicts while making them globally available.
741
-
744
+
742
745
  **All AI providers are included by default!** ✅ Gemini, OpenAI, Groq, Anthropic, Ollama, and Custom API support.
743
746
 
744
747
  2. **Set Up Configuration (Required: API Key)**
@@ -747,19 +750,21 @@ A command-line AI tool for autocommits.
747
750
  **Priority Order:** `.dcommit` file → Environment Variables → Defaults
748
751
 
749
752
  ### Option 1: Environment Variables (Quickest)
753
+
750
754
  ```bash
751
755
  # Using Gemini (default)
752
756
  export GEMINI_API_KEY='your-api-key-here'
753
-
757
+
754
758
  # Or using Groq (recommended for free tier)
755
759
  export AI_PROVIDER='groq'
756
760
  export GROQ_API_KEY='your-groq-key'
757
-
761
+
758
762
  # Add to ~/.bashrc or ~/.zshrc for persistence
759
763
  echo "export GEMINI_API_KEY='your-key'" >> ~/.bashrc
760
764
  ```
761
765
 
762
766
  ### Option 2: .dcommit File (Home Directory)
767
+
763
768
  ```bash
764
769
  cat > ~/.dcommit << 'EOF'
765
770
  GEMINI_API_KEY = your-api-key-here
@@ -772,6 +777,7 @@ A command-line AI tool for autocommits.
772
777
  ```
773
778
 
774
779
  ### Option 3: .dcommit File (Virtual Environment)
780
+
775
781
  ```bash
776
782
  mkdir -p $VIRTUAL_ENV/config
777
783
  cat > $VIRTUAL_ENV/config/.dcommit << 'EOF'
@@ -797,6 +803,7 @@ devcommit
797
803
  ### Basic Usage
798
804
 
799
805
  - **Stage all changes and commit:**
806
+
800
807
  ```bash
801
808
  devcommit --stageAll
802
809
  ```
@@ -823,6 +830,7 @@ You can set your preferred commit mode in the `.dcommit` configuration file usin
823
830
  #### Command-Line Usage
824
831
 
825
832
  - **Interactive mode (auto):** When you have changes in multiple directories, DevCommit will automatically ask if you want to:
833
+
826
834
  - Create one commit for all changes (global commit)
827
835
  - Create separate commits per directory
828
836
 
@@ -834,10 +842,40 @@ You can set your preferred commit mode in the `.dcommit` configuration file usin
834
842
  ```
835
843
 
836
844
  When using directory-based commits, you can:
845
+
837
846
  1. Select which directories to commit (use Space to select, Enter to confirm)
838
847
  2. For each selected directory, review and choose a commit message
839
848
  3. Each directory gets its own commit with AI-generated messages based on its changes
840
849
 
850
+ ### Commit Specific Files or Folders
851
+
852
+ DevCommit allows you to commit specific files or folders. This is useful when you want to commit only certain changes without affecting other staged files.
853
+
854
+ **Usage:**
855
+
856
+ ```bash
857
+ # Commit specific files (must be staged first)
858
+ git add file1.py file2.py
859
+ devcommit --files file1.py file2.py
860
+
861
+ # Stage and commit specific files in one command
862
+ devcommit --stageAll --files file1.py file2.py
863
+
864
+ # Commit specific folders (must be staged first)
865
+ git add src/ tests/
866
+ devcommit --files src/ tests/
867
+
868
+ # Short form
869
+ devcommit -s -f file1.py file2.py
870
+ ```
871
+
872
+ When using `--files` or `-f`:
873
+
874
+ - Without `--stageAll`: Only commits files that are already staged (filters staged files to match specified paths)
875
+ - With `--stageAll`: Stages the specified files/folders and then commits them
876
+ - AI generates commit messages based on changes in those files
877
+ - Works with both individual files and entire directories
878
+
841
879
  ### Additional Options
842
880
 
843
881
  - `--excludeFiles` or `-e`: Exclude specific files from the diff
@@ -845,6 +883,8 @@ When using directory-based commits, you can:
845
883
  - `--commitType` or `-t`: Specify the type of commit (e.g., conventional)
846
884
  - `--stageAll` or `-s`: Stage all changes before committing
847
885
  - `--directory` or `-d`: Force directory-based commits
886
+ - `--files` or `-f`: Stage and commit specific files or folders (can specify multiple)
887
+ - `--push` or `-p`: Push commits to remote after committing
848
888
 
849
889
  ### Examples
850
890
 
@@ -857,24 +897,40 @@ devcommit --commitType conventional
857
897
 
858
898
  # Exclude lock files
859
899
  devcommit --excludeFiles package-lock.json yarn.lock
900
+
901
+ # Stage and commit specific files
902
+ devcommit --files file1.py file2.py
903
+
904
+ # Stage and commit specific folders
905
+ devcommit --files src/ tests/
906
+
907
+ # Stage and commit multiple files and folders at once
908
+ devcommit --files src/ file1.py tests/ config.json
909
+
910
+ # Commit and push
911
+ devcommit --push
912
+
913
+ # Commit specific files and push
914
+ devcommit --files file1.py file2.py --push
860
915
  ```
861
916
 
862
917
  ## AI Provider Support
863
918
 
864
919
  DevCommit now supports **multiple AI providers**! Choose from:
865
920
 
866
- | Provider | Free Tier | Speed | Quality | Get API Key |
867
- |----------|-----------|-------|---------|-------------|
868
- | 🆓 **Gemini** | 15 req/min, 1M/day | Fast | Good | [Get Key](https://aistudio.google.com/app/apikey) |
869
- | ⚡ **Groq** | Very generous | **Fastest** | Good | [Get Key](https://console.groq.com/keys) |
870
- | 🤖 **OpenAI** | $5 trial | Medium | **Best** | [Get Key](https://platform.openai.com/api-keys) |
871
- | 🧠 **Anthropic** | Limited trial | Medium | Excellent | [Get Key](https://console.anthropic.com/) |
872
- | 🏠 **Ollama** | **Unlimited** | Medium | Good | [Install](https://ollama.ai/) |
873
- | 🔧 **Custom** | Varies | Varies | Varies | Your server |
921
+ | Provider | Free Tier | Speed | Quality | Get API Key |
922
+ | ---------------- | ------------------ | ----------- | --------- | ------------------------------------------------- |
923
+ | 🆓 **Gemini** | 15 req/min, 1M/day | Fast | Good | [Get Key](https://aistudio.google.com/app/apikey) |
924
+ | ⚡ **Groq** | Very generous | **Fastest** | Good | [Get Key](https://console.groq.com/keys) |
925
+ | 🤖 **OpenAI** | $5 trial | Medium | **Best** | [Get Key](https://platform.openai.com/api-keys) |
926
+ | 🧠 **Anthropic** | Limited trial | Medium | Excellent | [Get Key](https://console.anthropic.com/) |
927
+ | 🏠 **Ollama** | **Unlimited** | Medium | Good | [Install](https://ollama.ai/) |
928
+ | 🔧 **Custom** | Varies | Varies | Varies | Your server |
874
929
 
875
930
  ### Quick Setup Examples
876
931
 
877
932
  **Using Groq (Recommended for free tier):**
933
+
878
934
  ```bash
879
935
  export AI_PROVIDER=groq
880
936
  export GROQ_API_KEY='your-groq-api-key'
@@ -882,6 +938,7 @@ devcommit
882
938
  ```
883
939
 
884
940
  **Using Ollama (Local, no API key needed):**
941
+
885
942
  ```bash
886
943
  # Install Ollama: https://ollama.ai/
887
944
  ollama pull llama3
@@ -890,6 +947,7 @@ devcommit
890
947
  ```
891
948
 
892
949
  **Using Custom API:**
950
+
893
951
  ```bash
894
952
  export AI_PROVIDER=custom
895
953
  export CUSTOM_API_URL='http://localhost:8000/v1'
@@ -904,8 +962,8 @@ All configuration can be set via **environment variables** or **`.dcommit` file*
904
962
 
905
963
  ### AI Provider Settings
906
964
 
907
- | Variable | Description | Default | Options |
908
- |----------|-------------|---------|---------|
965
+ | Variable | Description | Default | Options |
966
+ | ------------- | ----------------------- | -------- | ----------------------------------------------------------- |
909
967
  | `AI_PROVIDER` | Which AI service to use | `gemini` | `gemini`, `openai`, `groq`, `anthropic`, `ollama`, `custom` |
910
968
 
911
969
  ### Provider-Specific Settings
@@ -949,21 +1007,23 @@ All configuration can be set via **environment variables** or **`.dcommit` file*
949
1007
 
950
1008
  ### General Settings
951
1009
 
952
- | Variable | Description | Default | Options |
953
- |----------|-------------|---------|---------|
954
- | `LOCALE` | Language for commit messages | `en-US` | Any locale code (e.g., `en`, `es`, `fr`) |
955
- | `MAX_NO` | Number of commit message suggestions | `1` | Any positive integer |
956
- | `COMMIT_TYPE` | Style of commit messages | `general` | `general`, `conventional`, etc. |
957
- | `COMMIT_MODE` | Default commit strategy | `auto` | `auto`, `directory`, `global` |
958
- | `EXCLUDE_FILES` | Files to exclude from diff | `package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock` | Comma-separated file patterns |
959
- | `MAX_TOKENS` | Maximum tokens for AI response | `8192` | Any positive integer |
1010
+ | Variable | Description | Default | Options |
1011
+ | --------------- | ------------------------------------ | ------------------------------------------------------ | ---------------------------------------- |
1012
+ | `LOCALE` | Language for commit messages | `en-US` | Any locale code (e.g., `en`, `es`, `fr`) |
1013
+ | `MAX_NO` | Number of commit message suggestions | `1` | Any positive integer |
1014
+ | `COMMIT_TYPE` | Style of commit messages | `general` | `general`, `conventional`, etc. |
1015
+ | `COMMIT_MODE` | Default commit strategy | `auto` | `auto`, `directory`, `global` |
1016
+ | `EXCLUDE_FILES` | Files to exclude from diff | `package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock` | Comma-separated file patterns |
1017
+ | `MAX_TOKENS` | Maximum tokens for AI response | `8192` | Any positive integer |
960
1018
 
961
1019
  ### Configuration Priority
1020
+
962
1021
  1. **`.dcommit` file** (highest priority)
963
1022
  2. **Environment variables**
964
1023
  3. **Built-in defaults** (lowest priority)
965
1024
 
966
1025
  ### Using Environment Variables
1026
+
967
1027
  ```bash
968
1028
  # Basic setup with Gemini (default)
969
1029
  export GEMINI_API_KEY='your-api-key-here'
@@ -978,6 +1038,7 @@ export GROQ_API_KEY='your-groq-key'
978
1038
  ```
979
1039
 
980
1040
  ### Using .dcommit File
1041
+
981
1042
  See `.dcommit.example` for a complete configuration template with all providers.
982
1043
 
983
1044
  **Note:** The `.dcommit` file is **optional**. DevCommit will work with just environment variables!
@@ -16,24 +16,27 @@ A command-line AI tool for autocommits.
16
16
 
17
17
  ## Installation
18
18
 
19
- 1. **Install DevCommit**
20
-
19
+ 1. **Install DevCommit**
20
+
21
21
  **Option 1: Using pip (local installation)**
22
+
22
23
  ```bash
23
24
  pip install devcommit
24
25
  ```
25
-
26
+
26
27
  **Option 2: Using pipx (global installation, recommended)**
28
+
27
29
  ```bash
28
30
  # Install pipx if you don't have it
29
31
  python3 -m pip install --user pipx
30
32
  python3 -m pipx ensurepath
31
-
33
+
32
34
  # Install DevCommit globally
33
35
  pipx install devcommit
34
36
  ```
37
+
35
38
  > **💡 Why pipx?** pipx installs CLI tools in isolated environments, preventing dependency conflicts while making them globally available.
36
-
39
+
37
40
  **All AI providers are included by default!** ✅ Gemini, OpenAI, Groq, Anthropic, Ollama, and Custom API support.
38
41
 
39
42
  2. **Set Up Configuration (Required: API Key)**
@@ -42,19 +45,21 @@ A command-line AI tool for autocommits.
42
45
  **Priority Order:** `.dcommit` file → Environment Variables → Defaults
43
46
 
44
47
  ### Option 1: Environment Variables (Quickest)
48
+
45
49
  ```bash
46
50
  # Using Gemini (default)
47
51
  export GEMINI_API_KEY='your-api-key-here'
48
-
52
+
49
53
  # Or using Groq (recommended for free tier)
50
54
  export AI_PROVIDER='groq'
51
55
  export GROQ_API_KEY='your-groq-key'
52
-
56
+
53
57
  # Add to ~/.bashrc or ~/.zshrc for persistence
54
58
  echo "export GEMINI_API_KEY='your-key'" >> ~/.bashrc
55
59
  ```
56
60
 
57
61
  ### Option 2: .dcommit File (Home Directory)
62
+
58
63
  ```bash
59
64
  cat > ~/.dcommit << 'EOF'
60
65
  GEMINI_API_KEY = your-api-key-here
@@ -67,6 +72,7 @@ A command-line AI tool for autocommits.
67
72
  ```
68
73
 
69
74
  ### Option 3: .dcommit File (Virtual Environment)
75
+
70
76
  ```bash
71
77
  mkdir -p $VIRTUAL_ENV/config
72
78
  cat > $VIRTUAL_ENV/config/.dcommit << 'EOF'
@@ -92,6 +98,7 @@ devcommit
92
98
  ### Basic Usage
93
99
 
94
100
  - **Stage all changes and commit:**
101
+
95
102
  ```bash
96
103
  devcommit --stageAll
97
104
  ```
@@ -118,6 +125,7 @@ You can set your preferred commit mode in the `.dcommit` configuration file usin
118
125
  #### Command-Line Usage
119
126
 
120
127
  - **Interactive mode (auto):** When you have changes in multiple directories, DevCommit will automatically ask if you want to:
128
+
121
129
  - Create one commit for all changes (global commit)
122
130
  - Create separate commits per directory
123
131
 
@@ -129,10 +137,40 @@ You can set your preferred commit mode in the `.dcommit` configuration file usin
129
137
  ```
130
138
 
131
139
  When using directory-based commits, you can:
140
+
132
141
  1. Select which directories to commit (use Space to select, Enter to confirm)
133
142
  2. For each selected directory, review and choose a commit message
134
143
  3. Each directory gets its own commit with AI-generated messages based on its changes
135
144
 
145
+ ### Commit Specific Files or Folders
146
+
147
+ DevCommit allows you to commit specific files or folders. This is useful when you want to commit only certain changes without affecting other staged files.
148
+
149
+ **Usage:**
150
+
151
+ ```bash
152
+ # Commit specific files (must be staged first)
153
+ git add file1.py file2.py
154
+ devcommit --files file1.py file2.py
155
+
156
+ # Stage and commit specific files in one command
157
+ devcommit --stageAll --files file1.py file2.py
158
+
159
+ # Commit specific folders (must be staged first)
160
+ git add src/ tests/
161
+ devcommit --files src/ tests/
162
+
163
+ # Short form
164
+ devcommit -s -f file1.py file2.py
165
+ ```
166
+
167
+ When using `--files` or `-f`:
168
+
169
+ - Without `--stageAll`: Only commits files that are already staged (filters staged files to match specified paths)
170
+ - With `--stageAll`: Stages the specified files/folders and then commits them
171
+ - AI generates commit messages based on changes in those files
172
+ - Works with both individual files and entire directories
173
+
136
174
  ### Additional Options
137
175
 
138
176
  - `--excludeFiles` or `-e`: Exclude specific files from the diff
@@ -140,6 +178,8 @@ When using directory-based commits, you can:
140
178
  - `--commitType` or `-t`: Specify the type of commit (e.g., conventional)
141
179
  - `--stageAll` or `-s`: Stage all changes before committing
142
180
  - `--directory` or `-d`: Force directory-based commits
181
+ - `--files` or `-f`: Stage and commit specific files or folders (can specify multiple)
182
+ - `--push` or `-p`: Push commits to remote after committing
143
183
 
144
184
  ### Examples
145
185
 
@@ -152,24 +192,40 @@ devcommit --commitType conventional
152
192
 
153
193
  # Exclude lock files
154
194
  devcommit --excludeFiles package-lock.json yarn.lock
195
+
196
+ # Stage and commit specific files
197
+ devcommit --files file1.py file2.py
198
+
199
+ # Stage and commit specific folders
200
+ devcommit --files src/ tests/
201
+
202
+ # Stage and commit multiple files and folders at once
203
+ devcommit --files src/ file1.py tests/ config.json
204
+
205
+ # Commit and push
206
+ devcommit --push
207
+
208
+ # Commit specific files and push
209
+ devcommit --files file1.py file2.py --push
155
210
  ```
156
211
 
157
212
  ## AI Provider Support
158
213
 
159
214
  DevCommit now supports **multiple AI providers**! Choose from:
160
215
 
161
- | Provider | Free Tier | Speed | Quality | Get API Key |
162
- |----------|-----------|-------|---------|-------------|
163
- | 🆓 **Gemini** | 15 req/min, 1M/day | Fast | Good | [Get Key](https://aistudio.google.com/app/apikey) |
164
- | ⚡ **Groq** | Very generous | **Fastest** | Good | [Get Key](https://console.groq.com/keys) |
165
- | 🤖 **OpenAI** | $5 trial | Medium | **Best** | [Get Key](https://platform.openai.com/api-keys) |
166
- | 🧠 **Anthropic** | Limited trial | Medium | Excellent | [Get Key](https://console.anthropic.com/) |
167
- | 🏠 **Ollama** | **Unlimited** | Medium | Good | [Install](https://ollama.ai/) |
168
- | 🔧 **Custom** | Varies | Varies | Varies | Your server |
216
+ | Provider | Free Tier | Speed | Quality | Get API Key |
217
+ | ---------------- | ------------------ | ----------- | --------- | ------------------------------------------------- |
218
+ | 🆓 **Gemini** | 15 req/min, 1M/day | Fast | Good | [Get Key](https://aistudio.google.com/app/apikey) |
219
+ | ⚡ **Groq** | Very generous | **Fastest** | Good | [Get Key](https://console.groq.com/keys) |
220
+ | 🤖 **OpenAI** | $5 trial | Medium | **Best** | [Get Key](https://platform.openai.com/api-keys) |
221
+ | 🧠 **Anthropic** | Limited trial | Medium | Excellent | [Get Key](https://console.anthropic.com/) |
222
+ | 🏠 **Ollama** | **Unlimited** | Medium | Good | [Install](https://ollama.ai/) |
223
+ | 🔧 **Custom** | Varies | Varies | Varies | Your server |
169
224
 
170
225
  ### Quick Setup Examples
171
226
 
172
227
  **Using Groq (Recommended for free tier):**
228
+
173
229
  ```bash
174
230
  export AI_PROVIDER=groq
175
231
  export GROQ_API_KEY='your-groq-api-key'
@@ -177,6 +233,7 @@ devcommit
177
233
  ```
178
234
 
179
235
  **Using Ollama (Local, no API key needed):**
236
+
180
237
  ```bash
181
238
  # Install Ollama: https://ollama.ai/
182
239
  ollama pull llama3
@@ -185,6 +242,7 @@ devcommit
185
242
  ```
186
243
 
187
244
  **Using Custom API:**
245
+
188
246
  ```bash
189
247
  export AI_PROVIDER=custom
190
248
  export CUSTOM_API_URL='http://localhost:8000/v1'
@@ -199,8 +257,8 @@ All configuration can be set via **environment variables** or **`.dcommit` file*
199
257
 
200
258
  ### AI Provider Settings
201
259
 
202
- | Variable | Description | Default | Options |
203
- |----------|-------------|---------|---------|
260
+ | Variable | Description | Default | Options |
261
+ | ------------- | ----------------------- | -------- | ----------------------------------------------------------- |
204
262
  | `AI_PROVIDER` | Which AI service to use | `gemini` | `gemini`, `openai`, `groq`, `anthropic`, `ollama`, `custom` |
205
263
 
206
264
  ### Provider-Specific Settings
@@ -244,21 +302,23 @@ All configuration can be set via **environment variables** or **`.dcommit` file*
244
302
 
245
303
  ### General Settings
246
304
 
247
- | Variable | Description | Default | Options |
248
- |----------|-------------|---------|---------|
249
- | `LOCALE` | Language for commit messages | `en-US` | Any locale code (e.g., `en`, `es`, `fr`) |
250
- | `MAX_NO` | Number of commit message suggestions | `1` | Any positive integer |
251
- | `COMMIT_TYPE` | Style of commit messages | `general` | `general`, `conventional`, etc. |
252
- | `COMMIT_MODE` | Default commit strategy | `auto` | `auto`, `directory`, `global` |
253
- | `EXCLUDE_FILES` | Files to exclude from diff | `package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock` | Comma-separated file patterns |
254
- | `MAX_TOKENS` | Maximum tokens for AI response | `8192` | Any positive integer |
305
+ | Variable | Description | Default | Options |
306
+ | --------------- | ------------------------------------ | ------------------------------------------------------ | ---------------------------------------- |
307
+ | `LOCALE` | Language for commit messages | `en-US` | Any locale code (e.g., `en`, `es`, `fr`) |
308
+ | `MAX_NO` | Number of commit message suggestions | `1` | Any positive integer |
309
+ | `COMMIT_TYPE` | Style of commit messages | `general` | `general`, `conventional`, etc. |
310
+ | `COMMIT_MODE` | Default commit strategy | `auto` | `auto`, `directory`, `global` |
311
+ | `EXCLUDE_FILES` | Files to exclude from diff | `package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock` | Comma-separated file patterns |
312
+ | `MAX_TOKENS` | Maximum tokens for AI response | `8192` | Any positive integer |
255
313
 
256
314
  ### Configuration Priority
315
+
257
316
  1. **`.dcommit` file** (highest priority)
258
317
  2. **Environment variables**
259
318
  3. **Built-in defaults** (lowest priority)
260
319
 
261
320
  ### Using Environment Variables
321
+
262
322
  ```bash
263
323
  # Basic setup with Gemini (default)
264
324
  export GEMINI_API_KEY='your-api-key-here'
@@ -273,6 +333,7 @@ export GROQ_API_KEY='your-groq-key'
273
333
  ```
274
334
 
275
335
  ### Using .dcommit File
336
+
276
337
  See `.dcommit.example` for a complete configuration template with all providers.
277
338
 
278
339
  **Note:** The `.dcommit` file is **optional**. DevCommit will work with just environment variables!
@@ -12,7 +12,9 @@ from rich.console import Console
12
12
  from devcommit.app.gemini_ai import generateCommitMessage
13
13
  from devcommit.utils.git import (KnownError, assert_git_repo,
14
14
  get_detected_message, get_diff_for_files,
15
- get_staged_diff, group_files_by_directory)
15
+ get_files_from_paths, get_staged_diff,
16
+ group_files_by_directory, has_commits_to_push,
17
+ push_to_remote, stage_files)
16
18
  from devcommit.utils.logger import Logger, config
17
19
  from devcommit.utils.parser import CommitFlag, parse_arguments
18
20
 
@@ -67,10 +69,90 @@ def main(flags: CommitFlag = None):
67
69
  console.print(f"[dim]Provider:[/dim] [bold magenta]{provider}[/bold magenta] [dim]│[/dim] [dim]Model:[/dim] [bold magenta]{model}[/bold magenta]")
68
70
  console.print()
69
71
 
72
+ # Handle staging
73
+ push_files_list = []
74
+ if flags["files"] and len(flags["files"]) > 0:
75
+
76
+ # Get the list of files from paths first
77
+ try:
78
+ push_files_list = get_files_from_paths(flags["files"])
79
+ if not push_files_list:
80
+ raise KnownError("No files found in the specified paths")
81
+ except KnownError as e:
82
+ raise e
83
+ except Exception as e:
84
+ raise KnownError(f"Failed to get files from paths: {str(e)}")
85
+
70
86
  if flags["stageAll"]:
71
- stage_changes(console)
72
-
73
- staged = detect_staged_files(console, flags["excludeFiles"])
87
+ if push_files_list:
88
+ # Stage specific files/folders only
89
+ console.print("[bold cyan]📦 Staging specific files/folders...[/bold cyan]")
90
+ console.print(f"[dim]Found {len(push_files_list)} file(s) to stage[/dim]")
91
+ for file in push_files_list:
92
+ console.print(f" [cyan]▸[/cyan] [white]{file}[/white]")
93
+
94
+ stage_files(push_files_list)
95
+ console.print("[bold green]✅ Files staged successfully[/bold green]\n")
96
+ else:
97
+ # Stage all changes
98
+ stage_changes(console)
99
+ console.print("[bold green]✅ All changes staged successfully[/bold green]\n")
100
+
101
+ # Get staged files
102
+ if push_files_list and len(push_files_list) > 0:
103
+ if flags["stageAll"]:
104
+ # If --files was used with --stageAll, we already staged those files
105
+ # Create a staged dict with only those files
106
+ staged = {
107
+ "files": push_files_list,
108
+ "diff": get_diff_for_files(push_files_list, flags["excludeFiles"])
109
+ }
110
+ if not staged["diff"]:
111
+ raise KnownError("No changes found in the specified files/folders")
112
+
113
+ console.print(f"\n[bold green]✅ {get_detected_message(staged['files'])}[/bold green]")
114
+ console.print("[dim]" + "─" * 60 + "[/dim]")
115
+ for file in staged["files"]:
116
+ console.print(f" [cyan]▸[/cyan] [white]{file}[/white]")
117
+ console.print("[dim]" + "─" * 60 + "[/dim]")
118
+ else:
119
+ # If --files was used without --stageAll, filter staged files to only those specified
120
+ # First, get all staged files (this will error if nothing is staged)
121
+ all_staged = get_staged_diff(flags["excludeFiles"])
122
+ if not all_staged:
123
+ raise KnownError(
124
+ "No staged changes found. Stage your changes manually, or "
125
+ "automatically stage specific files with the `--stageAll --files` flag."
126
+ )
127
+
128
+ # Filter to only include files that match the specified paths
129
+ filtered_files = []
130
+ for staged_file in all_staged["files"]:
131
+ # Check if this staged file is in our push_files_list
132
+ if staged_file in push_files_list:
133
+ filtered_files.append(staged_file)
134
+
135
+ if not filtered_files:
136
+ raise KnownError(
137
+ f"None of the specified files/folders are staged. "
138
+ f"Please stage them first with 'git add' or use '--stageAll --files'"
139
+ )
140
+
141
+ # Create a staged dict with only the filtered files
142
+ staged = {
143
+ "files": filtered_files,
144
+ "diff": get_diff_for_files(filtered_files, flags["excludeFiles"])
145
+ }
146
+ if not staged["diff"]:
147
+ raise KnownError("No changes found in the specified files/folders")
148
+
149
+ console.print(f"\n[bold green]✅ {get_detected_message(staged['files'])}[/bold green]")
150
+ console.print("[dim]" + "─" * 60 + "[/dim]")
151
+ for file in staged["files"]:
152
+ console.print(f" [cyan]▸[/cyan] [white]{file}[/white]")
153
+ console.print("[dim]" + "─" * 60 + "[/dim]")
154
+ else:
155
+ staged = detect_staged_files(console, flags["excludeFiles"])
74
156
 
75
157
  # Determine commit strategy
76
158
  # Priority: CLI flag > config (file or env) > interactive prompt
@@ -91,19 +173,28 @@ def main(flags: CommitFlag = None):
91
173
  if len(grouped) > 1:
92
174
  use_per_directory = prompt_commit_strategy(console, grouped)
93
175
 
176
+ # Track if any commits were made
177
+ commit_made = False
94
178
  if use_per_directory:
95
- process_per_directory_commits(console, staged, flags)
179
+ commit_made = process_per_directory_commits(console, staged, flags)
96
180
  else:
97
- process_global_commit(console, flags)
181
+ commit_made = process_global_commit(console, flags)
98
182
 
99
- # Print stylish completion message
100
- console.print()
101
- console.print("╭" + "─" * 60 + "╮", style="bold green")
102
- console.print("" + " " * 60 + "│", style="bold green")
103
- console.print("│" + " ✨ [bold white]All commits completed successfully![/bold white]".ljust(68) + "│", style="bold green")
104
- console.print("│" + " " * 60 + "│", style="bold green")
105
- console.print("╰" + "─" * 60 + "╯", style="bold green")
106
- console.print()
183
+ # Handle push if requested and a commit was actually made
184
+ if flags.get("push", False) and commit_made:
185
+ push_changes(console)
186
+ elif flags.get("push", False) and not commit_made:
187
+ console.print("\n[bold yellow]⚠️ No commits were made, skipping push[/bold yellow]\n")
188
+
189
+ # Print stylish completion message only if commits were made
190
+ if commit_made:
191
+ console.print()
192
+ console.print("╭" + "─" * 60 + "╮", style="bold green")
193
+ console.print("│" + " " * 60 + "│", style="bold green")
194
+ console.print("│" + " ✨ [bold white]All commits completed successfully![/bold white] ✨ ".ljust(68) + "│", style="bold green")
195
+ console.print("│" + " " * 60 + "│", style="bold green")
196
+ console.print("╰" + "─" * 60 + "╯", style="bold green")
197
+ console.print()
107
198
 
108
199
  except KeyboardInterrupt:
109
200
  console.print("\n\n[bold yellow]⚠️ Operation cancelled by user[/bold yellow]\n")
@@ -274,6 +365,43 @@ def commit_changes(console, commit, raw_argv):
274
365
  console.print("\n[bold green]✅ Committed successfully![/bold green]")
275
366
 
276
367
 
368
+ def push_changes(console):
369
+ """Push commits to remote repository."""
370
+ # Check if there are commits to push first
371
+ try:
372
+ if not has_commits_to_push():
373
+ console.print("\n[bold yellow]ℹ️ No commits to push (already up to date)[/bold yellow]\n")
374
+ return
375
+ except KnownError:
376
+ # If we can't determine, try to push anyway
377
+ pass
378
+
379
+ console.print("\n[cyan]🚀 Pushing to remote...[/cyan]")
380
+ console.print("[dim]Note: You may be prompted for authentication[/dim]\n")
381
+
382
+ try:
383
+ # Run push with stdin/stdout/stderr connected to terminal
384
+ # This allows interactive prompts (authentication) to work properly
385
+ result = subprocess.run(
386
+ ['git', 'push'],
387
+ check=False, # Don't raise on error, we'll check return code
388
+ stdin=None, # Inherit stdin for interactive prompts
389
+ stdout=None, # Don't capture stdout - let it show in terminal
390
+ stderr=None # Don't capture stderr - let it show in terminal
391
+ )
392
+
393
+ if result.returncode == 0:
394
+ console.print("\n[bold green]✅ Pushed to remote successfully![/bold green]")
395
+ else:
396
+ raise KnownError("Push failed. Please check the output above for details.")
397
+ except FileNotFoundError:
398
+ raise KnownError("Git command not found. Please ensure git is installed.")
399
+ except Exception as e:
400
+ if isinstance(e, KnownError):
401
+ raise
402
+ raise KnownError(f"Push failed: {str(e)}")
403
+
404
+
277
405
  def prompt_commit_strategy(console, grouped):
278
406
  """Prompt user to choose between global or directory-based commits."""
279
407
  console.print()
@@ -310,16 +438,21 @@ def prompt_commit_strategy(console, grouped):
310
438
 
311
439
 
312
440
  def process_global_commit(console, flags):
313
- """Process a single global commit for all changes."""
441
+ """Process a single global commit for all changes.
442
+ Returns True if a commit was made, False otherwise."""
314
443
  commit_message = analyze_changes(console)
315
444
  selected_commit = prompt_commit_message(console, commit_message)
316
445
  if selected_commit:
317
446
  commit_changes(console, selected_commit, flags["rawArgv"])
447
+ return True
448
+ return False
318
449
 
319
450
 
320
451
  def process_per_directory_commits(console, staged, flags):
321
- """Process separate commits for each directory."""
452
+ """Process separate commits for each directory.
453
+ Returns True if at least one commit was made, False otherwise."""
322
454
  grouped = group_files_by_directory(staged["files"])
455
+ commits_made = False
323
456
 
324
457
  console.print()
325
458
  console.print("╭" + "─" * 60 + "╮", style="bold magenta")
@@ -368,7 +501,7 @@ def process_per_directory_commits(console, staged, flags):
368
501
 
369
502
  if not selected_directories:
370
503
  console.print("\n[bold yellow]⚠️ No directories selected[/bold yellow]\n")
371
- return
504
+ return False
372
505
 
373
506
  # Process each selected directory
374
507
  for idx, directory in enumerate(selected_directories, 1):
@@ -420,8 +553,11 @@ def process_per_directory_commits(console, staged, flags):
420
553
  # Commit only the files in this directory
421
554
  subprocess.run(["git", "commit", "-m", selected_commit, *flags["rawArgv"], "--"] + files)
422
555
  console.print(f"\n[bold green]✅ Committed {directory}[/bold green]")
556
+ commits_made = True
423
557
  else:
424
558
  console.print(f"\n[bold yellow]⊘ Skipped {directory}[/bold yellow]")
559
+
560
+ return commits_made
425
561
 
426
562
 
427
563
  if __name__ == "__main__":
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env python3
2
+ """Git utilities"""
3
+
4
+ import os
5
+ import subprocess
6
+ from collections import defaultdict
7
+ from typing import Dict, List, Optional
8
+
9
+
10
+ class KnownError(Exception):
11
+ pass
12
+
13
+
14
+ def assert_git_repo() -> str:
15
+ """
16
+ Asserts that the current directory is a Git repository.
17
+ Returns the top-level directory path of the repository.
18
+ """
19
+
20
+ try:
21
+ result = subprocess.run(
22
+ ['git', 'rev-parse', '--show-toplevel'],
23
+ check=True,
24
+ stdout=subprocess.PIPE,
25
+ stderr=subprocess.PIPE,
26
+ text=True
27
+ )
28
+ return result.stdout.strip()
29
+ except subprocess.CalledProcessError:
30
+ raise KnownError('The current directory must be a Git repository!')
31
+
32
+
33
+ def exclude_from_diff(path: str) -> str:
34
+ """
35
+ Prepares a Git exclusion path string for the diff command.
36
+ """
37
+
38
+ return f':(exclude){path}'
39
+
40
+
41
+ def get_default_excludes() -> List[str]:
42
+ """
43
+ Get list of files to exclude from diff.
44
+ Priority: Config > Defaults
45
+ """
46
+ try:
47
+ from devcommit.utils.logger import config
48
+
49
+ # Get from config (supports comma-separated list)
50
+ exclude_config = config("EXCLUDE_FILES", default="")
51
+
52
+ if exclude_config:
53
+ # Parse comma-separated values and strip whitespace
54
+ config_excludes = [f.strip() for f in exclude_config.split(",") if f.strip()]
55
+ return config_excludes
56
+ except:
57
+ pass
58
+
59
+ # Default exclusions
60
+ return [
61
+ 'package-lock.json',
62
+ 'pnpm-lock.yaml',
63
+ 'yarn.lock',
64
+ '*.lock'
65
+ ]
66
+
67
+
68
+ # Get default files to exclude (can be overridden via config)
69
+ files_to_exclude = get_default_excludes()
70
+
71
+
72
+ def get_staged_diff(
73
+ exclude_files: Optional[List[str]] = None) -> Optional[dict]:
74
+ """
75
+ Gets the list of staged files and their diff, excluding specified files.
76
+ """
77
+ exclude_files = exclude_files or []
78
+ diff_cached = ['git', 'diff', '--cached', '--diff-algorithm=minimal']
79
+ excluded_from_diff = (
80
+ [exclude_from_diff(f) for f in files_to_exclude + exclude_files])
81
+
82
+ try:
83
+ # Get the list of staged files excluding specified files
84
+ files = subprocess.run(
85
+ diff_cached + ['--name-only'] + excluded_from_diff,
86
+ check=True,
87
+ stdout=subprocess.PIPE,
88
+ stderr=subprocess.PIPE,
89
+ text=True
90
+ )
91
+ files_result = (
92
+ files.stdout.strip().split('\n') if files.stdout.strip() else []
93
+ )
94
+ if not files_result:
95
+ return None
96
+
97
+ # Get the staged diff excluding specified files
98
+ diff = subprocess.run(
99
+ diff_cached + excluded_from_diff,
100
+ check=True,
101
+ stdout=subprocess.PIPE,
102
+ stderr=subprocess.PIPE,
103
+ text=True
104
+ )
105
+ diff_result = diff.stdout.strip()
106
+
107
+ return {
108
+ 'files': files_result,
109
+ 'diff': diff_result
110
+ }
111
+ except subprocess.CalledProcessError:
112
+ return None
113
+
114
+
115
+ def get_detected_message(files: List[str]) -> str:
116
+ """
117
+ Returns a message indicating the number of staged files.
118
+ """
119
+ return (
120
+ f"Detected {len(files):,} staged file{'s' if len(files) > 1 else ''}"
121
+ )
122
+
123
+
124
+ def group_files_by_directory(files: List[str]) -> Dict[str, List[str]]:
125
+ """
126
+ Groups files by their root directory (first-level directory).
127
+ Files in the repository root are grouped under 'root'.
128
+ """
129
+ grouped = defaultdict(list)
130
+
131
+ for file_path in files:
132
+ # Get the first directory in the path
133
+ parts = file_path.split(os.sep)
134
+ if len(parts) > 1:
135
+ root_dir = parts[0]
136
+ else:
137
+ root_dir = 'root'
138
+ grouped[root_dir].append(file_path)
139
+
140
+ return dict(grouped)
141
+
142
+
143
+ def get_diff_for_files(files: List[str], exclude_files: Optional[List[str]] = None) -> str:
144
+ """
145
+ Gets the diff for specific files.
146
+ """
147
+ exclude_files = exclude_files or []
148
+
149
+ # Filter out excluded files from the list
150
+ all_excluded = files_to_exclude + exclude_files
151
+ filtered_files = [
152
+ f for f in files
153
+ if not any(f.endswith(excl.replace('*', '')) or excl.replace(':(exclude)', '') in f
154
+ for excl in all_excluded)
155
+ ]
156
+
157
+ if not filtered_files:
158
+ return ""
159
+
160
+ try:
161
+ diff = subprocess.run(
162
+ ['git', 'diff', '--cached', '--diff-algorithm=minimal', '--'] + filtered_files,
163
+ check=True,
164
+ stdout=subprocess.PIPE,
165
+ stderr=subprocess.PIPE,
166
+ text=True
167
+ )
168
+ return diff.stdout.strip()
169
+ except subprocess.CalledProcessError:
170
+ return ""
171
+
172
+
173
+ def get_files_from_paths(paths: List[str]) -> List[str]:
174
+ """
175
+ Gets all files from given paths (handles both files and directories).
176
+ Returns a list of file paths relative to the repository root.
177
+ """
178
+ repo_root = assert_git_repo()
179
+ all_files = []
180
+
181
+ for path in paths:
182
+ # Normalize path
183
+ normalized_path = os.path.normpath(path)
184
+ full_path = os.path.join(repo_root, normalized_path) if not os.path.isabs(path) else path
185
+
186
+ if not os.path.exists(full_path):
187
+ raise KnownError(f"Path does not exist: {path}")
188
+
189
+ if os.path.isfile(full_path):
190
+ # It's a file, get relative path
191
+ rel_path = os.path.relpath(full_path, repo_root)
192
+ all_files.append(rel_path)
193
+ elif os.path.isdir(full_path):
194
+ # It's a directory, get all files in it
195
+ try:
196
+ result = subprocess.run(
197
+ ['git', 'ls-files', '--', normalized_path],
198
+ check=True,
199
+ stdout=subprocess.PIPE,
200
+ stderr=subprocess.PIPE,
201
+ text=True,
202
+ cwd=repo_root
203
+ )
204
+ files_in_dir = [f.strip() for f in result.stdout.strip().split('\n') if f.strip()]
205
+ all_files.extend(files_in_dir)
206
+ except subprocess.CalledProcessError:
207
+ # If git ls-files fails, try to find files manually
208
+ for root, dirs, files in os.walk(full_path):
209
+ # Skip .git directories
210
+ if '.git' in dirs:
211
+ dirs.remove('.git')
212
+ for file in files:
213
+ file_path = os.path.join(root, file)
214
+ rel_path = os.path.relpath(file_path, repo_root)
215
+ all_files.append(rel_path)
216
+
217
+ # Remove duplicates and return
218
+ return list(set(all_files))
219
+
220
+
221
+ def stage_files(files: List[str]) -> None:
222
+ """
223
+ Stages specific files.
224
+ """
225
+ if not files:
226
+ return
227
+
228
+ try:
229
+ subprocess.run(
230
+ ['git', 'add', '--'] + files,
231
+ check=True,
232
+ stdout=subprocess.PIPE,
233
+ stderr=subprocess.PIPE,
234
+ text=True
235
+ )
236
+ except subprocess.CalledProcessError as e:
237
+ raise KnownError(f"Failed to stage files: {e.stderr}")
238
+
239
+
240
+ def get_current_branch() -> str:
241
+ """
242
+ Gets the current git branch name.
243
+ """
244
+ try:
245
+ result = subprocess.run(
246
+ ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
247
+ check=True,
248
+ stdout=subprocess.PIPE,
249
+ stderr=subprocess.PIPE,
250
+ text=True
251
+ )
252
+ return result.stdout.strip()
253
+ except subprocess.CalledProcessError:
254
+ raise KnownError("Failed to get current branch name")
255
+
256
+
257
+ def has_commits_to_push(branch: Optional[str] = None, remote: str = "origin") -> bool:
258
+ """
259
+ Checks if there are commits ahead of the remote that need to be pushed.
260
+ Returns True if there are commits to push, False otherwise.
261
+ """
262
+ if branch is None:
263
+ branch = get_current_branch()
264
+
265
+ try:
266
+ # Check if remote tracking branch exists
267
+ result = subprocess.run(
268
+ ['git', 'rev-parse', '--abbrev-ref', f'{branch}@{{upstream}}'],
269
+ check=True,
270
+ stdout=subprocess.PIPE,
271
+ stderr=subprocess.PIPE,
272
+ text=True
273
+ )
274
+ upstream = result.stdout.strip()
275
+ except subprocess.CalledProcessError:
276
+ # No upstream branch, assume we need to push
277
+ return True
278
+
279
+ try:
280
+ # Check if local branch is ahead of remote
281
+ result = subprocess.run(
282
+ ['git', 'rev-list', '--count', f'{upstream}..{branch}'],
283
+ check=True,
284
+ stdout=subprocess.PIPE,
285
+ stderr=subprocess.PIPE,
286
+ text=True
287
+ )
288
+ ahead_count = int(result.stdout.strip())
289
+ return ahead_count > 0
290
+ except (subprocess.CalledProcessError, ValueError):
291
+ # If we can't determine, assume we need to push
292
+ return True
293
+
294
+
295
+ def push_to_remote(branch: Optional[str] = None, remote: str = "origin") -> None:
296
+ """
297
+ Pushes the current branch to the remote repository.
298
+ """
299
+ if branch is None:
300
+ branch = get_current_branch()
301
+
302
+ try:
303
+ # Check if remote exists
304
+ result = subprocess.run(
305
+ ['git', 'remote', 'get-url', remote],
306
+ check=True,
307
+ stdout=subprocess.PIPE,
308
+ stderr=subprocess.PIPE,
309
+ text=True
310
+ )
311
+ except subprocess.CalledProcessError:
312
+ raise KnownError(f"Remote '{remote}' does not exist. Please add a remote first.")
313
+
314
+ # Check if there are commits to push
315
+ if not has_commits_to_push(branch, remote):
316
+ return # Nothing to push
317
+
318
+ try:
319
+ # Don't capture stdout/stderr to allow interactive prompts (e.g., for authentication)
320
+ # This allows the user to see what's happening and enter credentials if needed
321
+ subprocess.run(
322
+ ['git', 'push', remote, branch],
323
+ check=True
324
+ )
325
+ except subprocess.CalledProcessError as e:
326
+ raise KnownError(f"Failed to push to remote. Please check your authentication and try again.")
@@ -9,6 +9,8 @@ class CommitFlag(TypedDict):
9
9
  stageAll: bool
10
10
  commitType: Optional[str]
11
11
  directory: bool
12
+ files: List[str]
13
+ push: bool
12
14
  rawArgv: List[str]
13
15
 
14
16
 
@@ -42,6 +44,14 @@ def parse_arguments() -> CommitFlag:
42
44
  "--directory", "-d", action="store_true",
43
45
  help="Generate separate commits per root directory"
44
46
  )
47
+ parser.add_argument(
48
+ "--files", "-f", nargs="*", default=[],
49
+ help="Specific files or folders to stage and commit (can specify multiple)"
50
+ )
51
+ parser.add_argument(
52
+ "--push", "-p", action="store_true",
53
+ help="Push commits to remote after committing"
54
+ )
45
55
  parser.add_argument(
46
56
  "rawArgv", nargs="*", help="Additional arguments for git commit"
47
57
  )
@@ -54,5 +64,7 @@ def parse_arguments() -> CommitFlag:
54
64
  stageAll=args.stageAll,
55
65
  commitType=args.commitType,
56
66
  directory=args.directory,
67
+ files=args.files or [],
68
+ push=args.push,
57
69
  rawArgv=args.rawArgv,
58
70
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devcommit"
3
- version = "0.1.4.6"
3
+ version = "0.1.4.7"
4
4
  description = "AI-powered git commit message generator"
5
5
  readme = "README.md"
6
6
  license = {file = "COPYING"}
@@ -1,170 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Git utilities"""
3
-
4
- import os
5
- import subprocess
6
- from collections import defaultdict
7
- from typing import Dict, List, Optional
8
-
9
-
10
- class KnownError(Exception):
11
- pass
12
-
13
-
14
- def assert_git_repo() -> str:
15
- """
16
- Asserts that the current directory is a Git repository.
17
- Returns the top-level directory path of the repository.
18
- """
19
-
20
- try:
21
- result = subprocess.run(
22
- ['git', 'rev-parse', '--show-toplevel'],
23
- check=True,
24
- stdout=subprocess.PIPE,
25
- stderr=subprocess.PIPE,
26
- text=True
27
- )
28
- return result.stdout.strip()
29
- except subprocess.CalledProcessError:
30
- raise KnownError('The current directory must be a Git repository!')
31
-
32
-
33
- def exclude_from_diff(path: str) -> str:
34
- """
35
- Prepares a Git exclusion path string for the diff command.
36
- """
37
-
38
- return f':(exclude){path}'
39
-
40
-
41
- def get_default_excludes() -> List[str]:
42
- """
43
- Get list of files to exclude from diff.
44
- Priority: Config > Defaults
45
- """
46
- try:
47
- from devcommit.utils.logger import config
48
-
49
- # Get from config (supports comma-separated list)
50
- exclude_config = config("EXCLUDE_FILES", default="")
51
-
52
- if exclude_config:
53
- # Parse comma-separated values and strip whitespace
54
- config_excludes = [f.strip() for f in exclude_config.split(",") if f.strip()]
55
- return config_excludes
56
- except:
57
- pass
58
-
59
- # Default exclusions
60
- return [
61
- 'package-lock.json',
62
- 'pnpm-lock.yaml',
63
- 'yarn.lock',
64
- '*.lock'
65
- ]
66
-
67
-
68
- # Get default files to exclude (can be overridden via config)
69
- files_to_exclude = get_default_excludes()
70
-
71
-
72
- def get_staged_diff(
73
- exclude_files: Optional[List[str]] = None) -> Optional[dict]:
74
- """
75
- Gets the list of staged files and their diff, excluding specified files.
76
- """
77
- exclude_files = exclude_files or []
78
- diff_cached = ['git', 'diff', '--cached', '--diff-algorithm=minimal']
79
- excluded_from_diff = (
80
- [exclude_from_diff(f) for f in files_to_exclude + exclude_files])
81
-
82
- try:
83
- # Get the list of staged files excluding specified files
84
- files = subprocess.run(
85
- diff_cached + ['--name-only'] + excluded_from_diff,
86
- check=True,
87
- stdout=subprocess.PIPE,
88
- stderr=subprocess.PIPE,
89
- text=True
90
- )
91
- files_result = (
92
- files.stdout.strip().split('\n') if files.stdout.strip() else []
93
- )
94
- if not files_result:
95
- return None
96
-
97
- # Get the staged diff excluding specified files
98
- diff = subprocess.run(
99
- diff_cached + excluded_from_diff,
100
- check=True,
101
- stdout=subprocess.PIPE,
102
- stderr=subprocess.PIPE,
103
- text=True
104
- )
105
- diff_result = diff.stdout.strip()
106
-
107
- return {
108
- 'files': files_result,
109
- 'diff': diff_result
110
- }
111
- except subprocess.CalledProcessError:
112
- return None
113
-
114
-
115
- def get_detected_message(files: List[str]) -> str:
116
- """
117
- Returns a message indicating the number of staged files.
118
- """
119
- return (
120
- f"Detected {len(files):,} staged file{'s' if len(files) > 1 else ''}"
121
- )
122
-
123
-
124
- def group_files_by_directory(files: List[str]) -> Dict[str, List[str]]:
125
- """
126
- Groups files by their root directory (first-level directory).
127
- Files in the repository root are grouped under 'root'.
128
- """
129
- grouped = defaultdict(list)
130
-
131
- for file_path in files:
132
- # Get the first directory in the path
133
- parts = file_path.split(os.sep)
134
- if len(parts) > 1:
135
- root_dir = parts[0]
136
- else:
137
- root_dir = 'root'
138
- grouped[root_dir].append(file_path)
139
-
140
- return dict(grouped)
141
-
142
-
143
- def get_diff_for_files(files: List[str], exclude_files: Optional[List[str]] = None) -> str:
144
- """
145
- Gets the diff for specific files.
146
- """
147
- exclude_files = exclude_files or []
148
-
149
- # Filter out excluded files from the list
150
- all_excluded = files_to_exclude + exclude_files
151
- filtered_files = [
152
- f for f in files
153
- if not any(f.endswith(excl.replace('*', '')) or excl.replace(':(exclude)', '') in f
154
- for excl in all_excluded)
155
- ]
156
-
157
- if not filtered_files:
158
- return ""
159
-
160
- try:
161
- diff = subprocess.run(
162
- ['git', 'diff', '--cached', '--diff-algorithm=minimal', '--'] + filtered_files,
163
- check=True,
164
- stdout=subprocess.PIPE,
165
- stderr=subprocess.PIPE,
166
- text=True
167
- )
168
- return diff.stdout.strip()
169
- except subprocess.CalledProcessError:
170
- return ""
File without changes