msdev 0.9.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.
- msdev/__init__.py +3 -0
- msdev/cli.py +1460 -0
- msdev/core/__init__.py +1 -0
- msdev/core/config.py +193 -0
- msdev/core/guides.py +100 -0
- msdev/core/inventory.py +1390 -0
- msdev/core/limits.py +44 -0
- msdev/core/resources.py +300 -0
- msdev/core/services/__init__.py +111 -0
- msdev/core/services/context.py +61 -0
- msdev/core/services/environment.py +84 -0
- msdev/core/services/execution.py +136 -0
- msdev/core/services/model.py +1085 -0
- msdev/core/services/node.py +210 -0
- msdev/core/services/npu.py +116 -0
- msdev/core/services/workspace.py +567 -0
- msdev/core/transport.py +1225 -0
- msdev/core/workspace/__init__.py +36 -0
- msdev/core/workspace/access.py +1216 -0
- msdev/core/workspace/client.py +274 -0
- msdev/core/workspace/paths.py +45 -0
- msdev/core/workspace/registry.py +151 -0
- msdev/daemon.py +1322 -0
- msdev/session/__init__.py +13 -0
- msdev/session/export.py +190 -0
- msdev/session/log.py +269 -0
- msdev-0.9.0.dist-info/METADATA +295 -0
- msdev-0.9.0.dist-info/RECORD +31 -0
- msdev-0.9.0.dist-info/WHEEL +5 -0
- msdev-0.9.0.dist-info/entry_points.txt +3 -0
- msdev-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"""Workspace registry, remote file, and Git application service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from typing import Any, Sequence
|
|
9
|
+
|
|
10
|
+
from ..workspace import ResolvedPath, Workspace, normalize_relative
|
|
11
|
+
from ..workspace import client as workspace_client
|
|
12
|
+
from .context import ServiceContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_MAX_READ_BYTES = 16 * 1024 * 1024
|
|
16
|
+
_MAX_RESULTS = 10_000
|
|
17
|
+
_DIGEST_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
18
|
+
_SAFE_GIT_DIFF_OPTIONS = ("--no-textconv", "--no-ext-diff")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class WorkspaceAddRequest:
|
|
23
|
+
name: str
|
|
24
|
+
environment: str
|
|
25
|
+
root: str
|
|
26
|
+
execution_root: str | None = None
|
|
27
|
+
force: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class WorkspaceListResult:
|
|
32
|
+
workspaces: tuple[Workspace, ...]
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, Any]:
|
|
35
|
+
return {"workspaces": [asdict(item) for item in self.workspaces]}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class WorkspaceInspectResult:
|
|
40
|
+
workspace: Workspace
|
|
41
|
+
effective_execution_root: str
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
return {
|
|
45
|
+
**asdict(self.workspace),
|
|
46
|
+
"effective_execution_root": self.effective_execution_root,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class WorkspaceRemoveResult:
|
|
52
|
+
name: str
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
return {"workspace": self.name, "removed": True}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class WorkspacePathResult:
|
|
60
|
+
workspace: str
|
|
61
|
+
path: str
|
|
62
|
+
raw: dict[str, Any] = field(repr=False)
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
value = dict(self.raw)
|
|
66
|
+
value["workspace"] = self.workspace
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class WorkspaceReadResult:
|
|
72
|
+
workspace: str
|
|
73
|
+
path: str
|
|
74
|
+
content: bytes = field(repr=False)
|
|
75
|
+
raw: dict[str, Any] = field(repr=False)
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict[str, Any]:
|
|
78
|
+
try:
|
|
79
|
+
rendered = self.content.decode("utf-8")
|
|
80
|
+
encoding = "utf-8"
|
|
81
|
+
except UnicodeDecodeError:
|
|
82
|
+
rendered = base64.b64encode(self.content).decode("ascii")
|
|
83
|
+
encoding = "base64"
|
|
84
|
+
return {
|
|
85
|
+
"workspace": self.workspace,
|
|
86
|
+
"path": self.path,
|
|
87
|
+
"encoding": encoding,
|
|
88
|
+
"size": len(self.content),
|
|
89
|
+
"content": rendered,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class WorkspaceGlobResult:
|
|
95
|
+
workspace: str
|
|
96
|
+
path: str
|
|
97
|
+
pattern: str
|
|
98
|
+
matches: tuple[str, ...]
|
|
99
|
+
raw: dict[str, Any] = field(repr=False)
|
|
100
|
+
|
|
101
|
+
def to_dict(self) -> dict[str, Any]:
|
|
102
|
+
return {
|
|
103
|
+
"workspace": self.workspace,
|
|
104
|
+
"path": self.path,
|
|
105
|
+
"pattern": self.pattern,
|
|
106
|
+
"matches": list(self.matches),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class WorkspaceSearchResult:
|
|
112
|
+
workspace: str
|
|
113
|
+
paths: tuple[str, ...]
|
|
114
|
+
pattern: str
|
|
115
|
+
matches: tuple[dict[str, Any], ...]
|
|
116
|
+
raw: dict[str, Any] = field(repr=False)
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> dict[str, Any]:
|
|
119
|
+
value: dict[str, Any] = {
|
|
120
|
+
"workspace": self.workspace,
|
|
121
|
+
"pattern": self.pattern,
|
|
122
|
+
"matches": [dict(item) for item in self.matches],
|
|
123
|
+
}
|
|
124
|
+
if len(self.paths) == 1:
|
|
125
|
+
value["path"] = self.paths[0]
|
|
126
|
+
else:
|
|
127
|
+
value["paths"] = list(self.paths)
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class WorkspaceGitResult:
|
|
133
|
+
workspace: str
|
|
134
|
+
returncode: int
|
|
135
|
+
stdout: str
|
|
136
|
+
stderr: str
|
|
137
|
+
stdout_truncated: bool = False
|
|
138
|
+
stderr_truncated: bool = False
|
|
139
|
+
output_drain_truncated: bool = False
|
|
140
|
+
timed_out: bool = False
|
|
141
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict[str, Any]:
|
|
144
|
+
value = dict(self.raw)
|
|
145
|
+
value["workspace"] = self.workspace
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class WorkspaceService:
|
|
150
|
+
"""Stateless workspace orchestration for the msdev CLI."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, context: ServiceContext):
|
|
153
|
+
self.context = context
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _required(value: Any, label: str) -> str:
|
|
157
|
+
if not isinstance(value, str) or not value.strip():
|
|
158
|
+
raise ValueError(f"{label} must be a non-empty string")
|
|
159
|
+
if "\0" in value:
|
|
160
|
+
raise ValueError(f"{label} must not contain NUL")
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _text(value: Any, label: str) -> str:
|
|
165
|
+
if not isinstance(value, str):
|
|
166
|
+
raise ValueError(f"{label} must be a string")
|
|
167
|
+
if "\0" in value:
|
|
168
|
+
raise ValueError(f"{label} must not contain NUL")
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _boolean(value: Any, label: str) -> bool:
|
|
173
|
+
if type(value) is not bool:
|
|
174
|
+
raise ValueError(f"{label} must be a boolean")
|
|
175
|
+
return value
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _limit(value: Any, label: str, maximum: int) -> int:
|
|
179
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
180
|
+
raise ValueError(f"{label} must be an integer")
|
|
181
|
+
if not 1 <= value <= maximum:
|
|
182
|
+
raise ValueError(f"{label} must be between 1 and {maximum}")
|
|
183
|
+
return value
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _object(value: Any, operation: str) -> dict[str, Any]:
|
|
187
|
+
if not isinstance(value, dict):
|
|
188
|
+
raise RuntimeError(f"{operation} returned a non-object result")
|
|
189
|
+
return dict(value)
|
|
190
|
+
|
|
191
|
+
def _workspace(self, name: Any) -> Workspace:
|
|
192
|
+
return self.context.workspace_store.get(
|
|
193
|
+
self._required(name, "workspace name")
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _resolved(self, name: Any, path: Any = ".") -> ResolvedPath:
|
|
197
|
+
workspace = self._workspace(name)
|
|
198
|
+
return ResolvedPath(
|
|
199
|
+
workspace=workspace,
|
|
200
|
+
relative=normalize_relative(path),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _create_transport(self, workspace: Workspace) -> Any:
|
|
204
|
+
return self.context.transport_for_environment(workspace.environment)
|
|
205
|
+
|
|
206
|
+
def add(self, request: WorkspaceAddRequest) -> Workspace:
|
|
207
|
+
if not isinstance(request, WorkspaceAddRequest):
|
|
208
|
+
raise TypeError("workspace add request must be WorkspaceAddRequest")
|
|
209
|
+
force = self._boolean(request.force, "force")
|
|
210
|
+
environment = self._required(
|
|
211
|
+
request.environment,
|
|
212
|
+
"workspace environment",
|
|
213
|
+
)
|
|
214
|
+
self.context.environment_store.get(environment)
|
|
215
|
+
workspace = Workspace(
|
|
216
|
+
name=self._required(request.name, "workspace name"),
|
|
217
|
+
environment=environment,
|
|
218
|
+
root=self._required(request.root, "workspace root"),
|
|
219
|
+
execution_root=request.execution_root,
|
|
220
|
+
)
|
|
221
|
+
self.context.workspace_store.add(workspace, force=force)
|
|
222
|
+
return workspace
|
|
223
|
+
|
|
224
|
+
def list(self) -> WorkspaceListResult:
|
|
225
|
+
return WorkspaceListResult(tuple(self.context.workspace_store.list()))
|
|
226
|
+
|
|
227
|
+
def inspect(self, name: Any) -> WorkspaceInspectResult:
|
|
228
|
+
workspace = self._workspace(name)
|
|
229
|
+
return WorkspaceInspectResult(
|
|
230
|
+
workspace,
|
|
231
|
+
workspace.execution_root or workspace.root,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def remove(self, name: Any) -> WorkspaceRemoveResult:
|
|
235
|
+
workspace_name = self._required(name, "workspace name")
|
|
236
|
+
self.context.workspace_store.remove(workspace_name)
|
|
237
|
+
return WorkspaceRemoveResult(workspace_name)
|
|
238
|
+
|
|
239
|
+
def stat(self, name: Any, path: Any = ".") -> WorkspacePathResult:
|
|
240
|
+
resolved = self._resolved(name, path)
|
|
241
|
+
raw = self._object(
|
|
242
|
+
workspace_client.stat_path(
|
|
243
|
+
resolved,
|
|
244
|
+
create_transport=self._create_transport,
|
|
245
|
+
),
|
|
246
|
+
"workspace.stat",
|
|
247
|
+
)
|
|
248
|
+
return WorkspacePathResult(
|
|
249
|
+
resolved.workspace.name,
|
|
250
|
+
resolved.relative,
|
|
251
|
+
raw,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def read(
|
|
255
|
+
self,
|
|
256
|
+
name: Any,
|
|
257
|
+
path: Any,
|
|
258
|
+
*,
|
|
259
|
+
max_bytes: Any = 1024 * 1024,
|
|
260
|
+
) -> WorkspaceReadResult:
|
|
261
|
+
resolved = self._resolved(name, path)
|
|
262
|
+
limit = self._limit(max_bytes, "max_bytes", _MAX_READ_BYTES)
|
|
263
|
+
content, raw = workspace_client.read_file(
|
|
264
|
+
resolved,
|
|
265
|
+
max_bytes=limit,
|
|
266
|
+
create_transport=self._create_transport,
|
|
267
|
+
)
|
|
268
|
+
return WorkspaceReadResult(
|
|
269
|
+
resolved.workspace.name,
|
|
270
|
+
resolved.relative,
|
|
271
|
+
content,
|
|
272
|
+
raw,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def list_directory(
|
|
276
|
+
self,
|
|
277
|
+
name: Any,
|
|
278
|
+
path: Any = ".",
|
|
279
|
+
) -> WorkspacePathResult:
|
|
280
|
+
resolved = self._resolved(name, path)
|
|
281
|
+
raw = self._object(
|
|
282
|
+
workspace_client.list_directory(
|
|
283
|
+
resolved,
|
|
284
|
+
create_transport=self._create_transport,
|
|
285
|
+
),
|
|
286
|
+
"workspace.list",
|
|
287
|
+
)
|
|
288
|
+
return WorkspacePathResult(
|
|
289
|
+
resolved.workspace.name,
|
|
290
|
+
resolved.relative,
|
|
291
|
+
raw,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def glob(
|
|
295
|
+
self,
|
|
296
|
+
name: Any,
|
|
297
|
+
pattern: Any,
|
|
298
|
+
*,
|
|
299
|
+
path: Any = ".",
|
|
300
|
+
max_results: Any = 1000,
|
|
301
|
+
) -> WorkspaceGlobResult:
|
|
302
|
+
resolved = self._resolved(name, path)
|
|
303
|
+
requested_pattern = self._text(pattern, "glob pattern")
|
|
304
|
+
limit = self._limit(max_results, "max_results", _MAX_RESULTS)
|
|
305
|
+
raw = self._object(
|
|
306
|
+
workspace_client.glob_result(
|
|
307
|
+
resolved,
|
|
308
|
+
requested_pattern,
|
|
309
|
+
max_results=limit,
|
|
310
|
+
create_transport=self._create_transport,
|
|
311
|
+
),
|
|
312
|
+
"workspace.glob",
|
|
313
|
+
)
|
|
314
|
+
raw_matches = raw.get("matches")
|
|
315
|
+
if not isinstance(raw_matches, list):
|
|
316
|
+
raise ValueError("workspace.glob returned invalid matches")
|
|
317
|
+
matches: list[str] = []
|
|
318
|
+
for item in raw_matches:
|
|
319
|
+
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
|
320
|
+
raise ValueError("workspace.glob returned an invalid match")
|
|
321
|
+
matches.append(item["path"])
|
|
322
|
+
return WorkspaceGlobResult(
|
|
323
|
+
resolved.workspace.name,
|
|
324
|
+
resolved.relative,
|
|
325
|
+
requested_pattern,
|
|
326
|
+
tuple(matches),
|
|
327
|
+
raw,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def search(
|
|
331
|
+
self,
|
|
332
|
+
name: Any,
|
|
333
|
+
pattern: Any,
|
|
334
|
+
*,
|
|
335
|
+
paths: Sequence[str] = (".",),
|
|
336
|
+
glob: str | None = None,
|
|
337
|
+
max_results: Any = 1000,
|
|
338
|
+
) -> WorkspaceSearchResult:
|
|
339
|
+
workspace = self._workspace(name)
|
|
340
|
+
search_pattern = self._text(pattern, "search pattern")
|
|
341
|
+
if (
|
|
342
|
+
isinstance(paths, (str, bytes))
|
|
343
|
+
or not isinstance(paths, (list, tuple))
|
|
344
|
+
or not paths
|
|
345
|
+
or not all(isinstance(item, str) for item in paths)
|
|
346
|
+
):
|
|
347
|
+
raise ValueError("search paths must be a non-empty string array")
|
|
348
|
+
normalized_paths = tuple(normalize_relative(item) for item in paths)
|
|
349
|
+
glob_filter = normalize_relative(glob) if glob is not None else None
|
|
350
|
+
limit = self._limit(max_results, "max_results", _MAX_RESULTS)
|
|
351
|
+
raw = self._object(
|
|
352
|
+
workspace_client.search_workspace(
|
|
353
|
+
workspace,
|
|
354
|
+
search_pattern,
|
|
355
|
+
paths=normalized_paths,
|
|
356
|
+
glob=glob_filter,
|
|
357
|
+
max_results=limit,
|
|
358
|
+
create_transport=self._create_transport,
|
|
359
|
+
),
|
|
360
|
+
"workspace.search",
|
|
361
|
+
)
|
|
362
|
+
raw_matches = raw.get("matches")
|
|
363
|
+
if not isinstance(raw_matches, list) or not all(
|
|
364
|
+
isinstance(item, dict) for item in raw_matches
|
|
365
|
+
):
|
|
366
|
+
raise ValueError("workspace.search returned invalid matches")
|
|
367
|
+
return WorkspaceSearchResult(
|
|
368
|
+
workspace.name,
|
|
369
|
+
normalized_paths,
|
|
370
|
+
search_pattern,
|
|
371
|
+
tuple(dict(item) for item in raw_matches),
|
|
372
|
+
raw,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def _content(value: Any) -> bytes:
|
|
377
|
+
if not isinstance(value, bytes):
|
|
378
|
+
raise TypeError("workspace content must be bytes")
|
|
379
|
+
return value
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _digest(value: Any, *, required: bool) -> str | None:
|
|
383
|
+
if value is None and not required:
|
|
384
|
+
return None
|
|
385
|
+
if not isinstance(value, str) or not _DIGEST_RE.fullmatch(value):
|
|
386
|
+
raise ValueError(
|
|
387
|
+
"expected_sha256 must be a 64-character hexadecimal digest"
|
|
388
|
+
)
|
|
389
|
+
return value.lower()
|
|
390
|
+
|
|
391
|
+
def _write(
|
|
392
|
+
self,
|
|
393
|
+
name: Any,
|
|
394
|
+
path: Any,
|
|
395
|
+
content: Any,
|
|
396
|
+
*,
|
|
397
|
+
expected_sha256: Any,
|
|
398
|
+
digest_required: bool,
|
|
399
|
+
) -> WorkspacePathResult:
|
|
400
|
+
resolved = self._resolved(name, path)
|
|
401
|
+
payload = self._content(content)
|
|
402
|
+
digest = self._digest(expected_sha256, required=digest_required)
|
|
403
|
+
raw = self._object(
|
|
404
|
+
workspace_client.write_bytes(
|
|
405
|
+
resolved,
|
|
406
|
+
payload,
|
|
407
|
+
expected_sha256=digest,
|
|
408
|
+
create_transport=self._create_transport,
|
|
409
|
+
),
|
|
410
|
+
"workspace.write",
|
|
411
|
+
)
|
|
412
|
+
return WorkspacePathResult(
|
|
413
|
+
resolved.workspace.name,
|
|
414
|
+
resolved.relative,
|
|
415
|
+
raw,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def write(
|
|
419
|
+
self,
|
|
420
|
+
name: Any,
|
|
421
|
+
path: Any,
|
|
422
|
+
content: Any,
|
|
423
|
+
*,
|
|
424
|
+
expected_sha256: Any = None,
|
|
425
|
+
) -> WorkspacePathResult:
|
|
426
|
+
return self._write(
|
|
427
|
+
name,
|
|
428
|
+
path,
|
|
429
|
+
content,
|
|
430
|
+
expected_sha256=expected_sha256,
|
|
431
|
+
digest_required=False,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def apply_patch(
|
|
435
|
+
self,
|
|
436
|
+
name: Any,
|
|
437
|
+
path: Any,
|
|
438
|
+
content: Any,
|
|
439
|
+
*,
|
|
440
|
+
expected_sha256: Any,
|
|
441
|
+
) -> WorkspacePathResult:
|
|
442
|
+
return self._write(
|
|
443
|
+
name,
|
|
444
|
+
path,
|
|
445
|
+
content,
|
|
446
|
+
expected_sha256=expected_sha256,
|
|
447
|
+
digest_required=True,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def delete(self, name: Any, path: Any) -> WorkspacePathResult:
|
|
451
|
+
resolved = self._resolved(name, path)
|
|
452
|
+
raw = self._object(
|
|
453
|
+
workspace_client.delete_path(
|
|
454
|
+
resolved,
|
|
455
|
+
create_transport=self._create_transport,
|
|
456
|
+
),
|
|
457
|
+
"workspace.delete",
|
|
458
|
+
)
|
|
459
|
+
return WorkspacePathResult(
|
|
460
|
+
resolved.workspace.name,
|
|
461
|
+
resolved.relative,
|
|
462
|
+
raw,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
@staticmethod
|
|
466
|
+
def _result_boolean(raw: dict[str, Any], key: str) -> bool:
|
|
467
|
+
value = raw.get(key, False)
|
|
468
|
+
if type(value) is not bool:
|
|
469
|
+
raise RuntimeError(f"workspace Git result {key} must be a boolean")
|
|
470
|
+
return value
|
|
471
|
+
|
|
472
|
+
def _git_result(
|
|
473
|
+
self,
|
|
474
|
+
name: Any,
|
|
475
|
+
operation: str,
|
|
476
|
+
argv: tuple[str, ...] = (),
|
|
477
|
+
) -> WorkspaceGitResult:
|
|
478
|
+
workspace = self._workspace(name)
|
|
479
|
+
raw = self._object(
|
|
480
|
+
workspace_client.git_workspace(
|
|
481
|
+
workspace,
|
|
482
|
+
operation,
|
|
483
|
+
argv,
|
|
484
|
+
execution_root=workspace.execution_root or workspace.root,
|
|
485
|
+
create_transport=self._create_transport,
|
|
486
|
+
),
|
|
487
|
+
f"workspace.git-{operation}",
|
|
488
|
+
)
|
|
489
|
+
returncode = raw.get("returncode")
|
|
490
|
+
if isinstance(returncode, bool) or not isinstance(returncode, int):
|
|
491
|
+
raise RuntimeError("workspace Git result is missing integer returncode")
|
|
492
|
+
stdout = raw.get("stdout", "")
|
|
493
|
+
stderr = raw.get("stderr", "")
|
|
494
|
+
if not isinstance(stdout, str) or not isinstance(stderr, str):
|
|
495
|
+
raise RuntimeError("workspace Git stdout and stderr must be strings")
|
|
496
|
+
return WorkspaceGitResult(
|
|
497
|
+
workspace=workspace.name,
|
|
498
|
+
returncode=returncode,
|
|
499
|
+
stdout=stdout,
|
|
500
|
+
stderr=stderr,
|
|
501
|
+
stdout_truncated=self._result_boolean(raw, "stdout_truncated"),
|
|
502
|
+
stderr_truncated=self._result_boolean(raw, "stderr_truncated"),
|
|
503
|
+
output_drain_truncated=self._result_boolean(
|
|
504
|
+
raw,
|
|
505
|
+
"output_drain_truncated",
|
|
506
|
+
),
|
|
507
|
+
timed_out=self._result_boolean(raw, "timed_out"),
|
|
508
|
+
raw=raw,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def git_status(self, name: Any) -> WorkspaceGitResult:
|
|
512
|
+
return self._git_result(name, "status")
|
|
513
|
+
|
|
514
|
+
def git_diff(
|
|
515
|
+
self,
|
|
516
|
+
name: Any,
|
|
517
|
+
argv: Sequence[str] = (),
|
|
518
|
+
) -> WorkspaceGitResult:
|
|
519
|
+
extra = self._git_diff_arguments(argv)
|
|
520
|
+
if extra[:1] == ("--",):
|
|
521
|
+
extra = extra[1:]
|
|
522
|
+
self._validate_safe_git_diff_arguments(extra)
|
|
523
|
+
return self._run_git_diff(name, extra)
|
|
524
|
+
|
|
525
|
+
@staticmethod
|
|
526
|
+
def _git_diff_arguments(argv: Any) -> tuple[str, ...]:
|
|
527
|
+
if (
|
|
528
|
+
isinstance(argv, (str, bytes))
|
|
529
|
+
or not isinstance(argv, (list, tuple))
|
|
530
|
+
or not all(isinstance(item, str) for item in argv)
|
|
531
|
+
):
|
|
532
|
+
raise ValueError("git diff arguments must be a string array")
|
|
533
|
+
return tuple(argv)
|
|
534
|
+
|
|
535
|
+
@staticmethod
|
|
536
|
+
def _validate_safe_git_diff_arguments(argv: tuple[str, ...]) -> None:
|
|
537
|
+
prohibited = (
|
|
538
|
+
"--output",
|
|
539
|
+
"--ext-diff",
|
|
540
|
+
"--textconv",
|
|
541
|
+
"--no-index",
|
|
542
|
+
)
|
|
543
|
+
for argument in argv:
|
|
544
|
+
if argument == "--":
|
|
545
|
+
return
|
|
546
|
+
if argument == "-o" or argument.startswith("-o"):
|
|
547
|
+
raise ValueError(
|
|
548
|
+
f"git diff argument is not allowed: {argument}"
|
|
549
|
+
)
|
|
550
|
+
if any(
|
|
551
|
+
argument == option or argument.startswith(f"{option}=")
|
|
552
|
+
for option in prohibited
|
|
553
|
+
):
|
|
554
|
+
raise ValueError(
|
|
555
|
+
f"git diff argument is not allowed: {argument}"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
def _run_git_diff(
|
|
559
|
+
self,
|
|
560
|
+
name: Any,
|
|
561
|
+
extra: tuple[str, ...],
|
|
562
|
+
) -> WorkspaceGitResult:
|
|
563
|
+
return self._git_result(
|
|
564
|
+
name,
|
|
565
|
+
"diff",
|
|
566
|
+
(*_SAFE_GIT_DIFF_OPTIONS, *extra),
|
|
567
|
+
)
|