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.
Files changed (25) hide show
  1. portacode/_version.py +16 -3
  2. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +188 -16
  3. portacode/connection/handlers/__init__.py +4 -0
  4. portacode/connection/handlers/base.py +9 -5
  5. portacode/connection/handlers/chunked_content.py +244 -0
  6. portacode/connection/handlers/file_handlers.py +68 -2
  7. portacode/connection/handlers/project_aware_file_handlers.py +143 -1
  8. portacode/connection/handlers/project_state/git_manager.py +326 -66
  9. portacode/connection/handlers/project_state/handlers.py +307 -31
  10. portacode/connection/handlers/project_state/manager.py +44 -1
  11. portacode/connection/handlers/project_state/models.py +7 -0
  12. portacode/connection/handlers/project_state/utils.py +17 -1
  13. portacode/connection/handlers/project_state_handlers.py +1 -0
  14. portacode/connection/handlers/tab_factory.py +60 -7
  15. portacode/connection/terminal.py +13 -7
  16. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/METADATA +14 -3
  17. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/RECORD +25 -24
  18. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/WHEEL +1 -1
  19. test_modules/test_git_status_ui.py +24 -66
  20. testing_framework/core/playwright_manager.py +23 -0
  21. testing_framework/core/runner.py +10 -2
  22. testing_framework/core/test_discovery.py +7 -3
  23. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/entry_points.txt +0 -0
  24. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info/licenses}/LICENSE +0 -0
  25. {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, 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())
@@ -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
- if content_hash:
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
- head_content = self.get_file_content_at_commit(file_abs_path) or ""
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
- if content_hash and os.path.exists(file_abs_path):
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
- staged_content = self.get_file_content_staged(file_abs_path) or ""
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
- # Stage the file
1185
- 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
+
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 repository has any commits (HEAD exists)
1203
- try:
1204
- self.repo.head.commit
1205
- has_head = True
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
- # For repositories with no commits, use git rm --cached to unstage
1214
- 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)
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: