portacode 0.3.23__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 CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.3.23'
32
- __version_tuple__ = version_tuple = (0, 3, 23)
31
+ __version__ = version = '0.3.24'
32
+ __version_tuple__ = version_tuple = (0, 3, 24)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -458,12 +458,19 @@ Requests the content for a specific diff tab identified by its diff parameters.
458
458
 
459
459
  ### `project_state_git_stage`
460
460
 
461
- Stages a file for commit in the project's git repository. Handled by [`project_state_git_stage`](./project_state_handlers.py).
461
+ Stages file(s) for commit in the project's git repository. Supports both single file and bulk operations. Handled by [`project_state_git_stage`](./project_state_handlers.py).
462
462
 
463
463
  **Payload Fields:**
464
464
 
465
465
  * `project_id` (string, mandatory): The project ID from the initialized project state.
466
- * `file_path` (string, mandatory): The absolute path to the file to stage.
466
+ * `file_path` (string, optional): The absolute path to a single file to stage. Used for backward compatibility.
467
+ * `file_paths` (array of strings, optional): Array of absolute paths to files to stage. Used for bulk operations.
468
+ * `stage_all` (boolean, optional): If true, stages all unstaged changes in the repository. Takes precedence over file_path/file_paths.
469
+
470
+ **Operation Modes:**
471
+ - Single file: Provide `file_path`
472
+ - Bulk operation: Provide `file_paths` array
473
+ - Stage all: Set `stage_all` to true
467
474
 
468
475
  **Responses:**
469
476
 
@@ -472,12 +479,19 @@ Stages a file for commit in the project's git repository. Handled by [`project_s
472
479
 
473
480
  ### `project_state_git_unstage`
474
481
 
475
- Unstages a file (removes from staging area) in the project's git repository. Handled by [`project_state_git_unstage`](./project_state_handlers.py).
482
+ Unstages file(s) (removes from staging area) in the project's git repository. Supports both single file and bulk operations. Handled by [`project_state_git_unstage`](./project_state_handlers.py).
476
483
 
477
484
  **Payload Fields:**
478
485
 
479
486
  * `project_id` (string, mandatory): The project ID from the initialized project state.
480
- * `file_path` (string, mandatory): The absolute path to the file to unstage.
487
+ * `file_path` (string, optional): The absolute path to a single file to unstage. Used for backward compatibility.
488
+ * `file_paths` (array of strings, optional): Array of absolute paths to files to unstage. Used for bulk operations.
489
+ * `unstage_all` (boolean, optional): If true, unstages all staged changes in the repository. Takes precedence over file_path/file_paths.
490
+
491
+ **Operation Modes:**
492
+ - Single file: Provide `file_path`
493
+ - Bulk operation: Provide `file_paths` array
494
+ - Unstage all: Set `unstage_all` to true
481
495
 
482
496
  **Responses:**
483
497
 
@@ -486,12 +500,19 @@ Unstages a file (removes from staging area) in the project's git repository. Han
486
500
 
487
501
  ### `project_state_git_revert`
488
502
 
489
- Reverts a file to its HEAD version, discarding local changes in the project's git repository. Handled by [`project_state_git_revert`](./project_state_handlers.py).
503
+ Reverts file(s) to their HEAD version, discarding local changes in the project's git repository. Supports both single file and bulk operations. Handled by [`project_state_git_revert`](./project_state_handlers.py).
490
504
 
491
505
  **Payload Fields:**
492
506
 
493
507
  * `project_id` (string, mandatory): The project ID from the initialized project state.
494
- * `file_path` (string, mandatory): The absolute path to the file to revert.
508
+ * `file_path` (string, optional): The absolute path to a single file to revert. Used for backward compatibility.
509
+ * `file_paths` (array of strings, optional): Array of absolute paths to files to revert. Used for bulk operations.
510
+ * `revert_all` (boolean, optional): If true, reverts all unstaged changes in the repository. Takes precedence over file_path/file_paths.
511
+
512
+ **Operation Modes:**
513
+ - Single file: Provide `file_path`
514
+ - Bulk operation: Provide `file_paths` array
515
+ - Revert all: Set `revert_all` to true
495
516
 
496
517
  **Responses:**
497
518
 
@@ -1019,34 +1040,40 @@ Returns the requested content for a specific diff tab, sent in response to a [`p
1019
1040
 
1020
1041
  ### <a name="project_state_git_stage_response"></a>`project_state_git_stage_response`
1021
1042
 
1022
- Confirms the result of a git stage operation.
1043
+ Confirms the result of a git stage operation. Supports responses for both single file and bulk operations.
1023
1044
 
1024
1045
  **Event Fields:**
1025
1046
 
1026
1047
  * `project_id` (string, mandatory): The project ID the operation was performed on.
1027
- * `file_path` (string, mandatory): The path to the file that was staged.
1048
+ * `file_path` (string, optional): The path to the file that was staged (for single file operations).
1049
+ * `file_paths` (array of strings, optional): Array of paths to files that were staged (for bulk operations).
1050
+ * `stage_all` (boolean, optional): Present if the operation was a "stage all" operation.
1028
1051
  * `success` (boolean, mandatory): Whether the stage operation was successful.
1029
1052
  * `error` (string, optional): Error message if the operation failed.
1030
1053
 
1031
1054
  ### <a name="project_state_git_unstage_response"></a>`project_state_git_unstage_response`
1032
1055
 
1033
- Confirms the result of a git unstage operation.
1056
+ Confirms the result of a git unstage operation. Supports responses for both single file and bulk operations.
1034
1057
 
1035
1058
  **Event Fields:**
1036
1059
 
1037
1060
  * `project_id` (string, mandatory): The project ID the operation was performed on.
1038
- * `file_path` (string, mandatory): The path to the file that was unstaged.
1061
+ * `file_path` (string, optional): The path to the file that was unstaged (for single file operations).
1062
+ * `file_paths` (array of strings, optional): Array of paths to files that were unstaged (for bulk operations).
1063
+ * `unstage_all` (boolean, optional): Present if the operation was an "unstage all" operation.
1039
1064
  * `success` (boolean, mandatory): Whether the unstage operation was successful.
1040
1065
  * `error` (string, optional): Error message if the operation failed.
1041
1066
 
1042
1067
  ### <a name="project_state_git_revert_response"></a>`project_state_git_revert_response`
1043
1068
 
1044
- Confirms the result of a git revert operation.
1069
+ Confirms the result of a git revert operation. Supports responses for both single file and bulk operations.
1045
1070
 
1046
1071
  **Event Fields:**
1047
1072
 
1048
1073
  * `project_id` (string, mandatory): The project ID the operation was performed on.
1049
- * `file_path` (string, mandatory): The path to the file that was reverted.
1074
+ * `file_path` (string, optional): The path to the file that was reverted (for single file operations).
1075
+ * `file_paths` (array of strings, optional): Array of paths to files that were reverted (for bulk operations).
1076
+ * `revert_all` (boolean, optional): Present if the operation was a "revert all" operation.
1050
1077
  * `success` (boolean, mandatory): Whether the revert operation was successful.
1051
1078
  * `error` (string, optional): Error message if the operation failed.
1052
1079
 
@@ -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, only report status if they contain tracked or untracked files
184
+ # For directories, aggregate status from contained files
185
185
  if os.path.isdir(file_path):
186
- # Check if directory contains any untracked files using path.startswith()
187
- # This handles cross-platform path separators correctly
188
- has_untracked = any(
189
- os.path.commonpath([f, rel_path]) == rel_path and f != rel_path
190
- for f in self.repo.untracked_files
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
- # Check if directory is dirty - GitPython handles path normalization
196
- if self.repo.is_dirty(path=rel_path):
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 - let GitPython handle paths
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())
@@ -1134,6 +1183,41 @@ class GitManager:
1134
1183
  logger.error("Error getting staged content for %s: %s", file_path, e)
1135
1184
  return None
1136
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
+
1137
1221
  def stage_file(self, file_path: str) -> bool:
1138
1222
  """Stage a file for commit."""
1139
1223
  if not self.is_git_repo or not self.repo:
@@ -1143,8 +1227,15 @@ class GitManager:
1143
1227
  # Convert to relative path from repo root
1144
1228
  rel_path = os.path.relpath(file_path, self.repo.working_dir)
1145
1229
 
1146
- # Stage the file
1147
- self.repo.index.add([rel_path])
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
+
1148
1239
  logger.info("Successfully staged file: %s", rel_path)
1149
1240
  return True
1150
1241
 
@@ -1161,19 +1252,25 @@ class GitManager:
1161
1252
  # Convert to relative path from repo root
1162
1253
  rel_path = os.path.relpath(file_path, self.repo.working_dir)
1163
1254
 
1164
- # Check if repository has any commits (HEAD exists)
1165
- try:
1166
- self.repo.head.commit
1167
- has_head = True
1168
- except Exception:
1169
- has_head = False
1170
-
1171
- if has_head:
1172
- # 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)
1173
1259
  self.repo.git.restore('--staged', rel_path)
1174
1260
  else:
1175
- # For repositories with no commits, use git rm --cached to unstage
1176
- self.repo.git.rm('--cached', rel_path)
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)
1177
1274
 
1178
1275
  logger.info("Successfully unstaged file: %s", rel_path)
1179
1276
  return True
@@ -1217,6 +1314,207 @@ class GitManager:
1217
1314
  logger.error("Error reverting file %s: %s", file_path, e)
1218
1315
  raise RuntimeError(f"Failed to revert file: {e}")
1219
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
+
1220
1518
  def commit_changes(self, message: str) -> bool:
1221
1519
  """Commit staged changes with the given message."""
1222
1520
  if not self.is_git_repo or not self.repo:
@@ -365,20 +365,33 @@ class ProjectStateGitStageHandler(AsyncHandler):
365
365
  return "project_state_git_stage"
366
366
 
367
367
  async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
368
- """Stage a file in git for a project."""
368
+ """Stage file(s) in git for a project. Supports both single file and bulk operations."""
369
369
  server_project_id = message.get("project_id")
370
- file_path = message.get("file_path")
370
+ file_path = message.get("file_path") # Single file (backward compatibility)
371
+ file_paths = message.get("file_paths") # Multiple files (bulk operation)
372
+ stage_all = message.get("stage_all", False) # Stage all changes
371
373
  source_client_session = message.get("source_client_session")
372
374
 
373
375
  if not server_project_id:
374
376
  raise ValueError("project_id is required")
375
- if not file_path:
376
- raise ValueError("file_path is required")
377
377
  if not source_client_session:
378
378
  raise ValueError("source_client_session is required")
379
379
 
380
- logger.info("Staging file %s for project %s (client session: %s)",
381
- file_path, server_project_id, source_client_session)
380
+ # Determine operation mode
381
+ if stage_all:
382
+ operation_desc = "staging all changes"
383
+ file_paths_to_stage = []
384
+ elif file_paths:
385
+ operation_desc = f"staging {len(file_paths)} files"
386
+ file_paths_to_stage = file_paths
387
+ elif file_path:
388
+ operation_desc = f"staging file {file_path}"
389
+ file_paths_to_stage = [file_path]
390
+ else:
391
+ raise ValueError("Either file_path, file_paths, or stage_all must be provided")
392
+
393
+ logger.info("%s for project %s (client session: %s)",
394
+ operation_desc.capitalize(), server_project_id, source_client_session)
382
395
 
383
396
  # Get the project state manager
384
397
  manager = get_or_create_project_state_manager(self.context, self.control_channel)
@@ -388,19 +401,34 @@ class ProjectStateGitStageHandler(AsyncHandler):
388
401
  if not git_manager:
389
402
  raise ValueError("No git repository found for this project")
390
403
 
391
- # Stage the file
392
- success = git_manager.stage_file(file_path)
404
+ # Perform the staging operation
405
+ if stage_all:
406
+ success = git_manager.stage_all_changes()
407
+ elif len(file_paths_to_stage) == 1:
408
+ success = git_manager.stage_file(file_paths_to_stage[0])
409
+ else:
410
+ success = git_manager.stage_files(file_paths_to_stage)
393
411
 
394
412
  if success:
395
413
  # Refresh entire project state to ensure consistency
396
414
  await manager._refresh_project_state(source_client_session)
397
415
 
398
- return {
416
+ # Build response
417
+ response = {
399
418
  "event": "project_state_git_stage_response",
400
419
  "project_id": server_project_id,
401
- "file_path": file_path,
402
420
  "success": success
403
421
  }
422
+
423
+ # Include appropriate file information in response for backward compatibility
424
+ if file_path:
425
+ response["file_path"] = file_path
426
+ if file_paths:
427
+ response["file_paths"] = file_paths
428
+ if stage_all:
429
+ response["stage_all"] = True
430
+
431
+ return response
404
432
 
405
433
 
406
434
  class ProjectStateGitUnstageHandler(AsyncHandler):
@@ -411,20 +439,33 @@ class ProjectStateGitUnstageHandler(AsyncHandler):
411
439
  return "project_state_git_unstage"
412
440
 
413
441
  async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
414
- """Unstage a file in git for a project."""
442
+ """Unstage file(s) in git for a project. Supports both single file and bulk operations."""
415
443
  server_project_id = message.get("project_id")
416
- file_path = message.get("file_path")
444
+ file_path = message.get("file_path") # Single file (backward compatibility)
445
+ file_paths = message.get("file_paths") # Multiple files (bulk operation)
446
+ unstage_all = message.get("unstage_all", False) # Unstage all changes
417
447
  source_client_session = message.get("source_client_session")
418
448
 
419
449
  if not server_project_id:
420
450
  raise ValueError("project_id is required")
421
- if not file_path:
422
- raise ValueError("file_path is required")
423
451
  if not source_client_session:
424
452
  raise ValueError("source_client_session is required")
425
453
 
426
- logger.info("Unstaging file %s for project %s (client session: %s)",
427
- file_path, server_project_id, source_client_session)
454
+ # Determine operation mode
455
+ if unstage_all:
456
+ operation_desc = "unstaging all changes"
457
+ file_paths_to_unstage = []
458
+ elif file_paths:
459
+ operation_desc = f"unstaging {len(file_paths)} files"
460
+ file_paths_to_unstage = file_paths
461
+ elif file_path:
462
+ operation_desc = f"unstaging file {file_path}"
463
+ file_paths_to_unstage = [file_path]
464
+ else:
465
+ raise ValueError("Either file_path, file_paths, or unstage_all must be provided")
466
+
467
+ logger.info("%s for project %s (client session: %s)",
468
+ operation_desc.capitalize(), server_project_id, source_client_session)
428
469
 
429
470
  # Get the project state manager
430
471
  manager = get_or_create_project_state_manager(self.context, self.control_channel)
@@ -434,19 +475,34 @@ class ProjectStateGitUnstageHandler(AsyncHandler):
434
475
  if not git_manager:
435
476
  raise ValueError("No git repository found for this project")
436
477
 
437
- # Unstage the file
438
- success = git_manager.unstage_file(file_path)
478
+ # Perform the unstaging operation
479
+ if unstage_all:
480
+ success = git_manager.unstage_all_changes()
481
+ elif len(file_paths_to_unstage) == 1:
482
+ success = git_manager.unstage_file(file_paths_to_unstage[0])
483
+ else:
484
+ success = git_manager.unstage_files(file_paths_to_unstage)
439
485
 
440
486
  if success:
441
487
  # Refresh entire project state to ensure consistency
442
488
  await manager._refresh_project_state(source_client_session)
443
489
 
444
- return {
490
+ # Build response
491
+ response = {
445
492
  "event": "project_state_git_unstage_response",
446
493
  "project_id": server_project_id,
447
- "file_path": file_path,
448
494
  "success": success
449
495
  }
496
+
497
+ # Include appropriate file information in response for backward compatibility
498
+ if file_path:
499
+ response["file_path"] = file_path
500
+ if file_paths:
501
+ response["file_paths"] = file_paths
502
+ if unstage_all:
503
+ response["unstage_all"] = True
504
+
505
+ return response
450
506
 
451
507
 
452
508
  class ProjectStateGitRevertHandler(AsyncHandler):
@@ -457,20 +513,33 @@ class ProjectStateGitRevertHandler(AsyncHandler):
457
513
  return "project_state_git_revert"
458
514
 
459
515
  async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
460
- """Revert a file in git for a project."""
516
+ """Revert file(s) in git for a project. Supports both single file and bulk operations."""
461
517
  server_project_id = message.get("project_id")
462
- file_path = message.get("file_path")
518
+ file_path = message.get("file_path") # Single file (backward compatibility)
519
+ file_paths = message.get("file_paths") # Multiple files (bulk operation)
520
+ revert_all = message.get("revert_all", False) # Revert all changes
463
521
  source_client_session = message.get("source_client_session")
464
522
 
465
523
  if not server_project_id:
466
524
  raise ValueError("project_id is required")
467
- if not file_path:
468
- raise ValueError("file_path is required")
469
525
  if not source_client_session:
470
526
  raise ValueError("source_client_session is required")
471
527
 
472
- logger.info("Reverting file %s for project %s (client session: %s)",
473
- file_path, server_project_id, source_client_session)
528
+ # Determine operation mode
529
+ if revert_all:
530
+ operation_desc = "reverting all changes"
531
+ file_paths_to_revert = []
532
+ elif file_paths:
533
+ operation_desc = f"reverting {len(file_paths)} files"
534
+ file_paths_to_revert = file_paths
535
+ elif file_path:
536
+ operation_desc = f"reverting file {file_path}"
537
+ file_paths_to_revert = [file_path]
538
+ else:
539
+ raise ValueError("Either file_path, file_paths, or revert_all must be provided")
540
+
541
+ logger.info("%s for project %s (client session: %s)",
542
+ operation_desc.capitalize(), server_project_id, source_client_session)
474
543
 
475
544
  # Get the project state manager
476
545
  manager = get_or_create_project_state_manager(self.context, self.control_channel)
@@ -480,19 +549,34 @@ class ProjectStateGitRevertHandler(AsyncHandler):
480
549
  if not git_manager:
481
550
  raise ValueError("No git repository found for this project")
482
551
 
483
- # Revert the file
484
- success = git_manager.revert_file(file_path)
552
+ # Perform the revert operation
553
+ if revert_all:
554
+ success = git_manager.revert_all_changes()
555
+ elif len(file_paths_to_revert) == 1:
556
+ success = git_manager.revert_file(file_paths_to_revert[0])
557
+ else:
558
+ success = git_manager.revert_files(file_paths_to_revert)
485
559
 
486
560
  if success:
487
561
  # Refresh entire project state to ensure consistency
488
562
  await manager._refresh_project_state(source_client_session)
489
563
 
490
- return {
564
+ # Build response
565
+ response = {
491
566
  "event": "project_state_git_revert_response",
492
567
  "project_id": server_project_id,
493
- "file_path": file_path,
494
568
  "success": success
495
569
  }
570
+
571
+ # Include appropriate file information in response for backward compatibility
572
+ if file_path:
573
+ response["file_path"] = file_path
574
+ if file_paths:
575
+ response["file_paths"] = file_paths
576
+ if revert_all:
577
+ response["revert_all"] = True
578
+
579
+ return response
496
580
 
497
581
 
498
582
  class ProjectStateGitCommitHandler(AsyncHandler):
@@ -59,7 +59,7 @@ MEDIA_EXTENSIONS = {
59
59
  '.ico', '.icns', '.cur', '.psd', '.ai', '.eps', '.raw', '.cr2', '.nef',
60
60
 
61
61
  # Audio
62
- '.mp3', '.wav', '.flac', '.aac', '.ogg', '.wma', '.m4a', '.opus', '.webm',
62
+ '.mp3', '.wav', '.flac', '.aac', '.ogg', '.oga', '.wma', '.m4a', '.opus', '.webm',
63
63
 
64
64
  # Video
65
65
  '.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.3gp',
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 0.3.23
3
+ Version: 0.3.24
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -18,15 +18,26 @@ Requires-Dist: websockets>=12.0
18
18
  Requires-Dist: pyperclip>=1.8
19
19
  Requires-Dist: psutil>=5.9
20
20
  Requires-Dist: pyte>=0.8
21
+ Requires-Dist: pywinpty>=2.0; platform_system == "Windows"
21
22
  Requires-Dist: GitPython>=3.1.45
22
23
  Requires-Dist: watchdog>=3.0
23
24
  Requires-Dist: diff-match-patch>=20230430
24
25
  Requires-Dist: Pygments>=2.14.0
25
- Requires-Dist: pywinpty>=2.0; platform_system == "Windows"
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: black; extra == "dev"
28
28
  Requires-Dist: flake8; extra == "dev"
29
29
  Requires-Dist: pytest; extra == "dev"
30
+ Dynamic: author
31
+ Dynamic: author-email
32
+ Dynamic: classifier
33
+ Dynamic: description
34
+ Dynamic: description-content-type
35
+ Dynamic: home-page
36
+ Dynamic: license-file
37
+ Dynamic: provides-extra
38
+ Dynamic: requires-dist
39
+ Dynamic: requires-python
40
+ Dynamic: summary
30
41
 
31
42
  # Portacode
32
43
 
@@ -1,7 +1,7 @@
1
1
  portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
2
2
  portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
3
3
  portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
4
- portacode/_version.py,sha256=isxlLzkGNxdPT011JL31oZc6SSnC8W3VtEFGWzPRCHQ,706
4
+ portacode/_version.py,sha256=-sGbc_e4_IvCzq_UpGJECDvP_BbU_Ba1ROhJvn2pYYU,706
5
5
  portacode/cli.py,sha256=Fcz8aXhgKhoWR9UbtmkN843DVuoJZtCTqBF3K-neVSc,16347
6
6
  portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
7
7
  portacode/keypair.py,sha256=PAcOYqlVLOoZTPYi6LvLjfsY6BkrWbLOhSZLb8r5sHs,3635
@@ -13,7 +13,7 @@ portacode/connection/client.py,sha256=tEM4rqzCRxIG5WXqYAT7s65NlEX2Z1sW42GosctBHI
13
13
  portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
14
14
  portacode/connection/terminal.py,sha256=OfjOjybC2RRrj_a3Eq17qVFF9mD1GlMn7l_O-mIcvIs,41716
15
15
  portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
16
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=zPj-OrsVNCB7reM9IfTQvj0lfm20kPjWlMEthileZMk,60173
16
+ portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=0KBoJzqemXbvpu8Ps7LitLwRYhJNtzcTJ5WAHMGgAuc,62509
17
17
  portacode/connection/handlers/__init__.py,sha256=4nv3Z4TGYjWcauKPWsbL_FbrTXApI94V7j6oiU1Vv-o,2144
18
18
  portacode/connection/handlers/base.py,sha256=C-H61CUHM2k431CG0usd7eEqklDj9pnuXHujBwhTugk,6666
19
19
  portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
@@ -23,16 +23,17 @@ portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2
23
23
  portacode/connection/handlers/registry.py,sha256=ebi0vhR1XXSYU7mJXlQJ4MjBYaMygGYqX7ReK7vsZ7o,5558
24
24
  portacode/connection/handlers/session.py,sha256=7fHC46YXe5Q3uGxYPGpwcBaU_0eBhH4Hg8fmNVZsVmQ,24528
25
25
  portacode/connection/handlers/system_handlers.py,sha256=65V5ctT0dIBc-oWG91e62MbdvU0z6x6JCTQuIqCWmZ0,5242
26
- portacode/connection/handlers/tab_factory.py,sha256=2DPCU_un8XakkDn9NYHGWdQiDSgHfBxPNaa4boYh1O4,17992
26
+ portacode/connection/handlers/tab_factory.py,sha256=VBZnwtxgeNJCsfBzUjkFWAAGBdijvai4MS2dXnhFY8U,18000
27
27
  portacode/connection/handlers/terminal_handlers.py,sha256=Yuo84zwKB5OiLuVtDLCQgMVrOS3T8ZOONxXpGnnougo,11019
28
28
  portacode/connection/handlers/project_state/README.md,sha256=trdd4ig6ungmwH5SpbSLfyxbL-QgPlGNU-_XrMEiXtw,10114
29
29
  portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL8r_wVs5Tlt6B9J7yQH6yQUt7gc,2541
30
30
  portacode/connection/handlers/project_state/file_system_watcher.py,sha256=w-93ioUZZKZxzPFr8djJnGhWjMVFVdDsmo0fVAukoKk,10150
31
- portacode/connection/handlers/project_state/git_manager.py,sha256=F0Ots9-gDC5Ictb_-ocX1S4xvgSV-4lZqAB4YXogjDo,62363
32
- portacode/connection/handlers/project_state/handlers.py,sha256=zSqFK8GQWnf1uFBGRoO8gAVo6aNeuJf1UOJNA83bEUg,34022
31
+ portacode/connection/handlers/project_state/git_manager.py,sha256=fA0RbWCblpJep13L4MdqnEP4sE1qWc7Y66vrIo_SWps,76575
32
+ portacode/connection/handlers/project_state/handlers.py,sha256=nkednSbCC-0n3ZtzesaWd9_NFfxNjS4lyVNnbsYs0Zk,37823
33
33
  portacode/connection/handlers/project_state/manager.py,sha256=03mN0H9TqVa_ohD5U5-5ZywDGj0s8-y1IxGdb07dZn8,57636
34
34
  portacode/connection/handlers/project_state/models.py,sha256=EZTKvxHKs8QlQUbzI0u2IqfzfRRXZixUIDBwTGCJATI,4313
35
35
  portacode/connection/handlers/project_state/utils.py,sha256=LsbQr9TH9Bz30FqikmtTxco4PlB_n0kUIuPKQ6Fb_mo,1665
36
+ portacode-0.3.24.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
36
37
  test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
37
38
  test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
38
39
  test_modules/test_device_online.py,sha256=yiSyVaMwKAugqIX_ZIxmLXiOlmA_8IRXiUp12YmpB98,1653
@@ -57,9 +58,8 @@ testing_framework/core/playwright_manager.py,sha256=9kGXJtRpRNEhaSlV7XVXvx4UQSHS
57
58
  testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
58
59
  testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
59
60
  testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
60
- portacode-0.3.23.dist-info/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
61
- portacode-0.3.23.dist-info/METADATA,sha256=g5mVNppNxBH1Xh5eRA_09n4lvJwtJHzOhorWlafs4oU,6930
62
- portacode-0.3.23.dist-info/WHEEL,sha256=iAkIy5fosb7FzIOwONchHf19Qu7_1wCWyFNR5gu9nU0,91
63
- portacode-0.3.23.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
64
- portacode-0.3.23.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
65
- portacode-0.3.23.dist-info/RECORD,,
61
+ portacode-0.3.24.dist-info/METADATA,sha256=SS67gt2k2J9c4RMudmfgmce73fY93nhqzDHdX7IDZN8,7173
62
+ portacode-0.3.24.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
63
+ portacode-0.3.24.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
64
+ portacode-0.3.24.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
65
+ portacode-0.3.24.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.3.2)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5