patchshuttle 0.1.0a2__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.
- patchshuttle/__init__.py +98 -0
- patchshuttle/_diff.py +317 -0
- patchshuttle/_process.py +198 -0
- patchshuttle/_version.py +3 -0
- patchshuttle/actions/__init__.py +80 -0
- patchshuttle/actions/constructors.py +211 -0
- patchshuttle/actions/create.py +155 -0
- patchshuttle/actions/modify.py +174 -0
- patchshuttle/audit.py +588 -0
- patchshuttle/backup.py +712 -0
- patchshuttle/checks/__init__.py +37 -0
- patchshuttle/checks/constructors.py +67 -0
- patchshuttle/checks/runner.py +233 -0
- patchshuttle/cli.py +766 -0
- patchshuttle/config.py +247 -0
- patchshuttle/context.py +370 -0
- patchshuttle/errors.py +291 -0
- patchshuttle/execution.py +651 -0
- patchshuttle/formatters/__init__.py +25 -0
- patchshuttle/formatters/runner.py +240 -0
- patchshuttle/identifiers.py +20 -0
- patchshuttle/inventory.py +331 -0
- patchshuttle/logging.py +741 -0
- patchshuttle/models.py +496 -0
- patchshuttle/operations.py +292 -0
- patchshuttle/parser.py +243 -0
- patchshuttle/planner.py +1144 -0
- patchshuttle/policy.py +377 -0
- patchshuttle/py.typed +1 -0
- patchshuttle/registry.py +275 -0
- patchshuttle/resources/AI_GUIDE.md +163 -0
- patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
- patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
- patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
- patchshuttle/resources/__init__.py +1 -0
- patchshuttle/rollback.py +306 -0
- patchshuttle/runner.py +880 -0
- patchshuttle/verification.py +107 -0
- patchshuttle/workspace.py +382 -0
- patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
- patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
- patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
- patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
- patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""One-pass controlled verification jobs with workspace observation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from patchshuttle.checks import CheckResult, CheckStatus, run_checks
|
|
8
|
+
from patchshuttle.errors import ExecutionError, ExecutionErrorCode, PolicyError
|
|
9
|
+
from patchshuttle.inventory import (
|
|
10
|
+
InventoryError,
|
|
11
|
+
WorkspaceComparison,
|
|
12
|
+
capture_inventory,
|
|
13
|
+
compare_inventories,
|
|
14
|
+
)
|
|
15
|
+
from patchshuttle.models import JobKind
|
|
16
|
+
from patchshuttle.planner import Plan, plan_job
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class VerificationRunResult:
|
|
21
|
+
"""Checks and workspace comparison from one verify plan."""
|
|
22
|
+
|
|
23
|
+
plan: Plan = field(repr=False)
|
|
24
|
+
checks: tuple[CheckResult, ...]
|
|
25
|
+
workspace_comparison: WorkspaceComparison
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def execute_verification_locked(plan: Plan) -> VerificationRunResult:
|
|
29
|
+
"""Execute a verify job while the caller holds the workspace run lock."""
|
|
30
|
+
|
|
31
|
+
if plan.job.kind is not JobKind.VERIFY:
|
|
32
|
+
raise ExecutionError(
|
|
33
|
+
ExecutionErrorCode.JOB_KIND_UNSUPPORTED,
|
|
34
|
+
"the verification runner accepts only verify jobs",
|
|
35
|
+
)
|
|
36
|
+
_revalidate_plan(plan)
|
|
37
|
+
baseline = _capture(plan, final=False)
|
|
38
|
+
check_run = run_checks(plan)
|
|
39
|
+
comparison = _compare(plan, baseline)
|
|
40
|
+
if check_run.failed is not None:
|
|
41
|
+
messages = {
|
|
42
|
+
CheckStatus.FAILED: "project check returned a non-zero exit code",
|
|
43
|
+
CheckStatus.TIMED_OUT: "project check timed out",
|
|
44
|
+
CheckStatus.ERROR: "project check could not be started",
|
|
45
|
+
}
|
|
46
|
+
raise ExecutionError(
|
|
47
|
+
ExecutionErrorCode.CHECK_FAILED,
|
|
48
|
+
messages[check_run.failed.status],
|
|
49
|
+
item_id=check_run.failed.id,
|
|
50
|
+
path=check_run.failed.name,
|
|
51
|
+
check_results=check_run.results,
|
|
52
|
+
workspace_comparison=comparison,
|
|
53
|
+
)
|
|
54
|
+
if comparison.unexpected_changes:
|
|
55
|
+
first = comparison.unexpected_changes[0]
|
|
56
|
+
raise ExecutionError(
|
|
57
|
+
ExecutionErrorCode.UNEXPECTED_WORKSPACE_CHANGE,
|
|
58
|
+
"project checks changed the workspace during verification",
|
|
59
|
+
path=first.path.as_posix(),
|
|
60
|
+
check_results=check_run.results,
|
|
61
|
+
workspace_comparison=comparison,
|
|
62
|
+
)
|
|
63
|
+
return VerificationRunResult(
|
|
64
|
+
plan=plan,
|
|
65
|
+
checks=check_run.results,
|
|
66
|
+
workspace_comparison=comparison,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _capture(plan: Plan, *, final: bool):
|
|
71
|
+
try:
|
|
72
|
+
return capture_inventory(plan.workspace)
|
|
73
|
+
except InventoryError as exc:
|
|
74
|
+
raise ExecutionError(
|
|
75
|
+
ExecutionErrorCode.WORKSPACE_INVENTORY_FAILED,
|
|
76
|
+
(
|
|
77
|
+
"final workspace inventory could not be captured"
|
|
78
|
+
if final
|
|
79
|
+
else "workspace baseline inventory could not be captured"
|
|
80
|
+
),
|
|
81
|
+
path=exc.path.as_posix() if exc.path is not None else None,
|
|
82
|
+
) from exc
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _compare(plan: Plan, baseline) -> WorkspaceComparison:
|
|
86
|
+
current = _capture(plan, final=True)
|
|
87
|
+
return compare_inventories(baseline, current)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _revalidate_plan(plan: Plan) -> None:
|
|
91
|
+
try:
|
|
92
|
+
current = plan_job(plan.job, plan.workspace)
|
|
93
|
+
except (PolicyError, ValueError) as exc:
|
|
94
|
+
raise ExecutionError(
|
|
95
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
96
|
+
"the workspace no longer matches the approved verification plan",
|
|
97
|
+
item_id=getattr(exc, "item_id", None),
|
|
98
|
+
path=getattr(exc, "path", None),
|
|
99
|
+
) from exc
|
|
100
|
+
if current != plan:
|
|
101
|
+
raise ExecutionError(
|
|
102
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
103
|
+
"the workspace no longer matches the approved verification plan",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["VerificationRunResult", "execute_verification_locked"]
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Workspace discovery and non-overwriting initialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from os import PathLike
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from patchshuttle.config import (
|
|
13
|
+
PatchShuttleConfig,
|
|
14
|
+
ProjectOrigin,
|
|
15
|
+
load_config,
|
|
16
|
+
render_default_config,
|
|
17
|
+
)
|
|
18
|
+
from patchshuttle.errors import WorkspaceError, WorkspaceErrorCode
|
|
19
|
+
from patchshuttle.identifiers import generate_project_id
|
|
20
|
+
from patchshuttle.models import Job
|
|
21
|
+
|
|
22
|
+
CONFIG_RELATIVE_PATH = Path("patches/patchshuttle.toml")
|
|
23
|
+
_MANAGED_DIRECTORIES = (
|
|
24
|
+
Path("patches"),
|
|
25
|
+
Path("patches/inbox"),
|
|
26
|
+
Path("patches/applied"),
|
|
27
|
+
Path("patches/failed"),
|
|
28
|
+
Path("patches/logs"),
|
|
29
|
+
Path("patches/backups"),
|
|
30
|
+
Path("patches/state"),
|
|
31
|
+
Path("patches/examples"),
|
|
32
|
+
)
|
|
33
|
+
_MANAGED_FILES = (
|
|
34
|
+
CONFIG_RELATIVE_PATH,
|
|
35
|
+
Path("patches/AI_GUIDE.md"),
|
|
36
|
+
Path("patches/PATCHSHUTTLE_PROTOCOL.md"),
|
|
37
|
+
Path("patches/patchshuttle.schema.json"),
|
|
38
|
+
Path("patches/state/registry.json"),
|
|
39
|
+
Path("patches/state/run.lock"),
|
|
40
|
+
Path("patches/examples/AUDIT-EXAMPLE.psh.yaml"),
|
|
41
|
+
Path("patches/examples/PATCH-EXAMPLE.psh.yaml"),
|
|
42
|
+
)
|
|
43
|
+
_OS_METADATA_FILES = frozenset({".DS_Store", "Thumbs.db", "desktop.ini"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WorkspaceInitStatus(str, Enum):
|
|
47
|
+
"""Observable result of one safe initialization attempt."""
|
|
48
|
+
|
|
49
|
+
INITIALIZED = "INITIALIZED"
|
|
50
|
+
UPDATED = "UPDATED"
|
|
51
|
+
UNCHANGED = "UNCHANGED"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class Workspace:
|
|
56
|
+
"""One resolved project root and its typed local configuration."""
|
|
57
|
+
|
|
58
|
+
root: Path
|
|
59
|
+
config: PatchShuttleConfig
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def project_id(self) -> str:
|
|
63
|
+
return self.config.project.project_id
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def origin(self) -> ProjectOrigin:
|
|
67
|
+
return self.config.project.origin
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def patches_dir(self) -> Path:
|
|
71
|
+
return self.root / "patches"
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def config_path(self) -> Path:
|
|
75
|
+
return self.root / CONFIG_RELATIVE_PATH
|
|
76
|
+
|
|
77
|
+
def require_project_id(self, project_id: str) -> None:
|
|
78
|
+
"""Reject a job created for a different workspace."""
|
|
79
|
+
|
|
80
|
+
if project_id != self.project_id:
|
|
81
|
+
raise WorkspaceError(
|
|
82
|
+
WorkspaceErrorCode.PROJECT_ID_MISMATCH,
|
|
83
|
+
"job project_id does not match the initialized workspace",
|
|
84
|
+
path="$.project_id",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class WorkspaceInitResult:
|
|
90
|
+
"""Workspace plus the exact relative entries created by ``init``."""
|
|
91
|
+
|
|
92
|
+
workspace: Workspace
|
|
93
|
+
status: WorkspaceInitStatus
|
|
94
|
+
created_paths: tuple[Path, ...]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def init_workspace(
|
|
98
|
+
root: str | PathLike[str] = ".",
|
|
99
|
+
*,
|
|
100
|
+
new_project: bool = False,
|
|
101
|
+
) -> WorkspaceInitResult:
|
|
102
|
+
"""Initialize one project root without overwriting any existing entry."""
|
|
103
|
+
|
|
104
|
+
workspace_root = _resolve_directory(root)
|
|
105
|
+
_preflight_managed_paths(workspace_root)
|
|
106
|
+
config_path = workspace_root / CONFIG_RELATIVE_PATH
|
|
107
|
+
config_preexisted = _path_exists(config_path)
|
|
108
|
+
created_paths: list[Path] = []
|
|
109
|
+
|
|
110
|
+
if config_preexisted:
|
|
111
|
+
_require_regular_managed_file(config_path, CONFIG_RELATIVE_PATH)
|
|
112
|
+
config = load_config(config_path)
|
|
113
|
+
_require_compatible_origin(config, new_project=new_project)
|
|
114
|
+
else:
|
|
115
|
+
if new_project:
|
|
116
|
+
_require_new_project_contents(workspace_root)
|
|
117
|
+
|
|
118
|
+
if _ensure_directory(workspace_root, Path("patches")):
|
|
119
|
+
created_paths.append(Path("patches"))
|
|
120
|
+
|
|
121
|
+
project_id = generate_project_id()
|
|
122
|
+
origin = ProjectOrigin.NEW if new_project else ProjectOrigin.EXISTING
|
|
123
|
+
config_created = _create_file_if_missing(
|
|
124
|
+
workspace_root,
|
|
125
|
+
CONFIG_RELATIVE_PATH,
|
|
126
|
+
render_default_config(project_id, origin),
|
|
127
|
+
)
|
|
128
|
+
if config_created:
|
|
129
|
+
created_paths.append(CONFIG_RELATIVE_PATH)
|
|
130
|
+
config = load_config(config_path)
|
|
131
|
+
_require_compatible_origin(config, new_project=new_project)
|
|
132
|
+
|
|
133
|
+
for relative_path in _MANAGED_DIRECTORIES:
|
|
134
|
+
if _ensure_directory(workspace_root, relative_path):
|
|
135
|
+
created_paths.append(relative_path)
|
|
136
|
+
|
|
137
|
+
for relative_path, content in _generated_files(config).items():
|
|
138
|
+
if relative_path == CONFIG_RELATIVE_PATH:
|
|
139
|
+
continue
|
|
140
|
+
if _create_file_if_missing(workspace_root, relative_path, content):
|
|
141
|
+
created_paths.append(relative_path)
|
|
142
|
+
|
|
143
|
+
status = (
|
|
144
|
+
WorkspaceInitStatus.INITIALIZED
|
|
145
|
+
if not config_preexisted
|
|
146
|
+
else (
|
|
147
|
+
WorkspaceInitStatus.UPDATED
|
|
148
|
+
if created_paths
|
|
149
|
+
else WorkspaceInitStatus.UNCHANGED
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
workspace = Workspace(root=workspace_root, config=config)
|
|
153
|
+
return WorkspaceInitResult(
|
|
154
|
+
workspace=workspace,
|
|
155
|
+
status=status,
|
|
156
|
+
created_paths=tuple(created_paths),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_workspace(root: str | PathLike[str] = ".") -> Workspace:
|
|
161
|
+
"""Load an initialized workspace at an exact project root."""
|
|
162
|
+
|
|
163
|
+
workspace_root = _resolve_directory(root)
|
|
164
|
+
_require_managed_directory_if_present(workspace_root, Path("patches"))
|
|
165
|
+
config_path = workspace_root / CONFIG_RELATIVE_PATH
|
|
166
|
+
if not _path_exists(config_path):
|
|
167
|
+
raise WorkspaceError(
|
|
168
|
+
WorkspaceErrorCode.WORKSPACE_NOT_INITIALIZED,
|
|
169
|
+
"no patches/patchshuttle.toml was found at the workspace root",
|
|
170
|
+
)
|
|
171
|
+
_require_regular_managed_file(config_path, CONFIG_RELATIVE_PATH)
|
|
172
|
+
return Workspace(root=workspace_root, config=load_config(config_path))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def discover_workspace(start: str | PathLike[str] = ".") -> Workspace:
|
|
176
|
+
"""Find the nearest initialized workspace at ``start`` or one of its parents."""
|
|
177
|
+
|
|
178
|
+
current = _resolve_directory(start)
|
|
179
|
+
for candidate in (current, *current.parents):
|
|
180
|
+
config_path = candidate / CONFIG_RELATIVE_PATH
|
|
181
|
+
if _path_exists(config_path):
|
|
182
|
+
return load_workspace(candidate)
|
|
183
|
+
raise WorkspaceError(
|
|
184
|
+
WorkspaceErrorCode.WORKSPACE_NOT_INITIALIZED,
|
|
185
|
+
"no initialized PatchShuttle workspace was found",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _resolve_directory(path: str | PathLike[str]) -> Path:
|
|
190
|
+
candidate = Path(path)
|
|
191
|
+
try:
|
|
192
|
+
resolved = candidate.resolve(strict=True)
|
|
193
|
+
except FileNotFoundError as exc:
|
|
194
|
+
raise WorkspaceError(
|
|
195
|
+
WorkspaceErrorCode.WORKSPACE_NOT_FOUND,
|
|
196
|
+
"workspace path was not found",
|
|
197
|
+
) from exc
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
raise WorkspaceError(
|
|
200
|
+
WorkspaceErrorCode.WORKSPACE_READ_FAILED,
|
|
201
|
+
"workspace path could not be resolved",
|
|
202
|
+
) from exc
|
|
203
|
+
|
|
204
|
+
if not resolved.is_dir():
|
|
205
|
+
raise WorkspaceError(
|
|
206
|
+
WorkspaceErrorCode.WORKSPACE_NOT_DIRECTORY,
|
|
207
|
+
"workspace path must identify a directory",
|
|
208
|
+
)
|
|
209
|
+
return resolved
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _require_new_project_contents(root: Path) -> None:
|
|
213
|
+
try:
|
|
214
|
+
entries = tuple(root.iterdir())
|
|
215
|
+
except OSError as exc:
|
|
216
|
+
raise WorkspaceError(
|
|
217
|
+
WorkspaceErrorCode.WORKSPACE_READ_FAILED,
|
|
218
|
+
"new-project directory could not be inspected",
|
|
219
|
+
) from exc
|
|
220
|
+
|
|
221
|
+
unexpected = [entry.name for entry in entries if not _is_allowed_metadata(entry)]
|
|
222
|
+
if unexpected:
|
|
223
|
+
visible = ", ".join(sorted(unexpected)[:5])
|
|
224
|
+
raise WorkspaceError(
|
|
225
|
+
WorkspaceErrorCode.NEW_PROJECT_NOT_EMPTY,
|
|
226
|
+
f"--new-project requires an empty directory; found: {visible}",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _is_allowed_metadata(entry: Path) -> bool:
|
|
231
|
+
if entry.is_symlink():
|
|
232
|
+
return False
|
|
233
|
+
if entry.name == ".git":
|
|
234
|
+
return entry.is_dir()
|
|
235
|
+
if entry.name in _OS_METADATA_FILES or entry.name.startswith("._"):
|
|
236
|
+
return entry.is_file()
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _require_compatible_origin(
|
|
241
|
+
config: PatchShuttleConfig,
|
|
242
|
+
*,
|
|
243
|
+
new_project: bool,
|
|
244
|
+
) -> None:
|
|
245
|
+
if new_project and config.project.origin is not ProjectOrigin.NEW:
|
|
246
|
+
raise WorkspaceError(
|
|
247
|
+
WorkspaceErrorCode.PROJECT_ORIGIN_CONFLICT,
|
|
248
|
+
"--new-project cannot change an existing-project workspace",
|
|
249
|
+
path="$.project.origin",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _ensure_directory(root: Path, relative_path: Path) -> bool:
|
|
254
|
+
path = root / relative_path
|
|
255
|
+
if path.is_symlink():
|
|
256
|
+
raise _managed_path_conflict(relative_path, "symbolic links are not allowed")
|
|
257
|
+
try:
|
|
258
|
+
path.mkdir()
|
|
259
|
+
except FileExistsError:
|
|
260
|
+
if path.is_dir() and not path.is_symlink():
|
|
261
|
+
return False
|
|
262
|
+
raise _managed_path_conflict(relative_path, "expected a directory")
|
|
263
|
+
except OSError as exc:
|
|
264
|
+
raise WorkspaceError(
|
|
265
|
+
WorkspaceErrorCode.WORKSPACE_WRITE_FAILED,
|
|
266
|
+
"managed directory could not be created",
|
|
267
|
+
path=relative_path.as_posix(),
|
|
268
|
+
) from exc
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _require_managed_directory_if_present(root: Path, relative_path: Path) -> None:
|
|
273
|
+
path = root / relative_path
|
|
274
|
+
if path.is_symlink():
|
|
275
|
+
raise _managed_path_conflict(relative_path, "symbolic links are not allowed")
|
|
276
|
+
if path.exists() and not path.is_dir():
|
|
277
|
+
raise _managed_path_conflict(relative_path, "expected a directory")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _preflight_managed_paths(root: Path) -> None:
|
|
281
|
+
for relative_path in _MANAGED_DIRECTORIES:
|
|
282
|
+
_require_managed_directory_if_present(root, relative_path)
|
|
283
|
+
for relative_path in _MANAGED_FILES:
|
|
284
|
+
path = root / relative_path
|
|
285
|
+
if _path_exists(path):
|
|
286
|
+
_require_regular_managed_file(path, relative_path)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _create_file_if_missing(root: Path, relative_path: Path, content: str) -> bool:
|
|
290
|
+
path = root / relative_path
|
|
291
|
+
if path.is_symlink():
|
|
292
|
+
raise _managed_path_conflict(relative_path, "symbolic links are not allowed")
|
|
293
|
+
|
|
294
|
+
created = False
|
|
295
|
+
try:
|
|
296
|
+
with path.open("x", encoding="utf-8", newline="\n") as stream:
|
|
297
|
+
created = True
|
|
298
|
+
stream.write(content)
|
|
299
|
+
except FileExistsError:
|
|
300
|
+
_require_regular_managed_file(path, relative_path)
|
|
301
|
+
return False
|
|
302
|
+
except OSError as exc:
|
|
303
|
+
if created:
|
|
304
|
+
try:
|
|
305
|
+
path.unlink()
|
|
306
|
+
except OSError:
|
|
307
|
+
pass # pragma: no cover - best-effort cleanup after write failure
|
|
308
|
+
raise WorkspaceError(
|
|
309
|
+
WorkspaceErrorCode.WORKSPACE_WRITE_FAILED,
|
|
310
|
+
"managed file could not be created",
|
|
311
|
+
path=relative_path.as_posix(),
|
|
312
|
+
) from exc
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _require_regular_managed_file(path: Path, relative_path: Path) -> None:
|
|
317
|
+
if path.is_symlink() or not path.is_file():
|
|
318
|
+
raise _managed_path_conflict(relative_path, "expected a regular file")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _managed_path_conflict(relative_path: Path, message: str) -> WorkspaceError:
|
|
322
|
+
return WorkspaceError(
|
|
323
|
+
WorkspaceErrorCode.MANAGED_PATH_CONFLICT,
|
|
324
|
+
message,
|
|
325
|
+
path=relative_path.as_posix(),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _path_exists(path: Path) -> bool:
|
|
330
|
+
return path.exists() or path.is_symlink()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _generated_files(config: PatchShuttleConfig) -> dict[Path, str]:
|
|
334
|
+
project_id = config.project.project_id
|
|
335
|
+
return {
|
|
336
|
+
CONFIG_RELATIVE_PATH: render_default_config(project_id, config.project.origin),
|
|
337
|
+
Path("patches/AI_GUIDE.md"): _render_resource("AI_GUIDE.md", project_id),
|
|
338
|
+
Path("patches/PATCHSHUTTLE_PROTOCOL.md"): _render_resource(
|
|
339
|
+
"PATCHSHUTTLE_PROTOCOL.md", project_id
|
|
340
|
+
),
|
|
341
|
+
Path("patches/patchshuttle.schema.json"): json.dumps(
|
|
342
|
+
Job.model_json_schema(),
|
|
343
|
+
ensure_ascii=False,
|
|
344
|
+
indent=2,
|
|
345
|
+
sort_keys=True,
|
|
346
|
+
)
|
|
347
|
+
+ "\n",
|
|
348
|
+
Path("patches/state/registry.json"): json.dumps(
|
|
349
|
+
{"jobs": {}, "project_id": project_id},
|
|
350
|
+
ensure_ascii=False,
|
|
351
|
+
indent=2,
|
|
352
|
+
sort_keys=True,
|
|
353
|
+
)
|
|
354
|
+
+ "\n",
|
|
355
|
+
Path("patches/state/run.lock"): "",
|
|
356
|
+
Path("patches/examples/AUDIT-EXAMPLE.psh.yaml"): _render_resource(
|
|
357
|
+
"AUDIT-EXAMPLE.psh.yaml", project_id
|
|
358
|
+
),
|
|
359
|
+
Path("patches/examples/PATCH-EXAMPLE.psh.yaml"): _render_resource(
|
|
360
|
+
"PATCH-EXAMPLE.psh.yaml", project_id
|
|
361
|
+
),
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _render_resource(name: str, project_id: str) -> str:
|
|
366
|
+
template = (
|
|
367
|
+
resources.files("patchshuttle.resources")
|
|
368
|
+
.joinpath(name)
|
|
369
|
+
.read_text(encoding="utf-8")
|
|
370
|
+
)
|
|
371
|
+
return template.replace("{{PROJECT_ID}}", project_id)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
__all__ = [
|
|
375
|
+
"CONFIG_RELATIVE_PATH",
|
|
376
|
+
"Workspace",
|
|
377
|
+
"WorkspaceInitResult",
|
|
378
|
+
"WorkspaceInitStatus",
|
|
379
|
+
"discover_workspace",
|
|
380
|
+
"init_workspace",
|
|
381
|
+
"load_workspace",
|
|
382
|
+
]
|