cortexshift 0.1.0__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 (100) hide show
  1. cortexshift/__init__.py +10 -0
  2. cortexshift/__main__.py +6 -0
  3. cortexshift/adapters/__init__.py +22 -0
  4. cortexshift/adapters/command_runner.py +116 -0
  5. cortexshift/adapters/discovery.py +55 -0
  6. cortexshift/adapters/git/__init__.py +10 -0
  7. cortexshift/adapters/git/inspector.py +321 -0
  8. cortexshift/adapters/git/parser.py +140 -0
  9. cortexshift/adapters/headless_runner.py +92 -0
  10. cortexshift/adapters/process_runner.py +56 -0
  11. cortexshift/adapters/providers/__init__.py +4 -0
  12. cortexshift/adapters/providers/antigravity.py +530 -0
  13. cortexshift/adapters/providers/claude.py +375 -0
  14. cortexshift/adapters/providers/codex.py +434 -0
  15. cortexshift/adapters/sqlite/__init__.py +10 -0
  16. cortexshift/adapters/sqlite/migrations.py +268 -0
  17. cortexshift/adapters/sqlite/store.py +914 -0
  18. cortexshift/adapters/workspace_lease.py +123 -0
  19. cortexshift/application/__init__.py +42 -0
  20. cortexshift/application/checkpoint_builder.py +218 -0
  21. cortexshift/application/checkpoint_service.py +273 -0
  22. cortexshift/application/doctor.py +80 -0
  23. cortexshift/application/handoff_builder.py +281 -0
  24. cortexshift/application/handoff_renderer.py +430 -0
  25. cortexshift/application/handoff_service.py +66 -0
  26. cortexshift/application/init_service.py +86 -0
  27. cortexshift/application/locator.py +48 -0
  28. cortexshift/application/native_session.py +65 -0
  29. cortexshift/application/recovery_service.py +235 -0
  30. cortexshift/application/repository_service.py +146 -0
  31. cortexshift/application/resume_service.py +124 -0
  32. cortexshift/application/run_service.py +270 -0
  33. cortexshift/application/session_launcher.py +183 -0
  34. cortexshift/application/session_service.py +63 -0
  35. cortexshift/application/source_session.py +62 -0
  36. cortexshift/application/status_service.py +73 -0
  37. cortexshift/application/switch_service.py +671 -0
  38. cortexshift/application/task_service.py +201 -0
  39. cortexshift/application/task_workspace.py +152 -0
  40. cortexshift/cli/__init__.py +5 -0
  41. cortexshift/cli/app.py +2477 -0
  42. cortexshift/domain/__init__.py +153 -0
  43. cortexshift/domain/checkpoint.py +174 -0
  44. cortexshift/domain/doctor.py +68 -0
  45. cortexshift/domain/errors.py +277 -0
  46. cortexshift/domain/git.py +102 -0
  47. cortexshift/domain/handoff.py +241 -0
  48. cortexshift/domain/identifiers.py +27 -0
  49. cortexshift/domain/launch.py +58 -0
  50. cortexshift/domain/mcp_binding.py +81 -0
  51. cortexshift/domain/native_session.py +19 -0
  52. cortexshift/domain/project.py +37 -0
  53. cortexshift/domain/provider.py +67 -0
  54. cortexshift/domain/session.py +92 -0
  55. cortexshift/domain/status.py +40 -0
  56. cortexshift/domain/task.py +191 -0
  57. cortexshift/mcp/__init__.py +38 -0
  58. cortexshift/mcp/context.py +165 -0
  59. cortexshift/mcp/facade.py +513 -0
  60. cortexshift/mcp/models.py +178 -0
  61. cortexshift/mcp/resources.py +45 -0
  62. cortexshift/mcp/server.py +52 -0
  63. cortexshift/mcp/tools.py +176 -0
  64. cortexshift/ports/__init__.py +39 -0
  65. cortexshift/ports/checkpoint_store.py +45 -0
  66. cortexshift/ports/command_runner.py +56 -0
  67. cortexshift/ports/discovery.py +41 -0
  68. cortexshift/ports/handoff_delivery.py +91 -0
  69. cortexshift/ports/handoff_store.py +43 -0
  70. cortexshift/ports/headless_runner.py +58 -0
  71. cortexshift/ports/native_session.py +20 -0
  72. cortexshift/ports/process_runner.py +31 -0
  73. cortexshift/ports/provider.py +152 -0
  74. cortexshift/ports/repository.py +44 -0
  75. cortexshift/ports/session_store.py +27 -0
  76. cortexshift/ports/state_store.py +55 -0
  77. cortexshift/ports/workspace_lease.py +39 -0
  78. cortexshift/tui/__init__.py +24 -0
  79. cortexshift/tui/actions.py +58 -0
  80. cortexshift/tui/app.py +1051 -0
  81. cortexshift/tui/coordinator.py +173 -0
  82. cortexshift/tui/cortexshift.tcss +258 -0
  83. cortexshift/tui/facade.py +614 -0
  84. cortexshift/tui/modals.py +594 -0
  85. cortexshift/tui/models.py +503 -0
  86. cortexshift/tui/screens/__init__.py +81 -0
  87. cortexshift/tui/screens/checkpoints.py +188 -0
  88. cortexshift/tui/screens/handoffs.py +180 -0
  89. cortexshift/tui/screens/help.py +117 -0
  90. cortexshift/tui/screens/overview.py +200 -0
  91. cortexshift/tui/screens/providers.py +169 -0
  92. cortexshift/tui/screens/repository.py +143 -0
  93. cortexshift/tui/screens/sessions.py +146 -0
  94. cortexshift/tui/screens/task.py +174 -0
  95. cortexshift/tui/widgets.py +209 -0
  96. cortexshift-0.1.0.dist-info/METADATA +202 -0
  97. cortexshift-0.1.0.dist-info/RECORD +100 -0
  98. cortexshift-0.1.0.dist-info/WHEEL +4 -0
  99. cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
  100. cortexshift-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,10 @@
1
+ """CortexShift: Switch agents. Keep the context.
2
+
3
+ Provider-agnostic task handoff and state management for AI coding agents.
4
+ """
5
+
6
+ from importlib.metadata import version
7
+ from typing import Final
8
+
9
+ __version__: Final[str] = version("cortexshift")
10
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Entrypoint for `python -m cortexshift`."""
2
+
3
+ from cortexshift.cli.app import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,22 @@
1
+ from cortexshift.adapters.command_runner import SubprocessCommandRunner
2
+ from cortexshift.adapters.discovery import BuiltinProviderDiscovery
3
+ from cortexshift.adapters.git import GitRepositoryInspector
4
+ from cortexshift.adapters.process_runner import SubprocessInteractiveProcessRunner
5
+ from cortexshift.adapters.providers.antigravity import AntigravityRuntimeAdapter
6
+ from cortexshift.adapters.providers.claude import ClaudeRuntimeAdapter
7
+ from cortexshift.adapters.providers.codex import CodexRuntimeAdapter
8
+ from cortexshift.adapters.sqlite import SQLiteStateStore
9
+ from cortexshift.adapters.workspace_lease import FileWorkspaceLease, FileWorkspaceLeaseManager
10
+
11
+ __all__ = [
12
+ "AntigravityRuntimeAdapter",
13
+ "BuiltinProviderDiscovery",
14
+ "ClaudeRuntimeAdapter",
15
+ "CodexRuntimeAdapter",
16
+ "FileWorkspaceLease",
17
+ "FileWorkspaceLeaseManager",
18
+ "GitRepositoryInspector",
19
+ "SQLiteStateStore",
20
+ "SubprocessCommandRunner",
21
+ "SubprocessInteractiveProcessRunner",
22
+ ]
@@ -0,0 +1,116 @@
1
+ """Concrete subprocess command runner adapter."""
2
+
3
+ import os
4
+ import re
5
+ import subprocess
6
+ from collections.abc import Mapping
7
+ from pathlib import Path
8
+
9
+ from cortexshift.ports.command_runner import CommandResult, CommandRunner
10
+
11
+ _ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
12
+ _MAX_OUTPUT_LENGTH = 10_000
13
+
14
+
15
+ def _sanitize_output(text: str | bytes | None) -> str:
16
+ """Sanitize and bound subprocess output string."""
17
+ if text is None:
18
+ return ""
19
+ if isinstance(text, bytes):
20
+ text = text.decode("utf-8", errors="replace")
21
+ cleaned = _ANSI_ESCAPE_RE.sub("", text).strip()
22
+ if len(cleaned) > _MAX_OUTPUT_LENGTH:
23
+ return cleaned[:_MAX_OUTPUT_LENGTH] + "... [truncated]"
24
+ return cleaned
25
+
26
+
27
+ class SubprocessCommandRunner(CommandRunner):
28
+ """Executes commands safely using standard library subprocess.
29
+
30
+ Invariants:
31
+ - Never uses shell=True.
32
+ - Commands must be supplied as a list of arguments.
33
+ - Always enforces a finite timeout.
34
+ - Catches OS and process errors without bubbling unhandled exceptions.
35
+ """
36
+
37
+ def __init__(self, default_timeout: float = 5.0) -> None:
38
+ self.default_timeout = default_timeout
39
+
40
+ def run(
41
+ self,
42
+ command: list[str],
43
+ timeout: float | None = None,
44
+ env: dict[str, str] | None = None,
45
+ cwd: Path | str | None = None,
46
+ sanitize: bool = True,
47
+ ) -> CommandResult:
48
+ if not command:
49
+ return CommandResult(
50
+ command=[],
51
+ exit_code=1,
52
+ stdout="",
53
+ stderr="Empty command provided",
54
+ error_message="Empty command provided",
55
+ )
56
+
57
+ eff_timeout = timeout if timeout is not None else self.default_timeout
58
+ eff_env: Mapping[str, str] | None = None
59
+ if env is not None:
60
+ eff_env = {**os.environ, **env}
61
+
62
+ cwd_path = str(cwd) if cwd is not None else None
63
+
64
+ try:
65
+ completed = subprocess.run(
66
+ command,
67
+ capture_output=True,
68
+ text=True,
69
+ timeout=eff_timeout,
70
+ shell=False,
71
+ env=eff_env,
72
+ cwd=cwd_path,
73
+ check=False,
74
+ )
75
+ stdout = _sanitize_output(completed.stdout) if sanitize else completed.stdout
76
+ stderr = _sanitize_output(completed.stderr) if sanitize else completed.stderr
77
+ return CommandResult(
78
+ command=command,
79
+ exit_code=completed.returncode,
80
+ stdout=stdout,
81
+ stderr=stderr,
82
+ )
83
+ except subprocess.TimeoutExpired:
84
+ return CommandResult(
85
+ command=command,
86
+ exit_code=-1,
87
+ stdout="",
88
+ stderr=f"Command timed out after {eff_timeout} seconds",
89
+ timed_out=True,
90
+ error_message=f"Command timed out after {eff_timeout} seconds",
91
+ )
92
+ except FileNotFoundError:
93
+ return CommandResult(
94
+ command=command,
95
+ exit_code=127,
96
+ stdout="",
97
+ stderr=f"Executable '{command[0]}' not found",
98
+ not_found=True,
99
+ error_message=f"Executable '{command[0]}' not found",
100
+ )
101
+ except PermissionError:
102
+ return CommandResult(
103
+ command=command,
104
+ exit_code=126,
105
+ stdout="",
106
+ stderr=f"Permission denied executing '{command[0]}'",
107
+ error_message=f"Permission denied executing '{command[0]}'",
108
+ )
109
+ except OSError as exc:
110
+ return CommandResult(
111
+ command=command,
112
+ exit_code=1,
113
+ stdout="",
114
+ stderr=str(exc),
115
+ error_message=str(exc),
116
+ )
@@ -0,0 +1,55 @@
1
+ """Built-in provider discovery adapter registering native agent probes."""
2
+
3
+ import shutil
4
+ from collections.abc import Callable
5
+
6
+ from cortexshift.adapters.command_runner import SubprocessCommandRunner
7
+ from cortexshift.adapters.providers.antigravity import AntigravityProviderProbe
8
+ from cortexshift.adapters.providers.claude import ClaudeProviderProbe
9
+ from cortexshift.adapters.providers.codex import CodexProviderProbe
10
+ from cortexshift.domain.doctor import ProviderDiagnostic
11
+ from cortexshift.domain.provider import (
12
+ PROVIDER_ANTIGRAVITY,
13
+ PROVIDER_CLAUDE,
14
+ PROVIDER_CODEX,
15
+ ProviderId,
16
+ )
17
+ from cortexshift.ports.command_runner import CommandRunner
18
+ from cortexshift.ports.discovery import ProviderDiscoveryPort, ProviderProbe
19
+
20
+
21
+ class BuiltinProviderDiscovery(ProviderDiscoveryPort):
22
+ """Discovery adapter registering and probing built-in coding agent providers.
23
+
24
+ Manages native probes for Claude Code, OpenAI Codex, and Google Antigravity.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ command_runner: CommandRunner | None = None,
30
+ which_fn: Callable[[str], str | None] = shutil.which,
31
+ ) -> None:
32
+ runner = command_runner or SubprocessCommandRunner()
33
+ self._probes: dict[ProviderId, ProviderProbe] = {
34
+ PROVIDER_CLAUDE: ClaudeProviderProbe(runner, which_fn=which_fn),
35
+ PROVIDER_CODEX: CodexProviderProbe(runner, which_fn=which_fn),
36
+ PROVIDER_ANTIGRAVITY: AntigravityProviderProbe(runner, which_fn=which_fn),
37
+ }
38
+
39
+ def get_supported_provider_ids(self) -> list[ProviderId]:
40
+ """Return the canonical IDs of all supported built-in providers."""
41
+ return list(self._probes.keys())
42
+
43
+ def discover_provider(self, provider_id: ProviderId) -> ProviderDiagnostic:
44
+ """Probe a specific provider by its ID.
45
+
46
+ Raises:
47
+ KeyError: If the provider is not registered.
48
+ """
49
+ if provider_id not in self._probes:
50
+ raise KeyError(f"Provider '{provider_id}' is not supported")
51
+ return self._probes[provider_id].probe()
52
+
53
+ def discover_all(self) -> list[ProviderDiagnostic]:
54
+ """Probe all registered providers in declaration order."""
55
+ return [probe.probe() for probe in self._probes.values()]
@@ -0,0 +1,10 @@
1
+ """Git adapter package for repository inspection."""
2
+
3
+ from cortexshift.adapters.git.inspector import GitRepositoryInspector
4
+ from cortexshift.adapters.git.parser import ParsedGitStatus, parse_porcelain_status
5
+
6
+ __all__ = [
7
+ "GitRepositoryInspector",
8
+ "ParsedGitStatus",
9
+ "parse_porcelain_status",
10
+ ]
@@ -0,0 +1,321 @@
1
+ """Native Git repository inspector adapter."""
2
+
3
+ import re
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from cortexshift.adapters.command_runner import SubprocessCommandRunner
8
+ from cortexshift.adapters.git.parser import parse_porcelain_status
9
+ from cortexshift.domain.git import (
10
+ GitSnapshot,
11
+ RepositoryInspection,
12
+ RepositoryInspectionStatus,
13
+ )
14
+ from cortexshift.domain.identifiers import generate_id, utc_now
15
+ from cortexshift.ports.command_runner import CommandRunner
16
+ from cortexshift.ports.repository import RepositoryInspector
17
+
18
+ _GIT_ENV: dict[str, str] = {
19
+ "GIT_TERMINAL_PROMPT": "0",
20
+ "GIT_PAGER": "cat",
21
+ "GIT_OPTIONAL_LOCKS": "0",
22
+ }
23
+
24
+ _GIT_VERSION_RE = re.compile(r"git\s+version\s+([0-9]+(?:\.[0-9]+)*)")
25
+
26
+
27
+ class GitRepositoryInspector(RepositoryInspector):
28
+ """Concrete repository inspector using the native local git CLI.
29
+
30
+ Invariants:
31
+ - Never modifies repository state (strictly read-only commands).
32
+ - Never uses shell=True.
33
+ - Bound by finite timeouts.
34
+ - Safe against NUL-delimited and unusual filenames.
35
+ - Accurately scopes status and diff summaries in monorepos.
36
+ - Distinguishes unborn repositories, detached HEADs, and non-git projects.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ command_runner: CommandRunner | None = None,
42
+ default_timeout: float = 10.0,
43
+ ) -> None:
44
+ self._runner = command_runner or SubprocessCommandRunner(default_timeout=default_timeout)
45
+ self.default_timeout = default_timeout
46
+
47
+ def _extract_git_version(self) -> str | None:
48
+ """Extract installed Git version string, or None if unavailable."""
49
+ res = self._runner.run(["git", "--version"], timeout=5.0)
50
+ if not res.success or not res.stdout:
51
+ return None
52
+ match = _GIT_VERSION_RE.search(res.stdout)
53
+ if match:
54
+ return match.group(1)
55
+ return res.stdout.strip()
56
+
57
+ def inspect(self, project_root: Path | str, project_id: str = "") -> RepositoryInspection:
58
+ """Perform a safe, read-only live inspection of the Git repository for a project root."""
59
+ resolved_root = Path(project_root).resolve()
60
+ root_str = str(resolved_root)
61
+
62
+ # 1. Verify git executable exists in PATH
63
+ if shutil.which("git") is None:
64
+ return RepositoryInspection(
65
+ status=RepositoryInspectionStatus.GIT_NOT_INSTALLED,
66
+ project_root=root_str,
67
+ git_available=False,
68
+ diagnostic="Git executable was not found in PATH.",
69
+ )
70
+
71
+ git_version = self._extract_git_version()
72
+
73
+ # 2. Check if inside a Git working tree
74
+ tree_check = self._runner.run(
75
+ ["git", "rev-parse", "--is-inside-work-tree"],
76
+ cwd=resolved_root,
77
+ env=_GIT_ENV,
78
+ timeout=self.default_timeout,
79
+ )
80
+ if tree_check.timed_out:
81
+ return RepositoryInspection(
82
+ status=RepositoryInspectionStatus.PROBE_ERROR,
83
+ project_root=root_str,
84
+ git_available=True,
85
+ git_version=git_version,
86
+ diagnostic="Git inspection timed out.",
87
+ )
88
+ if not tree_check.success or tree_check.stdout.strip() != "true":
89
+ return RepositoryInspection(
90
+ status=RepositoryInspectionStatus.NOT_GIT_REPOSITORY,
91
+ project_root=root_str,
92
+ git_available=True,
93
+ git_version=git_version,
94
+ diagnostic="This CortexShift project is not inside a Git repository.",
95
+ )
96
+
97
+ # 3. Detect Git repository root
98
+ toplevel_res = self._runner.run(
99
+ ["git", "rev-parse", "--show-toplevel"],
100
+ cwd=resolved_root,
101
+ env=_GIT_ENV,
102
+ timeout=self.default_timeout,
103
+ )
104
+ if toplevel_res.timed_out:
105
+ return RepositoryInspection(
106
+ status=RepositoryInspectionStatus.PROBE_ERROR,
107
+ project_root=root_str,
108
+ git_available=True,
109
+ git_version=git_version,
110
+ diagnostic="Git inspection timed out.",
111
+ )
112
+ if not toplevel_res.success or not toplevel_res.stdout.strip():
113
+ return RepositoryInspection(
114
+ status=RepositoryInspectionStatus.PROBE_ERROR,
115
+ project_root=root_str,
116
+ git_available=True,
117
+ git_version=git_version,
118
+ diagnostic="Git repository inspection failed.",
119
+ )
120
+ git_root = str(Path(toplevel_res.stdout.strip()).resolve())
121
+
122
+ # 4. Detect prefix relative to git root (for monorepo scoping)
123
+ prefix_res = self._runner.run(
124
+ ["git", "rev-parse", "--show-prefix"],
125
+ cwd=resolved_root,
126
+ env=_GIT_ENV,
127
+ timeout=self.default_timeout,
128
+ )
129
+ if prefix_res.timed_out:
130
+ return RepositoryInspection(
131
+ status=RepositoryInspectionStatus.PROBE_ERROR,
132
+ project_root=root_str,
133
+ git_available=True,
134
+ git_version=git_version,
135
+ diagnostic="Git inspection timed out.",
136
+ )
137
+ project_prefix = prefix_res.stdout.strip() if prefix_res.success else ""
138
+
139
+ # 5. Detect branch & detached HEAD
140
+ branch_res = self._runner.run(
141
+ ["git", "branch", "--show-current"],
142
+ cwd=resolved_root,
143
+ env=_GIT_ENV,
144
+ timeout=self.default_timeout,
145
+ )
146
+ if branch_res.timed_out:
147
+ return RepositoryInspection(
148
+ status=RepositoryInspectionStatus.PROBE_ERROR,
149
+ project_root=root_str,
150
+ git_available=True,
151
+ git_version=git_version,
152
+ diagnostic="Git inspection timed out.",
153
+ )
154
+
155
+ current_branch = branch_res.stdout.strip() if branch_res.success else ""
156
+ branch: str | None = None
157
+ detached_head = False
158
+
159
+ if current_branch:
160
+ branch = current_branch
161
+ detached_head = False
162
+ else:
163
+ # Fallback for unborn branches or detached HEAD
164
+ sym_res = self._runner.run(
165
+ ["git", "symbolic-ref", "--short", "HEAD"],
166
+ cwd=resolved_root,
167
+ env=_GIT_ENV,
168
+ timeout=self.default_timeout,
169
+ )
170
+ if sym_res.timed_out:
171
+ return RepositoryInspection(
172
+ status=RepositoryInspectionStatus.PROBE_ERROR,
173
+ project_root=root_str,
174
+ git_available=True,
175
+ git_version=git_version,
176
+ diagnostic="Git inspection timed out.",
177
+ )
178
+ if sym_res.success and sym_res.stdout.strip():
179
+ branch = sym_res.stdout.strip()
180
+ detached_head = False
181
+ else:
182
+ branch = None
183
+ # Check if HEAD commit exists
184
+ head_verify = self._runner.run(
185
+ ["git", "rev-parse", "--verify", "HEAD"],
186
+ cwd=resolved_root,
187
+ env=_GIT_ENV,
188
+ timeout=self.default_timeout,
189
+ )
190
+ if head_verify.timed_out:
191
+ return RepositoryInspection(
192
+ status=RepositoryInspectionStatus.PROBE_ERROR,
193
+ project_root=root_str,
194
+ git_available=True,
195
+ git_version=git_version,
196
+ diagnostic="Git inspection timed out.",
197
+ )
198
+ detached_head = head_verify.success
199
+
200
+ # 6. Detect HEAD commit SHA
201
+ head_sha: str | None = None
202
+ head_res = self._runner.run(
203
+ ["git", "rev-parse", "HEAD"],
204
+ cwd=resolved_root,
205
+ env=_GIT_ENV,
206
+ timeout=self.default_timeout,
207
+ )
208
+ if head_res.timed_out:
209
+ return RepositoryInspection(
210
+ status=RepositoryInspectionStatus.PROBE_ERROR,
211
+ project_root=root_str,
212
+ git_available=True,
213
+ git_version=git_version,
214
+ diagnostic="Git inspection timed out.",
215
+ )
216
+ if head_res.success and head_res.stdout.strip():
217
+ head_sha = head_res.stdout.strip()
218
+
219
+ # 7. Status and changed files (NUL-delimited, scoped to project pathspec)
220
+ status_res = self._runner.run(
221
+ ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."],
222
+ cwd=resolved_root,
223
+ env=_GIT_ENV,
224
+ timeout=self.default_timeout,
225
+ sanitize=False,
226
+ )
227
+ if status_res.timed_out:
228
+ return RepositoryInspection(
229
+ status=RepositoryInspectionStatus.PROBE_ERROR,
230
+ project_root=root_str,
231
+ git_available=True,
232
+ git_version=git_version,
233
+ diagnostic="Git inspection timed out.",
234
+ )
235
+ if not status_res.success:
236
+ return RepositoryInspection(
237
+ status=RepositoryInspectionStatus.PROBE_ERROR,
238
+ project_root=root_str,
239
+ git_available=True,
240
+ git_version=git_version,
241
+ diagnostic="Git repository inspection failed.",
242
+ )
243
+
244
+ parsed_status = parse_porcelain_status(status_res.stdout, project_prefix=project_prefix)
245
+
246
+ # 8. Working tree diff shortstat
247
+ diff_wt_res = self._runner.run(
248
+ ["git", "diff", "--shortstat", "--", "."],
249
+ cwd=resolved_root,
250
+ env=_GIT_ENV,
251
+ timeout=self.default_timeout,
252
+ )
253
+ if diff_wt_res.timed_out:
254
+ return RepositoryInspection(
255
+ status=RepositoryInspectionStatus.PROBE_ERROR,
256
+ project_root=root_str,
257
+ git_available=True,
258
+ git_version=git_version,
259
+ diagnostic="Git inspection timed out.",
260
+ )
261
+ wt_summary = (
262
+ diff_wt_res.stdout.strip()
263
+ if diff_wt_res.success and diff_wt_res.stdout.strip()
264
+ else None
265
+ )
266
+
267
+ # 9. Staged diff shortstat
268
+ diff_staged_res = self._runner.run(
269
+ ["git", "diff", "--cached", "--shortstat", "--", "."],
270
+ cwd=resolved_root,
271
+ env=_GIT_ENV,
272
+ timeout=self.default_timeout,
273
+ )
274
+ if diff_staged_res.timed_out:
275
+ return RepositoryInspection(
276
+ status=RepositoryInspectionStatus.PROBE_ERROR,
277
+ project_root=root_str,
278
+ git_available=True,
279
+ git_version=git_version,
280
+ diagnostic="Git inspection timed out.",
281
+ )
282
+ staged_summary = (
283
+ diff_staged_res.stdout.strip()
284
+ if diff_staged_res.success and diff_staged_res.stdout.strip()
285
+ else None
286
+ )
287
+
288
+ dirty = bool(
289
+ parsed_status.staged_files
290
+ or parsed_status.modified_files
291
+ or parsed_status.untracked_files
292
+ or parsed_status.conflicted_files
293
+ )
294
+
295
+ snapshot = GitSnapshot(
296
+ id=generate_id("snap"),
297
+ project_id=project_id or "proj_default",
298
+ project_root=root_str,
299
+ git_root=git_root,
300
+ git_version=git_version,
301
+ branch=branch,
302
+ head_sha=head_sha,
303
+ detached_head=detached_head,
304
+ dirty=dirty,
305
+ staged_files=parsed_status.staged_files,
306
+ modified_files=parsed_status.modified_files,
307
+ untracked_files=parsed_status.untracked_files,
308
+ conflicted_files=parsed_status.conflicted_files,
309
+ working_tree_diff_summary=wt_summary,
310
+ staged_diff_summary=staged_summary,
311
+ captured_at=utc_now(),
312
+ metadata={"renames": parsed_status.renames} if parsed_status.renames else {},
313
+ )
314
+
315
+ return RepositoryInspection(
316
+ status=RepositoryInspectionStatus.READY,
317
+ project_root=root_str,
318
+ git_available=True,
319
+ git_version=git_version,
320
+ snapshot=snapshot,
321
+ )
@@ -0,0 +1,140 @@
1
+ """Parser for machine-readable Git porcelain status output."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class ParsedGitStatus:
8
+ """Structured collections of changed files extracted from Git status."""
9
+
10
+ staged_files: list[str]
11
+ modified_files: list[str]
12
+ untracked_files: list[str]
13
+ conflicted_files: list[str]
14
+ renames: dict[str, str] = field(default_factory=dict)
15
+
16
+
17
+ def parse_porcelain_status(raw_output: str, project_prefix: str = "") -> ParsedGitStatus:
18
+ """Parse NUL-delimited machine-readable output from `git status --porcelain=v1 -z`.
19
+
20
+ Handles:
21
+ - Unusual filenames containing spaces, tabs, newlines, and Unicode
22
+ - Renames and copies consuming two consecutive NUL-delimited records
23
+ - Scope normalization relative to project_prefix (monorepo support)
24
+ - Conflict/unmerged status detection (UU, AA, DD, *U, U*)
25
+ - Mixed index and working tree modifications (e.g., MM, AM, RM)
26
+ - Deterministic ordering of all file collections
27
+
28
+ Args:
29
+ raw_output: NUL-separated stdout from `git status --porcelain=v1 -z`.
30
+ project_prefix: Path prefix of the CortexShift project relative to git root.
31
+
32
+ Returns:
33
+ A populated ParsedGitStatus object with project-relative paths.
34
+ """
35
+ if not raw_output:
36
+ return ParsedGitStatus(
37
+ staged_files=[],
38
+ modified_files=[],
39
+ untracked_files=[],
40
+ conflicted_files=[],
41
+ )
42
+
43
+ # Normalize prefix (strip leading/trailing slashes)
44
+ clean_prefix = project_prefix.strip("/")
45
+ prefix_with_slash = f"{clean_prefix}/" if clean_prefix else ""
46
+
47
+ tokens = raw_output.split("\x00")
48
+ if tokens and tokens[-1] == "":
49
+ tokens.pop()
50
+
51
+ staged: list[str] = []
52
+ modified: list[str] = []
53
+ untracked: list[str] = []
54
+ conflicted: list[str] = []
55
+ renames: dict[str, str] = {}
56
+
57
+ i = 0
58
+ while i < len(tokens):
59
+ entry = tokens[i]
60
+ if not entry:
61
+ i += 1
62
+ continue
63
+
64
+ if len(entry) < 3:
65
+ i += 1
66
+ continue
67
+
68
+ x = entry[0]
69
+ y = entry[1]
70
+ path = entry[3:]
71
+
72
+ orig_path: str | None = None
73
+ if x in ("R", "C") or y in ("R", "C"):
74
+ i += 1
75
+ if i < len(tokens):
76
+ orig_path = tokens[i]
77
+
78
+ i += 1
79
+
80
+ # Check project scope: in monorepos, filter out files outside the project scope
81
+ if clean_prefix:
82
+ if not (path == clean_prefix or path.startswith(prefix_with_slash)):
83
+ # Outside project scope
84
+ continue
85
+ # Normalize path relative to project root
86
+ normalized_path = (
87
+ path[len(prefix_with_slash) :] if path.startswith(prefix_with_slash) else "."
88
+ )
89
+ else:
90
+ normalized_path = path
91
+
92
+ # Normalize orig_path if present
93
+ normalized_orig: str | None = None
94
+ if orig_path is not None:
95
+ if clean_prefix:
96
+ if orig_path.startswith(prefix_with_slash):
97
+ normalized_orig = orig_path[len(prefix_with_slash) :]
98
+ else:
99
+ normalized_orig = orig_path
100
+ else:
101
+ normalized_orig = orig_path
102
+
103
+ if normalized_orig is not None:
104
+ renames[normalized_path] = normalized_orig
105
+
106
+ # Exclude CortexShift private runtime state directory
107
+ if normalized_path == ".cortexshift" or normalized_path.startswith(".cortexshift/"):
108
+ continue
109
+
110
+ # 1. Untracked
111
+ if x == "?" and y == "?":
112
+ untracked.append(normalized_path)
113
+ continue
114
+
115
+ # 2. Ignored
116
+ if x == "!" and y == "!":
117
+ continue
118
+
119
+ # 3. Unmerged / Conflicted
120
+ # In Git porcelain, unmerged states: DD, AU, UD, UA, DU, AA, UU
121
+ is_unmerged = "U" in (x, y) or (x == "A" and y == "A") or (x == "D" and y == "D")
122
+ if is_unmerged:
123
+ conflicted.append(normalized_path)
124
+ continue
125
+
126
+ # 4. Staged modifications (index changes)
127
+ if x in ("M", "A", "D", "R", "C", "T"):
128
+ staged.append(normalized_path)
129
+
130
+ # 5. Unstaged modifications (working tree changes)
131
+ if y in ("M", "D", "T"):
132
+ modified.append(normalized_path)
133
+
134
+ return ParsedGitStatus(
135
+ staged_files=sorted(set(staged)),
136
+ modified_files=sorted(set(modified)),
137
+ untracked_files=sorted(set(untracked)),
138
+ conflicted_files=sorted(set(conflicted)),
139
+ renames=renames,
140
+ )