portacode 0.3.22__py3-none-any.whl → 0.3.24__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.
- portacode/_version.py +16 -3
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +188 -16
- portacode/connection/handlers/__init__.py +4 -0
- portacode/connection/handlers/base.py +9 -5
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/file_handlers.py +68 -2
- portacode/connection/handlers/project_aware_file_handlers.py +143 -1
- portacode/connection/handlers/project_state/git_manager.py +326 -66
- portacode/connection/handlers/project_state/handlers.py +307 -31
- portacode/connection/handlers/project_state/manager.py +44 -1
- portacode/connection/handlers/project_state/models.py +7 -0
- portacode/connection/handlers/project_state/utils.py +17 -1
- portacode/connection/handlers/project_state_handlers.py +1 -0
- portacode/connection/handlers/tab_factory.py +60 -7
- portacode/connection/terminal.py +13 -7
- {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/METADATA +14 -3
- {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/RECORD +25 -24
- {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/WHEEL +1 -1
- test_modules/test_git_status_ui.py +24 -66
- testing_framework/core/playwright_manager.py +23 -0
- testing_framework/core/runner.py +10 -2
- testing_framework/core/test_discovery.py +7 -3
- {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info/licenses}/LICENSE +0 -0
- {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/top_level.txt +0 -0
|
@@ -181,22 +181,71 @@ class GitManager:
|
|
|
181
181
|
if is_ignored:
|
|
182
182
|
return {"is_tracked": False, "status": "ignored", "is_ignored": True, "is_staged": False}
|
|
183
183
|
|
|
184
|
-
# For directories,
|
|
184
|
+
# For directories, aggregate status from contained files
|
|
185
185
|
if os.path.isdir(file_path):
|
|
186
|
-
#
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
186
|
+
# Normalize the relative path for cross-platform compatibility
|
|
187
|
+
rel_path_normalized = rel_path.replace('\\', '/')
|
|
188
|
+
|
|
189
|
+
# Check for untracked files in this directory
|
|
190
|
+
has_untracked = False
|
|
191
|
+
for untracked_file in self.repo.untracked_files:
|
|
192
|
+
untracked_normalized = untracked_file.replace('\\', '/')
|
|
193
|
+
if untracked_normalized.startswith(rel_path_normalized + '/') or untracked_normalized == rel_path_normalized:
|
|
194
|
+
has_untracked = True
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
# Check for modified files in this directory using git status
|
|
198
|
+
has_modified = False
|
|
199
|
+
has_deleted = False
|
|
200
|
+
try:
|
|
201
|
+
# Get status for files in this directory
|
|
202
|
+
status_output = self.repo.git.status(rel_path, porcelain=True)
|
|
203
|
+
if status_output.strip():
|
|
204
|
+
for line in status_output.strip().split('\n'):
|
|
205
|
+
if len(line) >= 2:
|
|
206
|
+
# When filtering git status by path, GitPython strips the leading space
|
|
207
|
+
# So format is either "XY filename" or " XY filename"
|
|
208
|
+
if line.startswith(' '):
|
|
209
|
+
# Full status format: " XY filename"
|
|
210
|
+
index_status = line[0]
|
|
211
|
+
worktree_status = line[1]
|
|
212
|
+
file_path_from_status = line[3:] if len(line) > 3 else ""
|
|
213
|
+
else:
|
|
214
|
+
# Path-filtered format: "XY filename" (leading space stripped)
|
|
215
|
+
# Two possible formats:
|
|
216
|
+
# 1. Regular files: "M filename" (index + worktree + space + filename)
|
|
217
|
+
# 2. Submodules: "M filename" (index + space + filename)
|
|
218
|
+
index_status = line[0] if len(line) > 0 else ' '
|
|
219
|
+
worktree_status = line[1] if len(line) > 1 else ' '
|
|
220
|
+
|
|
221
|
+
# Detect format by checking if position 2 is a space
|
|
222
|
+
if len(line) > 2 and line[2] == ' ':
|
|
223
|
+
# Regular file format: "M filename"
|
|
224
|
+
file_path_from_status = line[3:] if len(line) > 3 else ""
|
|
225
|
+
else:
|
|
226
|
+
# Submodule format: "M filename"
|
|
227
|
+
file_path_from_status = line[2:] if len(line) > 2 else ""
|
|
228
|
+
|
|
229
|
+
# Check if this file is within our directory
|
|
230
|
+
file_normalized = file_path_from_status.replace('\\', '/')
|
|
231
|
+
if (file_normalized.startswith(rel_path_normalized + '/') or
|
|
232
|
+
file_normalized == rel_path_normalized):
|
|
233
|
+
if index_status in ['M', 'A', 'R', 'C'] or worktree_status in ['M', 'A', 'R', 'C']:
|
|
234
|
+
has_modified = True
|
|
235
|
+
elif index_status == 'D' or worktree_status == 'D':
|
|
236
|
+
has_deleted = True
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.debug("Error checking directory git status for %s: %s", rel_path, e)
|
|
239
|
+
|
|
240
|
+
# Priority order: untracked > modified/deleted > clean
|
|
192
241
|
if has_untracked:
|
|
193
242
|
return {"is_tracked": False, "status": "untracked", "is_ignored": False, "is_staged": is_staged}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
243
|
+
elif has_deleted:
|
|
244
|
+
return {"is_tracked": True, "status": "deleted", "is_ignored": False, "is_staged": is_staged}
|
|
245
|
+
elif has_modified:
|
|
197
246
|
return {"is_tracked": True, "status": "modified", "is_ignored": False, "is_staged": is_staged}
|
|
198
247
|
|
|
199
|
-
# Check if directory has tracked files
|
|
248
|
+
# Check if directory has tracked files to determine if it should show as clean
|
|
200
249
|
try:
|
|
201
250
|
tracked_files = self.repo.git.ls_files(rel_path)
|
|
202
251
|
is_tracked = bool(tracked_files.strip())
|
|
@@ -925,14 +974,6 @@ class GitManager:
|
|
|
925
974
|
|
|
926
975
|
# For staged files in no-HEAD repo, they are all "added" (new files)
|
|
927
976
|
diff_details = None
|
|
928
|
-
if content_hash and os.path.exists(file_abs_path):
|
|
929
|
-
try:
|
|
930
|
-
with open(file_abs_path, 'r', encoding='utf-8') as f:
|
|
931
|
-
working_content = f.read()
|
|
932
|
-
# Compare empty content vs staged content (new file)
|
|
933
|
-
diff_details = self._compute_diff_details("", working_content)
|
|
934
|
-
except (OSError, UnicodeDecodeError):
|
|
935
|
-
diff_details = None
|
|
936
977
|
|
|
937
978
|
change = GitFileChange(
|
|
938
979
|
file_repo_path=file_repo_path,
|
|
@@ -990,17 +1031,11 @@ class GitManager:
|
|
|
990
1031
|
elif change_type == 'untracked':
|
|
991
1032
|
content_hash = self._compute_file_hash(file_abs_path) if os.path.exists(file_abs_path) else None
|
|
992
1033
|
# For new files, compare empty content vs current staged content
|
|
993
|
-
|
|
994
|
-
staged_content = self.get_file_content_staged(file_abs_path) or ""
|
|
995
|
-
diff_details = self._compute_diff_details("", staged_content)
|
|
996
|
-
else:
|
|
997
|
-
diff_details = None
|
|
1034
|
+
diff_details = None
|
|
998
1035
|
else: # modified
|
|
999
1036
|
content_hash = self._compute_file_hash(file_abs_path) if os.path.exists(file_abs_path) else None
|
|
1000
1037
|
# Compare HEAD content vs staged content
|
|
1001
|
-
|
|
1002
|
-
staged_content = self.get_file_content_staged(file_abs_path) or ""
|
|
1003
|
-
diff_details = self._compute_diff_details(head_content, staged_content)
|
|
1038
|
+
diff_details = None
|
|
1004
1039
|
|
|
1005
1040
|
change = GitFileChange(
|
|
1006
1041
|
file_repo_path=file_repo_path,
|
|
@@ -1034,29 +1069,12 @@ class GitManager:
|
|
|
1034
1069
|
change_type = 'added'
|
|
1035
1070
|
content_hash = self._compute_file_hash(file_abs_path) if os.path.exists(file_abs_path) else None
|
|
1036
1071
|
# For new files, compare empty content vs current working content
|
|
1037
|
-
|
|
1038
|
-
try:
|
|
1039
|
-
with open(file_abs_path, 'r', encoding='utf-8') as f:
|
|
1040
|
-
working_content = f.read()
|
|
1041
|
-
diff_details = self._compute_diff_details("", working_content)
|
|
1042
|
-
except (OSError, UnicodeDecodeError):
|
|
1043
|
-
diff_details = None
|
|
1044
|
-
else:
|
|
1045
|
-
diff_details = None
|
|
1072
|
+
diff_details = None
|
|
1046
1073
|
else:
|
|
1047
1074
|
change_type = 'modified'
|
|
1048
1075
|
content_hash = self._compute_file_hash(file_abs_path) if os.path.exists(file_abs_path) else None
|
|
1049
1076
|
# Compare staged/index content vs working content
|
|
1050
|
-
|
|
1051
|
-
if os.path.exists(file_abs_path):
|
|
1052
|
-
try:
|
|
1053
|
-
with open(file_abs_path, 'r', encoding='utf-8') as f:
|
|
1054
|
-
working_content = f.read()
|
|
1055
|
-
diff_details = self._compute_diff_details(staged_content, working_content)
|
|
1056
|
-
except (OSError, UnicodeDecodeError):
|
|
1057
|
-
diff_details = None
|
|
1058
|
-
else:
|
|
1059
|
-
diff_details = None
|
|
1077
|
+
diff_details = None
|
|
1060
1078
|
|
|
1061
1079
|
change = GitFileChange(
|
|
1062
1080
|
file_repo_path=file_repo_path,
|
|
@@ -1084,13 +1102,6 @@ class GitManager:
|
|
|
1084
1102
|
|
|
1085
1103
|
# For untracked files, compare empty content vs current file content
|
|
1086
1104
|
diff_details = None
|
|
1087
|
-
if content_hash and os.path.exists(file_abs_path):
|
|
1088
|
-
try:
|
|
1089
|
-
with open(file_abs_path, 'r', encoding='utf-8') as f:
|
|
1090
|
-
working_content = f.read()
|
|
1091
|
-
diff_details = self._compute_diff_details("", working_content)
|
|
1092
|
-
except (OSError, UnicodeDecodeError):
|
|
1093
|
-
diff_details = None
|
|
1094
1105
|
|
|
1095
1106
|
change = GitFileChange(
|
|
1096
1107
|
file_repo_path=file_repo_path,
|
|
@@ -1172,6 +1183,41 @@ class GitManager:
|
|
|
1172
1183
|
logger.error("Error getting staged content for %s: %s", file_path, e)
|
|
1173
1184
|
return None
|
|
1174
1185
|
|
|
1186
|
+
def _is_submodule(self, file_path: str) -> bool:
|
|
1187
|
+
"""Check if the given path is a submodule."""
|
|
1188
|
+
if not self.is_git_repo or not self.repo:
|
|
1189
|
+
return False
|
|
1190
|
+
|
|
1191
|
+
try:
|
|
1192
|
+
# Convert to relative path from repo root
|
|
1193
|
+
rel_path = os.path.relpath(file_path, self.repo.working_dir)
|
|
1194
|
+
|
|
1195
|
+
# Check if this path is listed in .gitmodules
|
|
1196
|
+
gitmodules_path = os.path.join(self.repo.working_dir, '.gitmodules')
|
|
1197
|
+
if os.path.exists(gitmodules_path):
|
|
1198
|
+
try:
|
|
1199
|
+
with open(gitmodules_path, 'r') as f:
|
|
1200
|
+
content = f.read()
|
|
1201
|
+
# Simple check - look for path = rel_path in .gitmodules
|
|
1202
|
+
for line in content.splitlines():
|
|
1203
|
+
if line.strip().startswith('path ='):
|
|
1204
|
+
submodule_path = line.split('=', 1)[1].strip()
|
|
1205
|
+
if submodule_path == rel_path:
|
|
1206
|
+
return True
|
|
1207
|
+
except Exception as e:
|
|
1208
|
+
logger.warning("Error reading .gitmodules: %s", e)
|
|
1209
|
+
|
|
1210
|
+
# Alternative check: see if the path has a .git file (submodule indicator)
|
|
1211
|
+
git_path = os.path.join(file_path, '.git')
|
|
1212
|
+
if os.path.isfile(git_path): # Submodules have .git as a file, not directory
|
|
1213
|
+
return True
|
|
1214
|
+
|
|
1215
|
+
return False
|
|
1216
|
+
|
|
1217
|
+
except Exception as e:
|
|
1218
|
+
logger.warning("Error checking if %s is submodule: %s", file_path, e)
|
|
1219
|
+
return False
|
|
1220
|
+
|
|
1175
1221
|
def stage_file(self, file_path: str) -> bool:
|
|
1176
1222
|
"""Stage a file for commit."""
|
|
1177
1223
|
if not self.is_git_repo or not self.repo:
|
|
@@ -1181,8 +1227,15 @@ class GitManager:
|
|
|
1181
1227
|
# Convert to relative path from repo root
|
|
1182
1228
|
rel_path = os.path.relpath(file_path, self.repo.working_dir)
|
|
1183
1229
|
|
|
1184
|
-
#
|
|
1185
|
-
self.
|
|
1230
|
+
# Check if this is a submodule
|
|
1231
|
+
if self._is_submodule(file_path):
|
|
1232
|
+
logger.info("Detected submodule, using git add command directly: %s", rel_path)
|
|
1233
|
+
# For submodules, use git add directly to stage only the submodule reference
|
|
1234
|
+
self.repo.git.add(rel_path)
|
|
1235
|
+
else:
|
|
1236
|
+
# For regular files, use the index method
|
|
1237
|
+
self.repo.index.add([rel_path])
|
|
1238
|
+
|
|
1186
1239
|
logger.info("Successfully staged file: %s", rel_path)
|
|
1187
1240
|
return True
|
|
1188
1241
|
|
|
@@ -1199,19 +1252,25 @@ class GitManager:
|
|
|
1199
1252
|
# Convert to relative path from repo root
|
|
1200
1253
|
rel_path = os.path.relpath(file_path, self.repo.working_dir)
|
|
1201
1254
|
|
|
1202
|
-
# Check if
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
except Exception:
|
|
1207
|
-
has_head = False
|
|
1208
|
-
|
|
1209
|
-
if has_head:
|
|
1210
|
-
# Reset the file from HEAD (unstage) - for repos with commits
|
|
1255
|
+
# Check if this is a submodule
|
|
1256
|
+
if self._is_submodule(file_path):
|
|
1257
|
+
logger.info("Detected submodule, using git restore for unstaging: %s", rel_path)
|
|
1258
|
+
# For submodules, always use git restore --staged (works with submodules)
|
|
1211
1259
|
self.repo.git.restore('--staged', rel_path)
|
|
1212
1260
|
else:
|
|
1213
|
-
#
|
|
1214
|
-
|
|
1261
|
+
# Check if repository has any commits (HEAD exists)
|
|
1262
|
+
try:
|
|
1263
|
+
self.repo.head.commit
|
|
1264
|
+
has_head = True
|
|
1265
|
+
except Exception:
|
|
1266
|
+
has_head = False
|
|
1267
|
+
|
|
1268
|
+
if has_head:
|
|
1269
|
+
# Reset the file from HEAD (unstage) - for repos with commits
|
|
1270
|
+
self.repo.git.restore('--staged', rel_path)
|
|
1271
|
+
else:
|
|
1272
|
+
# For repositories with no commits, use git rm --cached to unstage
|
|
1273
|
+
self.repo.git.rm('--cached', rel_path)
|
|
1215
1274
|
|
|
1216
1275
|
logger.info("Successfully unstaged file: %s", rel_path)
|
|
1217
1276
|
return True
|
|
@@ -1255,6 +1314,207 @@ class GitManager:
|
|
|
1255
1314
|
logger.error("Error reverting file %s: %s", file_path, e)
|
|
1256
1315
|
raise RuntimeError(f"Failed to revert file: {e}")
|
|
1257
1316
|
|
|
1317
|
+
def stage_files(self, file_paths: List[str]) -> bool:
|
|
1318
|
+
"""Stage multiple files for commit in one atomic operation."""
|
|
1319
|
+
if not self.is_git_repo or not self.repo:
|
|
1320
|
+
raise RuntimeError("Not a git repository")
|
|
1321
|
+
|
|
1322
|
+
if not file_paths:
|
|
1323
|
+
logger.info("No files provided for staging")
|
|
1324
|
+
return True
|
|
1325
|
+
|
|
1326
|
+
try:
|
|
1327
|
+
# Convert all paths to relative paths from repo root
|
|
1328
|
+
rel_paths = []
|
|
1329
|
+
submodule_paths = []
|
|
1330
|
+
|
|
1331
|
+
for file_path in file_paths:
|
|
1332
|
+
rel_path = os.path.relpath(file_path, self.repo.working_dir)
|
|
1333
|
+
if self._is_submodule(file_path):
|
|
1334
|
+
submodule_paths.append(rel_path)
|
|
1335
|
+
else:
|
|
1336
|
+
rel_paths.append(rel_path)
|
|
1337
|
+
|
|
1338
|
+
# Stage submodules using git add directly
|
|
1339
|
+
if submodule_paths:
|
|
1340
|
+
logger.info("Staging submodules using git add directly: %s", submodule_paths)
|
|
1341
|
+
for submodule_path in submodule_paths:
|
|
1342
|
+
self.repo.git.add(submodule_path)
|
|
1343
|
+
|
|
1344
|
+
# Stage regular files using index.add for efficiency
|
|
1345
|
+
if rel_paths:
|
|
1346
|
+
logger.info("Staging regular files: %s", rel_paths)
|
|
1347
|
+
self.repo.index.add(rel_paths)
|
|
1348
|
+
|
|
1349
|
+
logger.info("Successfully staged %d files (%d submodules, %d regular)",
|
|
1350
|
+
len(file_paths), len(submodule_paths), len(rel_paths))
|
|
1351
|
+
return True
|
|
1352
|
+
|
|
1353
|
+
except Exception as e:
|
|
1354
|
+
logger.error("Error staging files %s: %s", file_paths, e)
|
|
1355
|
+
raise RuntimeError(f"Failed to stage files: {e}")
|
|
1356
|
+
|
|
1357
|
+
def unstage_files(self, file_paths: List[str]) -> bool:
|
|
1358
|
+
"""Unstage multiple files in one atomic operation."""
|
|
1359
|
+
if not self.is_git_repo or not self.repo:
|
|
1360
|
+
raise RuntimeError("Not a git repository")
|
|
1361
|
+
|
|
1362
|
+
if not file_paths:
|
|
1363
|
+
logger.info("No files provided for unstaging")
|
|
1364
|
+
return True
|
|
1365
|
+
|
|
1366
|
+
try:
|
|
1367
|
+
# Convert all paths to relative paths from repo root
|
|
1368
|
+
rel_paths = []
|
|
1369
|
+
submodule_paths = []
|
|
1370
|
+
|
|
1371
|
+
for file_path in file_paths:
|
|
1372
|
+
rel_path = os.path.relpath(file_path, self.repo.working_dir)
|
|
1373
|
+
if self._is_submodule(file_path):
|
|
1374
|
+
submodule_paths.append(rel_path)
|
|
1375
|
+
else:
|
|
1376
|
+
rel_paths.append(rel_path)
|
|
1377
|
+
|
|
1378
|
+
# Check if repository has any commits (HEAD exists)
|
|
1379
|
+
try:
|
|
1380
|
+
self.repo.head.commit
|
|
1381
|
+
has_head = True
|
|
1382
|
+
except Exception:
|
|
1383
|
+
has_head = False
|
|
1384
|
+
|
|
1385
|
+
# Unstage all files using appropriate method
|
|
1386
|
+
all_rel_paths = rel_paths + submodule_paths
|
|
1387
|
+
|
|
1388
|
+
if has_head:
|
|
1389
|
+
# Use git restore --staged for all files (works for both regular files and submodules)
|
|
1390
|
+
if all_rel_paths:
|
|
1391
|
+
self.repo.git.restore('--staged', *all_rel_paths)
|
|
1392
|
+
else:
|
|
1393
|
+
# For repositories with no commits, use git rm --cached
|
|
1394
|
+
if all_rel_paths:
|
|
1395
|
+
self.repo.git.rm('--cached', *all_rel_paths)
|
|
1396
|
+
|
|
1397
|
+
logger.info("Successfully unstaged %d files (%d submodules, %d regular)",
|
|
1398
|
+
len(file_paths), len(submodule_paths), len(rel_paths))
|
|
1399
|
+
return True
|
|
1400
|
+
|
|
1401
|
+
except Exception as e:
|
|
1402
|
+
logger.error("Error unstaging files %s: %s", file_paths, e)
|
|
1403
|
+
raise RuntimeError(f"Failed to unstage files: {e}")
|
|
1404
|
+
|
|
1405
|
+
def revert_files(self, file_paths: List[str]) -> bool:
|
|
1406
|
+
"""Revert multiple files to their HEAD version in one atomic operation."""
|
|
1407
|
+
if not self.is_git_repo or not self.repo:
|
|
1408
|
+
raise RuntimeError("Not a git repository")
|
|
1409
|
+
|
|
1410
|
+
if not file_paths:
|
|
1411
|
+
logger.info("No files provided for reverting")
|
|
1412
|
+
return True
|
|
1413
|
+
|
|
1414
|
+
try:
|
|
1415
|
+
# Check if repository has any commits (HEAD exists)
|
|
1416
|
+
try:
|
|
1417
|
+
self.repo.head.commit
|
|
1418
|
+
has_head = True
|
|
1419
|
+
except Exception:
|
|
1420
|
+
has_head = False
|
|
1421
|
+
|
|
1422
|
+
if has_head:
|
|
1423
|
+
# Convert to relative paths and restore all files at once
|
|
1424
|
+
rel_paths = [os.path.relpath(file_path, self.repo.working_dir) for file_path in file_paths]
|
|
1425
|
+
# Filter out submodules - we don't revert submodules as they don't have working directory changes
|
|
1426
|
+
regular_files = []
|
|
1427
|
+
for i, file_path in enumerate(file_paths):
|
|
1428
|
+
if not self._is_submodule(file_path):
|
|
1429
|
+
regular_files.append(rel_paths[i])
|
|
1430
|
+
|
|
1431
|
+
if regular_files:
|
|
1432
|
+
self.repo.git.restore(*regular_files)
|
|
1433
|
+
logger.info("Successfully reverted %d files", len(regular_files))
|
|
1434
|
+
else:
|
|
1435
|
+
# For repositories with no commits, remove files to "revert" them
|
|
1436
|
+
removed_count = 0
|
|
1437
|
+
for file_path in file_paths:
|
|
1438
|
+
if not self._is_submodule(file_path) and os.path.exists(file_path):
|
|
1439
|
+
os.remove(file_path)
|
|
1440
|
+
removed_count += 1
|
|
1441
|
+
logger.info("Successfully removed %d files (no HEAD to revert to)", removed_count)
|
|
1442
|
+
|
|
1443
|
+
return True
|
|
1444
|
+
|
|
1445
|
+
except Exception as e:
|
|
1446
|
+
logger.error("Error reverting files %s: %s", file_paths, e)
|
|
1447
|
+
raise RuntimeError(f"Failed to revert files: {e}")
|
|
1448
|
+
|
|
1449
|
+
def stage_all_changes(self) -> bool:
|
|
1450
|
+
"""Stage all changes (modified, deleted, untracked) in one atomic operation."""
|
|
1451
|
+
if not self.is_git_repo or not self.repo:
|
|
1452
|
+
raise RuntimeError("Not a git repository")
|
|
1453
|
+
|
|
1454
|
+
try:
|
|
1455
|
+
# Use git add . to stage everything - this is the most efficient way
|
|
1456
|
+
self.repo.git.add('.')
|
|
1457
|
+
logger.info("Successfully staged all changes using 'git add .'")
|
|
1458
|
+
return True
|
|
1459
|
+
|
|
1460
|
+
except Exception as e:
|
|
1461
|
+
logger.error("Error staging all changes: %s", e)
|
|
1462
|
+
raise RuntimeError(f"Failed to stage all changes: {e}")
|
|
1463
|
+
|
|
1464
|
+
def unstage_all_changes(self) -> bool:
|
|
1465
|
+
"""Unstage all staged changes in one atomic operation."""
|
|
1466
|
+
if not self.is_git_repo or not self.repo:
|
|
1467
|
+
raise RuntimeError("Not a git repository")
|
|
1468
|
+
|
|
1469
|
+
try:
|
|
1470
|
+
# Check if repository has any commits (HEAD exists)
|
|
1471
|
+
try:
|
|
1472
|
+
self.repo.head.commit
|
|
1473
|
+
has_head = True
|
|
1474
|
+
except Exception:
|
|
1475
|
+
has_head = False
|
|
1476
|
+
|
|
1477
|
+
if has_head:
|
|
1478
|
+
# Use git restore --staged . to unstage everything
|
|
1479
|
+
self.repo.git.restore('--staged', '.')
|
|
1480
|
+
else:
|
|
1481
|
+
# For repositories with no commits, remove everything from index
|
|
1482
|
+
self.repo.git.rm('--cached', '-r', '.')
|
|
1483
|
+
|
|
1484
|
+
logger.info("Successfully unstaged all changes")
|
|
1485
|
+
return True
|
|
1486
|
+
|
|
1487
|
+
except Exception as e:
|
|
1488
|
+
logger.error("Error unstaging all changes: %s", e)
|
|
1489
|
+
raise RuntimeError(f"Failed to unstage all changes: {e}")
|
|
1490
|
+
|
|
1491
|
+
def revert_all_changes(self) -> bool:
|
|
1492
|
+
"""Revert all working directory changes in one atomic operation."""
|
|
1493
|
+
if not self.is_git_repo or not self.repo:
|
|
1494
|
+
raise RuntimeError("Not a git repository")
|
|
1495
|
+
|
|
1496
|
+
try:
|
|
1497
|
+
# Check if repository has any commits (HEAD exists)
|
|
1498
|
+
try:
|
|
1499
|
+
self.repo.head.commit
|
|
1500
|
+
has_head = True
|
|
1501
|
+
except Exception:
|
|
1502
|
+
has_head = False
|
|
1503
|
+
|
|
1504
|
+
if has_head:
|
|
1505
|
+
# Use git restore . to revert all working directory changes
|
|
1506
|
+
self.repo.git.restore('.')
|
|
1507
|
+
logger.info("Successfully reverted all working directory changes")
|
|
1508
|
+
else:
|
|
1509
|
+
logger.warning("Cannot revert changes in repository with no commits")
|
|
1510
|
+
return False
|
|
1511
|
+
|
|
1512
|
+
return True
|
|
1513
|
+
|
|
1514
|
+
except Exception as e:
|
|
1515
|
+
logger.error("Error reverting all changes: %s", e)
|
|
1516
|
+
raise RuntimeError(f"Failed to revert all changes: {e}")
|
|
1517
|
+
|
|
1258
1518
|
def commit_changes(self, message: str) -> bool:
|
|
1259
1519
|
"""Commit staged changes with the given message."""
|
|
1260
1520
|
if not self.is_git_repo or not self.repo:
|