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.
- cortexshift/__init__.py +10 -0
- cortexshift/__main__.py +6 -0
- cortexshift/adapters/__init__.py +22 -0
- cortexshift/adapters/command_runner.py +116 -0
- cortexshift/adapters/discovery.py +55 -0
- cortexshift/adapters/git/__init__.py +10 -0
- cortexshift/adapters/git/inspector.py +321 -0
- cortexshift/adapters/git/parser.py +140 -0
- cortexshift/adapters/headless_runner.py +92 -0
- cortexshift/adapters/process_runner.py +56 -0
- cortexshift/adapters/providers/__init__.py +4 -0
- cortexshift/adapters/providers/antigravity.py +530 -0
- cortexshift/adapters/providers/claude.py +375 -0
- cortexshift/adapters/providers/codex.py +434 -0
- cortexshift/adapters/sqlite/__init__.py +10 -0
- cortexshift/adapters/sqlite/migrations.py +268 -0
- cortexshift/adapters/sqlite/store.py +914 -0
- cortexshift/adapters/workspace_lease.py +123 -0
- cortexshift/application/__init__.py +42 -0
- cortexshift/application/checkpoint_builder.py +218 -0
- cortexshift/application/checkpoint_service.py +273 -0
- cortexshift/application/doctor.py +80 -0
- cortexshift/application/handoff_builder.py +281 -0
- cortexshift/application/handoff_renderer.py +430 -0
- cortexshift/application/handoff_service.py +66 -0
- cortexshift/application/init_service.py +86 -0
- cortexshift/application/locator.py +48 -0
- cortexshift/application/native_session.py +65 -0
- cortexshift/application/recovery_service.py +235 -0
- cortexshift/application/repository_service.py +146 -0
- cortexshift/application/resume_service.py +124 -0
- cortexshift/application/run_service.py +270 -0
- cortexshift/application/session_launcher.py +183 -0
- cortexshift/application/session_service.py +63 -0
- cortexshift/application/source_session.py +62 -0
- cortexshift/application/status_service.py +73 -0
- cortexshift/application/switch_service.py +671 -0
- cortexshift/application/task_service.py +201 -0
- cortexshift/application/task_workspace.py +152 -0
- cortexshift/cli/__init__.py +5 -0
- cortexshift/cli/app.py +2477 -0
- cortexshift/domain/__init__.py +153 -0
- cortexshift/domain/checkpoint.py +174 -0
- cortexshift/domain/doctor.py +68 -0
- cortexshift/domain/errors.py +277 -0
- cortexshift/domain/git.py +102 -0
- cortexshift/domain/handoff.py +241 -0
- cortexshift/domain/identifiers.py +27 -0
- cortexshift/domain/launch.py +58 -0
- cortexshift/domain/mcp_binding.py +81 -0
- cortexshift/domain/native_session.py +19 -0
- cortexshift/domain/project.py +37 -0
- cortexshift/domain/provider.py +67 -0
- cortexshift/domain/session.py +92 -0
- cortexshift/domain/status.py +40 -0
- cortexshift/domain/task.py +191 -0
- cortexshift/mcp/__init__.py +38 -0
- cortexshift/mcp/context.py +165 -0
- cortexshift/mcp/facade.py +513 -0
- cortexshift/mcp/models.py +178 -0
- cortexshift/mcp/resources.py +45 -0
- cortexshift/mcp/server.py +52 -0
- cortexshift/mcp/tools.py +176 -0
- cortexshift/ports/__init__.py +39 -0
- cortexshift/ports/checkpoint_store.py +45 -0
- cortexshift/ports/command_runner.py +56 -0
- cortexshift/ports/discovery.py +41 -0
- cortexshift/ports/handoff_delivery.py +91 -0
- cortexshift/ports/handoff_store.py +43 -0
- cortexshift/ports/headless_runner.py +58 -0
- cortexshift/ports/native_session.py +20 -0
- cortexshift/ports/process_runner.py +31 -0
- cortexshift/ports/provider.py +152 -0
- cortexshift/ports/repository.py +44 -0
- cortexshift/ports/session_store.py +27 -0
- cortexshift/ports/state_store.py +55 -0
- cortexshift/ports/workspace_lease.py +39 -0
- cortexshift/tui/__init__.py +24 -0
- cortexshift/tui/actions.py +58 -0
- cortexshift/tui/app.py +1051 -0
- cortexshift/tui/coordinator.py +173 -0
- cortexshift/tui/cortexshift.tcss +258 -0
- cortexshift/tui/facade.py +614 -0
- cortexshift/tui/modals.py +594 -0
- cortexshift/tui/models.py +503 -0
- cortexshift/tui/screens/__init__.py +81 -0
- cortexshift/tui/screens/checkpoints.py +188 -0
- cortexshift/tui/screens/handoffs.py +180 -0
- cortexshift/tui/screens/help.py +117 -0
- cortexshift/tui/screens/overview.py +200 -0
- cortexshift/tui/screens/providers.py +169 -0
- cortexshift/tui/screens/repository.py +143 -0
- cortexshift/tui/screens/sessions.py +146 -0
- cortexshift/tui/screens/task.py +174 -0
- cortexshift/tui/widgets.py +209 -0
- cortexshift-0.1.0.dist-info/METADATA +202 -0
- cortexshift-0.1.0.dist-info/RECORD +100 -0
- cortexshift-0.1.0.dist-info/WHEEL +4 -0
- cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
- cortexshift-0.1.0.dist-info/licenses/LICENSE +21 -0
cortexshift/__init__.py
ADDED
|
@@ -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__"]
|
cortexshift/__main__.py
ADDED
|
@@ -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
|
+
)
|