Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions coding-agent/src/coding_agent/environment/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
CloudWorkspaceClientFactory,
CloudWorkspaceSource,
WorkspaceArchiveManifest,
WorkspaceDiff,
WorkspaceDiffFile,
WorkspaceInventoryEntry,
WorkspacePatch,
WorkspaceProvider,
cloud_client_factory_from_config,
cloud_workspace_ready_from_config,
Expand All @@ -24,6 +27,8 @@
provision_cloud_binding_from_config,
register_workspace_provider,
workspace_archive_manifest_from_config,
workspace_diff_from_config,
workspace_patch_from_config,
)

__all__ = [
Expand All @@ -38,7 +43,10 @@
"cleanup_stale_cloud_workspaces_from_config",
"LocalEnvironment",
"WorkspaceArchiveManifest",
"WorkspaceDiff",
"WorkspaceDiffFile",
"WorkspaceInventoryEntry",
"WorkspacePatch",
"WorkspaceProvider",
"cloud_client_factory_from_config",
"cloud_workspace_ready_from_config",
Expand All @@ -50,4 +58,6 @@
"provision_cloud_binding_from_config",
"register_workspace_provider",
"workspace_archive_manifest_from_config",
"workspace_diff_from_config",
"workspace_patch_from_config",
]
186 changes: 186 additions & 0 deletions coding-agent/src/coding_agent/environment/docker_workspace_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@
CloudWorkspaceSource,
WorkspaceProvider,
WorkspaceArchiveManifest,
WorkspaceDiff,
WorkspaceDiffFile,
WorkspaceDiffStatus,
WorkspaceInventoryEntry,
WorkspacePatch,
WorkspaceStatus,
register_workspace_provider,
)
Expand Down Expand Up @@ -627,6 +631,44 @@ def workspace_archive_manifest(
workspace_root=workspace_root,
)

@override
def workspace_diff(
self,
config: dict[str, object],
workspace_id: str,
) -> WorkspaceDiff:
provider_config = _docker_workspace_provider_config(config)
workspace_root = _git_workspace_root_for_operation(
provider_config,
workspace_id,
"diff",
)
return _git_workspace_diff(provider_config, workspace_id, workspace_root)

@override
def workspace_patch(
self,
config: dict[str, object],
workspace_id: str,
) -> WorkspacePatch:
provider_config = _docker_workspace_provider_config(config)
workspace_root = _git_workspace_root_for_operation(
provider_config,
workspace_id,
"patch",
)
patch = _run_git_workspace_command(
provider_config,
workspace_root,
["diff", "--binary", "HEAD", "--"],
"git diff",
)
return WorkspacePatch(
workspace_id=workspace_id,
format="unified_diff",
patch=patch,
)


def _docker_workspace_provider_config(
config: dict[str, object],
Expand Down Expand Up @@ -1026,6 +1068,150 @@ def _docker_workspace_archive_manifest(
)


def _git_workspace_root_for_operation(
provider_config: _DockerWorkspaceProviderConfig,
workspace_id: str,
operation: str,
) -> Path:
_validate_workspace_id(workspace_id)
if not _is_provider_workspace_id(workspace_id):
raise KeyError(f"workspace not found: {workspace_id}")
workspace_root = _workspace_root_for_id(
provider_config.workspace_root,
workspace_id,
)
if not workspace_root.is_dir():
raise KeyError(f"workspace not found: {workspace_id}")
if not (workspace_root / ".git").exists():
raise ValueError(f"workspace {operation} requires a Git workspace")
return workspace_root


def _git_workspace_diff(
provider_config: _DockerWorkspaceProviderConfig,
workspace_id: str,
workspace_root: Path,
) -> WorkspaceDiff:
name_status_output = _run_git_workspace_command(
provider_config,
workspace_root,
["diff", "--name-status", "--find-renames", "-z", "HEAD", "--"],
"git diff --name-status",
)
numstat_output = _run_git_workspace_command(
provider_config,
workspace_root,
["diff", "--numstat", "HEAD", "--"],
"git diff --numstat",
)
numstat = _parse_git_numstat(numstat_output)
files = _parse_git_name_status(name_status_output, numstat)
additions = sum(item[0] for item in numstat.values() if item[0] is not None)
deletions = sum(item[1] for item in numstat.values() if item[1] is not None)
return WorkspaceDiff(
workspace_id=workspace_id,
files=files,
additions=additions,
deletions=deletions,
)


def _run_git_workspace_command(
provider_config: _DockerWorkspaceProviderConfig,
workspace_root: Path,
args: list[str],
operation: str,
) -> str:
command = [
provider_config.git_binary,
"-C",
str(workspace_root),
*args,
]
try:
result = subprocess.run(
command,
shell=False,
capture_output=True,
text=True,
timeout=_GIT_OPERATION_TIMEOUT_SECONDS,
stdin=subprocess.DEVNULL,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
check=True,
)
except subprocess.CalledProcessError as exc:
_add_git_failure_output_notes(exc, operation, exc.stdout, exc.stderr)
raise
return result.stdout


def _parse_git_name_status(
output: str,
numstat: dict[str, tuple[int | None, int | None, bool]],
) -> list[WorkspaceDiffFile]:
tokens = [token for token in output.split("\0") if token]
files: list[WorkspaceDiffFile] = []
index = 0
while index < len(tokens):
status_and_path = tokens[index]
index += 1
status_parts = status_and_path.split("\t", 1)
if len(status_parts) != 2:
raise ValueError("git diff name-status output is malformed")
status_token, first_path = status_parts
status_code = status_token[:1]
old_path: str | None = None
if status_code in {"R", "C"}:
if index >= len(tokens):
raise ValueError("git diff name-status output is malformed")
old_path = first_path
path = tokens[index]
index += 1
status: WorkspaceDiffStatus = "renamed"
else:
path = first_path
status = _workspace_diff_status_from_git_status(status_code)

additions, deletions, binary = numstat.get(path, (None, None, False))
files.append(
WorkspaceDiffFile(
path=path,
status=status,
old_path=old_path,
additions=additions,
deletions=deletions,
binary=binary,
)
)
return files


def _workspace_diff_status_from_git_status(status_code: str) -> WorkspaceDiffStatus:
if status_code == "A":
return "added"
if status_code == "M":
return "modified"
if status_code == "D":
return "deleted"
return "unknown"


def _parse_git_numstat(output: str) -> dict[str, tuple[int | None, int | None, bool]]:
result: dict[str, tuple[int | None, int | None, bool]] = {}
for line in output.splitlines():
if not line:
continue
parts = line.split("\t", 2)
if len(parts) != 3:
raise ValueError("git diff numstat output is malformed")
raw_additions, raw_deletions, path = parts
if raw_additions == "-" or raw_deletions == "-":
result[path] = (None, None, True)
continue
result[path] = (int(raw_additions), int(raw_deletions), False)
return result


def _active_workspace_ids_from_config(config: dict[str, object]) -> set[str]:
raw_ids = config.get("_active_workspace_ids")
if raw_ids is None:
Expand Down
56 changes: 56 additions & 0 deletions coding-agent/src/coding_agent/environment/workspace_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
WorkspaceStatus: TypeAlias = Literal[
"active", "stale", "cleaning", "cleaned", "cleanup_failed"
]
WorkspaceDiffStatus: TypeAlias = Literal[
"added", "modified", "deleted", "renamed", "binary", "unknown"
]


@dataclass(frozen=True)
Expand All @@ -41,6 +44,31 @@ class WorkspaceArchiveManifest:
archive_sha256: str | None


@dataclass(frozen=True)
class WorkspaceDiffFile:
path: str
status: WorkspaceDiffStatus
old_path: str | None = None
additions: int | None = None
deletions: int | None = None
binary: bool = False


@dataclass(frozen=True)
class WorkspaceDiff:
workspace_id: str
files: list[WorkspaceDiffFile]
additions: int
deletions: int


@dataclass(frozen=True)
class WorkspacePatch:
workspace_id: str
format: Literal["unified_diff"]
patch: str


class WorkspaceProvider(Protocol):
def build_cloud_client_factory(
self, config: dict[str, object]
Expand Down Expand Up @@ -115,6 +143,18 @@ def workspace_archive_manifest(
session_id: str | None = None,
) -> WorkspaceArchiveManifest: ...

def workspace_diff(
self,
config: dict[str, object],
workspace_id: str,
) -> WorkspaceDiff: ...

def workspace_patch(
self,
config: dict[str, object],
workspace_id: str,
) -> WorkspacePatch: ...


_WORKSPACE_PROVIDERS: dict[str, WorkspaceProvider] = {}

Expand Down Expand Up @@ -238,6 +278,22 @@ def workspace_archive_manifest_from_config(
)


def workspace_diff_from_config(
config: dict[str, object],
workspace_id: str,
) -> WorkspaceDiff:
provider = _provider_from_config(config)
return provider.workspace_diff(config, workspace_id)


def workspace_patch_from_config(
config: dict[str, object],
workspace_id: str,
) -> WorkspacePatch:
provider = _provider_from_config(config)
return provider.workspace_patch(config, workspace_id)


def _provider_from_config(config: dict[str, object]) -> WorkspaceProvider:
provider_name = config.get("provider")
if not isinstance(provider_name, str) or not provider_name.strip():
Expand Down
Loading